mirror of
https://github.com/github/spec-kit.git
synced 2026-07-03 20:36:23 +08:00
Compare commits
68 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b9b868ad99 | ||
|
|
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 | ||
|
|
1c41aacbac | ||
|
|
cb0d9612ef | ||
|
|
71143598be | ||
|
|
9c73e68528 | ||
|
|
8472e44215 | ||
|
|
2972dec85c | ||
|
|
838bd0fedc | ||
|
|
3028a00b6e | ||
|
|
ac6714de31 | ||
|
|
4deb90f4f5 | ||
|
|
4d58ee945c | ||
|
|
feb839103d | ||
|
|
1c25b5af3b | ||
|
|
375b2fdb1d | ||
|
|
40fb276023 | ||
|
|
6536bc4102 | ||
|
|
1a9e4d1d8d | ||
|
|
aad6f68ae5 | ||
|
|
473a441720 | ||
|
|
55ff148475 | ||
|
|
7f08f31286 | ||
|
|
8b099585c7 | ||
|
|
9c0be46006 | ||
|
|
f92e7e8096 | ||
|
|
4178b61828 | ||
|
|
d9e63a51f1 | ||
|
|
7dc493e613 | ||
|
|
5678ca7757 | ||
|
|
94ba857b78 | ||
|
|
e1ab4f0486 | ||
|
|
535ddbe0d2 | ||
|
|
8353830f97 | ||
|
|
10be484868 | ||
|
|
48b84cc941 | ||
|
|
fac8e59c02 | ||
|
|
87c9e1ce75 | ||
|
|
d40c9a6428 | ||
|
|
cb508d7a36 | ||
|
|
b8e7851234 | ||
|
|
08f69e3d3e | ||
|
|
c8ccb0609d | ||
|
|
663d679f3b |
4
.github/workflows/docs.yml
vendored
4
.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
|
||||
@@ -47,7 +48,7 @@ jobs:
|
||||
docfx docfx.json
|
||||
|
||||
- name: Setup Pages
|
||||
uses: actions/configure-pages@v5
|
||||
uses: actions/configure-pages@v6
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
@@ -56,6 +57,7 @@ jobs:
|
||||
|
||||
# Deploy job
|
||||
deploy:
|
||||
if: github.repository == 'github/spec-kit'
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
|
||||
1
.github/workflows/stale.yml
vendored
1
.github/workflows/stale.yml
vendored
@@ -6,6 +6,7 @@ on:
|
||||
workflow_dispatch: # Allow manual triggering
|
||||
|
||||
permissions:
|
||||
actions: write
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
|
||||
4
.github/workflows/test.yml
vendored
4
.github/workflows/test.yml
vendored
@@ -16,7 +16,7 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7
|
||||
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7
|
||||
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v6
|
||||
|
||||
615
AGENTS.md
615
AGENTS.md
@@ -10,276 +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) |
|
||||
| **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]"
|
||||
]
|
||||
}
|
||||
@@ -287,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`:
|
||||
|
||||
@@ -297,62 +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
|
||||
|
||||
### IDE-Based Agents
|
||||
|
||||
Work within integrated development environments:
|
||||
|
||||
- **GitHub Copilot**: Built into VS Code/compatible editors
|
||||
- **Cursor**: Built into Cursor IDE (`--ai cursor-agent`)
|
||||
- **Windsurf**: Built into Windsurf IDE
|
||||
- **Kilo Code**: Built into Kilo Code IDE
|
||||
- **Roo Code**: Built into Roo Code IDE
|
||||
- **IBM Bob**: Built into IBM Bob IDE
|
||||
- **Trae**: Built into Trae IDE
|
||||
- **Antigravity**: Built into Antigravity IDE (`--ai agy --ai-skills`)
|
||||
---
|
||||
|
||||
## Command File Formats
|
||||
|
||||
### Markdown Format
|
||||
|
||||
Used by: Claude, Cursor, GitHub Copilot, opencode, Windsurf, Junie, Kiro CLI, Amp, SHAI, IBM Bob, Kimi Code, Qwen, Pi, Codex, Auggie, CodeBuddy, Qoder, Roo Code, Kilo Code, Trae, Antigravity, Mistral Vibe, iFlow
|
||||
|
||||
**Standard format:**
|
||||
|
||||
```markdown
|
||||
@@ -376,8 +336,6 @@ Command content with {SCRIPT} and $ARGUMENTS placeholders.
|
||||
|
||||
### TOML Format
|
||||
|
||||
Used by: Gemini, Tabnine
|
||||
|
||||
```toml
|
||||
description = "Command description"
|
||||
|
||||
@@ -386,69 +344,90 @@ 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}}`
|
||||
- **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)
|
||||
|
||||
## Testing New Agent Integration
|
||||
## Special Processing Requirements
|
||||
|
||||
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
|
||||
Some agents require custom processing beyond the standard template transformations:
|
||||
|
||||
### Copilot Integration
|
||||
|
||||
GitHub Copilot has unique requirements:
|
||||
- Commands use `.agent.md` extension (not `.md`)
|
||||
- Each command gets a companion `.prompt.md` file in `.github/prompts/`
|
||||
- Installs `.vscode/settings.json` with prompt file recommendations
|
||||
- Context file lives at `.github/copilot-instructions.md`
|
||||
|
||||
Implementation: Extends `IntegrationBase` with custom `setup()` method that:
|
||||
1. Processes templates with `process_template()`
|
||||
2. Generates companion `.prompt.md` files
|
||||
3. Merges VS Code settings
|
||||
|
||||
### Forge Integration
|
||||
|
||||
Forge has special frontmatter and argument requirements:
|
||||
- Uses `{{parameters}}` instead of `$ARGUMENTS`
|
||||
- Strips `handoffs` frontmatter key (Forge-specific collaboration feature)
|
||||
- Injects `name` field into frontmatter when missing
|
||||
|
||||
Implementation: Extends `MarkdownIntegration` with custom `setup()` method that:
|
||||
1. Inherits standard template processing from `MarkdownIntegration`
|
||||
2. Adds extra `$ARGUMENTS` → `{{parameters}}` replacement after template processing
|
||||
3. Applies Forge-specific transformations via `_apply_forge_transformations()`
|
||||
4. Strips `handoffs` frontmatter key
|
||||
5. Injects missing `name` fields
|
||||
6. Ensures the shared `update-agent-context.*` scripts include a `forge` case that maps context updates to `AGENTS.md` and lists `forge` in their usage/help text
|
||||
|
||||
### Goose Integration
|
||||
|
||||
Goose is a YAML-format agent using Block's recipe system:
|
||||
- Uses `.goose/recipes/` directory for YAML recipe files
|
||||
- Uses `{{args}}` argument placeholder
|
||||
- Produces YAML with `prompt: |` block scalar for command content
|
||||
|
||||
Implementation: Extends `YamlIntegration` (parallel to `TomlIntegration`):
|
||||
1. Processes templates through the standard placeholder pipeline
|
||||
2. Extracts title and description from frontmatter
|
||||
3. Renders output as Goose recipe YAML (version, title, description, author, extensions, activities, prompt)
|
||||
4. Uses `yaml.safe_dump()` for header fields to ensure proper escaping
|
||||
5. Context updates map to `AGENTS.md` (shared with opencode/codex/pi/forge)
|
||||
|
||||
## 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.*
|
||||
|
||||
92
CHANGELOG.md
92
CHANGELOG.md
@@ -2,6 +2,98 @@
|
||||
|
||||
<!-- insert new changelog below this comment -->
|
||||
|
||||
## [0.6.2] - 2026-04-13
|
||||
|
||||
### Changed
|
||||
|
||||
- feat: Register "What-if Analysis" community extension (#2182)
|
||||
- feat: add GitHub Issues Integration to community catalog (#2188)
|
||||
- feat(agents): add Goose AI agent support (#2015)
|
||||
- Update ralph extension to v1.0.1 in community catalog (#2192)
|
||||
- fix: skip docs deployment workflow on forks (#2171)
|
||||
- chore: release 0.6.1, begin 0.6.2.dev0 development (#2162)
|
||||
|
||||
## [0.6.1] - 2026-04-10
|
||||
|
||||
### Changed
|
||||
|
||||
- feat: add bundled lean preset with minimal workflow commands (#2161)
|
||||
- Add Brownfield Bootstrap extension to community catalog (#2145)
|
||||
- Add CI Guard extension to community catalog (#2157)
|
||||
- Add SpecTest extension to community catalog (#2159)
|
||||
- fix: bundled extensions should not have download URLs (#2155)
|
||||
- Add PR Bridge extension to community catalog (#2148)
|
||||
- feat(cursor-agent): migrate from .cursor/commands to .cursor/skills (#2156)
|
||||
- Add TinySpec extension to community catalog (#2147)
|
||||
- chore: bump spec-kit-verify to 1.0.3 and spec-kit-review to 1.0.1 (#2146)
|
||||
- Add Status Report extension to community catalog (#2123)
|
||||
- chore: release 0.6.0, begin 0.6.1.dev0 development (#2144)
|
||||
|
||||
## [0.6.0] - 2026-04-09
|
||||
|
||||
### Changed
|
||||
|
||||
- Add Bugfix Workflow community extension to catalog and README (#2135)
|
||||
- Add Worktree Isolation extension to community catalog (#2143)
|
||||
- Add multi-repo-branching preset to community catalog (#2139)
|
||||
- Readme clarity (#2013)
|
||||
- Rewrite AGENTS.md for integration architecture (#2119)
|
||||
- docs: add SpecKit Companion to Community Friends section (#2140)
|
||||
- feat: add memorylint extension to community catalog (#2138)
|
||||
- chore: release 0.5.1, begin 0.5.2.dev0 development (#2137)
|
||||
|
||||
## [0.5.1] - 2026-04-08
|
||||
|
||||
### Changed
|
||||
|
||||
- fix: pin typer>=0.24.0 and click>=8.2.1 to fix import crash (#2136)
|
||||
- feat: update fleet extension to v1.1.0 (#2029)
|
||||
- fix(forge): use hyphen notation in frontmatter name field (#2075)
|
||||
- fix(bash): sed replacement escaping, BSD portability, dead cleanup in update-agent-context.sh (#2090)
|
||||
- Add Spec Diagram community extension to catalog and README (#2129)
|
||||
- feat: Git extension stage 2 — GIT_BRANCH_NAME override, --force for existing dirs, auto-install tests (#1940) (#2117)
|
||||
- fix(git): surface checkout errors for existing branches (#2122)
|
||||
- Add Branch Convention community extension to catalog and README (#2128)
|
||||
- docs: lighten March 2026 newsletter for readability (#2127)
|
||||
- fix: restore alias compatibility for community extensions (#2110) (#2125)
|
||||
- Added March 2026 newsletter (#2124)
|
||||
- Add Spec Refine community extension to catalog and README (#2118)
|
||||
- Add explicit-task-dependencies community preset to catalog and README (#2091)
|
||||
- Add toc-navigation community preset to catalog and README (#2080)
|
||||
- fix: prevent ambiguous TOML closing quotes when body ends with `"` (#2113) (#2115)
|
||||
- fix speckit issue for trae (#2112)
|
||||
- feat: Git extension stage 1 — bundled `extensions/git` with hooks on all core commands (#1941)
|
||||
- Upgraded confluence extension to v.1.1.1 (#2109)
|
||||
- Update V-Model Extension Pack to v0.5.0 (#2108)
|
||||
- Add canon extension and canon-core preset. (#2022)
|
||||
- [stage2] fix: serialize multiline descriptions in legacy TOML renderer (#2097)
|
||||
- [stage1] fix: strip YAML frontmatter from TOML integration prompts (#2096)
|
||||
- Add Confluence extension (#2028)
|
||||
- fix: accept 4+ digit spec numbers in tests and docs (#2094)
|
||||
- fix(scripts): improve git branch creation error handling (#2089)
|
||||
- Add optimize extension to community catalog (#2088)
|
||||
- feat: add "VS Code Ask Questions" preset (#2086)
|
||||
- Add security-review v1.1.1 to community extensions catalog (#2073)
|
||||
- Add `specify integration` subcommand for post-init integration management (#2083)
|
||||
- Remove template version info from CLI, fix Claude user-invocable, cleanup dead code (#2081)
|
||||
- fix: add user-invocable: true to skill frontmatter (#2077)
|
||||
- fix: add actions:write permission to stale workflow (#2079)
|
||||
- feat: add argument-hint frontmatter to Claude Code commands (#1951) (#2059)
|
||||
- Update conduct extension to v1.0.1 (#2078)
|
||||
- chore(deps): bump astral-sh/setup-uv from 7.6.0 to 8.0.0 (#2072)
|
||||
- chore(deps): bump actions/configure-pages from 5 to 6 (#2071)
|
||||
- feat: add spec-kit-fixit extension to community catalog (#2024)
|
||||
- chore: release 0.5.0, begin 0.5.1.dev0 development (#2070)
|
||||
- feat: add Forgecode agent support (#2034)
|
||||
|
||||
## [0.5.0] - 2026-04-02
|
||||
|
||||
### Changed
|
||||
|
||||
- Introduces DEVELOPMENT.md (#2069)
|
||||
- Update cc-sdd reference to cc-spex in Community Friends (#2007)
|
||||
- chore: release 0.4.5, begin 0.4.6.dev0 development (#2064)
|
||||
|
||||
## [0.4.5] - 2026-04-02
|
||||
|
||||
### Changed
|
||||
|
||||
25
DEVELOPMENT.md
Normal file
25
DEVELOPMENT.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# Development Notes
|
||||
|
||||
Spec Kit is a toolkit for spec-driven development. At its core, it is a coordinated set of prompts, templates, scripts, and CLI/integration assets that define and deliver a spec-driven workflow for AI coding agents. This document is a starting point for people modifying Spec Kit itself, with a compact orientation to the key project documents and repository organization.
|
||||
|
||||
**Essential project documents:**
|
||||
|
||||
| Document | Role |
|
||||
| ---------------------------------------------------------- | ------------------------------------------------------------------------------------- |
|
||||
| [README.md](README.md) | Primary user-facing overview of Spec Kit and its workflow. |
|
||||
| [DEVELOPMENT.md](DEVELOPMENT.md) | This document. |
|
||||
| [spec-driven.md](spec-driven.md) | End-to-end explanation of the Spec-Driven Development workflow supported by Spec Kit. |
|
||||
| [RELEASE-PROCESS.md](.github/workflows/RELEASE-PROCESS.md) | Release workflow, versioning rules, and changelog generation process. |
|
||||
| [docs/index.md](docs/index.md) | Entry point to the `docs/` documentation set. |
|
||||
| [CONTRIBUTING.md](CONTRIBUTING.md) | Contribution process, review expectations, and required development practices. |
|
||||
| [TESTING.md](TESTING.md) | Validation strategy and testing procedures. |
|
||||
|
||||
**Main repository components:**
|
||||
|
||||
| Directory | Role |
|
||||
| ------------------ | ------------------------------------------------------------------------------------------- |
|
||||
| `templates/` | Prompt assets and templates that define the core workflow behavior and generated artifacts. |
|
||||
| `scripts/` | Supporting scripts used by the workflow, setup, and repository tooling. |
|
||||
| `src/specify_cli/` | Python source for the `specify` CLI, including agent-specific assets. |
|
||||
| `extensions/` | Extension-related docs, catalogs, and supporting assets. |
|
||||
| `presets/` | Preset-related docs, catalogs, and supporting assets. |
|
||||
156
README.md
156
README.md
@@ -185,13 +185,21 @@ The following community-contributed extensions are available in [`catalog.commun
|
||||
| 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) |
|
||||
| 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) |
|
||||
| CI Guard | Spec compliance gates for CI/CD — verify specs exist, check drift, and block merges on gaps | `process` | Read-only | [spec-kit-ci-guard](https://github.com/Quratulain-bilal/spec-kit-ci-guard) |
|
||||
| Checkpoint Extension | Commit the changes made during the middle of the implementation, so you don't end up with just one very large commit at the end | `code` | Read+Write | [spec-kit-checkpoint](https://github.com/aaronrsun/spec-kit-checkpoint) |
|
||||
| Cleanup Extension | Post-implementation quality gate that reviews changes, fixes small issues (scout rule), creates tasks for medium issues, and generates analysis for large issues | `code` | Read+Write | [spec-kit-cleanup](https://github.com/dsrednicki/spec-kit-cleanup) |
|
||||
| Conduct Extension | Orchestrates spec-kit phases via sub-agent delegation to reduce context pollution. | `process` | Read+Write | [spec-kit-conduct-ext](https://github.com/twbrandon7/spec-kit-conduct-ext) |
|
||||
| Confluence Extension | Create a doc in Confluence summarizing the specifications and planning files | `integration` | Read+Write | [spec-kit-confluence](https://github.com/aaronrsun/spec-kit-confluence) |
|
||||
| DocGuard — CDD Enforcement | Canonical-Driven Development enforcement. Validates, scores, and traces project documentation with automated checks, AI-driven workflows, and spec-kit hooks. Zero NPM runtime dependencies. | `docs` | Read+Write | [spec-kit-docguard](https://github.com/raccioly/docguard) |
|
||||
| Extensify | Create and validate extensions and extension catalogs | `process` | Read+Write | [extensify](https://github.com/mnriem/spec-kit-extensions/tree/main/extensify) |
|
||||
| Fix Findings | Automated analyze-fix-reanalyze loop that resolves spec findings until clean | `code` | Read+Write | [spec-kit-fix-findings](https://github.com/Quratulain-bilal/spec-kit-fix-findings) |
|
||||
| FixIt Extension | Spec-aware bug fixing — maps bugs to spec artifacts, proposes a plan, applies minimal changes | `code` | Read+Write | [spec-kit-fixit](https://github.com/speckit-community/spec-kit-fixit) |
|
||||
| Fleet Orchestrator | Orchestrate a full feature lifecycle with human-in-the-loop gates across all SpecKit phases | `process` | Read+Write | [spec-kit-fleet](https://github.com/sharathsatish/spec-kit-fleet) |
|
||||
| GitHub Issues Integration | 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) |
|
||||
| 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) |
|
||||
@@ -202,8 +210,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) |
|
||||
@@ -216,14 +227,22 @@ The following community-contributed extensions are available in [`catalog.commun
|
||||
| Retrospective Extension | Post-implementation retrospective with spec adherence scoring, drift analysis, and human-gated spec updates | `docs` | Read+Write | [spec-kit-retrospective](https://github.com/emi-dm/spec-kit-retrospective) |
|
||||
| Review Extension | Post-implementation comprehensive code review with specialized agents for code quality, comments, tests, error handling, type design, and simplification | `code` | Read-only | [spec-kit-review](https://github.com/ismaelJimenez/spec-kit-review) |
|
||||
| SDD Utilities | Resume interrupted workflows, validate project health, and verify spec-to-task traceability | `process` | Read+Write | [speckit-utils](https://github.com/mvanhorn/speckit-utils) |
|
||||
| Staff Review Extension | Staff-engineer-level code review that validates implementation against spec, checks security, performance, and test coverage | `code` | Read-only | [spec-kit-staff-review](https://github.com/arunt14/spec-kit-staff-review) |
|
||||
| Superpowers Bridge | Orchestrates obra/superpowers skills within the spec-kit SDD workflow across the full lifecycle (clarification, TDD, review, verification, critique, debugging, branch completion) | `process` | Read+Write | [superpowers-bridge](https://github.com/RbBtSn0w/spec-kit-extensions/tree/main/superpowers-bridge) |
|
||||
| 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) |
|
||||
| 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) |
|
||||
|
||||
To submit your own extension, see the [Extension Publishing Guide](extensions/EXTENSION-PUBLISHING-GUIDE.md).
|
||||
|
||||
@@ -237,7 +256,12 @@ The following community-contributed presets customize how Spec Kit behaves — o
|
||||
| Preset | Purpose | Provides | Requires | URL |
|
||||
|--------|---------|----------|----------|-----|
|
||||
| AIDE In-Place Migration | Adapts the AIDE extension workflow for in-place technology migrations (X → Y pattern) — adds migration objectives, verification gates, knowledge documents, and behavioral equivalence criteria | 2 templates, 8 commands | AIDE extension | [spec-kit-presets](https://github.com/mnriem/spec-kit-presets) |
|
||||
| Canon Core | Adapts original Spec Kit workflow to work together with Canon extension | 2 templates, 8 commands | — | [spec-kit-canon](https://github.com/maximiliamus/spec-kit-canon) |
|
||||
| Explicit Task Dependencies | Adds explicit `(depends on T###)` dependency declarations and an Execution Wave DAG to tasks.md for parallel scheduling | 1 template, 1 command | — | [spec-kit-preset-explicit-task-dependencies](https://github.com/Quratulain-bilal/spec-kit-preset-explicit-task-dependencies) |
|
||||
| Multi-Repo Branching | Coordinates feature branch creation across multiple git repositories (independent repos and submodules) during plan and tasks phases | 2 commands | — | [spec-kit-preset-multi-repo-branching](https://github.com/sakitA/spec-kit-preset-multi-repo-branching) |
|
||||
| Pirate Speak (Full) | Transforms all Spec Kit output into pirate speak — specs become "Voyage Manifests", plans become "Battle Plans", tasks become "Crew Assignments" | 6 templates, 9 commands | — | [spec-kit-presets](https://github.com/mnriem/spec-kit-presets) |
|
||||
| Table of Contents Navigation | Adds a navigable Table of Contents to generated spec.md, plan.md, and tasks.md documents | 3 templates, 3 commands | — | [spec-kit-preset-toc-navigation](https://github.com/Quratulain-bilal/spec-kit-preset-toc-navigation) |
|
||||
| VS Code Ask Questions | Enhances the clarify command to use `vscode/askQuestions` for batched interactive questioning. | 1 command | — | [spec-kit-presets](https://github.com/fdcastel/spec-kit-presets) |
|
||||
|
||||
To build and publish your own preset, see the [Presets Publishing Guide](presets/PUBLISHING.md).
|
||||
|
||||
@@ -269,12 +293,13 @@ See Spec-Driven Development in action across different scenarios with these comm
|
||||
|
||||
Community projects that extend, visualize, or build on Spec Kit:
|
||||
|
||||
- **[cc-sdd](https://github.com/rhuss/cc-sdd)** - A Claude Code plugin that adds composable traits on top of Spec Kit with [Superpowers](https://github.com/obra/superpowers)-based quality gates, spec/code review, git worktree isolation, and parallel implementation via agent teams.
|
||||
- **[cc-spex](https://github.com/rhuss/cc-spex)** - A Claude Code plugin that adds composable traits on top of Spec Kit with [Superpowers](https://github.com/obra/superpowers)-based quality gates, spec/code review, git worktree isolation, and parallel implementation via agent teams.
|
||||
|
||||
- **[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.
|
||||
|
||||
## 🤖 Supported AI Agents
|
||||
| Agent | Support | Notes |
|
||||
| ------------------------------------------------------------------------------------ | ------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| [Qoder CLI](https://qoder.com/cli) | ✅ | |
|
||||
@@ -285,7 +310,9 @@ Community projects that extend, visualize, or build on Spec Kit:
|
||||
| [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) | ✅ | |
|
||||
| [Goose](https://block.github.io/goose/) | ✅ | Uses YAML recipe format in `.goose/recipes/` with slash command support |
|
||||
| [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/) | ✅ | |
|
||||
@@ -305,23 +332,64 @@ Community projects that extend, visualize, or build on Spec Kit:
|
||||
| [Trae](https://www.trae.ai/) | ✅ | |
|
||||
| Generic | ✅ | Bring your own agent — use `--ai generic --ai-commands-dir <path>` for unsupported agents |
|
||||
|
||||
## Available Slash Commands
|
||||
|
||||
After running `specify init`, your AI coding agent will have access to these slash commands for structured development. If you pass `--ai <agent> --ai-skills`, Spec Kit installs agent skills instead of slash-command prompt files; `--ai-skills` requires `--ai`.
|
||||
|
||||
#### Core Commands
|
||||
|
||||
Essential commands for the Spec-Driven Development workflow:
|
||||
|
||||
| Command | Agent Skill | Description |
|
||||
| ------------------------ | ---------------------- | -------------------------------------------------------------------------- |
|
||||
| `/speckit.constitution` | `speckit-constitution` | Create or update project governing principles and development guidelines |
|
||||
| `/speckit.specify` | `speckit-specify` | Define what you want to build (requirements and user stories) |
|
||||
| `/speckit.plan` | `speckit-plan` | Create technical implementation plans with your chosen tech stack |
|
||||
| `/speckit.tasks` | `speckit-tasks` | Generate actionable task lists for implementation |
|
||||
| `/speckit.taskstoissues` | `speckit-taskstoissues`| Convert generated task lists into GitHub issues for tracking and execution |
|
||||
| `/speckit.implement` | `speckit-implement` | Execute all tasks to build the feature according to the plan |
|
||||
|
||||
#### Optional Commands
|
||||
|
||||
Additional commands for enhanced quality and validation:
|
||||
|
||||
| Command | Agent Skill | Description |
|
||||
| -------------------- | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `/speckit.clarify` | `speckit-clarify` | Clarify underspecified areas (recommended before `/speckit.plan`; formerly `/quizme`) |
|
||||
| `/speckit.analyze` | `speckit-analyze` | Cross-artifact consistency & coverage analysis (run after `/speckit.tasks`, before `/speckit.implement`) |
|
||||
| `/speckit.checklist` | `speckit-checklist` | Generate custom quality checklists that validate requirements completeness, clarity, and consistency (like "unit tests for English") |
|
||||
|
||||
## 🔧 Specify CLI Reference
|
||||
|
||||
The `specify` command supports the following options:
|
||||
The `specify` tool is invoked as
|
||||
|
||||
```text
|
||||
specify <COMMAND> [SUBCOMMAND] [OPTIONS]
|
||||
```
|
||||
|
||||
and supports the following commands:
|
||||
|
||||
### 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`, etc.) |
|
||||
| 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.) |
|
||||
| `version` | Show the currently installed Spec Kit version. |
|
||||
| `extension` | Manage extensions |
|
||||
| `preset` | Manage presets |
|
||||
| `integration` | Manage integrations |
|
||||
|
||||
### `specify init` Arguments & Options
|
||||
|
||||
```bash
|
||||
specify init [PROJECT_NAME] <OPTIONS>
|
||||
```
|
||||
|
||||
| Argument/Option | Type | Description |
|
||||
| ---------------------- | -------- |-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `<project-name>` | Argument | Name for your new project directory (optional if using `--here`, or use `.` for current directory) |
|
||||
| `--ai` | Option | AI assistant to use (see `AGENT_CONFIG` for the full, up-to-date list). Common options include: `claude`, `gemini`, `copilot`, `cursor-agent`, `qwen`, `opencode`, `codex`, `windsurf`, `junie`, `kilocode`, `auggie`, `roo`, `codebuddy`, `amp`, `shai`, `kiro-cli` (`kiro` alias), `agy`, `bob`, `qodercli`, `vibe`, `kimi`, `iflow`, `pi`, or `generic` (requires `--ai-commands-dir`) |
|
||||
| ---------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `<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 |
|
||||
@@ -332,7 +400,7 @@ The `specify` command supports the following options:
|
||||
| `--debug` | Flag | Enable detailed debug output for troubleshooting |
|
||||
| `--github-token` | Option | GitHub token for API requests (or set GH_TOKEN/GITHUB_TOKEN env variable) |
|
||||
| `--ai-skills` | Flag | Install Prompt.MD templates as agent skills in agent-specific `skills/` directory (requires `--ai`). Extension commands are also auto-registered as skills when extensions are added later. |
|
||||
| `--branch-numbering` | Option | Branch numbering strategy: `sequential` (default — `001`, `002`, `003`) or `timestamp` (`YYYYMMDD-HHMMSS`). Timestamp mode is useful for distributed teams to avoid numbering conflicts |
|
||||
| `--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
|
||||
|
||||
@@ -376,6 +444,9 @@ 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/
|
||||
|
||||
@@ -414,38 +485,6 @@ specify init my-project --ai claude --branch-numbering timestamp
|
||||
specify check
|
||||
```
|
||||
|
||||
### Available Slash Commands
|
||||
|
||||
After running `specify init`, your AI coding agent will have access to these structured development commands.
|
||||
|
||||
Most agents expose the traditional dotted slash commands shown below, like `/speckit.plan`.
|
||||
|
||||
Claude Code installs spec-kit as skills and invokes them as `/speckit-constitution`, `/speckit-specify`, `/speckit-plan`, `/speckit-tasks`, and `/speckit-implement`.
|
||||
|
||||
For Codex CLI, `--ai-skills` installs spec-kit as agent skills instead of slash-command prompt files. In Codex skills mode, invoke spec-kit as `$speckit-constitution`, `$speckit-specify`, `$speckit-plan`, `$speckit-tasks`, and `$speckit-implement`.
|
||||
|
||||
#### Core Commands
|
||||
|
||||
Essential commands for the Spec-Driven Development workflow:
|
||||
|
||||
| Command | Description |
|
||||
| ----------------------- | ------------------------------------------------------------------------ |
|
||||
| `/speckit.constitution` | Create or update project governing principles and development guidelines |
|
||||
| `/speckit.specify` | Define what you want to build (requirements and user stories) |
|
||||
| `/speckit.plan` | Create technical implementation plans with your chosen tech stack |
|
||||
| `/speckit.tasks` | Generate actionable task lists for implementation |
|
||||
| `/speckit.implement` | Execute all tasks to build the feature according to the plan |
|
||||
|
||||
#### Optional Commands
|
||||
|
||||
Additional commands for enhanced quality and validation:
|
||||
|
||||
| Command | 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") |
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Description |
|
||||
@@ -456,21 +495,18 @@ Additional commands for enhanced quality and validation:
|
||||
|
||||
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
|
||||
|
||||
@@ -621,7 +657,7 @@ specify init . --force --ai claude
|
||||
specify init --here --force --ai claude
|
||||
```
|
||||
|
||||
The CLI will check if you have Claude Code, Gemini CLI, Cursor CLI, Qwen CLI, opencode, Codex CLI, Qoder CLI, Tabnine CLI, Kiro CLI, Pi, 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
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -108,7 +108,7 @@ defaults: # Optional, default configuration values
|
||||
#### `hooks`
|
||||
|
||||
- **Type**: object
|
||||
- **Keys**: Event names (e.g., `after_specify`, `after_plan`, `after_tasks`, `after_implement`, `before_commit`)
|
||||
- **Keys**: Event names (e.g., `after_specify`, `after_plan`, `after_tasks`, `after_implement`, `before_analyze`)
|
||||
- **Description**: Hooks that execute at lifecycle events
|
||||
- **Events**: Defined by core spec-kit commands
|
||||
|
||||
@@ -559,8 +559,16 @@ Standard events (defined by core):
|
||||
- `after_tasks` - After task generation
|
||||
- `before_implement` - Before implementation
|
||||
- `after_implement` - After implementation
|
||||
- `before_commit` - Before git commit *(planned - not yet wired into core templates)*
|
||||
- `after_commit` - After git commit *(planned - not yet wired into core templates)*
|
||||
- `before_analyze` - Before cross-artifact analysis
|
||||
- `after_analyze` - After cross-artifact analysis
|
||||
- `before_checklist` - Before checklist generation
|
||||
- `after_checklist` - After checklist generation
|
||||
- `before_clarify` - Before spec clarification
|
||||
- `after_clarify` - After spec clarification
|
||||
- `before_constitution` - Before constitution update
|
||||
- `after_constitution` - After constitution update
|
||||
- `before_taskstoissues` - Before tasks-to-issues conversion
|
||||
- `after_taskstoissues` - After tasks-to-issues conversion
|
||||
|
||||
### Hook Configuration
|
||||
|
||||
|
||||
@@ -177,9 +177,9 @@ Compatibility requirements.
|
||||
|
||||
What the extension provides.
|
||||
|
||||
**Required sub-fields**:
|
||||
**Optional sub-fields**:
|
||||
|
||||
- `commands`: Array of command objects (must have at least one)
|
||||
- `commands`: Array of command objects (at least one command or hook is required)
|
||||
|
||||
**Command object**:
|
||||
|
||||
@@ -196,12 +196,19 @@ Integration hooks for automatic execution.
|
||||
|
||||
Available hook points:
|
||||
|
||||
- `after_tasks`: After `/speckit.tasks` completes
|
||||
- `after_implement`: After `/speckit.implement` completes (future)
|
||||
- `before_specify` / `after_specify`: Before/after specification generation
|
||||
- `before_plan` / `after_plan`: Before/after implementation planning
|
||||
- `before_tasks` / `after_tasks`: Before/after task generation
|
||||
- `before_implement` / `after_implement`: Before/after implementation
|
||||
- `before_analyze` / `after_analyze`: Before/after cross-artifact analysis
|
||||
- `before_checklist` / `after_checklist`: Before/after checklist generation
|
||||
- `before_clarify` / `after_clarify`: Before/after spec clarification
|
||||
- `before_constitution` / `after_constitution`: Before/after constitution update
|
||||
- `before_taskstoissues` / `after_taskstoissues`: Before/after tasks-to-issues conversion
|
||||
|
||||
Hook object:
|
||||
|
||||
- `command`: Command to execute (must be in `provides.commands`)
|
||||
- `command`: Command to execute (typically from `provides.commands`, but can reference any registered command)
|
||||
- `optional`: If true, prompt user before executing
|
||||
- `prompt`: Prompt text for optional hooks
|
||||
- `description`: Hook description
|
||||
|
||||
@@ -403,8 +403,10 @@ settings:
|
||||
|
||||
# Hook configuration
|
||||
# Available events: before_specify, after_specify, before_plan, after_plan,
|
||||
# before_tasks, after_tasks, before_implement, after_implement
|
||||
# Planned (not yet wired into core templates): before_commit, after_commit
|
||||
# before_tasks, after_tasks, before_implement, after_implement,
|
||||
# before_analyze, after_analyze, before_checklist, after_checklist,
|
||||
# before_clarify, after_clarify, before_constitution, after_constitution,
|
||||
# before_taskstoissues, after_taskstoissues
|
||||
hooks:
|
||||
after_tasks:
|
||||
- extension: jira
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-04-01T00:00:00Z",
|
||||
"updated_at": "2026-04-13T14:39:00Z",
|
||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json",
|
||||
"extensions": {
|
||||
"aide": {
|
||||
@@ -106,6 +106,170 @@
|
||||
"created_at": "2026-03-03T00:00:00Z",
|
||||
"updated_at": "2026-03-03T00:00:00Z"
|
||||
},
|
||||
"branch-convention": {
|
||||
"name": "Branch Convention",
|
||||
"id": "branch-convention",
|
||||
"description": "Configurable branch and folder naming conventions for /specify with presets and custom patterns.",
|
||||
"author": "Quratulain-bilal",
|
||||
"version": "1.0.0",
|
||||
"download_url": "https://github.com/Quratulain-bilal/spec-kit-branch-convention/archive/refs/tags/v1.0.0.zip",
|
||||
"repository": "https://github.com/Quratulain-bilal/spec-kit-branch-convention",
|
||||
"homepage": "https://github.com/Quratulain-bilal/spec-kit-branch-convention",
|
||||
"documentation": "https://github.com/Quratulain-bilal/spec-kit-branch-convention/blob/main/README.md",
|
||||
"changelog": "https://github.com/Quratulain-bilal/spec-kit-branch-convention/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.4.0"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 3,
|
||||
"hooks": 1
|
||||
},
|
||||
"tags": [
|
||||
"branch",
|
||||
"naming",
|
||||
"convention",
|
||||
"gitflow",
|
||||
"workflow"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"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",
|
||||
"description": "Adds canon-driven (baseline-driven) workflows: spec-first, code-first, spec-drift. Requires Canon Core preset installation.",
|
||||
"author": "Maxim Stupakov",
|
||||
"version": "0.1.0",
|
||||
"download_url": "https://github.com/maximiliamus/spec-kit-canon/releases/download/v0.1.0/spec-kit-canon-v0.1.0.zip",
|
||||
"repository": "https://github.com/maximiliamus/spec-kit-canon",
|
||||
"homepage": "https://github.com/maximiliamus/spec-kit-canon",
|
||||
"documentation": "https://github.com/maximiliamus/spec-kit-canon/blob/master/README.md",
|
||||
"changelog": "https://github.com/maximiliamus/spec-kit-canon/blob/master/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.4.3"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 16,
|
||||
"hooks": 0
|
||||
},
|
||||
"tags": [
|
||||
"process",
|
||||
"baseline",
|
||||
"canon",
|
||||
"drift",
|
||||
"spec-first",
|
||||
"code-first",
|
||||
"spec-drift",
|
||||
"vibecoding"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-03-29T00:00:00Z",
|
||||
"updated_at": "2026-03-29T00: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",
|
||||
@@ -172,8 +336,8 @@
|
||||
"id": "conduct",
|
||||
"description": "Executes a single spec-kit phase via sub-agent delegation to reduce context pollution.",
|
||||
"author": "twbrandon7",
|
||||
"version": "1.0.0",
|
||||
"download_url": "https://github.com/twbrandon7/spec-kit-conduct-ext/archive/refs/tags/v1.0.0.zip",
|
||||
"version": "1.0.1",
|
||||
"download_url": "https://github.com/twbrandon7/spec-kit-conduct-ext/archive/refs/tags/v1.0.1.zip",
|
||||
"repository": "https://github.com/twbrandon7/spec-kit-conduct-ext",
|
||||
"homepage": "https://github.com/twbrandon7/spec-kit-conduct-ext",
|
||||
"documentation": "https://github.com/twbrandon7/spec-kit-conduct-ext/blob/main/README.md",
|
||||
@@ -195,7 +359,7 @@
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-03-19T12:08:20Z",
|
||||
"updated_at": "2026-03-19T12:08:20Z"
|
||||
"updated_at": "2026-04-03T12:35:01Z"
|
||||
},
|
||||
"critique": {
|
||||
"name": "Spec Critique Extension",
|
||||
@@ -227,6 +391,66 @@
|
||||
"created_at": "2026-04-01T00:00:00Z",
|
||||
"updated_at": "2026-04-01T00:00:00Z"
|
||||
},
|
||||
"confluence": {
|
||||
"name": "Confluence Extension",
|
||||
"id": "confluence",
|
||||
"description": "Create, read, and update Confluence docs for your project",
|
||||
"author": "aaronrsun",
|
||||
"version": "1.1.1",
|
||||
"download_url": "https://github.com/aaronrsun/spec-kit-confluence/archive/refs/tags/v1.1.1.zip",
|
||||
"repository": "https://github.com/aaronrsun/spec-kit-confluence",
|
||||
"homepage": "https://github.com/aaronrsun/spec-kit-confluence",
|
||||
"documentation": "https://github.com/aaronrsun/spec-kit-confluence/blob/main/README.md",
|
||||
"changelog": "https://github.com/aaronrsun/spec-kit-confluence/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.1.0"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 1,
|
||||
"hooks": 0
|
||||
},
|
||||
"tags": [
|
||||
"confluence"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-03-29T00:00:00Z",
|
||||
"updated_at": "2026-03-29T00:00:00Z"
|
||||
},
|
||||
"diagram": {
|
||||
"name": "Spec Diagram",
|
||||
"id": "diagram",
|
||||
"description": "Auto-generate Mermaid diagrams of SDD workflow state, feature progress, and task dependencies.",
|
||||
"author": "Quratulain-bilal",
|
||||
"version": "1.0.0",
|
||||
"download_url": "https://github.com/Quratulain-bilal/spec-kit-diagram-/archive/refs/tags/v1.0.0.zip",
|
||||
"repository": "https://github.com/Quratulain-bilal/spec-kit-diagram-",
|
||||
"homepage": "https://github.com/Quratulain-bilal/spec-kit-diagram-",
|
||||
"documentation": "https://github.com/Quratulain-bilal/spec-kit-diagram-/blob/main/README.md",
|
||||
"changelog": "https://github.com/Quratulain-bilal/spec-kit-diagram-/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.4.0"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 3,
|
||||
"hooks": 1
|
||||
},
|
||||
"tags": [
|
||||
"diagram",
|
||||
"mermaid",
|
||||
"visualization",
|
||||
"workflow",
|
||||
"dependencies"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-04-08T00:00:00Z",
|
||||
"updated_at": "2026-04-08T00:00:00Z"
|
||||
},
|
||||
"docguard": {
|
||||
"name": "DocGuard — CDD Enforcement",
|
||||
"id": "docguard",
|
||||
@@ -363,13 +587,44 @@
|
||||
"created_at": "2026-04-01T00:00:00Z",
|
||||
"updated_at": "2026-04-01T00:00:00Z"
|
||||
},
|
||||
"fixit": {
|
||||
"name": "FixIt Extension",
|
||||
"id": "fixit",
|
||||
"description": "Spec-aware bug fixing: maps bugs to spec artifacts, proposes a plan, applies minimal changes.",
|
||||
"author": "ismaelJimenez",
|
||||
"version": "1.0.0",
|
||||
"download_url": "https://github.com/speckit-community/spec-kit-fixit/archive/refs/tags/v1.0.0.zip",
|
||||
"repository": "https://github.com/speckit-community/spec-kit-fixit",
|
||||
"homepage": "https://github.com/speckit-community/spec-kit-fixit",
|
||||
"documentation": "https://github.com/speckit-community/spec-kit-fixit/blob/main/README.md",
|
||||
"changelog": "https://github.com/speckit-community/spec-kit-fixit/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.1.0"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 1,
|
||||
"hooks": 0
|
||||
},
|
||||
"tags": [
|
||||
"debugging",
|
||||
"fixit",
|
||||
"spec-alignment",
|
||||
"post-implementation"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-03-30T00:00:00Z",
|
||||
"updated_at": "2026-03-30T00:00:00Z"
|
||||
},
|
||||
"fleet": {
|
||||
"name": "Fleet Orchestrator",
|
||||
"id": "fleet",
|
||||
"description": "Orchestrate a full feature lifecycle with human-in-the-loop gates across all SpecKit phases.",
|
||||
"author": "sharathsatish",
|
||||
"version": "1.0.0",
|
||||
"download_url": "https://github.com/sharathsatish/spec-kit-fleet/archive/refs/tags/v1.0.0.zip",
|
||||
"version": "1.1.0",
|
||||
"download_url": "https://github.com/sharathsatish/spec-kit-fleet/archive/refs/tags/v1.1.0.zip",
|
||||
"repository": "https://github.com/sharathsatish/spec-kit-fleet",
|
||||
"homepage": "https://github.com/sharathsatish/spec-kit-fleet",
|
||||
"documentation": "https://github.com/sharathsatish/spec-kit-fleet/blob/main/README.md",
|
||||
@@ -392,7 +647,47 @@
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-03-06T00:00:00Z",
|
||||
"updated_at": "2026-03-06T00:00:00Z"
|
||||
"updated_at": "2026-03-31T00:00:00Z"
|
||||
},
|
||||
"github-issues": {
|
||||
"name": "GitHub Issues Integration",
|
||||
"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"
|
||||
},
|
||||
"iterate": {
|
||||
"name": "Iterate",
|
||||
@@ -712,6 +1007,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.0.0",
|
||||
"download_url": "https://github.com/RbBtSn0w/spec-kit-extensions/archive/refs/tags/v1.0.0.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-09T00:00:00Z"
|
||||
},
|
||||
"onboard": {
|
||||
"name": "Onboard",
|
||||
"id": "onboard",
|
||||
@@ -745,6 +1072,38 @@
|
||||
"created_at": "2026-03-26T00:00:00Z",
|
||||
"updated_at": "2026-03-26T00:00:00Z"
|
||||
},
|
||||
"optimize": {
|
||||
"name": "Optimize Extension",
|
||||
"id": "optimize",
|
||||
"description": "Audits and optimizes AI governance for context efficiency",
|
||||
"author": "sakitA",
|
||||
"version": "1.0.0",
|
||||
"download_url": "https://github.com/sakitA/spec-kit-optimize/archive/refs/tags/v1.0.0.zip",
|
||||
"repository": "https://github.com/sakitA/spec-kit-optimize",
|
||||
"homepage": "https://github.com/sakitA/spec-kit-optimize",
|
||||
"documentation": "https://github.com/sakitA/spec-kit-optimize/blob/main/README.md",
|
||||
"changelog": "https://github.com/sakitA/spec-kit-optimize/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.1.0"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 3,
|
||||
"hooks": 0
|
||||
},
|
||||
"tags": [
|
||||
"constitution",
|
||||
"optimization",
|
||||
"token-budget",
|
||||
"governance",
|
||||
"audit"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-04-03T00:00:00Z",
|
||||
"updated_at": "2026-04-03T00:00:00Z"
|
||||
},
|
||||
"plan-review-gate": {
|
||||
"name": "Plan Review Gate",
|
||||
"id": "plan-review-gate",
|
||||
@@ -776,6 +1135,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",
|
||||
@@ -874,8 +1265,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",
|
||||
@@ -908,7 +1299,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",
|
||||
@@ -941,10 +1332,42 @@
|
||||
"created_at": "2026-03-14T00:00:00Z",
|
||||
"updated_at": "2026-03-14T00:00:00Z"
|
||||
},
|
||||
"repoindex":{
|
||||
"refine": {
|
||||
"name": "Spec Refine",
|
||||
"id": "refine",
|
||||
"description": "Update specs in-place, propagate changes to plan and tasks, and diff impact across artifacts.",
|
||||
"author": "Quratulain-bilal",
|
||||
"version": "1.0.0",
|
||||
"download_url": "https://github.com/Quratulain-bilal/spec-kit-refine/archive/refs/tags/v1.0.0.zip",
|
||||
"repository": "https://github.com/Quratulain-bilal/spec-kit-refine",
|
||||
"homepage": "https://github.com/Quratulain-bilal/spec-kit-refine",
|
||||
"documentation": "https://github.com/Quratulain-bilal/spec-kit-refine/blob/main/README.md",
|
||||
"changelog": "https://github.com/Quratulain-bilal/spec-kit-refine/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.4.0"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 4,
|
||||
"hooks": 2
|
||||
},
|
||||
"tags": [
|
||||
"refine",
|
||||
"iterate",
|
||||
"propagation",
|
||||
"workflow",
|
||||
"specifications"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-04-08T00:00:00Z",
|
||||
"updated_at": "2026-04-08T00:00:00Z"
|
||||
},
|
||||
"repoindex": {
|
||||
"name": "Repository Index",
|
||||
"id": "repoindex",
|
||||
"description": "Generate index of your repo for overview, architecuture and module",
|
||||
"description": "Generate index of your repo for overview, architecture and module",
|
||||
"author": "Yiyu Liu",
|
||||
"version": "1.0.0",
|
||||
"download_url": "https://github.com/liuyiyu/spec-kit-repoindex/archive/refs/tags/v1.0.0.zip",
|
||||
@@ -956,7 +1379,7 @@
|
||||
"requires": {
|
||||
"speckit_version": ">=0.1.0",
|
||||
"tools": [
|
||||
{
|
||||
{
|
||||
"name": "no need",
|
||||
"version": ">=1.0.0",
|
||||
"required": false
|
||||
@@ -1045,8 +1468,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",
|
||||
@@ -1072,7 +1495,39 @@
|
||||
"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",
|
||||
"id": "security-review",
|
||||
"description": "Comprehensive security audit of codebases using AI-powered DevSecOps analysis",
|
||||
"author": "DyanGalih",
|
||||
"version": "1.1.1",
|
||||
"download_url": "https://github.com/DyanGalih/spec-kit-security-review/archive/refs/tags/v1.1.1.zip",
|
||||
"repository": "https://github.com/DyanGalih/spec-kit-security-review",
|
||||
"homepage": "https://github.com/DyanGalih/spec-kit-security-review",
|
||||
"documentation": "https://github.com/DyanGalih/spec-kit-security-review/blob/main/README.md",
|
||||
"changelog": "https://github.com/DyanGalih/spec-kit-security-review/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.1.0"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 3,
|
||||
"hooks": 0
|
||||
},
|
||||
"tags": [
|
||||
"security",
|
||||
"devsecops",
|
||||
"audit",
|
||||
"owasp",
|
||||
"compliance"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-04-03T03:24:03Z",
|
||||
"updated_at": "2026-04-03T04:15:00Z"
|
||||
},
|
||||
"ship": {
|
||||
"name": "Ship Release Extension",
|
||||
@@ -1136,6 +1591,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",
|
||||
@@ -1198,6 +1686,36 @@
|
||||
"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",
|
||||
@@ -1273,13 +1791,45 @@
|
||||
"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",
|
||||
"description": "Enforces V-Model paired generation of development specs and test specs with full traceability.",
|
||||
"author": "leocamello",
|
||||
"version": "0.4.0",
|
||||
"download_url": "https://github.com/leocamello/spec-kit-v-model/archive/refs/tags/v0.4.0.zip",
|
||||
"version": "0.5.0",
|
||||
"download_url": "https://github.com/leocamello/spec-kit-v-model/archive/refs/tags/v0.5.0.zip",
|
||||
"repository": "https://github.com/leocamello/spec-kit-v-model",
|
||||
"homepage": "https://github.com/leocamello/spec-kit-v-model",
|
||||
"documentation": "https://github.com/leocamello/spec-kit-v-model/blob/main/README.md",
|
||||
@@ -1289,7 +1839,7 @@
|
||||
"speckit_version": ">=0.1.0"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 9,
|
||||
"commands": 14,
|
||||
"hooks": 1
|
||||
},
|
||||
"tags": [
|
||||
@@ -1303,15 +1853,15 @@
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-02-20T00:00:00Z",
|
||||
"updated_at": "2026-02-22T00:00:00Z"
|
||||
"updated_at": "2026-04-06T00:00:00Z"
|
||||
},
|
||||
"verify": {
|
||||
"name": "Verify Extension",
|
||||
"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",
|
||||
@@ -1335,7 +1885,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",
|
||||
@@ -1367,7 +1917,66 @@
|
||||
"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"
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
{
|
||||
"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/extensions/catalog.json",
|
||||
"extensions": {
|
||||
"selftest": {
|
||||
"name": "Spec Kit Self-Test Utility",
|
||||
"id": "selftest",
|
||||
"git": {
|
||||
"name": "Git Branching Workflow",
|
||||
"id": "git",
|
||||
"version": "1.0.0",
|
||||
"description": "Verifies catalog extensions by programmatically walking through the discovery, installation, and registration lifecycle.",
|
||||
"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/selftest-v1.0.0/selftest.zip",
|
||||
"bundled": true,
|
||||
"tags": [
|
||||
"testing",
|
||||
"core",
|
||||
"utility"
|
||||
"git",
|
||||
"branching",
|
||||
"workflow",
|
||||
"core"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
100
extensions/git/README.md
Normal file
100
extensions/git/README.md
Normal file
@@ -0,0 +1,100 @@
|
||||
# Git Branching Workflow Extension
|
||||
|
||||
Git repository initialization, feature branch creation, numbering (sequential/timestamp), validation, remote detection, and auto-commit for Spec Kit.
|
||||
|
||||
## Overview
|
||||
|
||||
This extension provides Git operations as an optional, self-contained module. It manages:
|
||||
|
||||
- **Repository initialization** with configurable commit messages
|
||||
- **Feature branch creation** with sequential (`001-feature-name`) or timestamp (`20260319-143022-feature-name`) numbering
|
||||
- **Branch validation** to ensure branches follow naming conventions
|
||||
- **Git remote detection** for GitHub integration (e.g., issue creation)
|
||||
- **Auto-commit** after core commands (configurable per-command with custom messages)
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `speckit.git.initialize` | Initialize a Git repository with a configurable commit message |
|
||||
| `speckit.git.feature` | Create a feature branch with sequential or timestamp numbering |
|
||||
| `speckit.git.validate` | Validate current branch follows feature branch naming conventions |
|
||||
| `speckit.git.remote` | Detect Git remote URL for GitHub integration |
|
||||
| `speckit.git.commit` | Auto-commit changes (configurable per-command enable/disable and messages) |
|
||||
|
||||
## Hooks
|
||||
|
||||
| Event | Command | Optional | Description |
|
||||
|-------|---------|----------|-------------|
|
||||
| `before_constitution` | `speckit.git.initialize` | No | Init git repo before constitution |
|
||||
| `before_specify` | `speckit.git.feature` | No | Create feature branch before specification |
|
||||
| `before_clarify` | `speckit.git.commit` | Yes | Commit outstanding changes before clarification |
|
||||
| `before_plan` | `speckit.git.commit` | Yes | Commit outstanding changes before planning |
|
||||
| `before_tasks` | `speckit.git.commit` | Yes | Commit outstanding changes before task generation |
|
||||
| `before_implement` | `speckit.git.commit` | Yes | Commit outstanding changes before implementation |
|
||||
| `before_checklist` | `speckit.git.commit` | Yes | Commit outstanding changes before checklist |
|
||||
| `before_analyze` | `speckit.git.commit` | Yes | Commit outstanding changes before analysis |
|
||||
| `before_taskstoissues` | `speckit.git.commit` | Yes | Commit outstanding changes before issue sync |
|
||||
| `after_constitution` | `speckit.git.commit` | Yes | Auto-commit after constitution update |
|
||||
| `after_specify` | `speckit.git.commit` | Yes | Auto-commit after specification |
|
||||
| `after_clarify` | `speckit.git.commit` | Yes | Auto-commit after clarification |
|
||||
| `after_plan` | `speckit.git.commit` | Yes | Auto-commit after planning |
|
||||
| `after_tasks` | `speckit.git.commit` | Yes | Auto-commit after task generation |
|
||||
| `after_implement` | `speckit.git.commit` | Yes | Auto-commit after implementation |
|
||||
| `after_checklist` | `speckit.git.commit` | Yes | Auto-commit after checklist |
|
||||
| `after_analyze` | `speckit.git.commit` | Yes | Auto-commit after analysis |
|
||||
| `after_taskstoissues` | `speckit.git.commit` | Yes | Auto-commit after issue sync |
|
||||
|
||||
## Configuration
|
||||
|
||||
Configuration is stored in `.specify/extensions/git/git-config.yml`:
|
||||
|
||||
```yaml
|
||||
# Branch numbering strategy: "sequential" or "timestamp"
|
||||
branch_numbering: sequential
|
||||
|
||||
# Custom commit message for git init
|
||||
init_commit_message: "[Spec Kit] Initial commit"
|
||||
|
||||
# Auto-commit per command (all disabled by default)
|
||||
# Example: enable auto-commit after specify
|
||||
auto_commit:
|
||||
default: false
|
||||
after_specify:
|
||||
enabled: true
|
||||
message: "[Spec Kit] Add specification"
|
||||
```
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
# Install the bundled git extension (no network required)
|
||||
specify extension add git
|
||||
```
|
||||
|
||||
## Disabling
|
||||
|
||||
```bash
|
||||
# Disable the git extension (spec creation continues without branching)
|
||||
specify extension disable git
|
||||
|
||||
# Re-enable it
|
||||
specify extension enable git
|
||||
```
|
||||
|
||||
## Graceful Degradation
|
||||
|
||||
When Git is not installed or the directory is not a Git repository:
|
||||
- Spec directories are still created under `specs/`
|
||||
- Branch creation is skipped with a warning
|
||||
- Branch validation is skipped with a warning
|
||||
- Remote detection returns empty results
|
||||
|
||||
## Scripts
|
||||
|
||||
The extension bundles cross-platform scripts:
|
||||
|
||||
- `scripts/bash/create-new-feature.sh` — Bash implementation
|
||||
- `scripts/bash/git-common.sh` — Shared Git utilities (Bash)
|
||||
- `scripts/powershell/create-new-feature.ps1` — PowerShell implementation
|
||||
- `scripts/powershell/git-common.ps1` — Shared Git utilities (PowerShell)
|
||||
48
extensions/git/commands/speckit.git.commit.md
Normal file
48
extensions/git/commands/speckit.git.commit.md
Normal file
@@ -0,0 +1,48 @@
|
||||
---
|
||||
description: "Auto-commit changes after a Spec Kit command completes"
|
||||
---
|
||||
|
||||
# Auto-Commit Changes
|
||||
|
||||
Automatically stage and commit all changes after a Spec Kit command completes.
|
||||
|
||||
## Behavior
|
||||
|
||||
This command is invoked as a hook after (or before) core commands. It:
|
||||
|
||||
1. Determines the event name from the hook context (e.g., if invoked as an `after_specify` hook, the event is `after_specify`; if `before_plan`, the event is `before_plan`)
|
||||
2. Checks `.specify/extensions/git/git-config.yml` for the `auto_commit` section
|
||||
3. Looks up the specific event key to see if auto-commit is enabled
|
||||
4. Falls back to `auto_commit.default` if no event-specific key exists
|
||||
5. Uses the per-command `message` if configured, otherwise a default message
|
||||
6. If enabled and there are uncommitted changes, runs `git add .` + `git commit`
|
||||
|
||||
## Execution
|
||||
|
||||
Determine the event name from the hook that triggered this command, then run the script:
|
||||
|
||||
- **Bash**: `.specify/extensions/git/scripts/bash/auto-commit.sh <event_name>`
|
||||
- **PowerShell**: `.specify/extensions/git/scripts/powershell/auto-commit.ps1 <event_name>`
|
||||
|
||||
Replace `<event_name>` with the actual hook event (e.g., `after_specify`, `before_plan`, `after_implement`).
|
||||
|
||||
## Configuration
|
||||
|
||||
In `.specify/extensions/git/git-config.yml`:
|
||||
|
||||
```yaml
|
||||
auto_commit:
|
||||
default: false # Global toggle — set true to enable for all commands
|
||||
after_specify:
|
||||
enabled: true # Override per-command
|
||||
message: "[Spec Kit] Add specification"
|
||||
after_plan:
|
||||
enabled: false
|
||||
message: "[Spec Kit] Add implementation plan"
|
||||
```
|
||||
|
||||
## Graceful Degradation
|
||||
|
||||
- If Git is not available or the current directory is not a repository: skips with a warning
|
||||
- If no config file exists: skips (disabled by default)
|
||||
- If no changes to commit: skips with a message
|
||||
67
extensions/git/commands/speckit.git.feature.md
Normal file
67
extensions/git/commands/speckit.git.feature.md
Normal file
@@ -0,0 +1,67 @@
|
||||
---
|
||||
description: "Create a feature branch with sequential or timestamp numbering"
|
||||
---
|
||||
|
||||
# Create Feature Branch
|
||||
|
||||
Create and switch to a new git feature branch for the given specification. This command handles **branch creation only** — the spec directory and files are created by the core `/speckit.specify` workflow.
|
||||
|
||||
## User Input
|
||||
|
||||
```text
|
||||
$ARGUMENTS
|
||||
```
|
||||
|
||||
You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Environment Variable Override
|
||||
|
||||
If the user explicitly provided `GIT_BRANCH_NAME` (e.g., via environment variable, argument, or in their request), pass it through to the script by setting the `GIT_BRANCH_NAME` environment variable before invoking the script. When `GIT_BRANCH_NAME` is set:
|
||||
- The script uses the exact value as the branch name, bypassing all prefix/suffix generation
|
||||
- `--short-name`, `--number`, and `--timestamp` flags are ignored
|
||||
- `FEATURE_NUM` is extracted from the name if it starts with a numeric prefix, otherwise set to the full branch name
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Verify Git is available by running `git rev-parse --is-inside-work-tree 2>/dev/null`
|
||||
- If Git is not available, warn the user and skip branch creation
|
||||
|
||||
## Branch Numbering Mode
|
||||
|
||||
Determine the branch numbering strategy by checking configuration in this order:
|
||||
|
||||
1. Check `.specify/extensions/git/git-config.yml` for `branch_numbering` value
|
||||
2. Check `.specify/init-options.json` for `branch_numbering` value (backward compatibility)
|
||||
3. Default to `sequential` if neither exists
|
||||
|
||||
## Execution
|
||||
|
||||
Generate a concise short name (2-4 words) for the branch:
|
||||
- Analyze the feature description and extract the most meaningful keywords
|
||||
- Use action-noun format when possible (e.g., "add-user-auth", "fix-payment-bug")
|
||||
- Preserve technical terms and acronyms (OAuth2, API, JWT, etc.)
|
||||
|
||||
Run the appropriate script based on your platform:
|
||||
|
||||
- **Bash**: `.specify/extensions/git/scripts/bash/create-new-feature.sh --json --short-name "<short-name>" "<feature description>"`
|
||||
- **Bash (timestamp)**: `.specify/extensions/git/scripts/bash/create-new-feature.sh --json --timestamp --short-name "<short-name>" "<feature description>"`
|
||||
- **PowerShell**: `.specify/extensions/git/scripts/powershell/create-new-feature.ps1 -Json -ShortName "<short-name>" "<feature description>"`
|
||||
- **PowerShell (timestamp)**: `.specify/extensions/git/scripts/powershell/create-new-feature.ps1 -Json -Timestamp -ShortName "<short-name>" "<feature description>"`
|
||||
|
||||
**IMPORTANT**:
|
||||
- Do NOT pass `--number` — the script determines the correct next number automatically
|
||||
- Always include the JSON flag (`--json` for Bash, `-Json` for PowerShell) so the output can be parsed reliably
|
||||
- You must only ever run this script once per feature
|
||||
- The JSON output will contain `BRANCH_NAME` and `FEATURE_NUM`
|
||||
|
||||
## Graceful Degradation
|
||||
|
||||
If Git is not installed or the current directory is not a Git repository:
|
||||
- Branch creation is skipped with a warning: `[specify] Warning: Git repository not detected; skipped branch creation`
|
||||
- The script still outputs `BRANCH_NAME` and `FEATURE_NUM` so the caller can reference them
|
||||
|
||||
## Output
|
||||
|
||||
The script outputs JSON with:
|
||||
- `BRANCH_NAME`: The branch name (e.g., `003-user-auth` or `20260319-143022-user-auth`)
|
||||
- `FEATURE_NUM`: The numeric or timestamp prefix used
|
||||
49
extensions/git/commands/speckit.git.initialize.md
Normal file
49
extensions/git/commands/speckit.git.initialize.md
Normal file
@@ -0,0 +1,49 @@
|
||||
---
|
||||
description: "Initialize a Git repository with an initial commit"
|
||||
---
|
||||
|
||||
# Initialize Git Repository
|
||||
|
||||
Initialize a Git repository in the current project directory if one does not already exist.
|
||||
|
||||
## Execution
|
||||
|
||||
Run the appropriate script from the project root:
|
||||
|
||||
- **Bash**: `.specify/extensions/git/scripts/bash/initialize-repo.sh`
|
||||
- **PowerShell**: `.specify/extensions/git/scripts/powershell/initialize-repo.ps1`
|
||||
|
||||
If the extension scripts are not found, fall back to:
|
||||
- **Bash**: `git init && git add . && git commit -m "Initial commit from Specify template"`
|
||||
- **PowerShell**: `git init; git add .; git commit -m "Initial commit from Specify template"`
|
||||
|
||||
The script handles all checks internally:
|
||||
- Skips if Git is not available
|
||||
- Skips if already inside a Git repository
|
||||
- Runs `git init`, `git add .`, and `git commit` with an initial commit message
|
||||
|
||||
## Customization
|
||||
|
||||
Replace the script to add project-specific Git initialization steps:
|
||||
- Custom `.gitignore` templates
|
||||
- Default branch naming (`git config init.defaultBranch`)
|
||||
- Git LFS setup
|
||||
- Git hooks installation
|
||||
- Commit signing configuration
|
||||
- Git Flow initialization
|
||||
|
||||
## Output
|
||||
|
||||
On success:
|
||||
- `✓ Git repository initialized`
|
||||
|
||||
## Graceful Degradation
|
||||
|
||||
If Git is not installed:
|
||||
- Warn the user
|
||||
- Skip repository initialization
|
||||
- The project continues to function without Git (specs can still be created under `specs/`)
|
||||
|
||||
If Git is installed but `git init`, `git add .`, or `git commit` fails:
|
||||
- Surface the error to the user
|
||||
- Stop this command rather than continuing with a partially initialized repository
|
||||
45
extensions/git/commands/speckit.git.remote.md
Normal file
45
extensions/git/commands/speckit.git.remote.md
Normal file
@@ -0,0 +1,45 @@
|
||||
---
|
||||
description: "Detect Git remote URL for GitHub integration"
|
||||
---
|
||||
|
||||
# Detect Git Remote URL
|
||||
|
||||
Detect the Git remote URL for integration with GitHub services (e.g., issue creation).
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Check if Git is available by running `git rev-parse --is-inside-work-tree 2>/dev/null`
|
||||
- If Git is not available, output a warning and return empty:
|
||||
```
|
||||
[specify] Warning: Git repository not detected; cannot determine remote URL
|
||||
```
|
||||
|
||||
## Execution
|
||||
|
||||
Run the following command to get the remote URL:
|
||||
|
||||
```bash
|
||||
git config --get remote.origin.url
|
||||
```
|
||||
|
||||
## Output
|
||||
|
||||
Parse the remote URL and determine:
|
||||
|
||||
1. **Repository owner**: Extract from the URL (e.g., `github` from `https://github.com/github/spec-kit.git`)
|
||||
2. **Repository name**: Extract from the URL (e.g., `spec-kit` from `https://github.com/github/spec-kit.git`)
|
||||
3. **Is GitHub**: Whether the remote points to a GitHub repository
|
||||
|
||||
Supported URL formats:
|
||||
- HTTPS: `https://github.com/<owner>/<repo>.git`
|
||||
- SSH: `git@github.com:<owner>/<repo>.git`
|
||||
|
||||
> [!CAUTION]
|
||||
> ONLY report a GitHub repository if the remote URL actually points to github.com.
|
||||
> Do NOT assume the remote is GitHub if the URL format doesn't match.
|
||||
|
||||
## Graceful Degradation
|
||||
|
||||
If Git is not installed, the directory is not a Git repository, or no remote is configured:
|
||||
- Return an empty result
|
||||
- Do NOT error — other workflows should continue without Git remote information
|
||||
49
extensions/git/commands/speckit.git.validate.md
Normal file
49
extensions/git/commands/speckit.git.validate.md
Normal file
@@ -0,0 +1,49 @@
|
||||
---
|
||||
description: "Validate current branch follows feature branch naming conventions"
|
||||
---
|
||||
|
||||
# Validate Feature Branch
|
||||
|
||||
Validate that the current Git branch follows the expected feature branch naming conventions.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Check if Git is available by running `git rev-parse --is-inside-work-tree 2>/dev/null`
|
||||
- If Git is not available, output a warning and skip validation:
|
||||
```
|
||||
[specify] Warning: Git repository not detected; skipped branch validation
|
||||
```
|
||||
|
||||
## Validation Rules
|
||||
|
||||
Get the current branch name:
|
||||
|
||||
```bash
|
||||
git rev-parse --abbrev-ref HEAD
|
||||
```
|
||||
|
||||
The branch name must match one of these patterns:
|
||||
|
||||
1. **Sequential**: `^[0-9]{3,}-` (e.g., `001-feature-name`, `042-fix-bug`, `1000-big-feature`)
|
||||
2. **Timestamp**: `^[0-9]{8}-[0-9]{6}-` (e.g., `20260319-143022-feature-name`)
|
||||
|
||||
## Execution
|
||||
|
||||
If on a feature branch (matches either pattern):
|
||||
- Output: `✓ On feature branch: <branch-name>`
|
||||
- Check if the corresponding spec directory exists under `specs/`:
|
||||
- For sequential branches, look for `specs/<prefix>-*` where prefix matches the numeric portion
|
||||
- For timestamp branches, look for `specs/<prefix>-*` where prefix matches the `YYYYMMDD-HHMMSS` portion
|
||||
- If spec directory exists: `✓ Spec directory found: <path>`
|
||||
- If spec directory missing: `⚠ No spec directory found for prefix <prefix>`
|
||||
|
||||
If NOT on a feature branch:
|
||||
- Output: `✗ Not on a feature branch. Current branch: <branch-name>`
|
||||
- Output: `Feature branches should be named like: 001-feature-name or 20260319-143022-feature-name`
|
||||
|
||||
## Graceful Degradation
|
||||
|
||||
If Git is not installed or the directory is not a Git repository:
|
||||
- Check the `SPECIFY_FEATURE` environment variable as a fallback
|
||||
- If set, validate that value against the naming patterns
|
||||
- If not set, skip validation with a warning
|
||||
62
extensions/git/config-template.yml
Normal file
62
extensions/git/config-template.yml
Normal file
@@ -0,0 +1,62 @@
|
||||
# Git Branching Workflow Extension Configuration
|
||||
# Copied to .specify/extensions/git/git-config.yml on install
|
||||
|
||||
# Branch numbering strategy: "sequential" (001, 002, ...) or "timestamp" (YYYYMMDD-HHMMSS)
|
||||
branch_numbering: sequential
|
||||
|
||||
# Commit message used by `git commit` during repository initialization
|
||||
init_commit_message: "[Spec Kit] Initial commit"
|
||||
|
||||
# Auto-commit before/after core commands.
|
||||
# Set "default" to enable for all commands, then override per-command.
|
||||
# Each key can be true/false. Message is customizable per-command.
|
||||
auto_commit:
|
||||
default: false
|
||||
before_clarify:
|
||||
enabled: false
|
||||
message: "[Spec Kit] Save progress before clarification"
|
||||
before_plan:
|
||||
enabled: false
|
||||
message: "[Spec Kit] Save progress before planning"
|
||||
before_tasks:
|
||||
enabled: false
|
||||
message: "[Spec Kit] Save progress before task generation"
|
||||
before_implement:
|
||||
enabled: false
|
||||
message: "[Spec Kit] Save progress before implementation"
|
||||
before_checklist:
|
||||
enabled: false
|
||||
message: "[Spec Kit] Save progress before checklist"
|
||||
before_analyze:
|
||||
enabled: false
|
||||
message: "[Spec Kit] Save progress before analysis"
|
||||
before_taskstoissues:
|
||||
enabled: false
|
||||
message: "[Spec Kit] Save progress before issue sync"
|
||||
after_constitution:
|
||||
enabled: false
|
||||
message: "[Spec Kit] Add project constitution"
|
||||
after_specify:
|
||||
enabled: false
|
||||
message: "[Spec Kit] Add specification"
|
||||
after_clarify:
|
||||
enabled: false
|
||||
message: "[Spec Kit] Clarify specification"
|
||||
after_plan:
|
||||
enabled: false
|
||||
message: "[Spec Kit] Add implementation plan"
|
||||
after_tasks:
|
||||
enabled: false
|
||||
message: "[Spec Kit] Add tasks"
|
||||
after_implement:
|
||||
enabled: false
|
||||
message: "[Spec Kit] Implementation progress"
|
||||
after_checklist:
|
||||
enabled: false
|
||||
message: "[Spec Kit] Add checklist"
|
||||
after_analyze:
|
||||
enabled: false
|
||||
message: "[Spec Kit] Add analysis report"
|
||||
after_taskstoissues:
|
||||
enabled: false
|
||||
message: "[Spec Kit] Sync tasks to issues"
|
||||
140
extensions/git/extension.yml
Normal file
140
extensions/git/extension.yml
Normal file
@@ -0,0 +1,140 @@
|
||||
schema_version: "1.0"
|
||||
|
||||
extension:
|
||||
id: git
|
||||
name: "Git Branching Workflow"
|
||||
version: "1.0.0"
|
||||
description: "Feature branch creation, numbering (sequential/timestamp), validation, and Git remote detection"
|
||||
author: spec-kit-core
|
||||
repository: https://github.com/github/spec-kit
|
||||
license: MIT
|
||||
|
||||
requires:
|
||||
speckit_version: ">=0.2.0"
|
||||
tools:
|
||||
- name: git
|
||||
required: false
|
||||
|
||||
provides:
|
||||
commands:
|
||||
- name: speckit.git.feature
|
||||
file: commands/speckit.git.feature.md
|
||||
description: "Create a feature branch with sequential or timestamp numbering"
|
||||
- name: speckit.git.validate
|
||||
file: commands/speckit.git.validate.md
|
||||
description: "Validate current branch follows feature branch naming conventions"
|
||||
- name: speckit.git.remote
|
||||
file: commands/speckit.git.remote.md
|
||||
description: "Detect Git remote URL for GitHub integration"
|
||||
- name: speckit.git.initialize
|
||||
file: commands/speckit.git.initialize.md
|
||||
description: "Initialize a Git repository with an initial commit"
|
||||
- name: speckit.git.commit
|
||||
file: commands/speckit.git.commit.md
|
||||
description: "Auto-commit changes after a Spec Kit command completes"
|
||||
|
||||
config:
|
||||
- name: "git-config.yml"
|
||||
template: "config-template.yml"
|
||||
description: "Git branching configuration"
|
||||
required: false
|
||||
|
||||
hooks:
|
||||
before_constitution:
|
||||
command: speckit.git.initialize
|
||||
optional: false
|
||||
description: "Initialize Git repository before constitution setup"
|
||||
before_specify:
|
||||
command: speckit.git.feature
|
||||
optional: false
|
||||
description: "Create feature branch before specification"
|
||||
before_clarify:
|
||||
command: speckit.git.commit
|
||||
optional: true
|
||||
prompt: "Commit outstanding changes before clarification?"
|
||||
description: "Auto-commit before spec clarification"
|
||||
before_plan:
|
||||
command: speckit.git.commit
|
||||
optional: true
|
||||
prompt: "Commit outstanding changes before planning?"
|
||||
description: "Auto-commit before implementation planning"
|
||||
before_tasks:
|
||||
command: speckit.git.commit
|
||||
optional: true
|
||||
prompt: "Commit outstanding changes before task generation?"
|
||||
description: "Auto-commit before task generation"
|
||||
before_implement:
|
||||
command: speckit.git.commit
|
||||
optional: true
|
||||
prompt: "Commit outstanding changes before implementation?"
|
||||
description: "Auto-commit before implementation"
|
||||
before_checklist:
|
||||
command: speckit.git.commit
|
||||
optional: true
|
||||
prompt: "Commit outstanding changes before checklist?"
|
||||
description: "Auto-commit before checklist generation"
|
||||
before_analyze:
|
||||
command: speckit.git.commit
|
||||
optional: true
|
||||
prompt: "Commit outstanding changes before analysis?"
|
||||
description: "Auto-commit before analysis"
|
||||
before_taskstoissues:
|
||||
command: speckit.git.commit
|
||||
optional: true
|
||||
prompt: "Commit outstanding changes before issue sync?"
|
||||
description: "Auto-commit before tasks-to-issues conversion"
|
||||
after_constitution:
|
||||
command: speckit.git.commit
|
||||
optional: true
|
||||
prompt: "Commit constitution changes?"
|
||||
description: "Auto-commit after constitution update"
|
||||
after_specify:
|
||||
command: speckit.git.commit
|
||||
optional: true
|
||||
prompt: "Commit specification changes?"
|
||||
description: "Auto-commit after specification"
|
||||
after_clarify:
|
||||
command: speckit.git.commit
|
||||
optional: true
|
||||
prompt: "Commit clarification changes?"
|
||||
description: "Auto-commit after spec clarification"
|
||||
after_plan:
|
||||
command: speckit.git.commit
|
||||
optional: true
|
||||
prompt: "Commit plan changes?"
|
||||
description: "Auto-commit after implementation planning"
|
||||
after_tasks:
|
||||
command: speckit.git.commit
|
||||
optional: true
|
||||
prompt: "Commit task changes?"
|
||||
description: "Auto-commit after task generation"
|
||||
after_implement:
|
||||
command: speckit.git.commit
|
||||
optional: true
|
||||
prompt: "Commit implementation changes?"
|
||||
description: "Auto-commit after implementation"
|
||||
after_checklist:
|
||||
command: speckit.git.commit
|
||||
optional: true
|
||||
prompt: "Commit checklist changes?"
|
||||
description: "Auto-commit after checklist generation"
|
||||
after_analyze:
|
||||
command: speckit.git.commit
|
||||
optional: true
|
||||
prompt: "Commit analysis results?"
|
||||
description: "Auto-commit after analysis"
|
||||
after_taskstoissues:
|
||||
command: speckit.git.commit
|
||||
optional: true
|
||||
prompt: "Commit after syncing issues?"
|
||||
description: "Auto-commit after tasks-to-issues conversion"
|
||||
|
||||
tags:
|
||||
- "git"
|
||||
- "branching"
|
||||
- "workflow"
|
||||
|
||||
config:
|
||||
defaults:
|
||||
branch_numbering: sequential
|
||||
init_commit_message: "[Spec Kit] Initial commit"
|
||||
62
extensions/git/git-config.yml
Normal file
62
extensions/git/git-config.yml
Normal file
@@ -0,0 +1,62 @@
|
||||
# Git Branching Workflow Extension Configuration
|
||||
# Copied to .specify/extensions/git/git-config.yml on install
|
||||
|
||||
# Branch numbering strategy: "sequential" (001, 002, ...) or "timestamp" (YYYYMMDD-HHMMSS)
|
||||
branch_numbering: sequential
|
||||
|
||||
# Commit message used by `git commit` during repository initialization
|
||||
init_commit_message: "[Spec Kit] Initial commit"
|
||||
|
||||
# Auto-commit before/after core commands.
|
||||
# Set "default" to enable for all commands, then override per-command.
|
||||
# Each key can be true/false. Message is customizable per-command.
|
||||
auto_commit:
|
||||
default: false
|
||||
before_clarify:
|
||||
enabled: false
|
||||
message: "[Spec Kit] Save progress before clarification"
|
||||
before_plan:
|
||||
enabled: false
|
||||
message: "[Spec Kit] Save progress before planning"
|
||||
before_tasks:
|
||||
enabled: false
|
||||
message: "[Spec Kit] Save progress before task generation"
|
||||
before_implement:
|
||||
enabled: false
|
||||
message: "[Spec Kit] Save progress before implementation"
|
||||
before_checklist:
|
||||
enabled: false
|
||||
message: "[Spec Kit] Save progress before checklist"
|
||||
before_analyze:
|
||||
enabled: false
|
||||
message: "[Spec Kit] Save progress before analysis"
|
||||
before_taskstoissues:
|
||||
enabled: false
|
||||
message: "[Spec Kit] Save progress before issue sync"
|
||||
after_constitution:
|
||||
enabled: false
|
||||
message: "[Spec Kit] Add project constitution"
|
||||
after_specify:
|
||||
enabled: false
|
||||
message: "[Spec Kit] Add specification"
|
||||
after_clarify:
|
||||
enabled: false
|
||||
message: "[Spec Kit] Clarify specification"
|
||||
after_plan:
|
||||
enabled: false
|
||||
message: "[Spec Kit] Add implementation plan"
|
||||
after_tasks:
|
||||
enabled: false
|
||||
message: "[Spec Kit] Add tasks"
|
||||
after_implement:
|
||||
enabled: false
|
||||
message: "[Spec Kit] Implementation progress"
|
||||
after_checklist:
|
||||
enabled: false
|
||||
message: "[Spec Kit] Add checklist"
|
||||
after_analyze:
|
||||
enabled: false
|
||||
message: "[Spec Kit] Add analysis report"
|
||||
after_taskstoissues:
|
||||
enabled: false
|
||||
message: "[Spec Kit] Sync tasks to issues"
|
||||
140
extensions/git/scripts/bash/auto-commit.sh
Executable file
140
extensions/git/scripts/bash/auto-commit.sh
Executable file
@@ -0,0 +1,140 @@
|
||||
#!/usr/bin/env bash
|
||||
# Git extension: auto-commit.sh
|
||||
# Automatically commit changes after a Spec Kit command completes.
|
||||
# Checks per-command config keys in git-config.yml before committing.
|
||||
#
|
||||
# Usage: auto-commit.sh <event_name>
|
||||
# e.g.: auto-commit.sh after_specify
|
||||
|
||||
set -e
|
||||
|
||||
EVENT_NAME="${1:-}"
|
||||
if [ -z "$EVENT_NAME" ]; then
|
||||
echo "Usage: $0 <event_name>" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
_find_project_root() {
|
||||
local dir="$1"
|
||||
while [ "$dir" != "/" ]; do
|
||||
if [ -d "$dir/.specify" ] || [ -d "$dir/.git" ]; then
|
||||
echo "$dir"
|
||||
return 0
|
||||
fi
|
||||
dir="$(dirname "$dir")"
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
REPO_ROOT=$(_find_project_root "$SCRIPT_DIR") || REPO_ROOT="$(pwd)"
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
# Check if git is available
|
||||
if ! command -v git >/dev/null 2>&1; then
|
||||
echo "[specify] Warning: Git not found; skipped auto-commit" >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
||||
echo "[specify] Warning: Not a Git repository; skipped auto-commit" >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Read per-command config from git-config.yml
|
||||
_config_file="$REPO_ROOT/.specify/extensions/git/git-config.yml"
|
||||
_enabled=false
|
||||
_commit_msg=""
|
||||
|
||||
if [ -f "$_config_file" ]; then
|
||||
# Parse the auto_commit section for this event.
|
||||
# Look for auto_commit.<event_name>.enabled and .message
|
||||
# Also check auto_commit.default as fallback.
|
||||
_in_auto_commit=false
|
||||
_in_event=false
|
||||
_default_enabled=false
|
||||
|
||||
while IFS= read -r _line; do
|
||||
# Detect auto_commit: section
|
||||
if echo "$_line" | grep -q '^auto_commit:'; then
|
||||
_in_auto_commit=true
|
||||
_in_event=false
|
||||
continue
|
||||
fi
|
||||
|
||||
# Exit auto_commit section on next top-level key
|
||||
if $_in_auto_commit && echo "$_line" | grep -Eq '^[a-z]'; then
|
||||
break
|
||||
fi
|
||||
|
||||
if $_in_auto_commit; then
|
||||
# Check default key
|
||||
if echo "$_line" | grep -Eq "^[[:space:]]+default:[[:space:]]"; then
|
||||
_val=$(echo "$_line" | sed 's/^[^:]*:[[:space:]]*//' | tr -d '[:space:]' | tr '[:upper:]' '[:lower:]')
|
||||
[ "$_val" = "true" ] && _default_enabled=true
|
||||
fi
|
||||
|
||||
# Detect our event subsection
|
||||
if echo "$_line" | grep -Eq "^[[:space:]]+${EVENT_NAME}:"; then
|
||||
_in_event=true
|
||||
continue
|
||||
fi
|
||||
|
||||
# Inside our event subsection
|
||||
if $_in_event; then
|
||||
# Exit on next sibling key (same indent level as event name)
|
||||
if echo "$_line" | grep -Eq '^[[:space:]]{2}[a-z]' && ! echo "$_line" | grep -Eq '^[[:space:]]{4}'; then
|
||||
_in_event=false
|
||||
continue
|
||||
fi
|
||||
if echo "$_line" | grep -Eq '[[:space:]]+enabled:'; then
|
||||
_val=$(echo "$_line" | sed 's/^[^:]*:[[:space:]]*//' | tr -d '[:space:]' | tr '[:upper:]' '[:lower:]')
|
||||
[ "$_val" = "true" ] && _enabled=true
|
||||
[ "$_val" = "false" ] && _enabled=false
|
||||
fi
|
||||
if echo "$_line" | grep -Eq '[[:space:]]+message:'; then
|
||||
_commit_msg=$(echo "$_line" | sed 's/^[^:]*:[[:space:]]*//' | sed 's/^["'\'']//' | sed 's/["'\'']*$//')
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
done < "$_config_file"
|
||||
|
||||
# If event-specific key not found, use default
|
||||
if [ "$_enabled" = "false" ] && [ "$_default_enabled" = "true" ]; then
|
||||
# Only use default if the event wasn't explicitly set to false
|
||||
# Check if event section existed at all
|
||||
if ! grep -q "^[[:space:]]*${EVENT_NAME}:" "$_config_file" 2>/dev/null; then
|
||||
_enabled=true
|
||||
fi
|
||||
fi
|
||||
else
|
||||
# No config file — auto-commit disabled by default
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$_enabled" != "true" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Check if there are changes to commit
|
||||
if git diff --quiet HEAD 2>/dev/null && git diff --cached --quiet 2>/dev/null && [ -z "$(git ls-files --others --exclude-standard 2>/dev/null)" ]; then
|
||||
echo "[specify] No changes to commit after $EVENT_NAME" >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Derive a human-readable command name from the event
|
||||
# e.g., after_specify -> specify, before_plan -> plan
|
||||
_command_name=$(echo "$EVENT_NAME" | sed 's/^after_//' | sed 's/^before_//')
|
||||
_phase=$(echo "$EVENT_NAME" | grep -q '^before_' && echo 'before' || echo 'after')
|
||||
|
||||
# Use custom message if configured, otherwise default
|
||||
if [ -z "$_commit_msg" ]; then
|
||||
_commit_msg="[Spec Kit] Auto-commit ${_phase} ${_command_name}"
|
||||
fi
|
||||
|
||||
# Stage and commit
|
||||
_git_out=$(git add . 2>&1) || { echo "[specify] Error: git add failed: $_git_out" >&2; exit 1; }
|
||||
_git_out=$(git commit -q -m "$_commit_msg" 2>&1) || { echo "[specify] Error: git commit failed: $_git_out" >&2; exit 1; }
|
||||
|
||||
echo "✓ Changes committed ${_phase} ${_command_name}" >&2
|
||||
453
extensions/git/scripts/bash/create-new-feature.sh
Executable file
453
extensions/git/scripts/bash/create-new-feature.sh
Executable file
@@ -0,0 +1,453 @@
|
||||
#!/usr/bin/env bash
|
||||
# Git extension: create-new-feature.sh
|
||||
# Adapted from core scripts/bash/create-new-feature.sh for extension layout.
|
||||
# Sources common.sh from the project's installed scripts, falling back to
|
||||
# git-common.sh for minimal git helpers.
|
||||
|
||||
set -e
|
||||
|
||||
JSON_MODE=false
|
||||
DRY_RUN=false
|
||||
ALLOW_EXISTING=false
|
||||
SHORT_NAME=""
|
||||
BRANCH_NUMBER=""
|
||||
USE_TIMESTAMP=false
|
||||
ARGS=()
|
||||
i=1
|
||||
while [ $i -le $# ]; do
|
||||
arg="${!i}"
|
||||
case "$arg" in
|
||||
--json)
|
||||
JSON_MODE=true
|
||||
;;
|
||||
--dry-run)
|
||||
DRY_RUN=true
|
||||
;;
|
||||
--allow-existing-branch)
|
||||
ALLOW_EXISTING=true
|
||||
;;
|
||||
--short-name)
|
||||
if [ $((i + 1)) -gt $# ]; then
|
||||
echo 'Error: --short-name requires a value' >&2
|
||||
exit 1
|
||||
fi
|
||||
i=$((i + 1))
|
||||
next_arg="${!i}"
|
||||
if [[ "$next_arg" == --* ]]; then
|
||||
echo 'Error: --short-name requires a value' >&2
|
||||
exit 1
|
||||
fi
|
||||
SHORT_NAME="$next_arg"
|
||||
;;
|
||||
--number)
|
||||
if [ $((i + 1)) -gt $# ]; then
|
||||
echo 'Error: --number requires a value' >&2
|
||||
exit 1
|
||||
fi
|
||||
i=$((i + 1))
|
||||
next_arg="${!i}"
|
||||
if [[ "$next_arg" == --* ]]; then
|
||||
echo 'Error: --number requires a value' >&2
|
||||
exit 1
|
||||
fi
|
||||
BRANCH_NUMBER="$next_arg"
|
||||
if [[ ! "$BRANCH_NUMBER" =~ ^[0-9]+$ ]]; then
|
||||
echo 'Error: --number must be a non-negative integer' >&2
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
--timestamp)
|
||||
USE_TIMESTAMP=true
|
||||
;;
|
||||
--help|-h)
|
||||
echo "Usage: $0 [--json] [--dry-run] [--allow-existing-branch] [--short-name <name>] [--number N] [--timestamp] <feature_description>"
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " --json Output in JSON format"
|
||||
echo " --dry-run Compute branch name without creating the branch"
|
||||
echo " --allow-existing-branch Switch to branch if it already exists instead of failing"
|
||||
echo " --short-name <name> Provide a custom short name (2-4 words) for the branch"
|
||||
echo " --number N Specify branch number manually (overrides auto-detection)"
|
||||
echo " --timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering"
|
||||
echo " --help, -h Show this help message"
|
||||
echo ""
|
||||
echo "Environment variables:"
|
||||
echo " GIT_BRANCH_NAME Use this exact branch name, bypassing all prefix/suffix generation"
|
||||
echo ""
|
||||
echo "Examples:"
|
||||
echo " $0 'Add user authentication system' --short-name 'user-auth'"
|
||||
echo " $0 'Implement OAuth2 integration for API' --number 5"
|
||||
echo " $0 --timestamp --short-name 'user-auth' 'Add user authentication'"
|
||||
echo " GIT_BRANCH_NAME=my-branch $0 'feature description'"
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
ARGS+=("$arg")
|
||||
;;
|
||||
esac
|
||||
i=$((i + 1))
|
||||
done
|
||||
|
||||
FEATURE_DESCRIPTION="${ARGS[*]}"
|
||||
if [ -z "$FEATURE_DESCRIPTION" ]; then
|
||||
echo "Usage: $0 [--json] [--dry-run] [--allow-existing-branch] [--short-name <name>] [--number N] [--timestamp] <feature_description>" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Trim whitespace and validate description is not empty
|
||||
FEATURE_DESCRIPTION=$(echo "$FEATURE_DESCRIPTION" | xargs)
|
||||
if [ -z "$FEATURE_DESCRIPTION" ]; then
|
||||
echo "Error: Feature description cannot be empty or contain only whitespace" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Function to get highest number from specs directory
|
||||
get_highest_from_specs() {
|
||||
local specs_dir="$1"
|
||||
local highest=0
|
||||
|
||||
if [ -d "$specs_dir" ]; then
|
||||
for dir in "$specs_dir"/*; do
|
||||
[ -d "$dir" ] || continue
|
||||
dirname=$(basename "$dir")
|
||||
# Match sequential prefixes (>=3 digits), but skip timestamp dirs.
|
||||
if echo "$dirname" | grep -Eq '^[0-9]{3,}-' && ! echo "$dirname" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then
|
||||
number=$(echo "$dirname" | grep -Eo '^[0-9]+')
|
||||
number=$((10#$number))
|
||||
if [ "$number" -gt "$highest" ]; then
|
||||
highest=$number
|
||||
fi
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
echo "$highest"
|
||||
}
|
||||
|
||||
# Function to get highest number from git branches
|
||||
get_highest_from_branches() {
|
||||
git branch -a 2>/dev/null | sed 's/^[* ]*//; s|^remotes/[^/]*/||' | _extract_highest_number
|
||||
}
|
||||
|
||||
# Extract the highest sequential feature number from a list of ref names (one per line).
|
||||
_extract_highest_number() {
|
||||
local highest=0
|
||||
while IFS= read -r name; do
|
||||
[ -z "$name" ] && continue
|
||||
if echo "$name" | grep -Eq '^[0-9]{3,}-' && ! echo "$name" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then
|
||||
number=$(echo "$name" | grep -Eo '^[0-9]+' || echo "0")
|
||||
number=$((10#$number))
|
||||
if [ "$number" -gt "$highest" ]; then
|
||||
highest=$number
|
||||
fi
|
||||
fi
|
||||
done
|
||||
echo "$highest"
|
||||
}
|
||||
|
||||
# Function to get highest number from remote branches without fetching (side-effect-free)
|
||||
get_highest_from_remote_refs() {
|
||||
local highest=0
|
||||
|
||||
for remote in $(git remote 2>/dev/null); do
|
||||
local remote_highest
|
||||
remote_highest=$(GIT_TERMINAL_PROMPT=0 git ls-remote --heads "$remote" 2>/dev/null | sed 's|.*refs/heads/||' | _extract_highest_number)
|
||||
if [ "$remote_highest" -gt "$highest" ]; then
|
||||
highest=$remote_highest
|
||||
fi
|
||||
done
|
||||
|
||||
echo "$highest"
|
||||
}
|
||||
|
||||
# Function to check existing branches and return next available number.
|
||||
check_existing_branches() {
|
||||
local specs_dir="$1"
|
||||
local skip_fetch="${2:-false}"
|
||||
|
||||
if [ "$skip_fetch" = true ]; then
|
||||
local highest_remote=$(get_highest_from_remote_refs)
|
||||
local highest_branch=$(get_highest_from_branches)
|
||||
if [ "$highest_remote" -gt "$highest_branch" ]; then
|
||||
highest_branch=$highest_remote
|
||||
fi
|
||||
else
|
||||
git fetch --all --prune >/dev/null 2>&1 || true
|
||||
local highest_branch=$(get_highest_from_branches)
|
||||
fi
|
||||
|
||||
local highest_spec=$(get_highest_from_specs "$specs_dir")
|
||||
|
||||
local max_num=$highest_branch
|
||||
if [ "$highest_spec" -gt "$max_num" ]; then
|
||||
max_num=$highest_spec
|
||||
fi
|
||||
|
||||
echo $((max_num + 1))
|
||||
}
|
||||
|
||||
# Function to clean and format a branch name
|
||||
clean_branch_name() {
|
||||
local name="$1"
|
||||
echo "$name" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/-\+/-/g' | sed 's/^-//' | sed 's/-$//'
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Source common.sh for resolve_template, json_escape, get_repo_root, has_git.
|
||||
#
|
||||
# Search locations in priority order:
|
||||
# 1. .specify/scripts/bash/common.sh under the project root (installed project)
|
||||
# 2. scripts/bash/common.sh under the project root (source checkout fallback)
|
||||
# 3. git-common.sh next to this script (minimal fallback — lacks resolve_template)
|
||||
# ---------------------------------------------------------------------------
|
||||
SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
# Find project root by walking up from the script location
|
||||
_find_project_root() {
|
||||
local dir="$1"
|
||||
while [ "$dir" != "/" ]; do
|
||||
if [ -d "$dir/.specify" ] || [ -d "$dir/.git" ]; then
|
||||
echo "$dir"
|
||||
return 0
|
||||
fi
|
||||
dir="$(dirname "$dir")"
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
_common_loaded=false
|
||||
_PROJECT_ROOT=$(_find_project_root "$SCRIPT_DIR") || true
|
||||
|
||||
if [ -n "$_PROJECT_ROOT" ] && [ -f "$_PROJECT_ROOT/.specify/scripts/bash/common.sh" ]; then
|
||||
source "$_PROJECT_ROOT/.specify/scripts/bash/common.sh"
|
||||
_common_loaded=true
|
||||
elif [ -n "$_PROJECT_ROOT" ] && [ -f "$_PROJECT_ROOT/scripts/bash/common.sh" ]; then
|
||||
source "$_PROJECT_ROOT/scripts/bash/common.sh"
|
||||
_common_loaded=true
|
||||
elif [ -f "$SCRIPT_DIR/git-common.sh" ]; then
|
||||
source "$SCRIPT_DIR/git-common.sh"
|
||||
_common_loaded=true
|
||||
fi
|
||||
|
||||
if [ "$_common_loaded" != "true" ]; then
|
||||
echo "Error: Could not locate common.sh or git-common.sh. Please ensure the Specify core scripts are installed." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Resolve repository root
|
||||
if type get_repo_root >/dev/null 2>&1; then
|
||||
REPO_ROOT=$(get_repo_root)
|
||||
elif git rev-parse --show-toplevel >/dev/null 2>&1; then
|
||||
REPO_ROOT=$(git rev-parse --show-toplevel)
|
||||
elif [ -n "$_PROJECT_ROOT" ]; then
|
||||
REPO_ROOT="$_PROJECT_ROOT"
|
||||
else
|
||||
echo "Error: Could not determine repository root." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if git is available at this repo root
|
||||
if type has_git >/dev/null 2>&1; then
|
||||
if has_git "$REPO_ROOT"; then
|
||||
HAS_GIT=true
|
||||
else
|
||||
HAS_GIT=false
|
||||
fi
|
||||
elif git -C "$REPO_ROOT" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
||||
HAS_GIT=true
|
||||
else
|
||||
HAS_GIT=false
|
||||
fi
|
||||
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
SPECS_DIR="$REPO_ROOT/specs"
|
||||
|
||||
# Function to generate branch name with stop word filtering
|
||||
generate_branch_name() {
|
||||
local description="$1"
|
||||
|
||||
local stop_words="^(i|a|an|the|to|for|of|in|on|at|by|with|from|is|are|was|were|be|been|being|have|has|had|do|does|did|will|would|should|could|can|may|might|must|shall|this|that|these|those|my|your|our|their|want|need|add|get|set)$"
|
||||
|
||||
local clean_name=$(echo "$description" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/ /g')
|
||||
|
||||
local meaningful_words=()
|
||||
for word in $clean_name; do
|
||||
[ -z "$word" ] && continue
|
||||
if ! echo "$word" | grep -qiE "$stop_words"; then
|
||||
if [ ${#word} -ge 3 ]; then
|
||||
meaningful_words+=("$word")
|
||||
elif echo "$description" | grep -qw -- "${word^^}"; then
|
||||
meaningful_words+=("$word")
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
if [ ${#meaningful_words[@]} -gt 0 ]; then
|
||||
local max_words=3
|
||||
if [ ${#meaningful_words[@]} -eq 4 ]; then max_words=4; fi
|
||||
|
||||
local result=""
|
||||
local count=0
|
||||
for word in "${meaningful_words[@]}"; do
|
||||
if [ $count -ge $max_words ]; then break; fi
|
||||
if [ -n "$result" ]; then result="$result-"; fi
|
||||
result="$result$word"
|
||||
count=$((count + 1))
|
||||
done
|
||||
echo "$result"
|
||||
else
|
||||
local cleaned=$(clean_branch_name "$description")
|
||||
echo "$cleaned" | tr '-' '\n' | grep -v '^$' | head -3 | tr '\n' '-' | sed 's/-$//'
|
||||
fi
|
||||
}
|
||||
|
||||
# Check for GIT_BRANCH_NAME env var override (exact branch name, no prefix/suffix)
|
||||
if [ -n "${GIT_BRANCH_NAME:-}" ]; then
|
||||
BRANCH_NAME="$GIT_BRANCH_NAME"
|
||||
# Extract FEATURE_NUM from the branch name if it starts with a numeric prefix
|
||||
# Check timestamp pattern first (YYYYMMDD-HHMMSS-) since it also matches the simpler ^[0-9]+ pattern
|
||||
if echo "$BRANCH_NAME" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then
|
||||
FEATURE_NUM=$(echo "$BRANCH_NAME" | grep -Eo '^[0-9]{8}-[0-9]{6}')
|
||||
BRANCH_SUFFIX="${BRANCH_NAME#${FEATURE_NUM}-}"
|
||||
elif echo "$BRANCH_NAME" | grep -Eq '^[0-9]+-'; then
|
||||
FEATURE_NUM=$(echo "$BRANCH_NAME" | grep -Eo '^[0-9]+')
|
||||
BRANCH_SUFFIX="${BRANCH_NAME#${FEATURE_NUM}-}"
|
||||
else
|
||||
FEATURE_NUM="$BRANCH_NAME"
|
||||
BRANCH_SUFFIX="$BRANCH_NAME"
|
||||
fi
|
||||
else
|
||||
# Generate branch name
|
||||
if [ -n "$SHORT_NAME" ]; then
|
||||
BRANCH_SUFFIX=$(clean_branch_name "$SHORT_NAME")
|
||||
else
|
||||
BRANCH_SUFFIX=$(generate_branch_name "$FEATURE_DESCRIPTION")
|
||||
fi
|
||||
|
||||
# Warn if --number and --timestamp are both specified
|
||||
if [ "$USE_TIMESTAMP" = true ] && [ -n "$BRANCH_NUMBER" ]; then
|
||||
>&2 echo "[specify] Warning: --number is ignored when --timestamp is used"
|
||||
BRANCH_NUMBER=""
|
||||
fi
|
||||
|
||||
# Determine branch prefix
|
||||
if [ "$USE_TIMESTAMP" = true ]; then
|
||||
FEATURE_NUM=$(date +%Y%m%d-%H%M%S)
|
||||
BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}"
|
||||
else
|
||||
if [ -z "$BRANCH_NUMBER" ]; then
|
||||
if [ "$DRY_RUN" = true ] && [ "$HAS_GIT" = true ]; then
|
||||
BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR" true)
|
||||
elif [ "$DRY_RUN" = true ]; then
|
||||
HIGHEST=$(get_highest_from_specs "$SPECS_DIR")
|
||||
BRANCH_NUMBER=$((HIGHEST + 1))
|
||||
elif [ "$HAS_GIT" = true ]; then
|
||||
BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR")
|
||||
else
|
||||
HIGHEST=$(get_highest_from_specs "$SPECS_DIR")
|
||||
BRANCH_NUMBER=$((HIGHEST + 1))
|
||||
fi
|
||||
fi
|
||||
|
||||
FEATURE_NUM=$(printf "%03d" "$((10#$BRANCH_NUMBER))")
|
||||
BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}"
|
||||
fi
|
||||
fi
|
||||
|
||||
# GitHub enforces a 244-byte limit on branch names
|
||||
MAX_BRANCH_LENGTH=244
|
||||
_byte_length() { printf '%s' "$1" | LC_ALL=C wc -c | tr -d ' '; }
|
||||
BRANCH_BYTE_LEN=$(_byte_length "$BRANCH_NAME")
|
||||
if [ -n "${GIT_BRANCH_NAME:-}" ] && [ "$BRANCH_BYTE_LEN" -gt $MAX_BRANCH_LENGTH ]; then
|
||||
>&2 echo "Error: GIT_BRANCH_NAME must be 244 bytes or fewer in UTF-8. Provided value is ${BRANCH_BYTE_LEN} bytes."
|
||||
exit 1
|
||||
elif [ "$BRANCH_BYTE_LEN" -gt $MAX_BRANCH_LENGTH ]; then
|
||||
PREFIX_LENGTH=$(( ${#FEATURE_NUM} + 1 ))
|
||||
MAX_SUFFIX_LENGTH=$((MAX_BRANCH_LENGTH - PREFIX_LENGTH))
|
||||
|
||||
TRUNCATED_SUFFIX=$(echo "$BRANCH_SUFFIX" | cut -c1-$MAX_SUFFIX_LENGTH)
|
||||
TRUNCATED_SUFFIX=$(echo "$TRUNCATED_SUFFIX" | sed 's/-$//')
|
||||
|
||||
ORIGINAL_BRANCH_NAME="$BRANCH_NAME"
|
||||
BRANCH_NAME="${FEATURE_NUM}-${TRUNCATED_SUFFIX}"
|
||||
|
||||
>&2 echo "[specify] Warning: Branch name exceeded GitHub's 244-byte limit"
|
||||
>&2 echo "[specify] Original: $ORIGINAL_BRANCH_NAME (${#ORIGINAL_BRANCH_NAME} bytes)"
|
||||
>&2 echo "[specify] Truncated to: $BRANCH_NAME (${#BRANCH_NAME} bytes)"
|
||||
fi
|
||||
|
||||
if [ "$DRY_RUN" != true ]; then
|
||||
if [ "$HAS_GIT" = true ]; then
|
||||
branch_create_error=""
|
||||
if ! branch_create_error=$(git checkout -q -b "$BRANCH_NAME" 2>&1); then
|
||||
current_branch="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || true)"
|
||||
if git branch --list "$BRANCH_NAME" | grep -q .; then
|
||||
if [ "$ALLOW_EXISTING" = true ]; then
|
||||
if [ "$current_branch" = "$BRANCH_NAME" ]; then
|
||||
:
|
||||
elif ! switch_branch_error=$(git checkout -q "$BRANCH_NAME" 2>&1); then
|
||||
>&2 echo "Error: Failed to switch to existing branch '$BRANCH_NAME'. Please resolve any local changes or conflicts and try again."
|
||||
if [ -n "$switch_branch_error" ]; then
|
||||
>&2 printf '%s\n' "$switch_branch_error"
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
elif [ "$USE_TIMESTAMP" = true ]; then
|
||||
>&2 echo "Error: Branch '$BRANCH_NAME' already exists. Rerun to get a new timestamp or use a different --short-name."
|
||||
exit 1
|
||||
else
|
||||
>&2 echo "Error: Branch '$BRANCH_NAME' already exists. Please use a different feature name or specify a different number with --number."
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
>&2 echo "Error: Failed to create git branch '$BRANCH_NAME'."
|
||||
if [ -n "$branch_create_error" ]; then
|
||||
>&2 printf '%s\n' "$branch_create_error"
|
||||
else
|
||||
>&2 echo "Please check your git configuration and try again."
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
else
|
||||
>&2 echo "[specify] Warning: Git repository not detected; skipped branch creation for $BRANCH_NAME"
|
||||
fi
|
||||
|
||||
printf '# To persist: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME" >&2
|
||||
fi
|
||||
|
||||
if $JSON_MODE; then
|
||||
if command -v jq >/dev/null 2>&1; then
|
||||
if [ "$DRY_RUN" = true ]; then
|
||||
jq -cn \
|
||||
--arg branch_name "$BRANCH_NAME" \
|
||||
--arg feature_num "$FEATURE_NUM" \
|
||||
'{BRANCH_NAME:$branch_name,FEATURE_NUM:$feature_num,DRY_RUN:true}'
|
||||
else
|
||||
jq -cn \
|
||||
--arg branch_name "$BRANCH_NAME" \
|
||||
--arg feature_num "$FEATURE_NUM" \
|
||||
'{BRANCH_NAME:$branch_name,FEATURE_NUM:$feature_num}'
|
||||
fi
|
||||
else
|
||||
if type json_escape >/dev/null 2>&1; then
|
||||
_je_branch=$(json_escape "$BRANCH_NAME")
|
||||
_je_num=$(json_escape "$FEATURE_NUM")
|
||||
else
|
||||
_je_branch="$BRANCH_NAME"
|
||||
_je_num="$FEATURE_NUM"
|
||||
fi
|
||||
if [ "$DRY_RUN" = true ]; then
|
||||
printf '{"BRANCH_NAME":"%s","FEATURE_NUM":"%s","DRY_RUN":true}\n' "$_je_branch" "$_je_num"
|
||||
else
|
||||
printf '{"BRANCH_NAME":"%s","FEATURE_NUM":"%s"}\n' "$_je_branch" "$_je_num"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
echo "BRANCH_NAME: $BRANCH_NAME"
|
||||
echo "FEATURE_NUM: $FEATURE_NUM"
|
||||
if [ "$DRY_RUN" != true ]; then
|
||||
printf '# To persist in your shell: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME"
|
||||
fi
|
||||
fi
|
||||
41
extensions/git/scripts/bash/git-common.sh
Executable file
41
extensions/git/scripts/bash/git-common.sh
Executable file
@@ -0,0 +1,41 @@
|
||||
#!/usr/bin/env bash
|
||||
# Git-specific common functions for the git extension.
|
||||
# Extracted from scripts/bash/common.sh — contains only git-specific
|
||||
# branch validation and detection logic.
|
||||
|
||||
# Check if we have git available at the repo root
|
||||
has_git() {
|
||||
local repo_root="${1:-$(pwd)}"
|
||||
{ [ -d "$repo_root/.git" ] || [ -f "$repo_root/.git" ]; } && \
|
||||
command -v git >/dev/null 2>&1 && \
|
||||
git -C "$repo_root" rev-parse --is-inside-work-tree >/dev/null 2>&1
|
||||
}
|
||||
|
||||
# Validate that a branch name matches the expected feature branch pattern.
|
||||
# Accepts sequential (###-* with >=3 digits) or timestamp (YYYYMMDD-HHMMSS-*) formats.
|
||||
check_feature_branch() {
|
||||
local branch="$1"
|
||||
local has_git_repo="$2"
|
||||
|
||||
# For non-git repos, we can't enforce branch naming but still provide output
|
||||
if [[ "$has_git_repo" != "true" ]]; then
|
||||
echo "[specify] Warning: Git repository not detected; skipped branch validation" >&2
|
||||
return 0
|
||||
fi
|
||||
|
||||
# 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
|
||||
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
|
||||
}
|
||||
54
extensions/git/scripts/bash/initialize-repo.sh
Executable file
54
extensions/git/scripts/bash/initialize-repo.sh
Executable file
@@ -0,0 +1,54 @@
|
||||
#!/usr/bin/env bash
|
||||
# Git extension: initialize-repo.sh
|
||||
# Initialize a Git repository with an initial commit.
|
||||
# Customizable — replace this script to add .gitignore templates,
|
||||
# default branch config, git-flow, LFS, signing, etc.
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
# Find project root
|
||||
_find_project_root() {
|
||||
local dir="$1"
|
||||
while [ "$dir" != "/" ]; do
|
||||
if [ -d "$dir/.specify" ] || [ -d "$dir/.git" ]; then
|
||||
echo "$dir"
|
||||
return 0
|
||||
fi
|
||||
dir="$(dirname "$dir")"
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
REPO_ROOT=$(_find_project_root "$SCRIPT_DIR") || REPO_ROOT="$(pwd)"
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
# Read commit message from extension config, fall back to default
|
||||
COMMIT_MSG="[Spec Kit] Initial commit"
|
||||
_config_file="$REPO_ROOT/.specify/extensions/git/git-config.yml"
|
||||
if [ -f "$_config_file" ]; then
|
||||
_msg=$(grep '^init_commit_message:' "$_config_file" 2>/dev/null | sed 's/^init_commit_message:[[:space:]]*//' | sed 's/^["'\'']//' | sed 's/["'\'']*$//')
|
||||
if [ -n "$_msg" ]; then
|
||||
COMMIT_MSG="$_msg"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Check if git is available
|
||||
if ! command -v git >/dev/null 2>&1; then
|
||||
echo "[specify] Warning: Git not found; skipped repository initialization" >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Check if already a git repo
|
||||
if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
||||
echo "[specify] Git repository already initialized; skipping" >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Initialize
|
||||
_git_out=$(git init -q 2>&1) || { echo "[specify] Error: git init failed: $_git_out" >&2; exit 1; }
|
||||
_git_out=$(git add . 2>&1) || { echo "[specify] Error: git add failed: $_git_out" >&2; exit 1; }
|
||||
_git_out=$(git commit --allow-empty -q -m "$COMMIT_MSG" 2>&1) || { echo "[specify] Error: git commit failed: $_git_out" >&2; exit 1; }
|
||||
|
||||
echo "✓ Git repository initialized" >&2
|
||||
149
extensions/git/scripts/powershell/auto-commit.ps1
Normal file
149
extensions/git/scripts/powershell/auto-commit.ps1
Normal file
@@ -0,0 +1,149 @@
|
||||
#!/usr/bin/env pwsh
|
||||
# Git extension: auto-commit.ps1
|
||||
# Automatically commit changes after a Spec Kit command completes.
|
||||
# Checks per-command config keys in git-config.yml before committing.
|
||||
#
|
||||
# Usage: auto-commit.ps1 <event_name>
|
||||
# e.g.: auto-commit.ps1 after_specify
|
||||
param(
|
||||
[Parameter(Position = 0, Mandatory = $true)]
|
||||
[string]$EventName
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
function Find-ProjectRoot {
|
||||
param([string]$StartDir)
|
||||
$current = Resolve-Path $StartDir
|
||||
while ($true) {
|
||||
foreach ($marker in @('.specify', '.git')) {
|
||||
if (Test-Path (Join-Path $current $marker)) {
|
||||
return $current
|
||||
}
|
||||
}
|
||||
$parent = Split-Path $current -Parent
|
||||
if ($parent -eq $current) { return $null }
|
||||
$current = $parent
|
||||
}
|
||||
}
|
||||
|
||||
$repoRoot = Find-ProjectRoot -StartDir $PSScriptRoot
|
||||
if (-not $repoRoot) { $repoRoot = Get-Location }
|
||||
Set-Location $repoRoot
|
||||
|
||||
# Check if git is available
|
||||
if (-not (Get-Command git -ErrorAction SilentlyContinue)) {
|
||||
Write-Warning "[specify] Warning: Git not found; skipped auto-commit"
|
||||
exit 0
|
||||
}
|
||||
|
||||
try {
|
||||
git rev-parse --is-inside-work-tree 2>$null | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) { throw "not a repo" }
|
||||
} catch {
|
||||
Write-Warning "[specify] Warning: Not a Git repository; skipped auto-commit"
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Read per-command config from git-config.yml
|
||||
$configFile = Join-Path $repoRoot ".specify/extensions/git/git-config.yml"
|
||||
$enabled = $false
|
||||
$commitMsg = ""
|
||||
|
||||
if (Test-Path $configFile) {
|
||||
# Parse YAML to find auto_commit section
|
||||
$inAutoCommit = $false
|
||||
$inEvent = $false
|
||||
$defaultEnabled = $false
|
||||
|
||||
foreach ($line in Get-Content $configFile) {
|
||||
# Detect auto_commit: section
|
||||
if ($line -match '^auto_commit:') {
|
||||
$inAutoCommit = $true
|
||||
$inEvent = $false
|
||||
continue
|
||||
}
|
||||
|
||||
# Exit auto_commit section on next top-level key
|
||||
if ($inAutoCommit -and $line -match '^[a-z]') {
|
||||
break
|
||||
}
|
||||
|
||||
if ($inAutoCommit) {
|
||||
# Check default key
|
||||
if ($line -match '^\s+default:\s*(.+)$') {
|
||||
$val = $matches[1].Trim().ToLower()
|
||||
if ($val -eq 'true') { $defaultEnabled = $true }
|
||||
}
|
||||
|
||||
# Detect our event subsection
|
||||
if ($line -match "^\s+${EventName}:") {
|
||||
$inEvent = $true
|
||||
continue
|
||||
}
|
||||
|
||||
# Inside our event subsection
|
||||
if ($inEvent) {
|
||||
# Exit on next sibling key (2-space indent, not 4+)
|
||||
if ($line -match '^\s{2}[a-z]' -and $line -notmatch '^\s{4}') {
|
||||
$inEvent = $false
|
||||
continue
|
||||
}
|
||||
if ($line -match '\s+enabled:\s*(.+)$') {
|
||||
$val = $matches[1].Trim().ToLower()
|
||||
if ($val -eq 'true') { $enabled = $true }
|
||||
if ($val -eq 'false') { $enabled = $false }
|
||||
}
|
||||
if ($line -match '\s+message:\s*(.+)$') {
|
||||
$commitMsg = $matches[1].Trim() -replace '^["'']' -replace '["'']$'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# If event-specific key not found, use default
|
||||
if (-not $enabled -and $defaultEnabled) {
|
||||
$hasEventKey = Select-String -Path $configFile -Pattern "^\s*${EventName}:" -Quiet
|
||||
if (-not $hasEventKey) {
|
||||
$enabled = $true
|
||||
}
|
||||
}
|
||||
} else {
|
||||
# No config file — auto-commit disabled by default
|
||||
exit 0
|
||||
}
|
||||
|
||||
if (-not $enabled) {
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Check if there are changes to commit
|
||||
$diffHead = git diff --quiet HEAD 2>$null; $d1 = $LASTEXITCODE
|
||||
$diffCached = git diff --cached --quiet 2>$null; $d2 = $LASTEXITCODE
|
||||
$untracked = git ls-files --others --exclude-standard 2>$null
|
||||
|
||||
if ($d1 -eq 0 -and $d2 -eq 0 -and -not $untracked) {
|
||||
Write-Host "[specify] No changes to commit after $EventName" -ForegroundColor DarkGray
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Derive a human-readable command name from the event
|
||||
$commandName = $EventName -replace '^after_', '' -replace '^before_', ''
|
||||
$phase = if ($EventName -match '^before_') { 'before' } else { 'after' }
|
||||
|
||||
# Use custom message if configured, otherwise default
|
||||
if (-not $commitMsg) {
|
||||
$commitMsg = "[Spec Kit] Auto-commit $phase $commandName"
|
||||
}
|
||||
|
||||
# Stage and commit
|
||||
try {
|
||||
$out = git add . 2>&1 | Out-String
|
||||
if ($LASTEXITCODE -ne 0) { throw "git add failed: $out" }
|
||||
$out = git commit -q -m $commitMsg 2>&1 | Out-String
|
||||
if ($LASTEXITCODE -ne 0) { throw "git commit failed: $out" }
|
||||
} catch {
|
||||
Write-Warning "[specify] Error: $_"
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "✓ Changes committed $phase $commandName"
|
||||
403
extensions/git/scripts/powershell/create-new-feature.ps1
Normal file
403
extensions/git/scripts/powershell/create-new-feature.ps1
Normal file
@@ -0,0 +1,403 @@
|
||||
#!/usr/bin/env pwsh
|
||||
# Git extension: create-new-feature.ps1
|
||||
# Adapted from core scripts/powershell/create-new-feature.ps1 for extension layout.
|
||||
# Sources common.ps1 from the project's installed scripts, falling back to
|
||||
# git-common.ps1 for minimal git helpers.
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[switch]$Json,
|
||||
[switch]$AllowExistingBranch,
|
||||
[switch]$DryRun,
|
||||
[string]$ShortName,
|
||||
[Parameter()]
|
||||
[long]$Number = 0,
|
||||
[switch]$Timestamp,
|
||||
[switch]$Help,
|
||||
[Parameter(Position = 0, ValueFromRemainingArguments = $true)]
|
||||
[string[]]$FeatureDescription
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
if ($Help) {
|
||||
Write-Host "Usage: ./create-new-feature.ps1 [-Json] [-DryRun] [-AllowExistingBranch] [-ShortName <name>] [-Number N] [-Timestamp] <feature description>"
|
||||
Write-Host ""
|
||||
Write-Host "Options:"
|
||||
Write-Host " -Json Output in JSON format"
|
||||
Write-Host " -DryRun Compute branch name without creating the branch"
|
||||
Write-Host " -AllowExistingBranch Switch to branch if it already exists instead of failing"
|
||||
Write-Host " -ShortName <name> Provide a custom short name (2-4 words) for the branch"
|
||||
Write-Host " -Number N Specify branch number manually (overrides auto-detection)"
|
||||
Write-Host " -Timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering"
|
||||
Write-Host " -Help Show this help message"
|
||||
Write-Host ""
|
||||
Write-Host "Environment variables:"
|
||||
Write-Host " GIT_BRANCH_NAME Use this exact branch name, bypassing all prefix/suffix generation"
|
||||
Write-Host ""
|
||||
exit 0
|
||||
}
|
||||
|
||||
if (-not $FeatureDescription -or $FeatureDescription.Count -eq 0) {
|
||||
Write-Error "Usage: ./create-new-feature.ps1 [-Json] [-DryRun] [-AllowExistingBranch] [-ShortName <name>] [-Number N] [-Timestamp] <feature description>"
|
||||
exit 1
|
||||
}
|
||||
|
||||
$featureDesc = ($FeatureDescription -join ' ').Trim()
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($featureDesc)) {
|
||||
Write-Error "Error: Feature description cannot be empty or contain only whitespace"
|
||||
exit 1
|
||||
}
|
||||
|
||||
function Get-HighestNumberFromSpecs {
|
||||
param([string]$SpecsDir)
|
||||
|
||||
[long]$highest = 0
|
||||
if (Test-Path $SpecsDir) {
|
||||
Get-ChildItem -Path $SpecsDir -Directory | ForEach-Object {
|
||||
if ($_.Name -match '^(\d{3,})-' -and $_.Name -notmatch '^\d{8}-\d{6}-') {
|
||||
[long]$num = 0
|
||||
if ([long]::TryParse($matches[1], [ref]$num) -and $num -gt $highest) {
|
||||
$highest = $num
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return $highest
|
||||
}
|
||||
|
||||
function Get-HighestNumberFromNames {
|
||||
param([string[]]$Names)
|
||||
|
||||
[long]$highest = 0
|
||||
foreach ($name in $Names) {
|
||||
if ($name -match '^(\d{3,})-' -and $name -notmatch '^\d{8}-\d{6}-') {
|
||||
[long]$num = 0
|
||||
if ([long]::TryParse($matches[1], [ref]$num) -and $num -gt $highest) {
|
||||
$highest = $num
|
||||
}
|
||||
}
|
||||
}
|
||||
return $highest
|
||||
}
|
||||
|
||||
function Get-HighestNumberFromBranches {
|
||||
param()
|
||||
|
||||
try {
|
||||
$branches = git branch -a 2>$null
|
||||
if ($LASTEXITCODE -eq 0 -and $branches) {
|
||||
$cleanNames = $branches | ForEach-Object {
|
||||
$_.Trim() -replace '^\*?\s+', '' -replace '^remotes/[^/]+/', ''
|
||||
}
|
||||
return Get-HighestNumberFromNames -Names $cleanNames
|
||||
}
|
||||
} catch {
|
||||
Write-Verbose "Could not check Git branches: $_"
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
function Get-HighestNumberFromRemoteRefs {
|
||||
[long]$highest = 0
|
||||
try {
|
||||
$remotes = git remote 2>$null
|
||||
if ($remotes) {
|
||||
foreach ($remote in $remotes) {
|
||||
$env:GIT_TERMINAL_PROMPT = '0'
|
||||
$refs = git ls-remote --heads $remote 2>$null
|
||||
$env:GIT_TERMINAL_PROMPT = $null
|
||||
if ($LASTEXITCODE -eq 0 -and $refs) {
|
||||
$refNames = $refs | ForEach-Object {
|
||||
if ($_ -match 'refs/heads/(.+)$') { $matches[1] }
|
||||
} | Where-Object { $_ }
|
||||
$remoteHighest = Get-HighestNumberFromNames -Names $refNames
|
||||
if ($remoteHighest -gt $highest) { $highest = $remoteHighest }
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
Write-Verbose "Could not query remote refs: $_"
|
||||
}
|
||||
return $highest
|
||||
}
|
||||
|
||||
function Get-NextBranchNumber {
|
||||
param(
|
||||
[string]$SpecsDir,
|
||||
[switch]$SkipFetch
|
||||
)
|
||||
|
||||
if ($SkipFetch) {
|
||||
$highestBranch = Get-HighestNumberFromBranches
|
||||
$highestRemote = Get-HighestNumberFromRemoteRefs
|
||||
$highestBranch = [Math]::Max($highestBranch, $highestRemote)
|
||||
} else {
|
||||
try {
|
||||
git fetch --all --prune 2>$null | Out-Null
|
||||
} catch { }
|
||||
$highestBranch = Get-HighestNumberFromBranches
|
||||
}
|
||||
|
||||
$highestSpec = Get-HighestNumberFromSpecs -SpecsDir $SpecsDir
|
||||
$maxNum = [Math]::Max($highestBranch, $highestSpec)
|
||||
return $maxNum + 1
|
||||
}
|
||||
|
||||
function ConvertTo-CleanBranchName {
|
||||
param([string]$Name)
|
||||
return $Name.ToLower() -replace '[^a-z0-9]', '-' -replace '-{2,}', '-' -replace '^-', '' -replace '-$', ''
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Source common.ps1 from the project's installed scripts.
|
||||
# Search locations in priority order:
|
||||
# 1. .specify/scripts/powershell/common.ps1 under the project root
|
||||
# 2. scripts/powershell/common.ps1 under the project root (source checkout)
|
||||
# 3. git-common.ps1 next to this script (minimal fallback)
|
||||
# ---------------------------------------------------------------------------
|
||||
function Find-ProjectRoot {
|
||||
param([string]$StartDir)
|
||||
$current = Resolve-Path $StartDir
|
||||
while ($true) {
|
||||
foreach ($marker in @('.specify', '.git')) {
|
||||
if (Test-Path (Join-Path $current $marker)) {
|
||||
return $current
|
||||
}
|
||||
}
|
||||
$parent = Split-Path $current -Parent
|
||||
if ($parent -eq $current) { return $null }
|
||||
$current = $parent
|
||||
}
|
||||
}
|
||||
|
||||
$projectRoot = Find-ProjectRoot -StartDir $PSScriptRoot
|
||||
$commonLoaded = $false
|
||||
|
||||
if ($projectRoot) {
|
||||
$candidates = @(
|
||||
(Join-Path $projectRoot ".specify/scripts/powershell/common.ps1"),
|
||||
(Join-Path $projectRoot "scripts/powershell/common.ps1")
|
||||
)
|
||||
foreach ($candidate in $candidates) {
|
||||
if (Test-Path $candidate) {
|
||||
. $candidate
|
||||
$commonLoaded = $true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $commonLoaded -and (Test-Path "$PSScriptRoot/git-common.ps1")) {
|
||||
. "$PSScriptRoot/git-common.ps1"
|
||||
$commonLoaded = $true
|
||||
}
|
||||
|
||||
if (-not $commonLoaded) {
|
||||
throw "Unable to locate common script file. Please ensure the Specify core scripts are installed."
|
||||
}
|
||||
|
||||
# Resolve repository root
|
||||
if (Get-Command Get-RepoRoot -ErrorAction SilentlyContinue) {
|
||||
$repoRoot = Get-RepoRoot
|
||||
} elseif ($projectRoot) {
|
||||
$repoRoot = $projectRoot
|
||||
} else {
|
||||
throw "Could not determine repository root."
|
||||
}
|
||||
|
||||
# Check if git is available
|
||||
if (Get-Command Test-HasGit -ErrorAction SilentlyContinue) {
|
||||
# Call without parameters for compatibility with core common.ps1 (no -RepoRoot param)
|
||||
# and git-common.ps1 (has -RepoRoot param with default).
|
||||
$hasGit = Test-HasGit
|
||||
} else {
|
||||
try {
|
||||
git -C $repoRoot rev-parse --is-inside-work-tree 2>$null | Out-Null
|
||||
$hasGit = ($LASTEXITCODE -eq 0)
|
||||
} catch {
|
||||
$hasGit = $false
|
||||
}
|
||||
}
|
||||
|
||||
Set-Location $repoRoot
|
||||
|
||||
$specsDir = Join-Path $repoRoot 'specs'
|
||||
|
||||
function Get-BranchName {
|
||||
param([string]$Description)
|
||||
|
||||
$stopWords = @(
|
||||
'i', 'a', 'an', 'the', 'to', 'for', 'of', 'in', 'on', 'at', 'by', 'with', 'from',
|
||||
'is', 'are', 'was', 'were', 'be', 'been', 'being', 'have', 'has', 'had',
|
||||
'do', 'does', 'did', 'will', 'would', 'should', 'could', 'can', 'may', 'might', 'must', 'shall',
|
||||
'this', 'that', 'these', 'those', 'my', 'your', 'our', 'their',
|
||||
'want', 'need', 'add', 'get', 'set'
|
||||
)
|
||||
|
||||
$cleanName = $Description.ToLower() -replace '[^a-z0-9\s]', ' '
|
||||
$words = $cleanName -split '\s+' | Where-Object { $_ }
|
||||
|
||||
$meaningfulWords = @()
|
||||
foreach ($word in $words) {
|
||||
if ($stopWords -contains $word) { continue }
|
||||
if ($word.Length -ge 3) {
|
||||
$meaningfulWords += $word
|
||||
} elseif ($Description -match "\b$($word.ToUpper())\b") {
|
||||
$meaningfulWords += $word
|
||||
}
|
||||
}
|
||||
|
||||
if ($meaningfulWords.Count -gt 0) {
|
||||
$maxWords = if ($meaningfulWords.Count -eq 4) { 4 } else { 3 }
|
||||
$result = ($meaningfulWords | Select-Object -First $maxWords) -join '-'
|
||||
return $result
|
||||
} else {
|
||||
$result = ConvertTo-CleanBranchName -Name $Description
|
||||
$fallbackWords = ($result -split '-') | Where-Object { $_ } | Select-Object -First 3
|
||||
return [string]::Join('-', $fallbackWords)
|
||||
}
|
||||
}
|
||||
|
||||
# Check for GIT_BRANCH_NAME env var override (exact branch name, no prefix/suffix)
|
||||
if ($env:GIT_BRANCH_NAME) {
|
||||
$branchName = $env:GIT_BRANCH_NAME
|
||||
# Check 244-byte limit (UTF-8) for override names
|
||||
$branchNameUtf8ByteCount = [System.Text.Encoding]::UTF8.GetByteCount($branchName)
|
||||
if ($branchNameUtf8ByteCount -gt 244) {
|
||||
throw "GIT_BRANCH_NAME must be 244 bytes or fewer in UTF-8. Provided value is $branchNameUtf8ByteCount bytes; please supply a shorter override branch name."
|
||||
}
|
||||
# Extract FEATURE_NUM from the branch name if it starts with a numeric prefix
|
||||
# Check timestamp pattern first (YYYYMMDD-HHMMSS-) since it also matches the simpler ^\d+ pattern
|
||||
if ($branchName -match '^(\d{8}-\d{6})-') {
|
||||
$featureNum = $matches[1]
|
||||
} elseif ($branchName -match '^(\d+)-') {
|
||||
$featureNum = $matches[1]
|
||||
} else {
|
||||
$featureNum = $branchName
|
||||
}
|
||||
} else {
|
||||
if ($ShortName) {
|
||||
$branchSuffix = ConvertTo-CleanBranchName -Name $ShortName
|
||||
} else {
|
||||
$branchSuffix = Get-BranchName -Description $featureDesc
|
||||
}
|
||||
|
||||
if ($Timestamp -and $Number -ne 0) {
|
||||
Write-Warning "[specify] Warning: -Number is ignored when -Timestamp is used"
|
||||
$Number = 0
|
||||
}
|
||||
|
||||
if ($Timestamp) {
|
||||
$featureNum = Get-Date -Format 'yyyyMMdd-HHmmss'
|
||||
$branchName = "$featureNum-$branchSuffix"
|
||||
} else {
|
||||
if ($Number -eq 0) {
|
||||
if ($DryRun -and $hasGit) {
|
||||
$Number = Get-NextBranchNumber -SpecsDir $specsDir -SkipFetch
|
||||
} elseif ($DryRun) {
|
||||
$Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1
|
||||
} elseif ($hasGit) {
|
||||
$Number = Get-NextBranchNumber -SpecsDir $specsDir
|
||||
} else {
|
||||
$Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1
|
||||
}
|
||||
}
|
||||
|
||||
$featureNum = ('{0:000}' -f $Number)
|
||||
$branchName = "$featureNum-$branchSuffix"
|
||||
}
|
||||
}
|
||||
|
||||
$maxBranchLength = 244
|
||||
if ($branchName.Length -gt $maxBranchLength) {
|
||||
$prefixLength = $featureNum.Length + 1
|
||||
$maxSuffixLength = $maxBranchLength - $prefixLength
|
||||
|
||||
$truncatedSuffix = $branchSuffix.Substring(0, [Math]::Min($branchSuffix.Length, $maxSuffixLength))
|
||||
$truncatedSuffix = $truncatedSuffix -replace '-$', ''
|
||||
|
||||
$originalBranchName = $branchName
|
||||
$branchName = "$featureNum-$truncatedSuffix"
|
||||
|
||||
Write-Warning "[specify] Branch name exceeded GitHub's 244-byte limit"
|
||||
Write-Warning "[specify] Original: $originalBranchName ($($originalBranchName.Length) bytes)"
|
||||
Write-Warning "[specify] Truncated to: $branchName ($($branchName.Length) bytes)"
|
||||
}
|
||||
|
||||
if (-not $DryRun) {
|
||||
if ($hasGit) {
|
||||
$branchCreated = $false
|
||||
$branchCreateError = ''
|
||||
try {
|
||||
$branchCreateError = git checkout -q -b $branchName 2>&1 | Out-String
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
$branchCreated = $true
|
||||
}
|
||||
} catch {
|
||||
$branchCreateError = $_.Exception.Message
|
||||
}
|
||||
|
||||
if (-not $branchCreated) {
|
||||
$currentBranch = ''
|
||||
try { $currentBranch = (git rev-parse --abbrev-ref HEAD 2>$null).Trim() } catch {}
|
||||
$existingBranch = git branch --list $branchName 2>$null
|
||||
if ($existingBranch) {
|
||||
if ($AllowExistingBranch) {
|
||||
if ($currentBranch -eq $branchName) {
|
||||
# Already on the target branch
|
||||
} else {
|
||||
$switchBranchError = git checkout -q $branchName 2>&1 | Out-String
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
if ($switchBranchError) {
|
||||
Write-Error "Error: Branch '$branchName' exists but could not be checked out.`n$($switchBranchError.Trim())"
|
||||
} else {
|
||||
Write-Error "Error: Branch '$branchName' exists but could not be checked out. Resolve any uncommitted changes or conflicts and try again."
|
||||
}
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
} elseif ($Timestamp) {
|
||||
Write-Error "Error: Branch '$branchName' already exists. Rerun to get a new timestamp or use a different -ShortName."
|
||||
exit 1
|
||||
} else {
|
||||
Write-Error "Error: Branch '$branchName' already exists. Please use a different feature name or specify a different number with -Number."
|
||||
exit 1
|
||||
}
|
||||
} else {
|
||||
if ($branchCreateError) {
|
||||
Write-Error "Error: Failed to create git branch '$branchName'.`n$($branchCreateError.Trim())"
|
||||
} else {
|
||||
Write-Error "Error: Failed to create git branch '$branchName'. Please check your git configuration and try again."
|
||||
}
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if ($Json) {
|
||||
[Console]::Error.WriteLine("[specify] Warning: Git repository not detected; skipped branch creation for $branchName")
|
||||
} else {
|
||||
Write-Warning "[specify] Warning: Git repository not detected; skipped branch creation for $branchName"
|
||||
}
|
||||
}
|
||||
|
||||
$env:SPECIFY_FEATURE = $branchName
|
||||
}
|
||||
|
||||
if ($Json) {
|
||||
$obj = [PSCustomObject]@{
|
||||
BRANCH_NAME = $branchName
|
||||
FEATURE_NUM = $featureNum
|
||||
HAS_GIT = $hasGit
|
||||
}
|
||||
if ($DryRun) {
|
||||
$obj | Add-Member -NotePropertyName 'DRY_RUN' -NotePropertyValue $true
|
||||
}
|
||||
$obj | ConvertTo-Json -Compress
|
||||
} else {
|
||||
Write-Output "BRANCH_NAME: $branchName"
|
||||
Write-Output "FEATURE_NUM: $featureNum"
|
||||
Write-Output "HAS_GIT: $hasGit"
|
||||
if (-not $DryRun) {
|
||||
Write-Output "SPECIFY_FEATURE environment variable set to: $branchName"
|
||||
}
|
||||
}
|
||||
50
extensions/git/scripts/powershell/git-common.ps1
Normal file
50
extensions/git/scripts/powershell/git-common.ps1
Normal file
@@ -0,0 +1,50 @@
|
||||
#!/usr/bin/env pwsh
|
||||
# Git-specific common functions for the git extension.
|
||||
# Extracted from scripts/powershell/common.ps1 — contains only git-specific
|
||||
# branch validation and detection logic.
|
||||
|
||||
function Test-HasGit {
|
||||
param([string]$RepoRoot = (Get-Location))
|
||||
try {
|
||||
if (-not (Test-Path (Join-Path $RepoRoot '.git'))) { return $false }
|
||||
if (-not (Get-Command git -ErrorAction SilentlyContinue)) { return $false }
|
||||
git -C $RepoRoot rev-parse --is-inside-work-tree 2>$null | Out-Null
|
||||
return ($LASTEXITCODE -eq 0)
|
||||
} catch {
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
function Test-FeatureBranch {
|
||||
param(
|
||||
[string]$Branch,
|
||||
[bool]$HasGit = $true
|
||||
)
|
||||
|
||||
# For non-git repos, we can't enforce branch naming but still provide output
|
||||
if (-not $HasGit) {
|
||||
Write-Warning "[specify] Warning: Git repository not detected; skipped branch validation"
|
||||
return $true
|
||||
}
|
||||
|
||||
# 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"
|
||||
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
|
||||
}
|
||||
69
extensions/git/scripts/powershell/initialize-repo.ps1
Normal file
69
extensions/git/scripts/powershell/initialize-repo.ps1
Normal file
@@ -0,0 +1,69 @@
|
||||
#!/usr/bin/env pwsh
|
||||
# Git extension: initialize-repo.ps1
|
||||
# Initialize a Git repository with an initial commit.
|
||||
# Customizable — replace this script to add .gitignore templates,
|
||||
# default branch config, git-flow, LFS, signing, etc.
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
# Find project root
|
||||
function Find-ProjectRoot {
|
||||
param([string]$StartDir)
|
||||
$current = Resolve-Path $StartDir
|
||||
while ($true) {
|
||||
foreach ($marker in @('.specify', '.git')) {
|
||||
if (Test-Path (Join-Path $current $marker)) {
|
||||
return $current
|
||||
}
|
||||
}
|
||||
$parent = Split-Path $current -Parent
|
||||
if ($parent -eq $current) { return $null }
|
||||
$current = $parent
|
||||
}
|
||||
}
|
||||
|
||||
$repoRoot = Find-ProjectRoot -StartDir $PSScriptRoot
|
||||
if (-not $repoRoot) { $repoRoot = Get-Location }
|
||||
Set-Location $repoRoot
|
||||
|
||||
# Read commit message from extension config, fall back to default
|
||||
$commitMsg = "[Spec Kit] Initial commit"
|
||||
$configFile = Join-Path $repoRoot ".specify/extensions/git/git-config.yml"
|
||||
if (Test-Path $configFile) {
|
||||
foreach ($line in Get-Content $configFile) {
|
||||
if ($line -match '^init_commit_message:\s*(.+)$') {
|
||||
$val = $matches[1].Trim() -replace '^["'']' -replace '["'']$'
|
||||
if ($val) { $commitMsg = $val }
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Check if git is available
|
||||
if (-not (Get-Command git -ErrorAction SilentlyContinue)) {
|
||||
Write-Warning "[specify] Warning: Git not found; skipped repository initialization"
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Check if already a git repo
|
||||
try {
|
||||
git rev-parse --is-inside-work-tree 2>$null | Out-Null
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
Write-Warning "[specify] Git repository already initialized; skipping"
|
||||
exit 0
|
||||
}
|
||||
} catch { }
|
||||
|
||||
# Initialize
|
||||
try {
|
||||
$out = git init -q 2>&1 | Out-String
|
||||
if ($LASTEXITCODE -ne 0) { throw "git init failed: $out" }
|
||||
$out = git add . 2>&1 | Out-String
|
||||
if ($LASTEXITCODE -ne 0) { throw "git add failed: $out" }
|
||||
$out = git commit --allow-empty -q -m $commitMsg 2>&1 | Out-String
|
||||
if ($LASTEXITCODE -ne 0) { throw "git commit failed: $out" }
|
||||
} catch {
|
||||
Write-Warning "[specify] Error: $_"
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "✓ Git repository initialized"
|
||||
80
newsletters/2026-March.md
Normal file
80
newsletters/2026-March.md
Normal file
@@ -0,0 +1,80 @@
|
||||
# Spec Kit - March 2026 Newsletter
|
||||
|
||||
This edition covers Spec Kit activity in March 2026. Nine releases shipped (v0.2.0 through v0.4.3), introducing a pluggable preset system, air-gapped deployment, automatic skill registration, and seven new AI agent integrations. The community extension catalog grew past 20 entries, independent walkthroughs and blog posts proliferated, and industry coverage debated whether "vibe coding" is dead. A summary is in the table below, followed by details.
|
||||
|
||||
| **Spec Kit Core (Mar 2026)** | **Community & Content** | **SDD Ecosystem & Next** |
|
||||
| --- | --- | --- |
|
||||
| Nine releases shipped with major features: multi-catalog extensions, pluggable presets, air-gapped deployment, and auto-registration of extension skills. Seven new agents added. The repo grew from ~71k to **82,616 stars**. [\[github.com\]](https://github.com/github/spec-kit/releases) | Walkthroughs by Tiago Valverde, Alfredo Perez, and Sergey Golubev. Over 20 community extensions. The Spec Kit Assistant VS Code extension was recognized as a Community Friend. A Microsoft Learn training module became available. | ByteIota reported AWS pushing SDD as the new standard. Augment Code published a Spec Kit vs. Intent comparison. Competitors differentiate on orchestration depth and living specs; Spec Kit leads in agent breadth and portability. |
|
||||
|
||||
***
|
||||
|
||||
## Spec Kit Project Updates
|
||||
|
||||
### Releases Overview
|
||||
|
||||
**v0.2.0** (March 10) opened the month with **simultaneous multi-catalog support**, enabling both core and community extension catalogs at the same time. It added **Tabnine CLI** and **Kimi Code CLI** agents, four community extensions (Understanding, Ralph, Review, Fleet Orchestrator), and `.extensionignore` support. Patch **v0.2.1** fixed broken quickstart links and added catalog CLI help. [\[github.com\]](https://github.com/github/spec-kit/releases)
|
||||
|
||||
**v0.3.0** (mid-March) delivered the **pluggable preset system** with catalog, resolver, and skills propagation. Presets let teams override default templates with their own conventions, using priority-based stacking. The release also added a **/selftest.extension** for testing extensions, **Mistral Vibe CLI**, migrated **Qwen Code CLI** from TOML to Markdown, and hardened bash scripts against shell injection. New community extensions included DocGuard CDD, Archive & Reconcile, specify-status, and specify-doctor. [\[github.com\]](https://github.com/github/spec-kit/releases)
|
||||
|
||||
**v0.3.1** added before/after hook events, JSONC deep-merge for `settings.json`, and the **Trae IDE** agent. **v0.3.2** added **Junie**, **iFlow CLI**, and **Pi Coding Agent**, plus a preset submission template and an Extension Comparison Guide. Community extensions continued arriving: verify-tasks, conduct, cognitive-squad, speckit-utils, spec-kit-iterate, and spec-kit-learn. [\[github.com\]](https://github.com/github/spec-kit/releases)
|
||||
|
||||
**v0.4.0** (late March) introduced **auto-registration of extension skills** — installed extensions' commands are now automatically exposed as agent skills. It also delivered **air-gapped/offline deployment** by embedding core templates in the CLI wheel and added timestamp-based branch naming. [\[github.com\]](https://github.com/github/spec-kit/releases)
|
||||
|
||||
Three patches closed the month. **v0.4.1** fixed a missing Assumptions section in the spec template and improved repo root detection. **v0.4.2** added AIDE, Extensify, and Presetify to the community catalog, moved the community extensions table into the main README, and recognized the **Spec Kit Assistant VS Code extension** as a Community Friend. **v0.4.3** unified skill naming conventions and restored **PowerShell 5.1 compatibility**. [\[github.com\]](https://github.com/github/spec-kit/releases)
|
||||
|
||||
### Bug Fixes and Security Hardening
|
||||
|
||||
The most significant fix was **shell injection hardening** of bash scripts, addressing potential vulnerabilities from unsanitized git branch names and environment variables. Other fixes included switching to **global branch numbering** for consistent sequencing, suppressing git checkout exceptions and fetch stdout leaks, properly encoding JSON control characters, and adding explicit PowerShell positional binding. [\[github.com\]](https://github.com/github/spec-kit/releases)
|
||||
|
||||
### The Extension Ecosystem
|
||||
|
||||
By late March, over **20 community extensions** had been built for Spec Kit. Thulasi Rajasekaran's LinkedIn article *"The Feature That Turns Spec Kit Into a Platform"* highlighted standouts: **Conduct** (orchestrates SDD phases via sub-agents to avoid context pollution), **Verify Tasks** (catches "phantom completions" — tasks marked done with no real code), **Understanding** (31 quality metrics against specs based on IEEE/ISO standards), and the **Jira and Azure DevOps integrations**. [\[linkedin.com\]](https://www.linkedin.com/pulse/feature-turns-spec-kit-platform-extensions-presets-rajasekaran-3ejgc)
|
||||
|
||||
Rajasekaran argued the real significance of presets is what they enable: the same machinery that turned "User Stories" into pirate-speak "Crew Tales" could enforce compliance requirements, add mandatory threat-model sections, or require test tasks before implementation tasks. Organizations can curate available extensions by hosting custom catalog URLs. [\[linkedin.com\]](https://www.linkedin.com/pulse/feature-turns-spec-kit-platform-extensions-presets-rajasekaran-3ejgc)
|
||||
|
||||
## Community & Content
|
||||
|
||||
### Developer Walkthroughs and Blog Posts
|
||||
|
||||
March produced a wave of independent content as developers explored SDD in practice.
|
||||
|
||||
**Tiago Valverde** published *"Spec-Driven Development in Practice: A Walkthrough with Spec Kit"* on March 14. He documents building an Instagram-style photo mural feature using the full Spec Kit workflow, contrasting it with previous ad-hoc prompting: while directly prompting Claude worked for small changes, complex work led to scope creep, ambiguous requirements discovered too late, and no artifacts left behind. Valverde recommends being specific in the initial prompt, reviewing `spec.md` immediately, and highlights the clarify step as particularly valuable. A shorter companion piece, *"The Shift from Vibe Coding to Spec-Driven Development,"* appeared on March 8. [\[tiagovalverde.com\]](https://www.tiagovalverde.com/posts/spec-driven-development-in-practice-a-walkthrough-with-spec-kit)
|
||||
|
||||
**Alfredo Perez** published *"Build Your Own SDD Workflow"* on March 21, taking a deliberately contrarian approach. He praises SDD in principle but argues the full seven-step workflow carries too much ceremony for smaller tasks. His solution is a lean **4-step custom workflow** — `specify → plan → tasks → implement` — dropping constitution, clarify, and review, wired into the **SpecKit Companion** VS Code extension. The article highlights an important tradeoff: full rigor vs. lightweight adoption. Perez also presented this workflow at an **Angular Community Meetup** on March 25. [\[alfredo-perez.dev\]](https://www.alfredo-perez.dev/blog/2026-03-21-build-your-own-sdd-workflow)
|
||||
|
||||
**Sergey Golubev** of prodfeat.ai published *"20+ SDD Frameworks: A Catalog for AI Development"* on March 17. The catalog organizes **20+ frameworks in 6 categories**, highlighting **BMAD-METHOD** (~41k stars, simulates an agile team from AI roles), **QuintCode + FPF** (preserves decision rationale via a 5-phase ADI Cycle), and **cc-sdd** (~2.9k stars, enforced SDD workflow for 8 tools). Golubev presents a three-level maturity model: *Spec-First* (spec per task, discarded after), *Spec-Anchored* (living document), and *Spec-as-Source* (spec is the only artifact). His conclusion: "SDD is not a fad… AI agents generate good code when the task is well-defined. Without a spec — you're rolling the dice." [\[prodfeat.ai\]](https://www.prodfeat.ai/en/blog/2026-03-17-sdd-frameworks-catalog)
|
||||
|
||||
### Community Tools and Documentation
|
||||
|
||||
The **Spec Kit Assistant VS Code extension** was formally recognized as a Community Friend and added to the README. The README was reorganized: community extensions table moved into the main page for discoverability, a community presets section was added, and the publishing guide gained Category and Effect columns. New walkthroughs included Java brownfield, Go/React brownfield dashboard, and the Spring Boot pirate-speak preset demo. [\[github.com\]](https://github.com/github/spec-kit/releases)
|
||||
|
||||
A notable community project appeared: **speckit-pipeline** by iandeherdt — a pipeline atop Spec Kit with a **design loop** (designer + critic agents iterating in a browser) and a **build loop** (developer + evaluator agents verifying against acceptance criteria). An open issue (#1966) requests a built-in pipeline command, suggesting this pattern may eventually reach core.
|
||||
|
||||
A public **Microsoft Learn** training module, *"Implement Spec-Driven Development using the GitHub Spec Kit"* (3 hours, 13 units), provided an onboarding path for enterprise developers.
|
||||
|
||||
## SDD Ecosystem & Industry Trends
|
||||
|
||||
### The "Vibe Coding Is Dead" Narrative
|
||||
|
||||
*ByteIota* published *"Spec-Driven Development Kills 'Vibe Coding'"* on March 20, reporting AWS pushing SDD as the new standard. Key claims: over 100,000 developers adopting SDD approaches in early tool previews, AWS demonstrating a two-week feature completed in two days using Kiro IDE, and WEF research indicating 65% of developers expect their role to shift toward spec-first workflows in 2026. [\[byteiota.com\]](https://byteiota.com/spec-driven-development-kills-vibe-coding-march-2026/)
|
||||
|
||||
Critics got equal space. *Marmelab* called SDD "the exact mistakes Agile was designed to solve." An *Isoform* controlled test found SDD took 33 minutes for 689 lines vs. 8 minutes with iterative prompting, with no measured quality improvement. The emerging consensus favored hybrids — a Red Hat developer captured it: "Use the vibes to explore. Use specifications to build." Other independent articles appeared from Shimon Ifrah, Raul Proenza (Cox Automotive), CGI, and Vishal Mysore. ByteIota also raised an underappreciated concern: if specs replace coding, how do juniors build the judgment to write good specs or review AI-generated code? [\[byteiota.com\]](https://byteiota.com/spec-driven-development-kills-vibe-coding-march-2026/)
|
||||
|
||||
### Competitive Landscape
|
||||
|
||||
**Augment Code** published *"Intent vs GitHub Spec Kit (2026): Platform or Framework?"* on March 31. The core tradeoff: Spec Kit's strength is **portability** across 22+ agents; Intent offers **living specs** with automated drift detection. The comparison surfaced spec drift as a key architectural concern — Spec Kit's specs can become stale post-implementation, and while community extensions address this, native real-time drift detection is not yet in core. [\[augmentcode.com\]](https://www.augmentcode.com/tools/intent-vs-github)
|
||||
|
||||
The broader landscape continued evolving. OpenSpec held ~29.3k stars, BMAD-METHOD grew to ~41k, and Tessl continued in private beta. While Spec Kit leads in GitHub popularity and agent breadth, alternatives differentiate on orchestration depth (Intent, BMAD), enforced discipline (cc-sdd), decision trails (QuintCode), and spec-as-source vision (Tessl). [\[prodfeat.ai\]](https://www.prodfeat.ai/en/blog/2026-03-17-sdd-frameworks-catalog)
|
||||
|
||||
## Roadmap
|
||||
|
||||
Areas under discussion or in progress for future development:
|
||||
|
||||
- **Spec lifecycle management** -- supporting longer-lived specifications that evolve across multiple iterations. The Augment Code comparison and community commentary highlighted "spec drift" as a key concern. The Archive & Reconcile extension (#1844) is a community step; a core solution is expected to be a focus area. [\[augmentcode.com\]](https://www.augmentcode.com/tools/intent-vs-github) [\[github.com\]](https://github.com/github/spec-kit/releases)
|
||||
- **CI/CD integration** -- incorporating Spec Kit verification into pull request workflows and failing builds when specs are out of alignment. The Jira and Azure DevOps extensions (#1764, #1734) are a first step. [\[github.com\]](https://github.com/github/spec-kit/releases)
|
||||
- **End-to-end workflow automation** -- an open issue (#1966) proposes a built-in pipeline command. The community-built **speckit-pipeline** by iandeherdt already demonstrates multi-agent loops with browser verification. [\[github.com\]](https://github.com/iandeherdt/speckit-pipeline)
|
||||
- **Continued agent expansion** -- seven new agents were added in March alone. The agent-agnostic design means support for emerging tools can be added by anyone. [\[byteiota.com\]](https://byteiota.com/spec-driven-development-kills-vibe-coding-march-2026/)
|
||||
- **Experience simplification** -- the preset system, custom workflows, and growing walkthrough library lower the learning curve, but extension discoverability will need a more robust solution as the catalog grows. [\[github.com\]](https://github.com/github/spec-kit/releases)
|
||||
- **Toward a stable release** -- nine releases in one month reflects pre-1.0 momentum. Reaching 1.0 will require stabilizing the extension and preset APIs and ensuring backward compatibility across the agent and extension surface area. [\[github.com\]](https://github.com/github/spec-kit/blob/main/newsletters/2026-February.md)
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-03-24T00:00:00Z",
|
||||
"updated_at": "2026-04-09T08:00:00Z",
|
||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.community.json",
|
||||
"presets": {
|
||||
"aide-in-place": {
|
||||
@@ -29,6 +29,82 @@
|
||||
"aide"
|
||||
]
|
||||
},
|
||||
"canon-core": {
|
||||
"name": "Canon Core",
|
||||
"id": "canon-core",
|
||||
"version": "0.1.0",
|
||||
"description": "Adapts original Spec Kit workflow to work together with Canon extension.",
|
||||
"author": "Maxim Stupakov",
|
||||
"download_url": "https://github.com/maximiliamus/spec-kit-canon/releases/download/v0.1.0/spec-kit-canon-core-v0.1.0.zip",
|
||||
"repository": "https://github.com/maximiliamus/spec-kit-canon",
|
||||
"homepage": "https://github.com/maximiliamus/spec-kit-canon",
|
||||
"documentation": "https://github.com/maximiliamus/spec-kit-canon/blob/master/README.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.4.3"
|
||||
},
|
||||
"provides": {
|
||||
"templates": 2,
|
||||
"commands": 8
|
||||
},
|
||||
"tags": [
|
||||
"baseline",
|
||||
"canon",
|
||||
"spec-first"
|
||||
]
|
||||
},
|
||||
"explicit-task-dependencies": {
|
||||
"name": "Explicit Task Dependencies",
|
||||
"id": "explicit-task-dependencies",
|
||||
"version": "1.0.0",
|
||||
"description": "Adds explicit (depends on T###) dependency declarations and an Execution Wave DAG to tasks.md for dependency-resolved parallel scheduling",
|
||||
"author": "Quratulain-bilal",
|
||||
"repository": "https://github.com/Quratulain-bilal/spec-kit-preset-explicit-task-dependencies",
|
||||
"download_url": "https://github.com/Quratulain-bilal/spec-kit-preset-explicit-task-dependencies/archive/refs/tags/v1.0.0.zip",
|
||||
"homepage": "https://github.com/Quratulain-bilal/spec-kit-preset-explicit-task-dependencies",
|
||||
"documentation": "https://github.com/Quratulain-bilal/spec-kit-preset-explicit-task-dependencies/blob/main/README.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.4.0"
|
||||
},
|
||||
"provides": {
|
||||
"templates": 1,
|
||||
"commands": 1
|
||||
},
|
||||
"tags": [
|
||||
"dependencies",
|
||||
"parallel",
|
||||
"scheduling",
|
||||
"wave-dag"
|
||||
]
|
||||
},
|
||||
"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",
|
||||
@@ -53,6 +129,55 @@
|
||||
"fun",
|
||||
"experimental"
|
||||
]
|
||||
},
|
||||
"toc-navigation": {
|
||||
"name": "Table of Contents Navigation",
|
||||
"id": "toc-navigation",
|
||||
"version": "1.0.0",
|
||||
"description": "Adds a navigable Table of Contents to generated spec.md, plan.md, and tasks.md documents",
|
||||
"author": "Quratulain-bilal",
|
||||
"repository": "https://github.com/Quratulain-bilal/spec-kit-preset-toc-navigation",
|
||||
"download_url": "https://github.com/Quratulain-bilal/spec-kit-preset-toc-navigation/archive/refs/tags/v1.0.0.zip",
|
||||
"homepage": "https://github.com/Quratulain-bilal/spec-kit-preset-toc-navigation",
|
||||
"documentation": "https://github.com/Quratulain-bilal/spec-kit-preset-toc-navigation/blob/main/README.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.4.0"
|
||||
},
|
||||
"provides": {
|
||||
"templates": 3,
|
||||
"commands": 3
|
||||
},
|
||||
"tags": [
|
||||
"navigation",
|
||||
"toc",
|
||||
"documentation"
|
||||
]
|
||||
},
|
||||
"vscode-ask-questions": {
|
||||
"name": "VS Code Ask Questions",
|
||||
"id": "vscode-ask-questions",
|
||||
"version": "1.0.0",
|
||||
"description": "Enhances the clarify command to use vscode/askQuestions for batched interactive questioning, reducing API request costs in GitHub Copilot.",
|
||||
"author": "fdcastel",
|
||||
"repository": "https://github.com/fdcastel/spec-kit-presets",
|
||||
"download_url": "https://github.com/fdcastel/spec-kit-presets/releases/download/vscode-ask-questions-v1.0.0/vscode-ask-questions.zip",
|
||||
"homepage": "https://github.com/fdcastel/spec-kit-presets",
|
||||
"documentation": "https://github.com/fdcastel/spec-kit-presets/blob/main/vscode-ask-questions/README.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.1.0"
|
||||
},
|
||||
"provides": {
|
||||
"templates": 0,
|
||||
"commands": 1
|
||||
},
|
||||
"tags": [
|
||||
"vscode",
|
||||
"askquestions",
|
||||
"clarify",
|
||||
"interactive"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,16 +1,14 @@
|
||||
[project]
|
||||
name = "specify-cli"
|
||||
version = "0.4.5"
|
||||
version = "0.6.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 = [
|
||||
"typer",
|
||||
"click>=8.1",
|
||||
"typer>=0.24.0",
|
||||
"click>=8.2.1",
|
||||
"rich",
|
||||
"httpx[socks]",
|
||||
"platformdirs",
|
||||
"readchar",
|
||||
"truststore>=0.10.4",
|
||||
"pyyaml>=6.0",
|
||||
"packaging>=23.0",
|
||||
"pathspec>=0.12.0",
|
||||
@@ -41,6 +39,10 @@ packages = ["src/specify_cli"]
|
||||
"templates/commands" = "specify_cli/core_pack/commands"
|
||||
"scripts/bash" = "specify_cli/core_pack/scripts/bash"
|
||||
"scripts/powershell" = "specify_cli/core_pack/scripts/powershell"
|
||||
# Bundled extensions (installable via `specify extension add <name>`)
|
||||
"extensions/git" = "specify_cli/core_pack/extensions/git"
|
||||
# Bundled presets (installable via `specify preset add <name>` or `specify init --preset <name>`)
|
||||
"presets/lean" = "specify_cli/core_pack/presets/lean"
|
||||
|
||||
[project.optional-dependencies]
|
||||
test = [
|
||||
|
||||
@@ -194,9 +194,35 @@ get_feature_paths() {
|
||||
has_git_repo="true"
|
||||
fi
|
||||
|
||||
# Use prefix-based lookup to support multiple branches per spec
|
||||
# Resolve feature directory. Priority:
|
||||
# 1. SPECIFY_FEATURE_DIRECTORY env var (explicit override)
|
||||
# 2. .specify/feature.json "feature_directory" key (persisted by /speckit.specify)
|
||||
# 3. Branch-name-based prefix lookup (legacy fallback)
|
||||
local feature_dir
|
||||
if ! feature_dir=$(find_feature_dir_by_prefix "$repo_root" "$current_branch"); then
|
||||
if [[ -n "${SPECIFY_FEATURE_DIRECTORY:-}" ]]; then
|
||||
feature_dir="$SPECIFY_FEATURE_DIRECTORY"
|
||||
# Normalize relative paths to absolute under repo root
|
||||
[[ "$feature_dir" != /* ]] && feature_dir="$repo_root/$feature_dir"
|
||||
elif [[ -f "$repo_root/.specify/feature.json" ]]; then
|
||||
local _fd
|
||||
if command -v jq >/dev/null 2>&1; then
|
||||
_fd=$(jq -r '.feature_directory // empty' "$repo_root/.specify/feature.json" 2>/dev/null)
|
||||
elif command -v python3 >/dev/null 2>&1; then
|
||||
# Fallback: use Python to parse JSON so pretty-printed/multi-line files work
|
||||
_fd=$(python3 -c "import json,sys; d=json.load(open(sys.argv[1])); print(d.get('feature_directory',''))" "$repo_root/.specify/feature.json" 2>/dev/null)
|
||||
else
|
||||
# Last resort: single-line grep fallback (won't work on multi-line JSON)
|
||||
_fd=$(grep -o '"feature_directory"[[:space:]]*:[[:space:]]*"[^"]*"' "$repo_root/.specify/feature.json" 2>/dev/null | sed 's/.*"\([^"]*\)"$/\1/')
|
||||
fi
|
||||
if [[ -n "$_fd" ]]; then
|
||||
feature_dir="$_fd"
|
||||
# Normalize relative paths to absolute under repo root
|
||||
[[ "$feature_dir" != /* ]] && feature_dir="$repo_root/$feature_dir"
|
||||
elif ! feature_dir=$(find_feature_dir_by_prefix "$repo_root" "$current_branch"); then
|
||||
echo "ERROR: Failed to resolve feature directory" >&2
|
||||
return 1
|
||||
fi
|
||||
elif ! feature_dir=$(find_feature_dir_by_prefix "$repo_root" "$current_branch"); then
|
||||
echo "ERROR: Failed to resolve feature directory" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
@@ -327,13 +327,21 @@ SPEC_FILE="$FEATURE_DIR/spec.md"
|
||||
|
||||
if [ "$DRY_RUN" != true ]; then
|
||||
if [ "$HAS_GIT" = true ]; then
|
||||
if ! git checkout -b "$BRANCH_NAME" 2>/dev/null; then
|
||||
branch_create_error=""
|
||||
if ! branch_create_error=$(git checkout -q -b "$BRANCH_NAME" 2>&1); then
|
||||
current_branch="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || true)"
|
||||
# Check if branch already exists
|
||||
if git branch --list "$BRANCH_NAME" | grep -q .; then
|
||||
if [ "$ALLOW_EXISTING" = true ]; then
|
||||
# Switch to the existing branch instead of failing
|
||||
if ! git checkout "$BRANCH_NAME" 2>/dev/null; then
|
||||
# If we're already on the branch, continue without another checkout.
|
||||
if [ "$current_branch" = "$BRANCH_NAME" ]; then
|
||||
:
|
||||
# Otherwise switch to the existing branch instead of failing.
|
||||
elif ! switch_branch_error=$(git checkout -q "$BRANCH_NAME" 2>&1); then
|
||||
>&2 echo "Error: Failed to switch to existing branch '$BRANCH_NAME'. Please resolve any local changes or conflicts and try again."
|
||||
if [ -n "$switch_branch_error" ]; then
|
||||
>&2 printf '%s\n' "$switch_branch_error"
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
elif [ "$USE_TIMESTAMP" = true ]; then
|
||||
@@ -344,7 +352,12 @@ if [ "$DRY_RUN" != true ]; then
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
>&2 echo "Error: Failed to create git branch '$BRANCH_NAME'. Please check your git configuration and try again."
|
||||
>&2 echo "Error: Failed to create git branch '$BRANCH_NAME'."
|
||||
if [ -n "$branch_create_error" ]; then
|
||||
>&2 printf '%s\n' "$branch_create_error"
|
||||
else
|
||||
>&2 echo "Please check your git configuration and try again."
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -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, 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|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, and Pi 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"
|
||||
@@ -84,8 +84,9 @@ AGY_FILE="$REPO_ROOT/.agent/rules/specify-rules.md"
|
||||
BOB_FILE="$AGENTS_FILE"
|
||||
VIBE_FILE="$REPO_ROOT/.vibe/agents/specify-agents.md"
|
||||
KIMI_FILE="$REPO_ROOT/KIMI.md"
|
||||
TRAE_FILE="$REPO_ROOT/.trae/rules/AGENTS.md"
|
||||
TRAE_FILE="$REPO_ROOT/.trae/rules/project_rules.md"
|
||||
IFLOW_FILE="$REPO_ROOT/IFLOW.md"
|
||||
FORGE_FILE="$AGENTS_FILE"
|
||||
|
||||
# Template file
|
||||
TEMPLATE_FILE="$REPO_ROOT/.specify/templates/agent-file-template.md"
|
||||
@@ -116,13 +117,19 @@ log_warning() {
|
||||
echo "WARNING: $1" >&2
|
||||
}
|
||||
|
||||
# Track temporary files for cleanup on interrupt
|
||||
_CLEANUP_FILES=()
|
||||
|
||||
# Cleanup function for temporary files
|
||||
cleanup() {
|
||||
local exit_code=$?
|
||||
# Disarm traps to prevent re-entrant loop
|
||||
trap - EXIT INT TERM
|
||||
rm -f /tmp/agent_update_*_$$
|
||||
rm -f /tmp/manual_additions_$$
|
||||
if [ ${#_CLEANUP_FILES[@]} -gt 0 ]; then
|
||||
for f in "${_CLEANUP_FILES[@]}"; do
|
||||
rm -f "$f" "$f.bak" "$f.tmp"
|
||||
done
|
||||
fi
|
||||
exit $exit_code
|
||||
}
|
||||
|
||||
@@ -267,7 +274,7 @@ get_commands_for_language() {
|
||||
echo "cargo test && cargo clippy"
|
||||
;;
|
||||
*"JavaScript"*|*"TypeScript"*)
|
||||
echo "npm test \\&\\& npm run lint"
|
||||
echo "npm test && npm run lint"
|
||||
;;
|
||||
*)
|
||||
echo "# Add commands for $lang"
|
||||
@@ -280,10 +287,15 @@ get_language_conventions() {
|
||||
echo "$lang: Follow standard conventions"
|
||||
}
|
||||
|
||||
# Escape sed replacement-side specials for | delimiter.
|
||||
# & and \ are replacement-side specials; | is our sed delimiter.
|
||||
_esc_sed() { printf '%s\n' "$1" | sed 's/[\\&|]/\\&/g'; }
|
||||
|
||||
create_new_agent_file() {
|
||||
local target_file="$1"
|
||||
local temp_file="$2"
|
||||
local project_name="$3"
|
||||
local project_name
|
||||
project_name=$(_esc_sed "$3")
|
||||
local current_date="$4"
|
||||
|
||||
if [[ ! -f "$TEMPLATE_FILE" ]]; then
|
||||
@@ -306,18 +318,19 @@ create_new_agent_file() {
|
||||
# Replace template placeholders
|
||||
local project_structure
|
||||
project_structure=$(get_project_structure "$NEW_PROJECT_TYPE")
|
||||
project_structure=$(_esc_sed "$project_structure")
|
||||
|
||||
local commands
|
||||
commands=$(get_commands_for_language "$NEW_LANG")
|
||||
|
||||
|
||||
local language_conventions
|
||||
language_conventions=$(get_language_conventions "$NEW_LANG")
|
||||
|
||||
# Perform substitutions with error checking using safer approach
|
||||
# Escape special characters for sed by using a different delimiter or escaping
|
||||
local escaped_lang=$(printf '%s\n' "$NEW_LANG" | sed 's/[\[\.*^$()+{}|]/\\&/g')
|
||||
local escaped_framework=$(printf '%s\n' "$NEW_FRAMEWORK" | sed 's/[\[\.*^$()+{}|]/\\&/g')
|
||||
local escaped_branch=$(printf '%s\n' "$CURRENT_BRANCH" | sed 's/[\[\.*^$()+{}|]/\\&/g')
|
||||
|
||||
local escaped_lang=$(_esc_sed "$NEW_LANG")
|
||||
local escaped_framework=$(_esc_sed "$NEW_FRAMEWORK")
|
||||
commands=$(_esc_sed "$commands")
|
||||
language_conventions=$(_esc_sed "$language_conventions")
|
||||
local escaped_branch=$(_esc_sed "$CURRENT_BRANCH")
|
||||
|
||||
# Build technology stack and recent change strings conditionally
|
||||
local tech_stack
|
||||
@@ -360,17 +373,18 @@ create_new_agent_file() {
|
||||
fi
|
||||
done
|
||||
|
||||
# Convert \n sequences to actual newlines
|
||||
newline=$(printf '\n')
|
||||
sed -i.bak2 "s/\\\\n/${newline}/g" "$temp_file"
|
||||
# Convert literal \n sequences to actual newlines (portable — works on BSD + GNU)
|
||||
awk '{gsub(/\\n/,"\n")}1' "$temp_file" > "$temp_file.tmp"
|
||||
mv "$temp_file.tmp" "$temp_file"
|
||||
|
||||
# Clean up backup files
|
||||
rm -f "$temp_file.bak" "$temp_file.bak2"
|
||||
# Clean up backup files from sed -i.bak
|
||||
rm -f "$temp_file.bak"
|
||||
|
||||
# Prepend Cursor frontmatter for .mdc files so rules are auto-included
|
||||
if [[ "$target_file" == *.mdc ]]; then
|
||||
local frontmatter_file
|
||||
frontmatter_file=$(mktemp) || return 1
|
||||
_CLEANUP_FILES+=("$frontmatter_file")
|
||||
printf '%s\n' "---" "description: Project Development Guidelines" "globs: [\"**/*\"]" "alwaysApply: true" "---" "" > "$frontmatter_file"
|
||||
cat "$temp_file" >> "$frontmatter_file"
|
||||
mv "$frontmatter_file" "$temp_file"
|
||||
@@ -394,6 +408,7 @@ update_existing_agent_file() {
|
||||
log_error "Failed to create temporary file"
|
||||
return 1
|
||||
}
|
||||
_CLEANUP_FILES+=("$temp_file")
|
||||
|
||||
# Process the file in one pass
|
||||
local tech_stack=$(format_technology_stack "$NEW_LANG" "$NEW_FRAMEWORK")
|
||||
@@ -518,6 +533,7 @@ update_existing_agent_file() {
|
||||
if ! head -1 "$temp_file" | grep -q '^---'; then
|
||||
local frontmatter_file
|
||||
frontmatter_file=$(mktemp) || { rm -f "$temp_file"; return 1; }
|
||||
_CLEANUP_FILES+=("$frontmatter_file")
|
||||
printf '%s\n' "---" "description: Project Development Guidelines" "globs: [\"**/*\"]" "alwaysApply: true" "---" "" > "$frontmatter_file"
|
||||
cat "$temp_file" >> "$frontmatter_file"
|
||||
mv "$frontmatter_file" "$temp_file"
|
||||
@@ -570,6 +586,7 @@ update_agent_file() {
|
||||
log_error "Failed to create temporary file"
|
||||
return 1
|
||||
}
|
||||
_CLEANUP_FILES+=("$temp_file")
|
||||
|
||||
if create_new_agent_file "$target_file" "$temp_file" "$project_name" "$current_date"; then
|
||||
if mv "$temp_file" "$target_file"; then
|
||||
@@ -690,12 +707,18 @@ update_specific_agent() {
|
||||
iflow)
|
||||
update_agent_file "$IFLOW_FILE" "iFlow CLI" || return 1
|
||||
;;
|
||||
forge)
|
||||
update_agent_file "$AGENTS_FILE" "Forge" || return 1
|
||||
;;
|
||||
goose)
|
||||
update_agent_file "$AGENTS_FILE" "Goose" || return 1
|
||||
;;
|
||||
generic)
|
||||
log_info "Generic agent: no predefined context file. Use the agent-specific update script for your agent."
|
||||
;;
|
||||
*)
|
||||
log_error "Unknown agent type '$agent_type'"
|
||||
log_error "Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|junie|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|iflow|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
|
||||
@@ -739,10 +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" || _all_ok=false
|
||||
_update_if_new "$AMP_FILE" "Amp" || _all_ok=false
|
||||
_update_if_new "$KIRO_FILE" "Kiro CLI" || _all_ok=false
|
||||
_update_if_new "$BOB_FILE" "IBM Bob" || _all_ok=false
|
||||
_update_if_new "$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
|
||||
@@ -783,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|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]"
|
||||
}
|
||||
|
||||
#==============================================================================
|
||||
|
||||
@@ -160,7 +160,36 @@ function Get-FeaturePathsEnv {
|
||||
$repoRoot = Get-RepoRoot
|
||||
$currentBranch = Get-CurrentBranch
|
||||
$hasGit = Test-HasGit
|
||||
$featureDir = Get-FeatureDir -RepoRoot $repoRoot -Branch $currentBranch
|
||||
|
||||
# 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)
|
||||
$featureJson = Join-Path $repoRoot '.specify/feature.json'
|
||||
if ($env:SPECIFY_FEATURE_DIRECTORY) {
|
||||
$featureDir = $env:SPECIFY_FEATURE_DIRECTORY
|
||||
# Normalize relative paths to absolute under repo root
|
||||
if (-not [System.IO.Path]::IsPathRooted($featureDir)) {
|
||||
$featureDir = Join-Path $repoRoot $featureDir
|
||||
}
|
||||
} elseif (Test-Path $featureJson) {
|
||||
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
|
||||
}
|
||||
} catch {
|
||||
$featureDir = Get-FeatureDir -RepoRoot $repoRoot -Branch $currentBranch
|
||||
}
|
||||
} else {
|
||||
$featureDir = Get-FeatureDir -RepoRoot $repoRoot -Branch $currentBranch
|
||||
}
|
||||
|
||||
[PSCustomObject]@{
|
||||
REPO_ROOT = $repoRoot
|
||||
|
||||
@@ -293,25 +293,37 @@ $specFile = Join-Path $featureDir 'spec.md'
|
||||
if (-not $DryRun) {
|
||||
if ($hasGit) {
|
||||
$branchCreated = $false
|
||||
$branchCreateError = ''
|
||||
try {
|
||||
git checkout -q -b $branchName 2>$null | Out-Null
|
||||
$branchCreateError = git checkout -q -b $branchName 2>&1 | Out-String
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
$branchCreated = $true
|
||||
}
|
||||
} catch {
|
||||
# Exception during git command
|
||||
$branchCreateError = $_.Exception.Message
|
||||
}
|
||||
|
||||
if (-not $branchCreated) {
|
||||
$currentBranch = ''
|
||||
try { $currentBranch = (git rev-parse --abbrev-ref HEAD 2>$null).Trim() } catch {}
|
||||
# Check if branch already exists
|
||||
$existingBranch = git branch --list $branchName 2>$null
|
||||
if ($existingBranch) {
|
||||
if ($AllowExistingBranch) {
|
||||
# Switch to the existing branch instead of failing
|
||||
git checkout -q $branchName 2>$null | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Error "Error: Branch '$branchName' exists but could not be checked out. Resolve any uncommitted changes or conflicts and try again."
|
||||
exit 1
|
||||
# If we're already on the branch, continue without another checkout.
|
||||
if ($currentBranch -eq $branchName) {
|
||||
# Already on the target branch — nothing to do
|
||||
} else {
|
||||
# Otherwise switch to the existing branch instead of failing.
|
||||
$switchBranchError = git checkout -q $branchName 2>&1 | Out-String
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
if ($switchBranchError) {
|
||||
Write-Error "Error: Branch '$branchName' exists but could not be checked out.`n$($switchBranchError.Trim())"
|
||||
} else {
|
||||
Write-Error "Error: Branch '$branchName' exists but could not be checked out. Resolve any uncommitted changes or conflicts and try again."
|
||||
}
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
} elseif ($Timestamp) {
|
||||
Write-Error "Error: Branch '$branchName' already exists. Rerun to get a new timestamp or use a different -ShortName."
|
||||
@@ -321,7 +333,11 @@ if (-not $DryRun) {
|
||||
exit 1
|
||||
}
|
||||
} else {
|
||||
Write-Error "Error: Failed to create git branch '$branchName'. Please check your git configuration and try again."
|
||||
if ($branchCreateError) {
|
||||
Write-Error "Error: Failed to create git branch '$branchName'.`n$($branchCreateError.Trim())"
|
||||
} else {
|
||||
Write-Error "Error: Failed to create git branch '$branchName'. Please check your git configuration and try again."
|
||||
}
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, 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','qodercli','vibe','kimi','trae','pi','iflow','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
|
||||
)
|
||||
|
||||
@@ -65,8 +65,10 @@ $AGY_FILE = Join-Path $REPO_ROOT '.agent/rules/specify-rules.md'
|
||||
$BOB_FILE = Join-Path $REPO_ROOT 'AGENTS.md'
|
||||
$VIBE_FILE = Join-Path $REPO_ROOT '.vibe/agents/specify-agents.md'
|
||||
$KIMI_FILE = Join-Path $REPO_ROOT 'KIMI.md'
|
||||
$TRAE_FILE = Join-Path $REPO_ROOT '.trae/rules/AGENTS.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'
|
||||
|
||||
@@ -415,36 +417,67 @@ function Update-SpecificAgent {
|
||||
'trae' { Update-AgentFile -TargetFile $TRAE_FILE -AgentName 'Trae' }
|
||||
'pi' { Update-AgentFile -TargetFile $AGENTS_FILE -AgentName 'Pi Coding Agent' }
|
||||
'iflow' { Update-AgentFile -TargetFile $IFLOW_FILE -AgentName 'iFlow CLI' }
|
||||
'forge' { Update-AgentFile -TargetFile $FORGE_FILE -AgentName 'Forge' }
|
||||
'goose' { Update-AgentFile -TargetFile $GOOSE_FILE -AgentName 'Goose' }
|
||||
'generic' { Write-Info 'Generic agent: no predefined context file. Use the agent-specific update script for your agent.' }
|
||||
default { Write-Err "Unknown agent type '$Type'"; Write-Err 'Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|junie|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|iflow|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 }
|
||||
}
|
||||
}
|
||||
|
||||
function Update-AllExistingAgents {
|
||||
$found = $false
|
||||
$ok = $true
|
||||
if (Test-Path $CLAUDE_FILE) { if (-not (Update-AgentFile -TargetFile $CLAUDE_FILE -AgentName 'Claude Code')) { $ok = $false }; $found = $true }
|
||||
if (Test-Path $GEMINI_FILE) { if (-not (Update-AgentFile -TargetFile $GEMINI_FILE -AgentName 'Gemini CLI')) { $ok = $false }; $found = $true }
|
||||
if (Test-Path $COPILOT_FILE) { if (-not (Update-AgentFile -TargetFile $COPILOT_FILE -AgentName 'GitHub Copilot')) { $ok = $false }; $found = $true }
|
||||
if (Test-Path $CURSOR_FILE) { if (-not (Update-AgentFile -TargetFile $CURSOR_FILE -AgentName 'Cursor IDE')) { $ok = $false }; $found = $true }
|
||||
if (Test-Path $QWEN_FILE) { if (-not (Update-AgentFile -TargetFile $QWEN_FILE -AgentName 'Qwen Code')) { $ok = $false }; $found = $true }
|
||||
if (Test-Path $AGENTS_FILE) { if (-not (Update-AgentFile -TargetFile $AGENTS_FILE -AgentName 'Codex/opencode')) { $ok = $false }; $found = $true }
|
||||
if (Test-Path $WINDSURF_FILE) { if (-not (Update-AgentFile -TargetFile $WINDSURF_FILE -AgentName 'Windsurf')) { $ok = $false }; $found = $true }
|
||||
if (Test-Path $JUNIE_FILE) { if (-not (Update-AgentFile -TargetFile $JUNIE_FILE -AgentName 'Junie')) { $ok = $false }; $found = $true }
|
||||
if (Test-Path $KILOCODE_FILE) { if (-not (Update-AgentFile -TargetFile $KILOCODE_FILE -AgentName 'Kilo Code')) { $ok = $false }; $found = $true }
|
||||
if (Test-Path $AUGGIE_FILE) { if (-not (Update-AgentFile -TargetFile $AUGGIE_FILE -AgentName 'Auggie CLI')) { $ok = $false }; $found = $true }
|
||||
if (Test-Path $ROO_FILE) { if (-not (Update-AgentFile -TargetFile $ROO_FILE -AgentName 'Roo Code')) { $ok = $false }; $found = $true }
|
||||
if (Test-Path $CODEBUDDY_FILE) { if (-not (Update-AgentFile -TargetFile $CODEBUDDY_FILE -AgentName 'CodeBuddy CLI')) { $ok = $false }; $found = $true }
|
||||
if (Test-Path $QODER_FILE) { if (-not (Update-AgentFile -TargetFile $QODER_FILE -AgentName 'Qoder CLI')) { $ok = $false }; $found = $true }
|
||||
if (Test-Path $SHAI_FILE) { if (-not (Update-AgentFile -TargetFile $SHAI_FILE -AgentName 'SHAI')) { $ok = $false }; $found = $true }
|
||||
if (Test-Path $TABNINE_FILE) { if (-not (Update-AgentFile -TargetFile $TABNINE_FILE -AgentName 'Tabnine CLI')) { $ok = $false }; $found = $true }
|
||||
if (Test-Path $KIRO_FILE) { if (-not (Update-AgentFile -TargetFile $KIRO_FILE -AgentName 'Kiro CLI')) { $ok = $false }; $found = $true }
|
||||
if (Test-Path $AGY_FILE) { if (-not (Update-AgentFile -TargetFile $AGY_FILE -AgentName 'Antigravity')) { $ok = $false }; $found = $true }
|
||||
if (Test-Path $BOB_FILE) { if (-not (Update-AgentFile -TargetFile $BOB_FILE -AgentName 'IBM Bob')) { $ok = $false }; $found = $true }
|
||||
if (Test-Path $VIBE_FILE) { if (-not (Update-AgentFile -TargetFile $VIBE_FILE -AgentName 'Mistral Vibe')) { $ok = $false }; $found = $true }
|
||||
if (Test-Path $KIMI_FILE) { if (-not (Update-AgentFile -TargetFile $KIMI_FILE -AgentName 'Kimi Code')) { $ok = $false }; $found = $true }
|
||||
if (Test-Path $TRAE_FILE) { if (-not (Update-AgentFile -TargetFile $TRAE_FILE -AgentName 'Trae')) { $ok = $false }; $found = $true }
|
||||
if (Test-Path $IFLOW_FILE) { if (-not (Update-AgentFile -TargetFile $IFLOW_FILE -AgentName 'iFlow CLI')) { $ok = $false }; $found = $true }
|
||||
$updatedPaths = @()
|
||||
|
||||
# Helper function to update only if file exists and hasn't been updated yet
|
||||
function Update-IfNew {
|
||||
param(
|
||||
[Parameter(Mandatory=$true)]
|
||||
[string]$FilePath,
|
||||
[Parameter(Mandatory=$true)]
|
||||
[string]$AgentName
|
||||
)
|
||||
|
||||
if (-not (Test-Path $FilePath)) { return $true }
|
||||
|
||||
# Get the real path to detect duplicates (e.g., AMP_FILE, KIRO_FILE, BOB_FILE all point to AGENTS.md)
|
||||
$realPath = (Get-Item -LiteralPath $FilePath).FullName
|
||||
|
||||
# Check if we've already updated this file
|
||||
if ($updatedPaths -contains $realPath) {
|
||||
return $true
|
||||
}
|
||||
|
||||
# Record the file as seen before attempting the update
|
||||
# Use parent scope (1) to modify Update-AllExistingAgents' local variables
|
||||
Set-Variable -Name updatedPaths -Value ($updatedPaths + $realPath) -Scope 1
|
||||
Set-Variable -Name found -Value $true -Scope 1
|
||||
|
||||
# Perform the update
|
||||
return (Update-AgentFile -TargetFile $FilePath -AgentName $AgentName)
|
||||
}
|
||||
|
||||
if (-not (Update-IfNew -FilePath $CLAUDE_FILE -AgentName 'Claude Code')) { $ok = $false }
|
||||
if (-not (Update-IfNew -FilePath $GEMINI_FILE -AgentName 'Gemini CLI')) { $ok = $false }
|
||||
if (-not (Update-IfNew -FilePath $COPILOT_FILE -AgentName 'GitHub Copilot')) { $ok = $false }
|
||||
if (-not (Update-IfNew -FilePath $CURSOR_FILE -AgentName 'Cursor IDE')) { $ok = $false }
|
||||
if (-not (Update-IfNew -FilePath $QWEN_FILE -AgentName 'Qwen Code')) { $ok = $false }
|
||||
if (-not (Update-IfNew -FilePath $AGENTS_FILE -AgentName 'Codex/opencode/Amp/Kiro/Bob/Pi/Forge/Goose')) { $ok = $false }
|
||||
if (-not (Update-IfNew -FilePath $WINDSURF_FILE -AgentName 'Windsurf')) { $ok = $false }
|
||||
if (-not (Update-IfNew -FilePath $JUNIE_FILE -AgentName 'Junie')) { $ok = $false }
|
||||
if (-not (Update-IfNew -FilePath $KILOCODE_FILE -AgentName 'Kilo Code')) { $ok = $false }
|
||||
if (-not (Update-IfNew -FilePath $AUGGIE_FILE -AgentName 'Auggie CLI')) { $ok = $false }
|
||||
if (-not (Update-IfNew -FilePath $ROO_FILE -AgentName 'Roo Code')) { $ok = $false }
|
||||
if (-not (Update-IfNew -FilePath $CODEBUDDY_FILE -AgentName 'CodeBuddy CLI')) { $ok = $false }
|
||||
if (-not (Update-IfNew -FilePath $QODER_FILE -AgentName 'Qoder CLI')) { $ok = $false }
|
||||
if (-not (Update-IfNew -FilePath $SHAI_FILE -AgentName 'SHAI')) { $ok = $false }
|
||||
if (-not (Update-IfNew -FilePath $TABNINE_FILE -AgentName 'Tabnine CLI')) { $ok = $false }
|
||||
if (-not (Update-IfNew -FilePath $AGY_FILE -AgentName 'Antigravity')) { $ok = $false }
|
||||
if (-not (Update-IfNew -FilePath $VIBE_FILE -AgentName 'Mistral Vibe')) { $ok = $false }
|
||||
if (-not (Update-IfNew -FilePath $KIMI_FILE -AgentName 'Kimi Code')) { $ok = $false }
|
||||
if (-not (Update-IfNew -FilePath $TRAE_FILE -AgentName 'Trae')) { $ok = $false }
|
||||
if (-not (Update-IfNew -FilePath $IFLOW_FILE -AgentName 'iFlow CLI')) { $ok = $false }
|
||||
|
||||
if (-not $found) {
|
||||
Write-Info 'No existing agent files found, creating default Claude file...'
|
||||
if (-not (Update-AgentFile -TargetFile $CLAUDE_FILE -AgentName 'Claude Code')) { $ok = $false }
|
||||
@@ -459,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|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 {
|
||||
|
||||
@@ -78,7 +78,7 @@ The SDD methodology is significantly enhanced through three powerful commands th
|
||||
|
||||
This command transforms a simple feature description (the user-prompt) into a complete, structured specification with automatic repository management:
|
||||
|
||||
1. **Automatic Feature Numbering**: Scans existing specs to determine the next feature number (e.g., 001, 002, 003)
|
||||
1. **Automatic Feature Numbering**: Scans existing specs to determine the next feature number (e.g., 001, 002, 003, …, 1000 — expands beyond 3 digits automatically)
|
||||
2. **Branch Creation**: Generates a semantic branch name from your description and creates it automatically
|
||||
3. **Template-Based Generation**: Copies and customizes the feature specification template with your requirements
|
||||
4. **Directory Structure**: Creates the proper `specs/[branch-name]/` structure for all related documents
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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:
|
||||
@@ -191,8 +189,9 @@ class CommandRegistrar:
|
||||
toml_lines = []
|
||||
|
||||
if "description" in frontmatter:
|
||||
desc = frontmatter["description"].replace('"', '\\"')
|
||||
toml_lines.append(f'description = "{desc}"')
|
||||
toml_lines.append(
|
||||
f"description = {self._render_basic_toml_string(frontmatter['description'])}"
|
||||
)
|
||||
toml_lines.append("")
|
||||
|
||||
toml_lines.append(f"# Source: {source_id}")
|
||||
@@ -209,17 +208,57 @@ class CommandRegistrar:
|
||||
toml_lines.append(body)
|
||||
toml_lines.append("'''")
|
||||
else:
|
||||
escaped_body = (
|
||||
body.replace("\\", "\\\\")
|
||||
.replace('"', '\\"')
|
||||
.replace("\n", "\\n")
|
||||
.replace("\r", "\\r")
|
||||
.replace("\t", "\\t")
|
||||
)
|
||||
toml_lines.append(f'prompt = "{escaped_body}"')
|
||||
toml_lines.append(f"prompt = {self._render_basic_toml_string(body)}")
|
||||
|
||||
return "\n".join(toml_lines)
|
||||
|
||||
@staticmethod
|
||||
def _render_basic_toml_string(value: str) -> str:
|
||||
"""Render *value* as a TOML basic string literal."""
|
||||
escaped = (
|
||||
value.replace("\\", "\\\\")
|
||||
.replace('"', '\\"')
|
||||
.replace("\n", "\\n")
|
||||
.replace("\r", "\\r")
|
||||
.replace("\t", "\\t")
|
||||
)
|
||||
return f'"{escaped}"'
|
||||
|
||||
def render_yaml_command(
|
||||
self,
|
||||
frontmatter: dict,
|
||||
body: str,
|
||||
source_id: str,
|
||||
cmd_name: str = "",
|
||||
) -> str:
|
||||
"""Render command in YAML recipe format for Goose.
|
||||
|
||||
Args:
|
||||
frontmatter: Command frontmatter
|
||||
body: Command body content
|
||||
source_id: Source identifier (extension or preset ID)
|
||||
cmd_name: Command name used as title fallback
|
||||
|
||||
Returns:
|
||||
Formatted YAML recipe file content
|
||||
"""
|
||||
from specify_cli.integrations.base import YamlIntegration
|
||||
|
||||
title = frontmatter.get("title", "") or frontmatter.get("name", "")
|
||||
if not isinstance(title, str):
|
||||
title = str(title) if title is not None else ""
|
||||
if not title and cmd_name:
|
||||
title = YamlIntegration._human_title(cmd_name)
|
||||
if not title and source_id:
|
||||
title = YamlIntegration._human_title(Path(str(source_id)).stem)
|
||||
if not title:
|
||||
title = "Command"
|
||||
|
||||
description = frontmatter.get("description", "")
|
||||
if not isinstance(description, str):
|
||||
description = str(description) if description is not None else ""
|
||||
return YamlIntegration._render_yaml(title, description, body, source_id)
|
||||
|
||||
def render_skill_command(
|
||||
self,
|
||||
agent_name: str,
|
||||
@@ -246,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,
|
||||
@@ -275,12 +318,16 @@ class CommandRegistrar:
|
||||
},
|
||||
}
|
||||
if agent_name == "claude":
|
||||
# Claude skills should only run when explicitly invoked.
|
||||
# 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
|
||||
@@ -304,7 +351,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:
|
||||
@@ -326,7 +375,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)
|
||||
@@ -334,7 +385,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:
|
||||
@@ -348,14 +401,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}"
|
||||
@@ -367,7 +422,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.
|
||||
|
||||
@@ -408,6 +463,14 @@ class CommandRegistrar:
|
||||
|
||||
frontmatter = self._adjust_script_paths(frontmatter)
|
||||
|
||||
for key in agent_config.get("strip_frontmatter_keys", []):
|
||||
frontmatter.pop(key, None)
|
||||
|
||||
if agent_config.get("inject_name") and not frontmatter.get("name"):
|
||||
# Use custom name formatter if provided (e.g., Forge's hyphenated format)
|
||||
format_name = agent_config.get("format_name")
|
||||
frontmatter["name"] = format_name(cmd_name) if format_name else cmd_name
|
||||
|
||||
body = self._convert_argument_placeholder(
|
||||
body, "$ARGUMENTS", agent_config["args"]
|
||||
)
|
||||
@@ -416,12 +479,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']}")
|
||||
|
||||
@@ -435,13 +510,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 = output
|
||||
if agent_config["extension"] == "/SKILL.md":
|
||||
alias_output = self.render_skill_command(
|
||||
agent_name, alias_output_name, frontmatter, body, source_id, cmd_file, project_root
|
||||
alias_output_name = self._compute_output_name(
|
||||
agent_name, alias, agent_config
|
||||
)
|
||||
|
||||
# For agents with inject_name, render with alias-specific frontmatter
|
||||
if agent_config.get("inject_name"):
|
||||
alias_frontmatter = deepcopy(frontmatter)
|
||||
# Use custom name formatter if provided (e.g., Forge's hyphenated format)
|
||||
format_name = agent_config.get("format_name")
|
||||
alias_frontmatter["name"] = (
|
||||
format_name(alias) if format_name else alias
|
||||
)
|
||||
|
||||
if agent_config["extension"] == "/SKILL.md":
|
||||
alias_output = self.render_skill_command(
|
||||
agent_name,
|
||||
alias_output_name,
|
||||
alias_frontmatter,
|
||||
body,
|
||||
source_id,
|
||||
cmd_file,
|
||||
project_root,
|
||||
)
|
||||
elif agent_config["format"] == "markdown":
|
||||
alias_output = self.render_markdown_command(
|
||||
alias_frontmatter, body, source_id, context_note
|
||||
)
|
||||
elif agent_config["format"] == "toml":
|
||||
alias_output = self.render_toml_command(
|
||||
alias_frontmatter, body, source_id
|
||||
)
|
||||
elif agent_config["format"] == "yaml":
|
||||
alias_output = self.render_yaml_command(
|
||||
alias_frontmatter, body, source_id, alias
|
||||
)
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Unsupported format: {agent_config['format']}"
|
||||
)
|
||||
else:
|
||||
# For other agents, reuse the primary output
|
||||
alias_output = output
|
||||
if agent_config["extension"] == "/SKILL.md":
|
||||
alias_output = self.render_skill_command(
|
||||
agent_name,
|
||||
alias_output_name,
|
||||
frontmatter,
|
||||
body,
|
||||
source_id,
|
||||
cmd_file,
|
||||
project_root,
|
||||
)
|
||||
|
||||
alias_file = (
|
||||
commands_dir / f"{alias_output_name}{agent_config['extension']}"
|
||||
)
|
||||
try:
|
||||
alias_file.resolve().relative_to(commands_dir.resolve())
|
||||
except ValueError:
|
||||
raise ValueError(
|
||||
f"Alias output path escapes commands directory: {alias_file!r}"
|
||||
)
|
||||
alias_file = commands_dir / f"{alias_output_name}{agent_config['extension']}"
|
||||
alias_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
alias_file.write_text(alias_output, encoding="utf-8")
|
||||
if agent_name == "copilot":
|
||||
@@ -469,7 +599,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.
|
||||
|
||||
@@ -492,8 +622,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
|
||||
@@ -503,9 +637,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.
|
||||
|
||||
@@ -522,13 +654,17 @@ 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()
|
||||
|
||||
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()
|
||||
|
||||
@@ -540,4 +676,3 @@ try:
|
||||
CommandRegistrar._ensure_configs()
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -183,11 +185,40 @@ class ExtensionManifest:
|
||||
|
||||
# Validate provides section
|
||||
provides = self.data["provides"]
|
||||
if "commands" not in provides or not provides["commands"]:
|
||||
raise ValidationError("Extension must provide at least one command")
|
||||
commands = provides.get("commands", [])
|
||||
hooks = self.data.get("hooks")
|
||||
|
||||
# Validate commands
|
||||
for cmd in provides["commands"]:
|
||||
if "commands" in provides and not isinstance(commands, list):
|
||||
raise ValidationError(
|
||||
"Invalid provides.commands: expected a list"
|
||||
)
|
||||
if "hooks" in self.data and not isinstance(hooks, dict):
|
||||
raise ValidationError(
|
||||
"Invalid hooks: expected a mapping"
|
||||
)
|
||||
|
||||
has_commands = bool(commands)
|
||||
has_hooks = bool(hooks)
|
||||
|
||||
if not has_commands and not has_hooks:
|
||||
raise ValidationError(
|
||||
"Extension must provide at least one command or hook"
|
||||
)
|
||||
|
||||
# Validate hook values (if present)
|
||||
if hooks:
|
||||
for hook_name, hook_config in hooks.items():
|
||||
if not isinstance(hook_config, dict):
|
||||
raise ValidationError(
|
||||
f"Invalid hook '{hook_name}': expected a mapping"
|
||||
)
|
||||
if not hook_config.get("command"):
|
||||
raise ValidationError(
|
||||
f"Hook '{hook_name}' missing required 'command' field"
|
||||
)
|
||||
|
||||
# Validate commands (if present)
|
||||
for cmd in commands:
|
||||
if "name" not in cmd or "file" not in cmd:
|
||||
raise ValidationError("Command missing 'name' or 'file'")
|
||||
|
||||
@@ -226,7 +257,7 @@ class ExtensionManifest:
|
||||
@property
|
||||
def commands(self) -> List[Dict[str, Any]]:
|
||||
"""Get list of provided commands."""
|
||||
return self.data["provides"]["commands"]
|
||||
return self.data.get("provides", {}).get("commands", [])
|
||||
|
||||
@property
|
||||
def hooks(self) -> Dict[str, Any]:
|
||||
@@ -494,10 +525,11 @@ class ExtensionManager:
|
||||
"""Collect command and alias names declared by a manifest.
|
||||
|
||||
Performs install-time validation for extension-specific constraints:
|
||||
- commands and aliases must use the canonical `speckit.{extension}.{command}` shape
|
||||
- commands and aliases must use this extension's namespace
|
||||
- primary commands must use the canonical `speckit.{extension}.{command}` shape
|
||||
- primary commands must use this extension's namespace
|
||||
- command namespaces must not shadow core commands
|
||||
- duplicate command/alias names inside one manifest are rejected
|
||||
- aliases are validated for type and uniqueness only (no pattern enforcement)
|
||||
|
||||
Args:
|
||||
manifest: Parsed extension manifest
|
||||
@@ -534,23 +566,26 @@ class ExtensionManager:
|
||||
f"{kind.capitalize()} for command '{primary_name}' must be a string"
|
||||
)
|
||||
|
||||
match = EXTENSION_COMMAND_NAME_PATTERN.match(name)
|
||||
if match is None:
|
||||
raise ValidationError(
|
||||
f"Invalid {kind} '{name}': "
|
||||
"must follow pattern 'speckit.{extension}.{command}'"
|
||||
)
|
||||
# Enforce canonical pattern only for primary command names;
|
||||
# aliases are free-form to preserve community extension compat.
|
||||
if kind == "command":
|
||||
match = EXTENSION_COMMAND_NAME_PATTERN.match(name)
|
||||
if match is None:
|
||||
raise ValidationError(
|
||||
f"Invalid {kind} '{name}': "
|
||||
"must follow pattern 'speckit.{extension}.{command}'"
|
||||
)
|
||||
|
||||
namespace = match.group(1)
|
||||
if namespace != manifest.id:
|
||||
raise ValidationError(
|
||||
f"{kind.capitalize()} '{name}' must use extension namespace '{manifest.id}'"
|
||||
)
|
||||
namespace = match.group(1)
|
||||
if namespace != manifest.id:
|
||||
raise ValidationError(
|
||||
f"{kind.capitalize()} '{name}' must use extension namespace '{manifest.id}'"
|
||||
)
|
||||
|
||||
if namespace in CORE_COMMAND_NAMES:
|
||||
raise ValidationError(
|
||||
f"{kind.capitalize()} '{name}' conflicts with core command namespace '{namespace}'"
|
||||
)
|
||||
if namespace in CORE_COMMAND_NAMES:
|
||||
raise ValidationError(
|
||||
f"{kind.capitalize()} '{name}' conflicts with core command namespace '{namespace}'"
|
||||
)
|
||||
|
||||
if name in declared_names:
|
||||
raise ValidationError(
|
||||
@@ -1837,6 +1872,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")
|
||||
@@ -2137,6 +2180,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:
|
||||
@@ -2145,6 +2189,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,12 +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
|
||||
@@ -79,12 +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.
|
||||
|
||||
@@ -275,7 +277,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 +350,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 +361,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 +437,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 +454,7 @@ class IntegrationBase(ABC):
|
||||
# MarkdownIntegration — covers ~20 standard agents
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class MarkdownIntegration(IntegrationBase):
|
||||
"""Concrete base for integrations that use standard Markdown commands.
|
||||
|
||||
@@ -492,12 +495,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 +521,7 @@ class MarkdownIntegration(IntegrationBase):
|
||||
# TomlIntegration — TOML-format agents (Gemini, Tabnine)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TomlIntegration(IntegrationBase):
|
||||
"""Concrete base for integrations that use TOML command format.
|
||||
|
||||
@@ -532,23 +542,89 @@ class TomlIntegration(IntegrationBase):
|
||||
def _extract_description(content: str) -> str:
|
||||
"""Extract the ``description`` value from YAML frontmatter.
|
||||
|
||||
Scans lines between the first pair of ``---`` delimiters for a
|
||||
top-level ``description:`` key. Returns the value (with
|
||||
surrounding quotes stripped) or an empty string if not found.
|
||||
Parses the YAML frontmatter so block scalar descriptions (``|``
|
||||
and ``>``) keep their YAML semantics instead of being treated as
|
||||
raw text.
|
||||
"""
|
||||
in_frontmatter = False
|
||||
for line in content.splitlines():
|
||||
stripped = line.rstrip("\n\r")
|
||||
if stripped == "---":
|
||||
if not in_frontmatter:
|
||||
in_frontmatter = True
|
||||
continue
|
||||
break # second ---
|
||||
if in_frontmatter and stripped.startswith("description:"):
|
||||
_, _, value = stripped.partition(":")
|
||||
return value.strip().strip('"').strip("'")
|
||||
import yaml
|
||||
|
||||
frontmatter_text, _ = TomlIntegration._split_frontmatter(content)
|
||||
if not frontmatter_text:
|
||||
return ""
|
||||
try:
|
||||
frontmatter = yaml.safe_load(frontmatter_text) or {}
|
||||
except yaml.YAMLError:
|
||||
return ""
|
||||
|
||||
if not isinstance(frontmatter, dict):
|
||||
return ""
|
||||
|
||||
description = frontmatter.get("description", "")
|
||||
if isinstance(description, str):
|
||||
return description
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _split_frontmatter(content: str) -> tuple[str, str]:
|
||||
"""Split YAML frontmatter from the remaining content.
|
||||
|
||||
Returns ``("", content)`` when no complete frontmatter block is
|
||||
present. The body is preserved exactly as written so prompt text
|
||||
keeps its intended formatting.
|
||||
"""
|
||||
if not content.startswith("---"):
|
||||
return "", content
|
||||
|
||||
lines = content.splitlines(keepends=True)
|
||||
if not lines or lines[0].rstrip("\r\n") != "---":
|
||||
return "", content
|
||||
|
||||
frontmatter_end = -1
|
||||
for i, line in enumerate(lines[1:], start=1):
|
||||
if line.rstrip("\r\n") == "---":
|
||||
frontmatter_end = i
|
||||
break
|
||||
|
||||
if frontmatter_end == -1:
|
||||
return "", content
|
||||
|
||||
frontmatter = "".join(lines[1:frontmatter_end])
|
||||
body = "".join(lines[frontmatter_end + 1 :])
|
||||
return frontmatter, body
|
||||
|
||||
@staticmethod
|
||||
def _render_toml_string(value: str) -> str:
|
||||
"""Render *value* as a TOML string literal.
|
||||
|
||||
Uses a basic string for single-line values, multiline basic
|
||||
strings for values containing newlines, and falls back to a
|
||||
literal string or escaped basic string when delimiters appear in
|
||||
the content.
|
||||
"""
|
||||
if "\n" not in value and "\r" not in value:
|
||||
escaped = value.replace("\\", "\\\\").replace('"', '\\"')
|
||||
return f'"{escaped}"'
|
||||
|
||||
escaped = value.replace("\\", "\\\\")
|
||||
if '"""' not in escaped:
|
||||
if escaped.endswith('"'):
|
||||
return '"""\n' + escaped + '\\\n"""'
|
||||
return '"""\n' + escaped + '"""'
|
||||
if "'''" not in value and not value.endswith("'"):
|
||||
return "'''\n" + value + "'''"
|
||||
|
||||
return (
|
||||
'"'
|
||||
+ (
|
||||
value.replace("\\", "\\\\")
|
||||
.replace('"', '\\"')
|
||||
.replace("\n", "\\n")
|
||||
.replace("\r", "\\r")
|
||||
.replace("\t", "\\t")
|
||||
)
|
||||
+ '"'
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _render_toml(description: str, body: str) -> str:
|
||||
"""Render a TOML command file from description and body.
|
||||
@@ -558,39 +634,21 @@ class TomlIntegration(IntegrationBase):
|
||||
to multiline literal strings (``'''``) if the body contains
|
||||
``\"\"\"``, then to an escaped basic string as a last resort.
|
||||
|
||||
The body is rstrip'd so the closing delimiter appears on the line
|
||||
immediately after the last content line — matching the release
|
||||
script's ``echo "$body"; echo '\"\"\"'`` pattern.
|
||||
The body is ``rstrip("\\n")``'d before rendering, so the TOML
|
||||
value preserves content without forcing a trailing newline. As a
|
||||
result, multiline delimiters appear on their own line only when
|
||||
the rendered value itself ends with a newline.
|
||||
"""
|
||||
toml_lines: list[str] = []
|
||||
|
||||
if description:
|
||||
desc = description.replace('"', '\\"')
|
||||
toml_lines.append(f'description = "{desc}"')
|
||||
toml_lines.append(
|
||||
f"description = {TomlIntegration._render_toml_string(description)}"
|
||||
)
|
||||
toml_lines.append("")
|
||||
|
||||
body = body.rstrip("\n")
|
||||
|
||||
# Escape backslashes for basic multiline strings.
|
||||
escaped = body.replace("\\", "\\\\")
|
||||
|
||||
if '"""' not in escaped:
|
||||
toml_lines.append('prompt = """')
|
||||
toml_lines.append(escaped)
|
||||
toml_lines.append('"""')
|
||||
elif "'''" not in body:
|
||||
toml_lines.append("prompt = '''")
|
||||
toml_lines.append(body)
|
||||
toml_lines.append("'''")
|
||||
else:
|
||||
escaped_body = (
|
||||
body.replace("\\", "\\\\")
|
||||
.replace('"', '\\"')
|
||||
.replace("\n", "\\n")
|
||||
.replace("\r", "\\r")
|
||||
.replace("\t", "\\t")
|
||||
)
|
||||
toml_lines.append(f'prompt = "{escaped_body}"')
|
||||
toml_lines.append(f"prompt = {TomlIntegration._render_toml_string(body)}")
|
||||
|
||||
return "\n".join(toml_lines) + "\n"
|
||||
|
||||
@@ -623,14 +681,21 @@ 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)
|
||||
toml_content = self._render_toml(description, processed)
|
||||
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)
|
||||
dst_file = self.write_file_and_record(
|
||||
toml_content, dest / dst_name, project_root, manifest
|
||||
@@ -641,6 +706,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)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -670,9 +917,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(
|
||||
|
||||
@@ -10,6 +10,20 @@ import yaml
|
||||
from ..base import SkillsIntegration
|
||||
from ..manifest import IntegrationManifest
|
||||
|
||||
# Mapping of command template stem → argument-hint text shown inline
|
||||
# when a user invokes the slash command in Claude Code.
|
||||
ARGUMENT_HINTS: dict[str, str] = {
|
||||
"specify": "Describe the feature you want to specify",
|
||||
"plan": "Optional guidance for the planning phase",
|
||||
"tasks": "Optional task generation constraints",
|
||||
"implement": "Optional implementation guidance or task filter",
|
||||
"analyze": "Optional focus areas for analysis",
|
||||
"clarify": "Optional areas to clarify in the spec",
|
||||
"constitution": "Principles or values for the project constitution",
|
||||
"checklist": "Domain or focus area for the checklist",
|
||||
"taskstoissues": "Optional filter or label for GitHub issues",
|
||||
}
|
||||
|
||||
|
||||
class ClaudeIntegration(SkillsIntegration):
|
||||
"""Integration for Claude Code skills."""
|
||||
@@ -30,10 +44,53 @@ class ClaudeIntegration(SkillsIntegration):
|
||||
}
|
||||
context_file = "CLAUDE.md"
|
||||
|
||||
def command_filename(self, template_name: str) -> str:
|
||||
"""Claude skills live at .claude/skills/<name>/SKILL.md."""
|
||||
skill_name = f"speckit-{template_name.replace('.', '-')}"
|
||||
return f"{skill_name}/SKILL.md"
|
||||
@staticmethod
|
||||
def inject_argument_hint(content: str, hint: str) -> str:
|
||||
"""Insert ``argument-hint`` after the first ``description:`` in YAML frontmatter.
|
||||
|
||||
Skips injection if ``argument-hint:`` already exists in the
|
||||
frontmatter to avoid duplicate keys.
|
||||
"""
|
||||
lines = content.splitlines(keepends=True)
|
||||
|
||||
# Pre-scan: bail out if argument-hint already present in frontmatter
|
||||
dash_count = 0
|
||||
for line in lines:
|
||||
stripped = line.rstrip("\n\r")
|
||||
if stripped == "---":
|
||||
dash_count += 1
|
||||
if dash_count == 2:
|
||||
break
|
||||
continue
|
||||
if dash_count == 1 and stripped.startswith("argument-hint:"):
|
||||
return content # already present
|
||||
|
||||
out: list[str] = []
|
||||
in_fm = False
|
||||
dash_count = 0
|
||||
injected = False
|
||||
for line in lines:
|
||||
stripped = line.rstrip("\n\r")
|
||||
if stripped == "---":
|
||||
dash_count += 1
|
||||
in_fm = dash_count == 1
|
||||
out.append(line)
|
||||
continue
|
||||
if in_fm and not injected and stripped.startswith("description:"):
|
||||
out.append(line)
|
||||
# Preserve the exact line-ending style (\r\n vs \n)
|
||||
if line.endswith("\r\n"):
|
||||
eol = "\r\n"
|
||||
elif line.endswith("\n"):
|
||||
eol = "\n"
|
||||
else:
|
||||
eol = ""
|
||||
escaped = hint.replace("\\", "\\\\").replace('"', '\\"')
|
||||
out.append(f'argument-hint: "{escaped}"{eol}')
|
||||
injected = True
|
||||
continue
|
||||
out.append(line)
|
||||
return "".join(out)
|
||||
|
||||
def _render_skill(self, template_name: str, frontmatter: dict[str, Any], body: str) -> str:
|
||||
"""Render a processed command template as a Claude skill."""
|
||||
@@ -54,6 +111,43 @@ class ClaudeIntegration(SkillsIntegration):
|
||||
self.key, name, description, source
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _inject_frontmatter_flag(content: str, key: str, value: str = "true") -> str:
|
||||
"""Insert ``key: value`` before the closing ``---`` if not already present."""
|
||||
lines = content.splitlines(keepends=True)
|
||||
|
||||
# Pre-scan: bail out if already present in frontmatter
|
||||
dash_count = 0
|
||||
for line in lines:
|
||||
stripped = line.rstrip("\n\r")
|
||||
if stripped == "---":
|
||||
dash_count += 1
|
||||
if dash_count == 2:
|
||||
break
|
||||
continue
|
||||
if dash_count == 1 and stripped.startswith(f"{key}:"):
|
||||
return content
|
||||
|
||||
# Inject before the closing --- of frontmatter
|
||||
out: list[str] = []
|
||||
dash_count = 0
|
||||
injected = False
|
||||
for line in lines:
|
||||
stripped = line.rstrip("\n\r")
|
||||
if stripped == "---":
|
||||
dash_count += 1
|
||||
if dash_count == 2 and not injected:
|
||||
if line.endswith("\r\n"):
|
||||
eol = "\r\n"
|
||||
elif line.endswith("\n"):
|
||||
eol = "\n"
|
||||
else:
|
||||
eol = ""
|
||||
out.append(f"{key}: {value}{eol}")
|
||||
injected = True
|
||||
out.append(line)
|
||||
return "".join(out)
|
||||
|
||||
def setup(
|
||||
self,
|
||||
project_root: Path,
|
||||
@@ -61,49 +155,41 @@ class ClaudeIntegration(SkillsIntegration):
|
||||
parsed_options: dict[str, Any] | None = None,
|
||||
**opts: Any,
|
||||
) -> list[Path]:
|
||||
"""Install Claude skills into .claude/skills."""
|
||||
templates = self.list_command_templates()
|
||||
if not templates:
|
||||
return []
|
||||
"""Install Claude skills, then inject user-invocable, disable-model-invocation, and argument-hint."""
|
||||
created = super().setup(project_root, manifest, parsed_options, **opts)
|
||||
|
||||
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})"
|
||||
)
|
||||
# Post-process generated skill files
|
||||
skills_dir = self.skills_dest(project_root).resolve()
|
||||
|
||||
dest = self.skills_dest(project_root).resolve()
|
||||
try:
|
||||
dest.relative_to(project_root_resolved)
|
||||
except ValueError as exc:
|
||||
raise ValueError(
|
||||
f"Integration destination {dest} escapes "
|
||||
f"project root {project_root_resolved}"
|
||||
) from exc
|
||||
dest.mkdir(parents=True, exist_ok=True)
|
||||
for path in created:
|
||||
# Only touch SKILL.md files under the skills directory
|
||||
try:
|
||||
path.resolve().relative_to(skills_dir)
|
||||
except ValueError:
|
||||
continue
|
||||
if path.name != "SKILL.md":
|
||||
continue
|
||||
|
||||
script_type = opts.get("script_type", "sh")
|
||||
arg_placeholder = self.registrar_config.get("args", "$ARGUMENTS")
|
||||
from specify_cli.agents import CommandRegistrar
|
||||
registrar = CommandRegistrar()
|
||||
created: list[Path] = []
|
||||
content_bytes = path.read_bytes()
|
||||
content = content_bytes.decode("utf-8")
|
||||
|
||||
for src_file in templates:
|
||||
raw = src_file.read_text(encoding="utf-8")
|
||||
processed = self.process_template(raw, self.key, script_type, arg_placeholder)
|
||||
frontmatter, body = registrar.parse_frontmatter(processed)
|
||||
if not isinstance(frontmatter, dict):
|
||||
frontmatter = {}
|
||||
# Inject user-invocable: true (Claude skills are accessible via /command)
|
||||
updated = self._inject_frontmatter_flag(content, "user-invocable")
|
||||
|
||||
rendered = self._render_skill(src_file.stem, frontmatter, body)
|
||||
dst_file = self.write_file_and_record(
|
||||
rendered,
|
||||
dest / self.command_filename(src_file.stem),
|
||||
project_root,
|
||||
manifest,
|
||||
)
|
||||
created.append(dst_file)
|
||||
# Inject disable-model-invocation: true (Claude skills run only when invoked)
|
||||
updated = self._inject_frontmatter_flag(updated, "disable-model-invocation")
|
||||
|
||||
# Inject argument-hint if available for this skill
|
||||
skill_dir_name = path.parent.name # e.g. "speckit-plan"
|
||||
stem = skill_dir_name
|
||||
if stem.startswith("speckit-"):
|
||||
stem = stem[len("speckit-"):]
|
||||
hint = ARGUMENT_HINTS.get(stem, "")
|
||||
if hint:
|
||||
updated = self.inject_argument_hint(updated, hint)
|
||||
|
||||
if updated != content:
|
||||
path.write_bytes(updated.encode("utf-8"))
|
||||
self.record_file_in_manifest(path, project_root, manifest)
|
||||
|
||||
created.extend(self.install_scripts(project_root, manifest))
|
||||
return created
|
||||
|
||||
@@ -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)",
|
||||
),
|
||||
]
|
||||
|
||||
203
src/specify_cli/integrations/forge/__init__.py
Normal file
203
src/specify_cli/integrations/forge/__init__.py
Normal file
@@ -0,0 +1,203 @@
|
||||
"""Forge integration — forgecode.dev AI coding agent.
|
||||
|
||||
Forge has several unique behaviors compared to standard markdown agents:
|
||||
- Uses `{{parameters}}` instead of `$ARGUMENTS` for argument passing
|
||||
- Strips `handoffs` frontmatter key (Claude Code feature that causes Forge to hang)
|
||||
- Injects `name` field into frontmatter when missing
|
||||
- Uses a hyphenated frontmatter `name` value (e.g., `speckit-foo-bar`) for shell compatibility, especially with ZSH
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from ..base import MarkdownIntegration
|
||||
from ..manifest import IntegrationManifest
|
||||
|
||||
|
||||
def format_forge_command_name(cmd_name: str) -> str:
|
||||
"""Convert command name to Forge-compatible hyphenated format.
|
||||
|
||||
Forge requires command names to use hyphens instead of dots for
|
||||
compatibility with ZSH and other shells. This function converts
|
||||
dot-notation command names to hyphenated format.
|
||||
|
||||
The function is idempotent: already-formatted names are returned unchanged.
|
||||
|
||||
Examples:
|
||||
>>> format_forge_command_name("plan")
|
||||
'speckit-plan'
|
||||
>>> format_forge_command_name("speckit.plan")
|
||||
'speckit-plan'
|
||||
>>> format_forge_command_name("speckit-plan")
|
||||
'speckit-plan'
|
||||
>>> format_forge_command_name("speckit.my-extension.example")
|
||||
'speckit-my-extension-example'
|
||||
>>> format_forge_command_name("speckit-my-extension-example")
|
||||
'speckit-my-extension-example'
|
||||
>>> format_forge_command_name("speckit.jira.sync-status")
|
||||
'speckit-jira-sync-status'
|
||||
|
||||
Args:
|
||||
cmd_name: Command name in dot notation (speckit.foo.bar),
|
||||
hyphenated format (speckit-foo-bar), or plain name (foo)
|
||||
|
||||
Returns:
|
||||
Hyphenated command name with 'speckit-' prefix
|
||||
"""
|
||||
# Already in hyphenated format - return as-is (idempotent)
|
||||
if cmd_name.startswith("speckit-"):
|
||||
return cmd_name
|
||||
|
||||
# Strip 'speckit.' prefix if present
|
||||
short_name = cmd_name
|
||||
if short_name.startswith("speckit."):
|
||||
short_name = short_name[len("speckit."):]
|
||||
|
||||
# Replace all dots with hyphens
|
||||
short_name = short_name.replace(".", "-")
|
||||
|
||||
# Return with 'speckit-' prefix
|
||||
return f"speckit-{short_name}"
|
||||
|
||||
|
||||
class ForgeIntegration(MarkdownIntegration):
|
||||
"""Integration for Forge (forgecode.dev).
|
||||
|
||||
Extends MarkdownIntegration to add Forge-specific processing:
|
||||
- Replaces $ARGUMENTS with {{parameters}}
|
||||
- Strips 'handoffs' frontmatter key (incompatible with Forge)
|
||||
- Injects 'name' field into frontmatter when missing
|
||||
"""
|
||||
|
||||
key = "forge"
|
||||
config = {
|
||||
"name": "Forge",
|
||||
"folder": ".forge/",
|
||||
"commands_subdir": "commands",
|
||||
"install_url": "https://forgecode.dev/docs/",
|
||||
"requires_cli": True,
|
||||
}
|
||||
registrar_config = {
|
||||
"dir": ".forge/commands",
|
||||
"format": "markdown",
|
||||
"args": "{{parameters}}",
|
||||
"extension": ".md",
|
||||
"strip_frontmatter_keys": ["handoffs"],
|
||||
"inject_name": True,
|
||||
"format_name": format_forge_command_name, # Custom name formatter
|
||||
}
|
||||
context_file = "AGENTS.md"
|
||||
|
||||
def setup(
|
||||
self,
|
||||
project_root: Path,
|
||||
manifest: IntegrationManifest,
|
||||
parsed_options: dict[str, Any] | None = None,
|
||||
**opts: Any,
|
||||
) -> list[Path]:
|
||||
"""Install Forge commands with custom processing.
|
||||
|
||||
Extends MarkdownIntegration.setup() to inject Forge-specific transformations
|
||||
after standard template processing.
|
||||
"""
|
||||
templates = self.list_command_templates()
|
||||
if not templates:
|
||||
return []
|
||||
|
||||
project_root_resolved = project_root.resolve()
|
||||
if manifest.project_root != project_root_resolved:
|
||||
raise ValueError(
|
||||
f"manifest.project_root ({manifest.project_root}) does not match "
|
||||
f"project_root ({project_root_resolved})"
|
||||
)
|
||||
|
||||
dest = self.commands_dest(project_root).resolve()
|
||||
try:
|
||||
dest.relative_to(project_root_resolved)
|
||||
except ValueError as exc:
|
||||
raise ValueError(
|
||||
f"Integration destination {dest} escapes "
|
||||
f"project root {project_root_resolved}"
|
||||
) from exc
|
||||
dest.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
script_type = opts.get("script_type", "sh")
|
||||
arg_placeholder = self.registrar_config.get("args", "{{parameters}}")
|
||||
created: list[Path] = []
|
||||
|
||||
for src_file in templates:
|
||||
raw = src_file.read_text(encoding="utf-8")
|
||||
# Process template with standard MarkdownIntegration logic
|
||||
processed = self.process_template(raw, self.key, script_type, arg_placeholder)
|
||||
|
||||
# FORGE-SPECIFIC: Ensure any remaining $ARGUMENTS placeholders are
|
||||
# converted to {{parameters}}
|
||||
processed = processed.replace("$ARGUMENTS", arg_placeholder)
|
||||
|
||||
# FORGE-SPECIFIC: Apply frontmatter transformations
|
||||
processed = self._apply_forge_transformations(processed, src_file.stem)
|
||||
|
||||
dst_name = self.command_filename(src_file.stem)
|
||||
dst_file = self.write_file_and_record(
|
||||
processed, dest / dst_name, project_root, manifest
|
||||
)
|
||||
created.append(dst_file)
|
||||
|
||||
# Install integration-specific update-context scripts
|
||||
created.extend(self.install_scripts(project_root, manifest))
|
||||
|
||||
return created
|
||||
|
||||
def _apply_forge_transformations(self, content: str, template_name: str) -> str:
|
||||
"""Apply Forge-specific transformations to processed content.
|
||||
|
||||
1. Strip 'handoffs' frontmatter key (from Claude Code templates; incompatible with Forge)
|
||||
2. Inject 'name' field if missing (using hyphenated format)
|
||||
"""
|
||||
# Parse frontmatter
|
||||
lines = content.split('\n')
|
||||
if not lines or lines[0].strip() != '---':
|
||||
return content
|
||||
|
||||
# Find end of frontmatter
|
||||
frontmatter_end = -1
|
||||
for i in range(1, len(lines)):
|
||||
if lines[i].strip() == '---':
|
||||
frontmatter_end = i
|
||||
break
|
||||
|
||||
if frontmatter_end == -1:
|
||||
return content
|
||||
|
||||
frontmatter_lines = lines[1:frontmatter_end]
|
||||
body_lines = lines[frontmatter_end + 1:]
|
||||
|
||||
# 1. Strip 'handoffs' key
|
||||
filtered_frontmatter = []
|
||||
skip_until_outdent = False
|
||||
for line in frontmatter_lines:
|
||||
if skip_until_outdent:
|
||||
# Skip indented lines under handoffs:
|
||||
if line and (line[0] == ' ' or line[0] == '\t'):
|
||||
continue
|
||||
else:
|
||||
skip_until_outdent = False
|
||||
|
||||
if line.strip().startswith('handoffs:'):
|
||||
skip_until_outdent = True
|
||||
continue
|
||||
|
||||
filtered_frontmatter.append(line)
|
||||
|
||||
# 2. Inject 'name' field if missing (using centralized formatter)
|
||||
has_name = any(line.strip().startswith('name:') for line in filtered_frontmatter)
|
||||
if not has_name:
|
||||
# Use centralized formatter to ensure consistent hyphenated format
|
||||
cmd_name = format_forge_command_name(template_name)
|
||||
filtered_frontmatter.insert(0, f'name: {cmd_name}')
|
||||
|
||||
# Reconstruct content
|
||||
result = ['---'] + filtered_frontmatter + ['---'] + body_lines
|
||||
return '\n'.join(result)
|
||||
@@ -0,0 +1,33 @@
|
||||
# update-context.ps1 — Forge integration: create/update AGENTS.md
|
||||
#
|
||||
# Thin wrapper that delegates to the shared update-agent-context script.
|
||||
# Activated in Stage 7 when the shared script uses integration.json dispatch.
|
||||
#
|
||||
# Until then, this delegates to the shared script as a subprocess.
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
# Derive repo root from script location (walks up to find .specify/)
|
||||
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition
|
||||
$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null }
|
||||
# If git did not return a repo root, or the git root does not contain .specify,
|
||||
# fall back to walking up from the script directory to find the initialized project root.
|
||||
if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) {
|
||||
$repoRoot = $scriptDir
|
||||
$fsRoot = [System.IO.Path]::GetPathRoot($repoRoot)
|
||||
while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) {
|
||||
$repoRoot = Split-Path -Parent $repoRoot
|
||||
}
|
||||
}
|
||||
|
||||
$sharedScript = "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1"
|
||||
|
||||
# Always delegate to the shared updater; fail clearly if it is unavailable.
|
||||
if (-not (Test-Path $sharedScript)) {
|
||||
Write-Error "Error: shared agent context updater not found: $sharedScript"
|
||||
Write-Error "Forge integration requires support in scripts/powershell/update-agent-context.ps1."
|
||||
exit 1
|
||||
}
|
||||
|
||||
& $sharedScript -AgentType forge
|
||||
exit $LASTEXITCODE
|
||||
38
src/specify_cli/integrations/forge/scripts/update-context.sh
Executable file
38
src/specify_cli/integrations/forge/scripts/update-context.sh
Executable file
@@ -0,0 +1,38 @@
|
||||
#!/usr/bin/env bash
|
||||
# update-context.sh — Forge integration: create/update AGENTS.md
|
||||
#
|
||||
# Thin wrapper that delegates to the shared update-agent-context script.
|
||||
# Activated in Stage 7 when the shared script uses integration.json dispatch.
|
||||
#
|
||||
# Until then, this delegates to the shared script as a subprocess.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Derive repo root from script location (walks up to find .specify/)
|
||||
_script_dir="$(cd "$(dirname "$0")" && pwd)"
|
||||
_root="$_script_dir"
|
||||
while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done
|
||||
if [ -z "${REPO_ROOT:-}" ]; then
|
||||
if [ -d "$_root/.specify" ]; then
|
||||
REPO_ROOT="$_root"
|
||||
else
|
||||
git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)"
|
||||
if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then
|
||||
REPO_ROOT="$git_root"
|
||||
else
|
||||
REPO_ROOT="$_root"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
shared_script="$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh"
|
||||
|
||||
# Always delegate to the shared updater; fail clearly if it is unavailable.
|
||||
if [ ! -x "$shared_script" ]; then
|
||||
echo "Error: shared agent context updater not found or not executable:" >&2
|
||||
echo " $shared_script" >&2
|
||||
echo "Forge integration requires support in scripts/bash/update-agent-context.sh." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exec "$shared_script" forge
|
||||
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
|
||||
@@ -1,21 +1,40 @@
|
||||
"""Trae IDE integration."""
|
||||
"""Trae IDE integration. — skills-based agent.
|
||||
|
||||
from ..base import MarkdownIntegration
|
||||
Trae IDE uses ``.trae/skills/speckit-<name>/SKILL.md`` layout.
|
||||
In the Specify CLI Trae integration, explicit command support was deprecated
|
||||
since v0.5.1; ``--skills`` defaults to ``True``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
from ..base import IntegrationOption, SkillsIntegration
|
||||
|
||||
|
||||
class TraeIntegration(MarkdownIntegration):
|
||||
class TraeIntegration(SkillsIntegration):
|
||||
"""Integration for Trae IDE."""
|
||||
|
||||
key = "trae"
|
||||
config = {
|
||||
"name": "Trae",
|
||||
"folder": ".trae/",
|
||||
"commands_subdir": "rules",
|
||||
"commands_subdir": "skills",
|
||||
"install_url": None,
|
||||
"requires_cli": False,
|
||||
}
|
||||
registrar_config = {
|
||||
"dir": ".trae/rules",
|
||||
"dir": ".trae/skills",
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": ".md",
|
||||
"extension": "/SKILL.md",
|
||||
}
|
||||
context_file = ".trae/rules/AGENTS.md"
|
||||
context_file = ".trae/rules/project_rules.md"
|
||||
|
||||
@classmethod
|
||||
def options(cls) -> list[IntegrationOption]:
|
||||
return [
|
||||
IntegrationOption(
|
||||
"--skills",
|
||||
is_flag=True,
|
||||
default=True,
|
||||
help="Install as agent skills (default for trae since v0.5.1)",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# update-context.ps1 — Trae integration: create/update .trae/rules/AGENTS.md
|
||||
# update-context.ps1 — Trae integration: create/update .trae/rules/project_rules.md
|
||||
#
|
||||
# Thin wrapper that delegates to the shared update-agent-context script.
|
||||
# Activated in Stage 7 when the shared script uses integration.json dispatch.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
# update-context.sh — Trae integration: create/update .trae/rules/AGENTS.md
|
||||
# update-context.sh — Trae integration: create/update .trae/rules/project_rules.md
|
||||
#
|
||||
# Thin wrapper that delegates to the shared update-agent-context script.
|
||||
# Activated in Stage 7 when the shared script uses integration.json dispatch.
|
||||
|
||||
@@ -717,7 +717,7 @@ class PresetManager:
|
||||
ai_skills_enabled = bool(init_opts.get("ai_skills"))
|
||||
registrar = CommandRegistrar()
|
||||
agent_config = registrar.AGENT_CONFIGS.get(selected_ai, {})
|
||||
# Native skill agents (e.g. codex/kimi/agy) materialize brand-new
|
||||
# Native skill agents (e.g. codex/kimi/agy/trae) materialize brand-new
|
||||
# preset skills in _register_commands() because their detected agent
|
||||
# directory is already the skills directory. This flag is only for
|
||||
# command-backed agents that also mirror commands into skills.
|
||||
@@ -1587,6 +1587,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(
|
||||
|
||||
@@ -13,6 +13,40 @@ $ARGUMENTS
|
||||
|
||||
You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Pre-Execution Checks
|
||||
|
||||
**Check for extension hooks (before analysis)**:
|
||||
- Check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it exists, read it and look for entries under the `hooks.before_analyze` key
|
||||
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Optional hook** (`optional: true`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Optional Pre-Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
- **Mandatory hook** (`optional: false`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Automatic Pre-Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
|
||||
Wait for the result of the hook command before proceeding to the Goal.
|
||||
```
|
||||
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||
|
||||
## Goal
|
||||
|
||||
Identify inconsistencies, duplications, ambiguities, and underspecified items across the three core artifacts (`spec.md`, `plan.md`, `tasks.md`) before implementation. This command MUST run only after `/speckit.tasks` has successfully produced a complete `tasks.md`.
|
||||
@@ -165,6 +199,37 @@ At end of report, output a concise Next Actions block:
|
||||
|
||||
Ask the user: "Would you like me to suggest concrete remediation edits for the top N issues?" (Do NOT apply them automatically.)
|
||||
|
||||
### 9. Check for extension hooks
|
||||
|
||||
After reporting, check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it exists, read it and look for entries under the `hooks.after_analyze` key
|
||||
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Optional hook** (`optional: true`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Optional Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
- **Mandatory hook** (`optional: false`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Automatic Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
```
|
||||
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||
|
||||
## Operating Principles
|
||||
|
||||
### Context Efficiency
|
||||
|
||||
@@ -34,6 +34,40 @@ $ARGUMENTS
|
||||
|
||||
You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Pre-Execution Checks
|
||||
|
||||
**Check for extension hooks (before checklist generation)**:
|
||||
- Check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it exists, read it and look for entries under the `hooks.before_checklist` key
|
||||
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Optional hook** (`optional: true`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Optional Pre-Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
- **Mandatory hook** (`optional: false`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Automatic Pre-Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
|
||||
Wait for the result of the hook command before proceeding to the Execution Steps.
|
||||
```
|
||||
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||
|
||||
## Execution Steps
|
||||
|
||||
1. **Setup**: Run `{SCRIPT}` from repo root and parse JSON for FEATURE_DIR and AVAILABLE_DOCS list.
|
||||
@@ -296,3 +330,35 @@ Sample items:
|
||||
- Correct: Validation of requirement quality
|
||||
- Wrong: "Does it do X?"
|
||||
- Correct: "Is X clearly specified?"
|
||||
|
||||
## Post-Execution Checks
|
||||
|
||||
**Check for extension hooks (after checklist generation)**:
|
||||
Check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it exists, read it and look for entries under the `hooks.after_checklist` key
|
||||
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Optional hook** (`optional: true`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Optional Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
- **Mandatory hook** (`optional: false`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Automatic Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
```
|
||||
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||
|
||||
@@ -17,6 +17,40 @@ $ARGUMENTS
|
||||
|
||||
You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Pre-Execution Checks
|
||||
|
||||
**Check for extension hooks (before clarification)**:
|
||||
- Check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it exists, read it and look for entries under the `hooks.before_clarify` key
|
||||
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Optional hook** (`optional: true`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Optional Pre-Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
- **Mandatory hook** (`optional: false`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Automatic Pre-Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
|
||||
Wait for the result of the hook command before proceeding to the Outline.
|
||||
```
|
||||
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||
|
||||
## Outline
|
||||
|
||||
Goal: Detect and reduce ambiguity or missing decision points in the active feature specification and record the clarifications directly in the spec file.
|
||||
@@ -182,3 +216,35 @@ Behavior rules:
|
||||
- If quota reached with unresolved high-impact categories remaining, explicitly flag them under Deferred with rationale.
|
||||
|
||||
Context for prioritization: {ARGS}
|
||||
|
||||
## Post-Execution Checks
|
||||
|
||||
**Check for extension hooks (after clarification)**:
|
||||
Check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it exists, read it and look for entries under the `hooks.after_clarify` key
|
||||
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Optional hook** (`optional: true`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Optional Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
- **Mandatory hook** (`optional: false`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Automatic Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
```
|
||||
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||
|
||||
@@ -14,6 +14,40 @@ $ARGUMENTS
|
||||
|
||||
You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Pre-Execution Checks
|
||||
|
||||
**Check for extension hooks (before constitution update)**:
|
||||
- Check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it exists, read it and look for entries under the `hooks.before_constitution` key
|
||||
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Optional hook** (`optional: true`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Optional Pre-Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
- **Mandatory hook** (`optional: false`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Automatic Pre-Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
|
||||
Wait for the result of the hook command before proceeding to the Outline.
|
||||
```
|
||||
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||
|
||||
## Outline
|
||||
|
||||
You are updating the project constitution at `.specify/memory/constitution.md`. This file is a TEMPLATE containing placeholder tokens in square brackets (e.g. `[PROJECT_NAME]`, `[PRINCIPLE_1_NAME]`). Your job is to (a) collect/derive concrete values, (b) fill the template precisely, and (c) propagate any amendments across dependent artifacts.
|
||||
@@ -82,3 +116,35 @@ If the user supplies partial updates (e.g., only one principle revision), still
|
||||
If critical info missing (e.g., ratification date truly unknown), insert `TODO(<FIELD_NAME>): explanation` and include in the Sync Impact Report under deferred items.
|
||||
|
||||
Do not create a new template; always operate on the existing `.specify/memory/constitution.md` file.
|
||||
|
||||
## Post-Execution Checks
|
||||
|
||||
**Check for extension hooks (after constitution update)**:
|
||||
Check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it exists, read it and look for entries under the `hooks.after_constitution` key
|
||||
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Optional hook** (`optional: true`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Optional Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
- **Mandatory hook** (`optional: false`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Automatic Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
```
|
||||
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||
|
||||
@@ -8,9 +8,6 @@ handoffs:
|
||||
agent: speckit.clarify
|
||||
prompt: Clarify specification requirements
|
||||
send: true
|
||||
scripts:
|
||||
sh: scripts/bash/create-new-feature.sh "{ARGS}"
|
||||
ps: scripts/powershell/create-new-feature.ps1 "{ARGS}"
|
||||
---
|
||||
|
||||
## User Input
|
||||
@@ -61,7 +58,7 @@ The text the user typed after `/speckit.specify` in the triggering message **is*
|
||||
|
||||
Given that feature description, do this:
|
||||
|
||||
1. **Generate a concise short name** (2-4 words) for the branch:
|
||||
1. **Generate a concise short name** (2-4 words) for the feature:
|
||||
- Analyze the feature description and extract the most meaningful keywords
|
||||
- Create a 2-4 word short name that captures the essence of the feature
|
||||
- Use action-noun format when possible (e.g., "add-user-auth", "fix-payment-bug")
|
||||
@@ -73,30 +70,47 @@ Given that feature description, do this:
|
||||
- "Create a dashboard for analytics" → "analytics-dashboard"
|
||||
- "Fix payment processing timeout bug" → "fix-payment-timeout"
|
||||
|
||||
2. **Create the feature branch** by running the script with `--short-name` (and `--json`). In sequential mode, do NOT pass `--number` — the script auto-detects the next available number. In timestamp mode, the script generates a `YYYYMMDD-HHMMSS` prefix automatically:
|
||||
2. **Branch creation** (optional, via hook):
|
||||
|
||||
**Branch numbering mode**: Before running the script, check if `.specify/init-options.json` exists and read the `branch_numbering` value.
|
||||
- If `"timestamp"`, add `--timestamp` (Bash) or `-Timestamp` (PowerShell) to the script invocation
|
||||
- If `"sequential"` or absent, do not add any extra flag (default behavior)
|
||||
If a `before_specify` hook ran successfully in the Pre-Execution Checks above, it will have created/switched to a git branch and output JSON containing `BRANCH_NAME` and `FEATURE_NUM`. Note these values for reference, but the branch name does **not** dictate the spec directory name.
|
||||
|
||||
- Bash example: `{SCRIPT} --json --short-name "user-auth" "Add user authentication"`
|
||||
- Bash (timestamp): `{SCRIPT} --json --timestamp --short-name "user-auth" "Add user authentication"`
|
||||
- PowerShell example: `{SCRIPT} -Json -ShortName "user-auth" "Add user authentication"`
|
||||
- PowerShell (timestamp): `{SCRIPT} -Json -Timestamp -ShortName "user-auth" "Add user authentication"`
|
||||
If the user explicitly provided `GIT_BRANCH_NAME`, pass it through to the hook so the branch script uses the exact value as the branch name (bypassing all prefix/suffix generation).
|
||||
|
||||
3. **Create the spec feature directory**:
|
||||
|
||||
Specs live under the default `specs/` directory unless the user explicitly provides `SPECIFY_FEATURE_DIRECTORY`.
|
||||
|
||||
**Resolution order for `SPECIFY_FEATURE_DIRECTORY`**:
|
||||
1. If the user explicitly provided `SPECIFY_FEATURE_DIRECTORY` (e.g., via environment variable, argument, or configuration), use it as-is
|
||||
2. Otherwise, auto-generate it under `specs/`:
|
||||
- Check `.specify/init-options.json` for `branch_numbering`
|
||||
- If `"timestamp"`: prefix is `YYYYMMDD-HHMMSS` (current timestamp)
|
||||
- If `"sequential"` or absent: prefix is `NNN` (next available 3-digit number after scanning existing directories in `specs/`)
|
||||
- Construct the directory name: `<prefix>-<short-name>` (e.g., `003-user-auth` or `20260319-143022-user-auth`)
|
||||
- Set `SPECIFY_FEATURE_DIRECTORY` to `specs/<directory-name>`
|
||||
|
||||
**Create the directory and spec file**:
|
||||
- `mkdir -p SPECIFY_FEATURE_DIRECTORY`
|
||||
- Copy `templates/spec-template.md` to `SPECIFY_FEATURE_DIRECTORY/spec.md` as the starting point
|
||||
- Set `SPEC_FILE` to `SPECIFY_FEATURE_DIRECTORY/spec.md`
|
||||
- Persist the resolved path to `.specify/feature.json`:
|
||||
```json
|
||||
{
|
||||
"feature_directory": "<resolved feature dir>"
|
||||
}
|
||||
```
|
||||
Write the actual resolved directory path value (for example, `specs/003-user-auth`), not the literal string `SPECIFY_FEATURE_DIRECTORY`.
|
||||
This allows downstream commands (`/speckit.plan`, `/speckit.tasks`, etc.) to locate the feature directory without relying on git branch name conventions.
|
||||
|
||||
**IMPORTANT**:
|
||||
- Do NOT pass `--number` — the script determines the correct next number automatically
|
||||
- Always include the JSON flag (`--json` for Bash, `-Json` for PowerShell) so the output can be parsed reliably
|
||||
- You must only ever run this script once per feature
|
||||
- The JSON is provided in the terminal as output - always refer to it to get the actual content you're looking for
|
||||
- The JSON output will contain BRANCH_NAME and SPEC_FILE paths
|
||||
- For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot")
|
||||
- You must only create one feature per `/speckit.specify` invocation
|
||||
- The spec directory name and the git branch name are independent — they may be the same but that is the user's choice
|
||||
- The spec directory and file are always created by this command, never by the hook
|
||||
|
||||
3. Load `templates/spec-template.md` to understand required sections.
|
||||
4. Load `templates/spec-template.md` to understand required sections.
|
||||
|
||||
4. Follow this execution flow:
|
||||
|
||||
1. Parse user description from Input
|
||||
5. Follow this execution flow:
|
||||
1. Parse user description from arguments
|
||||
If empty: ERROR "No feature description provided"
|
||||
2. Extract key concepts from description
|
||||
Identify: actors, actions, data, constraints
|
||||
@@ -120,11 +134,11 @@ Given that feature description, do this:
|
||||
7. Identify Key Entities (if data involved)
|
||||
8. Return: SUCCESS (spec ready for planning)
|
||||
|
||||
5. Write the specification to SPEC_FILE using the template structure, replacing placeholders with concrete details derived from the feature description (arguments) while preserving section order and headings.
|
||||
6. Write the specification to SPEC_FILE using the template structure, replacing placeholders with concrete details derived from the feature description (arguments) while preserving section order and headings.
|
||||
|
||||
6. **Specification Quality Validation**: After writing the initial spec, validate it against quality criteria:
|
||||
7. **Specification Quality Validation**: After writing the initial spec, validate it against quality criteria:
|
||||
|
||||
a. **Create Spec Quality Checklist**: Generate a checklist file at `FEATURE_DIR/checklists/requirements.md` using the checklist template structure with these validation items:
|
||||
a. **Create Spec Quality Checklist**: Generate a checklist file at `SPECIFY_FEATURE_DIRECTORY/checklists/requirements.md` using the checklist template structure with these validation items:
|
||||
|
||||
```markdown
|
||||
# Specification Quality Checklist: [FEATURE NAME]
|
||||
@@ -214,9 +228,13 @@ Given that feature description, do this:
|
||||
|
||||
d. **Update Checklist**: After each validation iteration, update the checklist file with current pass/fail status
|
||||
|
||||
7. Report completion with branch name, spec file path, checklist results, and readiness for the next phase (`/speckit.clarify` or `/speckit.plan`).
|
||||
8. **Report completion** to the user with:
|
||||
- `SPECIFY_FEATURE_DIRECTORY` — the feature directory path
|
||||
- `SPEC_FILE` — the spec file path
|
||||
- Checklist results summary
|
||||
- Readiness for the next phase (`/speckit.clarify` or `/speckit.plan`)
|
||||
|
||||
8. **Check for extension hooks**: After reporting completion, check if `.specify/extensions.yml` exists in the project root.
|
||||
9. **Check for extension hooks**: After reporting completion, check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it exists, read it and look for entries under the `hooks.after_specify` key
|
||||
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
@@ -245,7 +263,7 @@ Given that feature description, do this:
|
||||
```
|
||||
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||
|
||||
**NOTE:** The script creates and checks out the new branch and initializes the spec file before writing.
|
||||
**NOTE:** Branch creation is handled by the `before_specify` hook (git extension). Spec directory and file creation are always handled by this core command.
|
||||
|
||||
## Quick Guidelines
|
||||
|
||||
|
||||
@@ -14,6 +14,40 @@ $ARGUMENTS
|
||||
|
||||
You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Pre-Execution Checks
|
||||
|
||||
**Check for extension hooks (before tasks-to-issues conversion)**:
|
||||
- Check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it exists, read it and look for entries under the `hooks.before_taskstoissues` key
|
||||
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Optional hook** (`optional: true`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Optional Pre-Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
- **Mandatory hook** (`optional: false`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Automatic Pre-Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
|
||||
Wait for the result of the hook command before proceeding to the Outline.
|
||||
```
|
||||
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||
|
||||
## Outline
|
||||
|
||||
1. Run `{SCRIPT}` from repo root and parse FEATURE_DIR and AVAILABLE_DOCS list. All paths must be absolute. For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
|
||||
@@ -31,3 +65,35 @@ git config --get remote.origin.url
|
||||
|
||||
> [!CAUTION]
|
||||
> UNDER NO CIRCUMSTANCES EVER CREATE ISSUES IN REPOSITORIES THAT DO NOT MATCH THE REMOTE URL
|
||||
|
||||
## Post-Execution Checks
|
||||
|
||||
**Check for extension hooks (after tasks-to-issues conversion)**:
|
||||
Check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it exists, read it and look for entries under the `hooks.after_taskstoissues` key
|
||||
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Optional hook** (`optional: true`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Optional Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
- **Mandatory hook** (`optional: false`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Automatic Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
```
|
||||
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||
|
||||
1
tests/extensions/__init__.py
Normal file
1
tests/extensions/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Extensions test package."""
|
||||
1
tests/extensions/git/__init__.py
Normal file
1
tests/extensions/git/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Tests for the bundled git extension."""
|
||||
589
tests/extensions/git/test_git_extension.py
Normal file
589
tests/extensions/git/test_git_extension.py
Normal file
@@ -0,0 +1,589 @@
|
||||
"""
|
||||
Tests for the bundled git extension (extensions/git/).
|
||||
|
||||
Validates:
|
||||
- extension.yml manifest
|
||||
- Bash scripts (create-new-feature.sh, initialize-repo.sh, auto-commit.sh, git-common.sh)
|
||||
- PowerShell scripts (where pwsh is available)
|
||||
- Config reading from git-config.yml
|
||||
- Extension install via ExtensionManager
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent.parent
|
||||
EXT_DIR = PROJECT_ROOT / "extensions" / "git"
|
||||
EXT_BASH = EXT_DIR / "scripts" / "bash"
|
||||
EXT_PS = EXT_DIR / "scripts" / "powershell"
|
||||
CORE_COMMON_SH = PROJECT_ROOT / "scripts" / "bash" / "common.sh"
|
||||
CORE_COMMON_PS = PROJECT_ROOT / "scripts" / "powershell" / "common.ps1"
|
||||
|
||||
HAS_PWSH = shutil.which("pwsh") is not None
|
||||
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _init_git(path: Path) -> None:
|
||||
"""Initialize a git repo with a dummy commit."""
|
||||
subprocess.run(["git", "init", "-q"], cwd=path, check=True)
|
||||
subprocess.run(["git", "config", "user.email", "test@example.com"], cwd=path, check=True)
|
||||
subprocess.run(["git", "config", "user.name", "Test User"], cwd=path, check=True)
|
||||
subprocess.run(
|
||||
["git", "commit", "--allow-empty", "-m", "seed", "-q"],
|
||||
cwd=path,
|
||||
check=True,
|
||||
)
|
||||
|
||||
|
||||
def _setup_project(tmp_path: Path, *, git: bool = True) -> Path:
|
||||
"""Create a project directory with core scripts and .specify."""
|
||||
# Core scripts (needed by extension scripts that source common.sh)
|
||||
bash_dir = tmp_path / "scripts" / "bash"
|
||||
bash_dir.mkdir(parents=True)
|
||||
shutil.copy(CORE_COMMON_SH, bash_dir / "common.sh")
|
||||
|
||||
ps_dir = tmp_path / "scripts" / "powershell"
|
||||
ps_dir.mkdir(parents=True)
|
||||
shutil.copy(CORE_COMMON_PS, ps_dir / "common.ps1")
|
||||
|
||||
# .specify structure
|
||||
(tmp_path / ".specify" / "templates").mkdir(parents=True)
|
||||
|
||||
# Extension scripts (as if installed)
|
||||
ext_bash = tmp_path / ".specify" / "extensions" / "git" / "scripts" / "bash"
|
||||
ext_bash.mkdir(parents=True)
|
||||
for f in EXT_BASH.iterdir():
|
||||
dest = ext_bash / f.name
|
||||
shutil.copy(f, dest)
|
||||
dest.chmod(0o755)
|
||||
|
||||
ext_ps = tmp_path / ".specify" / "extensions" / "git" / "scripts" / "powershell"
|
||||
ext_ps.mkdir(parents=True)
|
||||
for f in EXT_PS.iterdir():
|
||||
shutil.copy(f, ext_ps / f.name)
|
||||
|
||||
# Copy extension.yml
|
||||
shutil.copy(EXT_DIR / "extension.yml", tmp_path / ".specify" / "extensions" / "git" / "extension.yml")
|
||||
|
||||
if git:
|
||||
_init_git(tmp_path)
|
||||
|
||||
return tmp_path
|
||||
|
||||
|
||||
def _write_config(project: Path, content: str) -> Path:
|
||||
"""Write git-config.yml into the extension config directory."""
|
||||
config_path = project / ".specify" / "extensions" / "git" / "git-config.yml"
|
||||
config_path.write_text(content, encoding="utf-8")
|
||||
return config_path
|
||||
|
||||
|
||||
# Git identity env vars for CI runners without global git config
|
||||
_GIT_ENV = {
|
||||
"GIT_AUTHOR_NAME": "Test User",
|
||||
"GIT_AUTHOR_EMAIL": "test@example.com",
|
||||
"GIT_COMMITTER_NAME": "Test User",
|
||||
"GIT_COMMITTER_EMAIL": "test@example.com",
|
||||
}
|
||||
|
||||
|
||||
def _run_bash(script_name: str, cwd: Path, *args: str, env_extra: dict | None = None) -> subprocess.CompletedProcess:
|
||||
"""Run an extension bash script."""
|
||||
script = cwd / ".specify" / "extensions" / "git" / "scripts" / "bash" / script_name
|
||||
env = {**os.environ, **_GIT_ENV, **(env_extra or {})}
|
||||
return subprocess.run(
|
||||
["bash", str(script), *args],
|
||||
cwd=cwd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env,
|
||||
)
|
||||
|
||||
|
||||
def _run_pwsh(script_name: str, cwd: Path, *args: str) -> subprocess.CompletedProcess:
|
||||
"""Run an extension PowerShell script."""
|
||||
script = cwd / ".specify" / "extensions" / "git" / "scripts" / "powershell" / script_name
|
||||
env = {**os.environ, **_GIT_ENV}
|
||||
return subprocess.run(
|
||||
["pwsh", "-NoProfile", "-File", str(script), *args],
|
||||
cwd=cwd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env,
|
||||
)
|
||||
|
||||
|
||||
# ── Manifest Tests ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestGitExtensionManifest:
|
||||
def test_manifest_validates(self):
|
||||
"""extension.yml passes manifest validation."""
|
||||
from specify_cli.extensions import ExtensionManifest
|
||||
|
||||
m = ExtensionManifest(EXT_DIR / "extension.yml")
|
||||
assert m.id == "git"
|
||||
assert m.version == "1.0.0"
|
||||
|
||||
def test_manifest_commands(self):
|
||||
"""Manifest declares expected commands."""
|
||||
from specify_cli.extensions import ExtensionManifest
|
||||
|
||||
m = ExtensionManifest(EXT_DIR / "extension.yml")
|
||||
names = [c["name"] for c in m.commands]
|
||||
assert "speckit.git.feature" in names
|
||||
assert "speckit.git.validate" in names
|
||||
assert "speckit.git.remote" in names
|
||||
assert "speckit.git.initialize" in names
|
||||
assert "speckit.git.commit" in names
|
||||
|
||||
def test_manifest_hooks(self):
|
||||
"""Manifest declares expected hooks."""
|
||||
from specify_cli.extensions import ExtensionManifest
|
||||
|
||||
m = ExtensionManifest(EXT_DIR / "extension.yml")
|
||||
assert "before_constitution" in m.hooks
|
||||
assert "before_specify" in m.hooks
|
||||
assert "after_specify" in m.hooks
|
||||
assert "after_implement" in m.hooks
|
||||
assert m.hooks["before_constitution"]["command"] == "speckit.git.initialize"
|
||||
assert m.hooks["before_specify"]["command"] == "speckit.git.feature"
|
||||
|
||||
def test_manifest_command_files_exist(self):
|
||||
"""All command files referenced in the manifest exist."""
|
||||
from specify_cli.extensions import ExtensionManifest
|
||||
|
||||
m = ExtensionManifest(EXT_DIR / "extension.yml")
|
||||
for cmd in m.commands:
|
||||
cmd_path = EXT_DIR / cmd["file"]
|
||||
assert cmd_path.is_file(), f"Missing command file: {cmd['file']}"
|
||||
|
||||
|
||||
# ── Install Tests ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestGitExtensionInstall:
|
||||
def test_install_from_directory(self, tmp_path: Path):
|
||||
"""Extension installs via ExtensionManager.install_from_directory."""
|
||||
from specify_cli.extensions import ExtensionManager
|
||||
|
||||
(tmp_path / ".specify").mkdir()
|
||||
manager = ExtensionManager(tmp_path)
|
||||
manifest = manager.install_from_directory(EXT_DIR, "0.5.0", register_commands=False)
|
||||
assert manifest.id == "git"
|
||||
assert manager.registry.is_installed("git")
|
||||
|
||||
def test_install_copies_scripts(self, tmp_path: Path):
|
||||
"""Extension install copies script files."""
|
||||
from specify_cli.extensions import ExtensionManager
|
||||
|
||||
(tmp_path / ".specify").mkdir()
|
||||
manager = ExtensionManager(tmp_path)
|
||||
manager.install_from_directory(EXT_DIR, "0.5.0", register_commands=False)
|
||||
|
||||
ext_installed = tmp_path / ".specify" / "extensions" / "git"
|
||||
assert (ext_installed / "scripts" / "bash" / "create-new-feature.sh").is_file()
|
||||
assert (ext_installed / "scripts" / "bash" / "initialize-repo.sh").is_file()
|
||||
assert (ext_installed / "scripts" / "bash" / "auto-commit.sh").is_file()
|
||||
assert (ext_installed / "scripts" / "bash" / "git-common.sh").is_file()
|
||||
assert (ext_installed / "scripts" / "powershell" / "create-new-feature.ps1").is_file()
|
||||
assert (ext_installed / "scripts" / "powershell" / "initialize-repo.ps1").is_file()
|
||||
assert (ext_installed / "scripts" / "powershell" / "auto-commit.ps1").is_file()
|
||||
assert (ext_installed / "scripts" / "powershell" / "git-common.ps1").is_file()
|
||||
|
||||
def test_bundled_extension_locator(self):
|
||||
"""_locate_bundled_extension finds the git extension."""
|
||||
from specify_cli import _locate_bundled_extension
|
||||
|
||||
path = _locate_bundled_extension("git")
|
||||
assert path is not None
|
||||
assert (path / "extension.yml").is_file()
|
||||
|
||||
|
||||
# ── initialize-repo.sh Tests ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestInitializeRepoBash:
|
||||
def test_initializes_git_repo(self, tmp_path: Path):
|
||||
"""initialize-repo.sh creates a git repo with initial commit."""
|
||||
project = _setup_project(tmp_path, git=False)
|
||||
result = _run_bash("initialize-repo.sh", project)
|
||||
assert result.returncode == 0, result.stderr
|
||||
|
||||
# Verify git repo exists
|
||||
assert (project / ".git").exists()
|
||||
|
||||
# Verify at least one commit exists
|
||||
log = subprocess.run(
|
||||
["git", "log", "--oneline", "-1"],
|
||||
cwd=project, capture_output=True, text=True,
|
||||
)
|
||||
assert log.returncode == 0
|
||||
|
||||
def test_skips_if_already_git_repo(self, tmp_path: Path):
|
||||
"""initialize-repo.sh skips if already a git repo."""
|
||||
project = _setup_project(tmp_path, git=True)
|
||||
result = _run_bash("initialize-repo.sh", project)
|
||||
assert result.returncode == 0
|
||||
assert "already initialized" in result.stderr.lower()
|
||||
|
||||
def test_custom_commit_message(self, tmp_path: Path):
|
||||
"""initialize-repo.sh reads custom commit message from config."""
|
||||
project = _setup_project(tmp_path, git=False)
|
||||
_write_config(project, 'init_commit_message: "Custom init message"\n')
|
||||
|
||||
result = _run_bash("initialize-repo.sh", project)
|
||||
assert result.returncode == 0
|
||||
|
||||
log = subprocess.run(
|
||||
["git", "log", "--oneline", "-1"],
|
||||
cwd=project, capture_output=True, text=True,
|
||||
)
|
||||
assert "Custom init message" in log.stdout
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAS_PWSH, reason="pwsh not available")
|
||||
class TestInitializeRepoPowerShell:
|
||||
def test_initializes_git_repo(self, tmp_path: Path):
|
||||
"""initialize-repo.ps1 creates a git repo with initial commit."""
|
||||
project = _setup_project(tmp_path, git=False)
|
||||
result = _run_pwsh("initialize-repo.ps1", project)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert (project / ".git").exists()
|
||||
|
||||
def test_skips_if_already_git_repo(self, tmp_path: Path):
|
||||
"""initialize-repo.ps1 skips if already a git repo."""
|
||||
project = _setup_project(tmp_path, git=True)
|
||||
result = _run_pwsh("initialize-repo.ps1", project)
|
||||
assert result.returncode == 0
|
||||
|
||||
|
||||
# ── create-new-feature.sh Tests ──────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestCreateFeatureBash:
|
||||
def test_creates_branch_sequential(self, tmp_path: Path):
|
||||
"""Extension create-new-feature.sh creates sequential branch."""
|
||||
project = _setup_project(tmp_path)
|
||||
result = _run_bash(
|
||||
"create-new-feature.sh", project,
|
||||
"--json", "--short-name", "user-auth", "Add user authentication",
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
data = json.loads(result.stdout)
|
||||
assert data["BRANCH_NAME"] == "001-user-auth"
|
||||
assert data["FEATURE_NUM"] == "001"
|
||||
|
||||
def test_creates_branch_timestamp(self, tmp_path: Path):
|
||||
"""Extension create-new-feature.sh creates timestamp branch."""
|
||||
project = _setup_project(tmp_path)
|
||||
result = _run_bash(
|
||||
"create-new-feature.sh", project,
|
||||
"--json", "--timestamp", "--short-name", "feat", "Feature",
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
data = json.loads(result.stdout)
|
||||
assert re.match(r"^\d{8}-\d{6}-feat$", data["BRANCH_NAME"])
|
||||
|
||||
def test_increments_from_existing_specs(self, tmp_path: Path):
|
||||
"""Sequential numbering increments past existing spec directories."""
|
||||
project = _setup_project(tmp_path)
|
||||
(project / "specs" / "001-first").mkdir(parents=True)
|
||||
(project / "specs" / "002-second").mkdir(parents=True)
|
||||
|
||||
result = _run_bash(
|
||||
"create-new-feature.sh", project,
|
||||
"--json", "--short-name", "third", "Third feature",
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
data = json.loads(result.stdout)
|
||||
assert data["FEATURE_NUM"] == "003"
|
||||
|
||||
def test_no_git_graceful_degradation(self, tmp_path: Path):
|
||||
"""create-new-feature.sh works without git (outputs branch name, skips branch creation)."""
|
||||
project = _setup_project(tmp_path, git=False)
|
||||
result = _run_bash(
|
||||
"create-new-feature.sh", project,
|
||||
"--json", "--short-name", "no-git", "No git feature",
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert "Warning" in result.stderr
|
||||
data = json.loads(result.stdout)
|
||||
assert "BRANCH_NAME" in data
|
||||
assert "FEATURE_NUM" in data
|
||||
|
||||
def test_dry_run(self, tmp_path: Path):
|
||||
"""--dry-run computes branch name without creating anything."""
|
||||
project = _setup_project(tmp_path)
|
||||
result = _run_bash(
|
||||
"create-new-feature.sh", project,
|
||||
"--json", "--dry-run", "--short-name", "dry", "Dry run test",
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
data = json.loads(result.stdout)
|
||||
assert data.get("DRY_RUN") is True
|
||||
assert not (project / "specs" / data["BRANCH_NAME"]).exists()
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAS_PWSH, reason="pwsh not available")
|
||||
class TestCreateFeaturePowerShell:
|
||||
def test_creates_branch_sequential(self, tmp_path: Path):
|
||||
"""Extension create-new-feature.ps1 creates sequential branch."""
|
||||
project = _setup_project(tmp_path)
|
||||
result = _run_pwsh(
|
||||
"create-new-feature.ps1", project,
|
||||
"-Json", "-ShortName", "user-auth", "Add user authentication",
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
data = json.loads(result.stdout)
|
||||
assert data["BRANCH_NAME"] == "001-user-auth"
|
||||
|
||||
def test_creates_branch_timestamp(self, tmp_path: Path):
|
||||
"""Extension create-new-feature.ps1 creates timestamp branch."""
|
||||
project = _setup_project(tmp_path)
|
||||
result = _run_pwsh(
|
||||
"create-new-feature.ps1", project,
|
||||
"-Json", "-Timestamp", "-ShortName", "feat", "Feature",
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
data = json.loads(result.stdout)
|
||||
assert re.match(r"^\d{8}-\d{6}-feat$", data["BRANCH_NAME"])
|
||||
|
||||
def test_no_git_graceful_degradation(self, tmp_path: Path):
|
||||
"""create-new-feature.ps1 works without git."""
|
||||
project = _setup_project(tmp_path, git=False)
|
||||
result = _run_pwsh(
|
||||
"create-new-feature.ps1", project,
|
||||
"-Json", "-ShortName", "no-git", "No git feature",
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
# pwsh may prefix warnings to stdout; find the JSON line
|
||||
json_line = [l for l in result.stdout.splitlines() if l.strip().startswith("{")]
|
||||
assert json_line, f"No JSON in output: {result.stdout}"
|
||||
data = json.loads(json_line[-1])
|
||||
assert "BRANCH_NAME" in data
|
||||
assert "FEATURE_NUM" in data
|
||||
|
||||
|
||||
# ── auto-commit.sh Tests ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestAutoCommitBash:
|
||||
def test_disabled_by_default(self, tmp_path: Path):
|
||||
"""auto-commit.sh exits silently when config is all false."""
|
||||
project = _setup_project(tmp_path)
|
||||
_write_config(project, "auto_commit:\n default: false\n")
|
||||
result = _run_bash("auto-commit.sh", project, "after_specify")
|
||||
assert result.returncode == 0
|
||||
# Should not have created any new commits
|
||||
log = subprocess.run(
|
||||
["git", "log", "--oneline"],
|
||||
cwd=project, capture_output=True, text=True,
|
||||
)
|
||||
assert log.stdout.strip().count("\n") == 0 # only the seed commit
|
||||
|
||||
def test_enabled_per_command(self, tmp_path: Path):
|
||||
"""auto-commit.sh commits when per-command key is enabled."""
|
||||
project = _setup_project(tmp_path)
|
||||
_write_config(project, (
|
||||
"auto_commit:\n"
|
||||
" default: false\n"
|
||||
" after_specify:\n"
|
||||
" enabled: true\n"
|
||||
' message: "test commit after specify"\n'
|
||||
))
|
||||
# Create a file to commit
|
||||
(project / "specs" / "001-test" / "spec.md").parent.mkdir(parents=True)
|
||||
(project / "specs" / "001-test" / "spec.md").write_text("test spec")
|
||||
|
||||
result = _run_bash("auto-commit.sh", project, "after_specify")
|
||||
assert result.returncode == 0
|
||||
|
||||
log = subprocess.run(
|
||||
["git", "log", "--oneline", "-1"],
|
||||
cwd=project, capture_output=True, text=True,
|
||||
)
|
||||
assert "test commit after specify" in log.stdout
|
||||
|
||||
def test_custom_message(self, tmp_path: Path):
|
||||
"""auto-commit.sh uses the per-command message."""
|
||||
project = _setup_project(tmp_path)
|
||||
_write_config(project, (
|
||||
"auto_commit:\n"
|
||||
" default: false\n"
|
||||
" after_plan:\n"
|
||||
" enabled: true\n"
|
||||
' message: "[Project] Plan complete"\n'
|
||||
))
|
||||
(project / "new-file.txt").write_text("content")
|
||||
|
||||
result = _run_bash("auto-commit.sh", project, "after_plan")
|
||||
assert result.returncode == 0
|
||||
|
||||
log = subprocess.run(
|
||||
["git", "log", "--oneline", "-1"],
|
||||
cwd=project, capture_output=True, text=True,
|
||||
)
|
||||
assert "[Project] Plan complete" in log.stdout
|
||||
|
||||
def test_default_true_with_no_event_key(self, tmp_path: Path):
|
||||
"""auto-commit.sh uses default: true when event key is absent."""
|
||||
project = _setup_project(tmp_path)
|
||||
_write_config(project, "auto_commit:\n default: true\n")
|
||||
(project / "new-file.txt").write_text("content")
|
||||
|
||||
result = _run_bash("auto-commit.sh", project, "after_tasks")
|
||||
assert result.returncode == 0
|
||||
|
||||
log = subprocess.run(
|
||||
["git", "log", "--oneline", "-1"],
|
||||
cwd=project, capture_output=True, text=True,
|
||||
)
|
||||
assert "Auto-commit after tasks" in log.stdout
|
||||
|
||||
def test_no_changes_skips(self, tmp_path: Path):
|
||||
"""auto-commit.sh skips when there are no changes."""
|
||||
project = _setup_project(tmp_path)
|
||||
_write_config(project, (
|
||||
"auto_commit:\n"
|
||||
" default: false\n"
|
||||
" after_specify:\n"
|
||||
" enabled: true\n"
|
||||
' message: "should not appear"\n'
|
||||
))
|
||||
# Commit all existing files so nothing is dirty
|
||||
subprocess.run(["git", "add", "."], cwd=project, check=True)
|
||||
subprocess.run(["git", "commit", "-m", "setup", "-q"], cwd=project, check=True)
|
||||
|
||||
result = _run_bash("auto-commit.sh", project, "after_specify")
|
||||
assert result.returncode == 0
|
||||
assert "No changes" in result.stderr
|
||||
|
||||
def test_no_config_file_skips(self, tmp_path: Path):
|
||||
"""auto-commit.sh exits silently when no config file exists."""
|
||||
project = _setup_project(tmp_path)
|
||||
# Remove config if it was copied
|
||||
config = project / ".specify" / "extensions" / "git" / "git-config.yml"
|
||||
config.unlink(missing_ok=True)
|
||||
|
||||
result = _run_bash("auto-commit.sh", project, "after_specify")
|
||||
assert result.returncode == 0
|
||||
|
||||
def test_no_git_repo_skips(self, tmp_path: Path):
|
||||
"""auto-commit.sh skips when not in a git repo."""
|
||||
project = _setup_project(tmp_path, git=False)
|
||||
_write_config(project, "auto_commit:\n default: true\n")
|
||||
result = _run_bash("auto-commit.sh", project, "after_specify")
|
||||
assert result.returncode == 0
|
||||
assert "not a Git repository" in result.stderr.lower() or "Warning" in result.stderr
|
||||
|
||||
def test_requires_event_name_argument(self, tmp_path: Path):
|
||||
"""auto-commit.sh fails without event name argument."""
|
||||
project = _setup_project(tmp_path)
|
||||
result = _run_bash("auto-commit.sh", project)
|
||||
assert result.returncode != 0
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAS_PWSH, reason="pwsh not available")
|
||||
class TestAutoCommitPowerShell:
|
||||
def test_disabled_by_default(self, tmp_path: Path):
|
||||
"""auto-commit.ps1 exits silently when config is all false."""
|
||||
project = _setup_project(tmp_path)
|
||||
_write_config(project, "auto_commit:\n default: false\n")
|
||||
result = _run_pwsh("auto-commit.ps1", project, "after_specify")
|
||||
assert result.returncode == 0
|
||||
|
||||
def test_enabled_per_command(self, tmp_path: Path):
|
||||
"""auto-commit.ps1 commits when per-command key is enabled."""
|
||||
project = _setup_project(tmp_path)
|
||||
_write_config(project, (
|
||||
"auto_commit:\n"
|
||||
" default: false\n"
|
||||
" after_specify:\n"
|
||||
" enabled: true\n"
|
||||
' message: "ps commit"\n'
|
||||
))
|
||||
(project / "specs" / "001-test").mkdir(parents=True)
|
||||
(project / "specs" / "001-test" / "spec.md").write_text("test")
|
||||
|
||||
result = _run_pwsh("auto-commit.ps1", project, "after_specify")
|
||||
assert result.returncode == 0
|
||||
|
||||
log = subprocess.run(
|
||||
["git", "log", "--oneline", "-1"],
|
||||
cwd=project, capture_output=True, text=True,
|
||||
)
|
||||
assert "ps commit" in log.stdout
|
||||
|
||||
|
||||
# ── git-common.sh Tests ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestGitCommonBash:
|
||||
def test_has_git_true(self, tmp_path: Path):
|
||||
"""has_git returns 0 in a git repo."""
|
||||
project = _setup_project(tmp_path, git=True)
|
||||
script = project / ".specify" / "extensions" / "git" / "scripts" / "bash" / "git-common.sh"
|
||||
result = subprocess.run(
|
||||
["bash", "-c", f'source "{script}" && has_git "{project}"'],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
assert result.returncode == 0
|
||||
|
||||
def test_has_git_false(self, tmp_path: Path):
|
||||
"""has_git returns non-zero outside a git repo."""
|
||||
project = _setup_project(tmp_path, git=False)
|
||||
script = project / ".specify" / "extensions" / "git" / "scripts" / "bash" / "git-common.sh"
|
||||
result = subprocess.run(
|
||||
["bash", "-c", f'source "{script}" && has_git "{project}"'],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
assert result.returncode != 0
|
||||
|
||||
def test_check_feature_branch_sequential(self, tmp_path: Path):
|
||||
"""check_feature_branch accepts sequential branch names."""
|
||||
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 "001-my-feature" "true"'],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
assert result.returncode == 0
|
||||
|
||||
def test_check_feature_branch_timestamp(self, tmp_path: Path):
|
||||
"""check_feature_branch accepts timestamp branch names."""
|
||||
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 "20260319-143022-feat" "true"'],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
assert result.returncode == 0
|
||||
|
||||
def test_check_feature_branch_rejects_main(self, tmp_path: Path):
|
||||
"""check_feature_branch rejects non-feature branch names."""
|
||||
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 "main" "true"'],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
assert result.returncode != 0
|
||||
|
||||
def test_check_feature_branch_rejects_malformed_timestamp(self, tmp_path: Path):
|
||||
"""check_feature_branch rejects malformed timestamps (7-digit date)."""
|
||||
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 "2026031-143022-feat" "true"'],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
assert result.returncode != 0
|
||||
@@ -3,6 +3,8 @@
|
||||
import json
|
||||
import os
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
class TestInitIntegrationFlag:
|
||||
def test_integration_and_ai_mutually_exclusive(self, tmp_path):
|
||||
@@ -147,3 +149,142 @@ class TestInitIntegrationFlag:
|
||||
# Other shared files should still be installed
|
||||
assert (scripts_dir / "setup-plan.sh").exists()
|
||||
assert (templates_dir / "plan-template.md").exists()
|
||||
|
||||
|
||||
class TestForceExistingDirectory:
|
||||
"""Tests for --force merging into an existing named directory."""
|
||||
|
||||
def test_force_merges_into_existing_dir(self, tmp_path):
|
||||
"""specify init <dir> --force succeeds when the directory already exists."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
target = tmp_path / "existing-proj"
|
||||
target.mkdir()
|
||||
# Place a pre-existing file to verify it survives the merge
|
||||
marker = target / "user-file.txt"
|
||||
marker.write_text("keep me", encoding="utf-8")
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, [
|
||||
"init", str(target), "--integration", "copilot", "--force",
|
||||
"--no-git", "--script", "sh",
|
||||
], catch_exceptions=False)
|
||||
|
||||
assert result.exit_code == 0, f"init --force failed: {result.output}"
|
||||
|
||||
# Pre-existing file should survive
|
||||
assert marker.read_text(encoding="utf-8") == "keep me"
|
||||
|
||||
# Spec Kit files should be installed
|
||||
assert (target / ".specify" / "init-options.json").exists()
|
||||
assert (target / ".specify" / "templates" / "spec-template.md").exists()
|
||||
|
||||
def test_without_force_errors_on_existing_dir(self, tmp_path):
|
||||
"""specify init <dir> without --force errors when directory exists."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
target = tmp_path / "existing-proj"
|
||||
target.mkdir()
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, [
|
||||
"init", str(target), "--integration", "copilot",
|
||||
"--no-git", "--script", "sh",
|
||||
], catch_exceptions=False)
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "already exists" in result.output
|
||||
|
||||
|
||||
class TestGitExtensionAutoInstall:
|
||||
"""Tests for auto-installation of the git extension during specify init."""
|
||||
|
||||
def test_git_extension_auto_installed(self, tmp_path):
|
||||
"""Without --no-git, the git extension is installed during init."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
project = tmp_path / "git-auto"
|
||||
project.mkdir()
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, [
|
||||
"init", "--here", "--ai", "claude", "--script", "sh",
|
||||
"--ignore-agent-tools",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
|
||||
assert result.exit_code == 0, f"init failed: {result.output}"
|
||||
|
||||
# Check that the tracker didn't report a git error
|
||||
assert "install failed" not in result.output, f"git extension install failed: {result.output}"
|
||||
|
||||
# Git extension files should be installed
|
||||
ext_dir = project / ".specify" / "extensions" / "git"
|
||||
assert ext_dir.exists(), "git extension directory not installed"
|
||||
assert (ext_dir / "extension.yml").exists()
|
||||
assert (ext_dir / "scripts" / "bash" / "create-new-feature.sh").exists()
|
||||
assert (ext_dir / "scripts" / "bash" / "initialize-repo.sh").exists()
|
||||
|
||||
# Hooks should be registered
|
||||
extensions_yml = project / ".specify" / "extensions.yml"
|
||||
assert extensions_yml.exists(), "extensions.yml not created"
|
||||
hooks_data = yaml.safe_load(extensions_yml.read_text(encoding="utf-8"))
|
||||
assert "hooks" in hooks_data
|
||||
assert "before_specify" in hooks_data["hooks"]
|
||||
assert "before_constitution" in hooks_data["hooks"]
|
||||
|
||||
def test_no_git_skips_extension(self, tmp_path):
|
||||
"""With --no-git, the git extension is NOT installed."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
project = tmp_path / "no-git"
|
||||
project.mkdir()
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, [
|
||||
"init", "--here", "--ai", "claude", "--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}"
|
||||
|
||||
# Git extension should NOT be installed
|
||||
ext_dir = project / ".specify" / "extensions" / "git"
|
||||
assert not ext_dir.exists(), "git extension should not be installed with --no-git"
|
||||
|
||||
def test_git_extension_commands_registered(self, tmp_path):
|
||||
"""Git extension commands are registered with the agent during init."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
project = tmp_path / "git-cmds"
|
||||
project.mkdir()
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, [
|
||||
"init", "--here", "--ai", "claude", "--script", "sh",
|
||||
"--ignore-agent-tools",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
|
||||
assert result.exit_code == 0, f"init failed: {result.output}"
|
||||
|
||||
# Git extension commands should be registered with the agent
|
||||
claude_skills = project / ".claude" / "skills"
|
||||
assert claude_skills.exists(), "Claude skills directory was not created"
|
||||
git_skills = [f for f in claude_skills.iterdir() if f.name.startswith("speckit-git-")]
|
||||
assert len(git_skills) > 0, "no git extension commands registered"
|
||||
|
||||
@@ -9,6 +9,9 @@ adapted for TOML output format.
|
||||
"""
|
||||
|
||||
import os
|
||||
import tomllib
|
||||
|
||||
import pytest
|
||||
|
||||
from specify_cli.integrations import INTEGRATION_REGISTRY, get_integration
|
||||
from specify_cli.integrations.base import TomlIntegration
|
||||
@@ -81,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:
|
||||
@@ -131,14 +136,168 @@ 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"),
|
||||
[
|
||||
(
|
||||
"---\ndescription: |\n First line\n Second line\n---\nBody\n",
|
||||
"First line\nSecond line\n",
|
||||
),
|
||||
(
|
||||
"---\ndescription: >\n First line\n Second line\n---\nBody\n",
|
||||
"First line Second line\n",
|
||||
),
|
||||
(
|
||||
"---\ndescription: |-\n First line\n Second line\n---\nBody\n",
|
||||
"First line\nSecond line",
|
||||
),
|
||||
(
|
||||
"---\ndescription: >-\n First line\n Second line\n---\nBody\n",
|
||||
"First line Second line",
|
||||
),
|
||||
],
|
||||
)
|
||||
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 = "---\ndescription: |\n line one\n ---\n line two\n---\nBody\n"
|
||||
|
||||
frontmatter, body = TomlIntegration._split_frontmatter(content)
|
||||
|
||||
assert "line two" in frontmatter
|
||||
assert body == "Body\n"
|
||||
|
||||
def test_toml_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
|
||||
|
||||
generated = cmd_files[0].read_text(encoding="utf-8")
|
||||
parsed = tomllib.loads(generated)
|
||||
|
||||
assert parsed["description"] == "Summary line one"
|
||||
assert parsed["prompt"] == "Body line one\nBody line two"
|
||||
assert "description:" not in parsed["prompt"]
|
||||
assert "scripts:" not in parsed["prompt"]
|
||||
assert "---" not in parsed["prompt"]
|
||||
|
||||
def test_toml_no_ambiguous_closing_quotes(self, tmp_path, monkeypatch):
|
||||
"""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(
|
||||
"---\n"
|
||||
"description: Test\n"
|
||||
"scripts:\n"
|
||||
" sh: echo ok\n"
|
||||
"---\n"
|
||||
"Check the following:\n"
|
||||
'- Correct: "Is X clearly specified?"\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
|
||||
|
||||
raw = cmd_files[0].read_text(encoding="utf-8")
|
||||
assert '""""' not in raw, "closing delimiter must not merge with body quote"
|
||||
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"
|
||||
)
|
||||
|
||||
def test_toml_triple_double_and_single_quote_ending(self, tmp_path, monkeypatch):
|
||||
"""Body containing `\"\"\"` and ending with `'` falls back to escaped basic string."""
|
||||
i = get_integration(self.KEY)
|
||||
template = tmp_path / "sample.md"
|
||||
template.write_text(
|
||||
"---\n"
|
||||
"description: Test\n"
|
||||
"scripts:\n"
|
||||
" sh: echo ok\n"
|
||||
"---\n"
|
||||
'Use """triple""" quotes\n'
|
||||
"and end with 'single'\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
|
||||
|
||||
raw = cmd_files[0].read_text(encoding="utf-8")
|
||||
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"
|
||||
)
|
||||
|
||||
def test_toml_closing_delimiter_inline_when_safe(self, tmp_path, monkeypatch):
|
||||
"""Body NOT ending with `"` keeps closing `\"\"\"` inline (no extra newline)."""
|
||||
i = get_integration(self.KEY)
|
||||
template = tmp_path / "sample.md"
|
||||
template.write_text(
|
||||
"---\n"
|
||||
"description: Test\n"
|
||||
"scripts:\n"
|
||||
" sh: echo ok\n"
|
||||
"---\n"
|
||||
"Line one\n"
|
||||
"Plain body content\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
|
||||
|
||||
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"""'), (
|
||||
"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."""
|
||||
try:
|
||||
import tomllib
|
||||
except ModuleNotFoundError:
|
||||
import tomli as tomllib # type: ignore[no-redef]
|
||||
|
||||
i = get_integration(self.KEY)
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
created = i.setup(tmp_path, m)
|
||||
@@ -204,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 -------------------------------------------------
|
||||
@@ -219,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}"
|
||||
@@ -240,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"
|
||||
@@ -256,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]:
|
||||
@@ -275,23 +470,38 @@ 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")
|
||||
@@ -307,15 +517,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"
|
||||
@@ -332,15 +553,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"
|
||||
|
||||
459
tests/integrations/test_integration_base_yaml.py
Normal file
459
tests/integrations/test_integration_base_yaml.py
Normal file
@@ -0,0 +1,459 @@
|
||||
"""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")
|
||||
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))}"
|
||||
)
|
||||
@@ -8,6 +8,7 @@ import yaml
|
||||
|
||||
from specify_cli.integrations import INTEGRATION_REGISTRY, get_integration
|
||||
from specify_cli.integrations.base import IntegrationBase
|
||||
from specify_cli.integrations.claude import ARGUMENT_HINTS
|
||||
from specify_cli.integrations.manifest import IntegrationManifest
|
||||
|
||||
|
||||
@@ -57,6 +58,7 @@ class TestClaudeIntegration:
|
||||
parts = content.split("---", 2)
|
||||
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["metadata"]["source"] == "templates/commands/plan.md"
|
||||
|
||||
@@ -175,7 +177,9 @@ class TestClaudeIntegration:
|
||||
|
||||
skill_file = project / ".claude" / "skills" / "speckit-plan" / "SKILL.md"
|
||||
assert skill_file.exists()
|
||||
assert "disable-model-invocation: true" in skill_file.read_text(encoding="utf-8")
|
||||
skill_content = skill_file.read_text(encoding="utf-8")
|
||||
assert "user-invocable: true" in skill_content
|
||||
assert "disable-model-invocation: true" in skill_content
|
||||
|
||||
init_options = json.loads(
|
||||
(project / ".specify" / "init-options.json").read_text(encoding="utf-8")
|
||||
@@ -275,7 +279,124 @@ class TestClaudeIntegration:
|
||||
content = skill_file.read_text(encoding="utf-8")
|
||||
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
|
||||
|
||||
metadata = manager.registry.get("claude-skill-command")
|
||||
assert "speckit-research" in metadata.get("registered_skills", [])
|
||||
|
||||
|
||||
class TestClaudeArgumentHints:
|
||||
"""Verify that argument-hint frontmatter is injected for Claude skills."""
|
||||
|
||||
def test_all_skills_have_hints(self, tmp_path):
|
||||
"""Every generated SKILL.md must contain an argument-hint line."""
|
||||
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")
|
||||
assert "argument-hint:" in content, (
|
||||
f"{f.parent.name}/SKILL.md is missing argument-hint frontmatter"
|
||||
)
|
||||
|
||||
def test_hints_match_expected_values(self, tmp_path):
|
||||
"""Each skill's argument-hint must match the expected text."""
|
||||
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"]
|
||||
for f in skill_files:
|
||||
# Extract stem: speckit-plan -> plan
|
||||
stem = f.parent.name
|
||||
if stem.startswith("speckit-"):
|
||||
stem = stem[len("speckit-"):]
|
||||
expected_hint = ARGUMENT_HINTS.get(stem)
|
||||
assert expected_hint is not None, (
|
||||
f"No expected hint defined for skill '{stem}'"
|
||||
)
|
||||
content = f.read_text(encoding="utf-8")
|
||||
assert f'argument-hint: "{expected_hint}"' in content, (
|
||||
f"{f.parent.name}/SKILL.md: expected hint '{expected_hint}' not found"
|
||||
)
|
||||
|
||||
def test_hint_is_inside_frontmatter(self, tmp_path):
|
||||
"""argument-hint must appear between the --- delimiters, not in the body."""
|
||||
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"]
|
||||
for f in skill_files:
|
||||
content = f.read_text(encoding="utf-8")
|
||||
parts = content.split("---", 2)
|
||||
assert len(parts) >= 3, f"No frontmatter in {f.parent.name}/SKILL.md"
|
||||
frontmatter = parts[1]
|
||||
body = parts[2]
|
||||
assert "argument-hint:" in frontmatter, (
|
||||
f"{f.parent.name}/SKILL.md: argument-hint not in frontmatter section"
|
||||
)
|
||||
assert "argument-hint:" not in body, (
|
||||
f"{f.parent.name}/SKILL.md: argument-hint leaked into body"
|
||||
)
|
||||
|
||||
def test_hint_appears_after_description(self, tmp_path):
|
||||
"""argument-hint must immediately follow the description line."""
|
||||
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"]
|
||||
for f in skill_files:
|
||||
content = f.read_text(encoding="utf-8")
|
||||
lines = content.splitlines()
|
||||
found_description = False
|
||||
for idx, line in enumerate(lines):
|
||||
if line.startswith("description:"):
|
||||
found_description = True
|
||||
assert idx + 1 < len(lines), (
|
||||
f"{f.parent.name}/SKILL.md: description is last line"
|
||||
)
|
||||
assert lines[idx + 1].startswith("argument-hint:"), (
|
||||
f"{f.parent.name}/SKILL.md: argument-hint does not follow description"
|
||||
)
|
||||
break
|
||||
assert found_description, (
|
||||
f"{f.parent.name}/SKILL.md: no description: line found in output"
|
||||
)
|
||||
|
||||
def test_inject_argument_hint_only_in_frontmatter(self):
|
||||
"""inject_argument_hint must not modify description: lines in the body."""
|
||||
from specify_cli.integrations.claude import ClaudeIntegration
|
||||
|
||||
content = (
|
||||
"---\n"
|
||||
"description: My command\n"
|
||||
"---\n"
|
||||
"\n"
|
||||
"description: this is body text\n"
|
||||
)
|
||||
result = ClaudeIntegration.inject_argument_hint(content, "Test hint")
|
||||
lines = result.splitlines()
|
||||
hint_count = sum(1 for ln in lines if ln.startswith("argument-hint:"))
|
||||
assert hint_count == 1, (
|
||||
f"Expected exactly 1 argument-hint line, found {hint_count}"
|
||||
)
|
||||
|
||||
def test_inject_argument_hint_skips_if_already_present(self):
|
||||
"""inject_argument_hint must not duplicate if argument-hint already exists."""
|
||||
from specify_cli.integrations.claude import ClaudeIntegration
|
||||
|
||||
content = (
|
||||
"---\n"
|
||||
"description: My command\n"
|
||||
'argument-hint: "Existing hint"\n'
|
||||
"---\n"
|
||||
"\n"
|
||||
"Body text\n"
|
||||
)
|
||||
result = ClaudeIntegration.inject_argument_hint(content, "New hint")
|
||||
assert result == content, "Content should be unchanged when hint already exists"
|
||||
lines = result.splitlines()
|
||||
hint_count = sum(1 for ln in lines if ln.startswith("argument-hint:"))
|
||||
assert hint_count == 1
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
392
tests/integrations/test_integration_forge.py
Normal file
392
tests/integrations/test_integration_forge.py
Normal file
@@ -0,0 +1,392 @@
|
||||
"""Tests for ForgeIntegration."""
|
||||
|
||||
from specify_cli.integrations import get_integration
|
||||
from specify_cli.integrations.manifest import IntegrationManifest
|
||||
from specify_cli.integrations.forge import format_forge_command_name
|
||||
|
||||
|
||||
class TestForgeCommandNameFormatter:
|
||||
"""Test the centralized Forge command name formatter."""
|
||||
|
||||
def test_simple_name_without_prefix(self):
|
||||
"""Test formatting a simple name without 'speckit.' prefix."""
|
||||
assert format_forge_command_name("plan") == "speckit-plan"
|
||||
assert format_forge_command_name("tasks") == "speckit-tasks"
|
||||
assert format_forge_command_name("specify") == "speckit-specify"
|
||||
|
||||
def test_name_with_speckit_prefix(self):
|
||||
"""Test formatting a name that already has 'speckit.' prefix."""
|
||||
assert format_forge_command_name("speckit.plan") == "speckit-plan"
|
||||
assert format_forge_command_name("speckit.tasks") == "speckit-tasks"
|
||||
|
||||
def test_extension_command_name(self):
|
||||
"""Test formatting extension command names with dots."""
|
||||
assert format_forge_command_name("speckit.my-extension.example") == "speckit-my-extension-example"
|
||||
assert format_forge_command_name("my-extension.example") == "speckit-my-extension-example"
|
||||
|
||||
def test_complex_nested_name(self):
|
||||
"""Test formatting deeply nested command names."""
|
||||
assert format_forge_command_name("speckit.jira.sync-status") == "speckit-jira-sync-status"
|
||||
assert format_forge_command_name("speckit.foo.bar.baz") == "speckit-foo-bar-baz"
|
||||
|
||||
def test_name_with_hyphens_preserved(self):
|
||||
"""Test that existing hyphens are preserved."""
|
||||
assert format_forge_command_name("my-extension") == "speckit-my-extension"
|
||||
assert format_forge_command_name("speckit.my-ext.test-cmd") == "speckit-my-ext-test-cmd"
|
||||
|
||||
def test_alias_formatting(self):
|
||||
"""Test formatting alias names."""
|
||||
assert format_forge_command_name("speckit.my-extension.example-short") == "speckit-my-extension-example-short"
|
||||
|
||||
def test_idempotent_already_hyphenated(self):
|
||||
"""Test that already-hyphenated names are returned unchanged (idempotent)."""
|
||||
assert format_forge_command_name("speckit-plan") == "speckit-plan"
|
||||
assert format_forge_command_name("speckit-my-extension-example") == "speckit-my-extension-example"
|
||||
assert format_forge_command_name("speckit-jira-sync-status") == "speckit-jira-sync-status"
|
||||
|
||||
|
||||
class TestForgeIntegration:
|
||||
def test_forge_key_and_config(self):
|
||||
forge = get_integration("forge")
|
||||
assert forge is not None
|
||||
assert forge.key == "forge"
|
||||
assert forge.config["folder"] == ".forge/"
|
||||
assert forge.config["commands_subdir"] == "commands"
|
||||
assert forge.config["requires_cli"] is True
|
||||
assert forge.registrar_config["args"] == "{{parameters}}"
|
||||
assert forge.registrar_config["extension"] == ".md"
|
||||
assert forge.context_file == "AGENTS.md"
|
||||
|
||||
def test_command_filename_md(self):
|
||||
forge = get_integration("forge")
|
||||
assert forge.command_filename("plan") == "speckit.plan.md"
|
||||
|
||||
def test_setup_creates_md_files(self, tmp_path):
|
||||
from specify_cli.integrations.forge import ForgeIntegration
|
||||
forge = ForgeIntegration()
|
||||
m = IntegrationManifest("forge", tmp_path)
|
||||
created = forge.setup(tmp_path, m)
|
||||
assert len(created) > 0
|
||||
# Separate command files from scripts
|
||||
command_files = [f for f in created if f.parent == tmp_path / ".forge" / "commands"]
|
||||
assert len(command_files) > 0
|
||||
for f in command_files:
|
||||
assert f.name.endswith(".md")
|
||||
|
||||
def test_setup_installs_update_scripts(self, tmp_path):
|
||||
from specify_cli.integrations.forge import ForgeIntegration
|
||||
forge = ForgeIntegration()
|
||||
m = IntegrationManifest("forge", tmp_path)
|
||||
created = forge.setup(tmp_path, m)
|
||||
script_files = [f for f in created if "scripts" in f.parts]
|
||||
assert len(script_files) > 0
|
||||
sh_script = tmp_path / ".specify" / "integrations" / "forge" / "scripts" / "update-context.sh"
|
||||
ps_script = tmp_path / ".specify" / "integrations" / "forge" / "scripts" / "update-context.ps1"
|
||||
assert sh_script in created
|
||||
assert ps_script in created
|
||||
assert sh_script.exists()
|
||||
assert ps_script.exists()
|
||||
|
||||
def test_all_created_files_tracked_in_manifest(self, tmp_path):
|
||||
from specify_cli.integrations.forge import ForgeIntegration
|
||||
forge = ForgeIntegration()
|
||||
m = IntegrationManifest("forge", tmp_path)
|
||||
created = forge.setup(tmp_path, m)
|
||||
for f in created:
|
||||
rel = f.resolve().relative_to(tmp_path.resolve()).as_posix()
|
||||
assert rel in m.files, f"Created file {rel} not tracked in manifest"
|
||||
|
||||
def test_install_uninstall_roundtrip(self, tmp_path):
|
||||
from specify_cli.integrations.forge import ForgeIntegration
|
||||
forge = ForgeIntegration()
|
||||
m = IntegrationManifest("forge", tmp_path)
|
||||
created = forge.install(tmp_path, m)
|
||||
assert len(created) > 0
|
||||
m.save()
|
||||
for f in created:
|
||||
assert f.exists()
|
||||
removed, skipped = forge.uninstall(tmp_path, m)
|
||||
assert len(removed) == len(created)
|
||||
assert skipped == []
|
||||
|
||||
def test_modified_file_survives_uninstall(self, tmp_path):
|
||||
from specify_cli.integrations.forge import ForgeIntegration
|
||||
forge = ForgeIntegration()
|
||||
m = IntegrationManifest("forge", tmp_path)
|
||||
created = forge.install(tmp_path, m)
|
||||
m.save()
|
||||
# Modify a command file (not a script)
|
||||
command_files = [f for f in created if f.parent == tmp_path / ".forge" / "commands"]
|
||||
modified_file = command_files[0]
|
||||
modified_file.write_text("user modified this", encoding="utf-8")
|
||||
removed, skipped = forge.uninstall(tmp_path, m)
|
||||
assert modified_file.exists()
|
||||
assert modified_file in skipped
|
||||
|
||||
def test_directory_structure(self, tmp_path):
|
||||
from specify_cli.integrations.forge import ForgeIntegration
|
||||
forge = ForgeIntegration()
|
||||
m = IntegrationManifest("forge", tmp_path)
|
||||
forge.setup(tmp_path, m)
|
||||
commands_dir = tmp_path / ".forge" / "commands"
|
||||
assert commands_dir.is_dir()
|
||||
|
||||
# Derive expected command names from the Forge command templates so the test
|
||||
# stays in sync if templates are added/removed.
|
||||
templates = forge.list_command_templates()
|
||||
expected_commands = {t.stem for t in templates}
|
||||
assert len(expected_commands) > 0, "No command templates found"
|
||||
|
||||
# Check generated files match templates
|
||||
command_files = sorted(commands_dir.glob("speckit.*.md"))
|
||||
assert len(command_files) == len(expected_commands)
|
||||
actual_commands = {f.name.removeprefix("speckit.").removesuffix(".md") for f in command_files}
|
||||
assert actual_commands == expected_commands
|
||||
|
||||
def test_templates_are_processed(self, tmp_path):
|
||||
from specify_cli.integrations.forge import ForgeIntegration
|
||||
forge = ForgeIntegration()
|
||||
m = IntegrationManifest("forge", tmp_path)
|
||||
forge.setup(tmp_path, m)
|
||||
commands_dir = tmp_path / ".forge" / "commands"
|
||||
for cmd_file in commands_dir.glob("speckit.*.md"):
|
||||
content = cmd_file.read_text(encoding="utf-8")
|
||||
# Check standard replacements
|
||||
assert "{SCRIPT}" not in content, f"{cmd_file.name} has unprocessed {{SCRIPT}}"
|
||||
assert "__AGENT__" not in content, f"{cmd_file.name} has unprocessed __AGENT__"
|
||||
assert "{ARGS}" not in content, f"{cmd_file.name} has unprocessed {{ARGS}}"
|
||||
# Check Forge-specific: $ARGUMENTS should be replaced with {{parameters}}
|
||||
assert "$ARGUMENTS" not in content, f"{cmd_file.name} has unprocessed $ARGUMENTS"
|
||||
# Frontmatter sections should be stripped
|
||||
assert "\nscripts:\n" not in content
|
||||
assert "\nagent_scripts:\n" not in content
|
||||
|
||||
def test_forge_specific_transformations(self, tmp_path):
|
||||
"""Test Forge-specific processing: name injection and handoffs stripping."""
|
||||
from specify_cli.integrations.forge import ForgeIntegration
|
||||
from specify_cli.agents import CommandRegistrar
|
||||
forge = ForgeIntegration()
|
||||
m = IntegrationManifest("forge", tmp_path)
|
||||
forge.setup(tmp_path, m)
|
||||
commands_dir = tmp_path / ".forge" / "commands"
|
||||
|
||||
registrar = CommandRegistrar()
|
||||
for cmd_file in commands_dir.glob("speckit.*.md"):
|
||||
content = cmd_file.read_text(encoding="utf-8")
|
||||
frontmatter, _ = registrar.parse_frontmatter(content)
|
||||
|
||||
# Check that name field is injected in frontmatter
|
||||
assert "name" in frontmatter, f"{cmd_file.name} missing injected 'name' field in frontmatter"
|
||||
|
||||
# Check that handoffs frontmatter key is stripped
|
||||
assert "handoffs" not in frontmatter, f"{cmd_file.name} has unstripped 'handoffs' key in frontmatter"
|
||||
|
||||
def test_uses_parameters_placeholder(self, tmp_path):
|
||||
"""Verify Forge replaces $ARGUMENTS with {{parameters}} in generated files."""
|
||||
from specify_cli.integrations.forge import ForgeIntegration
|
||||
forge = ForgeIntegration()
|
||||
|
||||
# The registrar_config should specify {{parameters}}
|
||||
assert forge.registrar_config["args"] == "{{parameters}}"
|
||||
|
||||
# Generate files and verify $ARGUMENTS is replaced with {{parameters}}
|
||||
from specify_cli.integrations.manifest import IntegrationManifest
|
||||
m = IntegrationManifest("forge", tmp_path)
|
||||
forge.setup(tmp_path, m)
|
||||
commands_dir = tmp_path / ".forge" / "commands"
|
||||
|
||||
# Check all generated command files
|
||||
for cmd_file in commands_dir.glob("speckit.*.md"):
|
||||
content = cmd_file.read_text(encoding="utf-8")
|
||||
# $ARGUMENTS should be replaced with {{parameters}}
|
||||
assert "$ARGUMENTS" not in content, (
|
||||
f"{cmd_file.name} still contains $ARGUMENTS - it should be replaced with {{{{parameters}}}}"
|
||||
)
|
||||
# At least some files should have {{parameters}} (those with user input sections)
|
||||
# We'll check the checklist file specifically as it has a User Input section
|
||||
|
||||
# Verify checklist specifically has {{parameters}} in the User Input section
|
||||
checklist = commands_dir / "speckit.checklist.md"
|
||||
if checklist.exists():
|
||||
content = checklist.read_text(encoding="utf-8")
|
||||
assert "{{parameters}}" in content, (
|
||||
"checklist should contain {{parameters}} in User Input section"
|
||||
)
|
||||
|
||||
def test_name_field_uses_hyphenated_format(self, tmp_path):
|
||||
"""Verify that injected name fields use hyphenated format (speckit-plan, not speckit.plan)."""
|
||||
from specify_cli.integrations.forge import ForgeIntegration
|
||||
from specify_cli.agents import CommandRegistrar
|
||||
forge = ForgeIntegration()
|
||||
m = IntegrationManifest("forge", tmp_path)
|
||||
forge.setup(tmp_path, m)
|
||||
commands_dir = tmp_path / ".forge" / "commands"
|
||||
|
||||
# Check that name fields use hyphenated format
|
||||
registrar = CommandRegistrar()
|
||||
for cmd_file in commands_dir.glob("speckit.*.md"):
|
||||
content = cmd_file.read_text(encoding="utf-8")
|
||||
# Extract the name field from frontmatter using the parser
|
||||
frontmatter, _ = registrar.parse_frontmatter(content)
|
||||
assert "name" in frontmatter, (
|
||||
f"{cmd_file.name} missing injected 'name' field in frontmatter"
|
||||
)
|
||||
name_value = frontmatter["name"]
|
||||
# Name should use hyphens, not dots
|
||||
assert "." not in name_value, (
|
||||
f"{cmd_file.name} has name field with dots: {name_value} "
|
||||
f"(should use hyphens for Forge/ZSH compatibility)"
|
||||
)
|
||||
assert name_value.startswith("speckit-"), (
|
||||
f"{cmd_file.name} name field should start with 'speckit-': {name_value}"
|
||||
)
|
||||
|
||||
|
||||
class TestForgeCommandRegistrar:
|
||||
"""Test CommandRegistrar's Forge-specific name formatting."""
|
||||
|
||||
def test_registrar_formats_extension_command_names_for_forge(self, tmp_path):
|
||||
"""Verify CommandRegistrar converts dot notation to hyphens for Forge."""
|
||||
from specify_cli.agents import CommandRegistrar
|
||||
|
||||
# Create a mock extension command file
|
||||
ext_dir = tmp_path / "extension"
|
||||
ext_dir.mkdir()
|
||||
cmd_dir = ext_dir / "commands"
|
||||
cmd_dir.mkdir()
|
||||
|
||||
# Create a test command with dot notation name
|
||||
cmd_file = cmd_dir / "example.md"
|
||||
cmd_file.write_text(
|
||||
"---\n"
|
||||
"description: Test extension command\n"
|
||||
"---\n\n"
|
||||
"Test content with $ARGUMENTS\n",
|
||||
encoding="utf-8"
|
||||
)
|
||||
|
||||
# Register with Forge
|
||||
registrar = CommandRegistrar()
|
||||
commands = [
|
||||
{
|
||||
"name": "speckit.my-extension.example",
|
||||
"file": "commands/example.md"
|
||||
}
|
||||
]
|
||||
|
||||
registered = registrar.register_commands(
|
||||
"forge",
|
||||
commands,
|
||||
"test-extension",
|
||||
ext_dir,
|
||||
tmp_path
|
||||
)
|
||||
|
||||
# Verify registration succeeded
|
||||
assert "speckit.my-extension.example" in registered
|
||||
|
||||
# Check the generated file has hyphenated name in frontmatter
|
||||
forge_cmd = tmp_path / ".forge" / "commands" / "speckit.my-extension.example.md"
|
||||
assert forge_cmd.exists()
|
||||
|
||||
content = forge_cmd.read_text(encoding="utf-8")
|
||||
# Parse frontmatter to validate name field precisely
|
||||
frontmatter, _ = registrar.parse_frontmatter(content)
|
||||
assert "name" in frontmatter, "name field should be injected in frontmatter"
|
||||
# Name field should use hyphens, not dots
|
||||
assert frontmatter["name"] == "speckit-my-extension-example"
|
||||
|
||||
def test_registrar_formats_alias_names_for_forge(self, tmp_path):
|
||||
"""Verify CommandRegistrar converts alias names to hyphens for Forge."""
|
||||
from specify_cli.agents import CommandRegistrar
|
||||
|
||||
# Create a mock extension command file
|
||||
ext_dir = tmp_path / "extension"
|
||||
ext_dir.mkdir()
|
||||
cmd_dir = ext_dir / "commands"
|
||||
cmd_dir.mkdir()
|
||||
|
||||
cmd_file = cmd_dir / "example.md"
|
||||
cmd_file.write_text(
|
||||
"---\n"
|
||||
"description: Test command with alias\n"
|
||||
"---\n\n"
|
||||
"Test content\n",
|
||||
encoding="utf-8"
|
||||
)
|
||||
|
||||
# Register with Forge including an alias
|
||||
registrar = CommandRegistrar()
|
||||
commands = [
|
||||
{
|
||||
"name": "speckit.my-extension.example",
|
||||
"file": "commands/example.md",
|
||||
"aliases": ["speckit.my-extension.ex"]
|
||||
}
|
||||
]
|
||||
|
||||
registrar.register_commands(
|
||||
"forge",
|
||||
commands,
|
||||
"test-extension",
|
||||
ext_dir,
|
||||
tmp_path
|
||||
)
|
||||
|
||||
# Check the alias file has hyphenated name in frontmatter
|
||||
alias_file = tmp_path / ".forge" / "commands" / "speckit.my-extension.ex.md"
|
||||
assert alias_file.exists()
|
||||
|
||||
content = alias_file.read_text(encoding="utf-8")
|
||||
# Parse frontmatter to validate alias name field precisely
|
||||
frontmatter, _ = registrar.parse_frontmatter(content)
|
||||
assert "name" in frontmatter, "name field should be injected in alias frontmatter"
|
||||
# Alias name field should also use hyphens
|
||||
assert frontmatter["name"] == "speckit-my-extension-ex"
|
||||
|
||||
def test_registrar_does_not_affect_other_agents(self, tmp_path):
|
||||
"""Verify format_name callback is Forge-specific and doesn't affect other agents."""
|
||||
from specify_cli.agents import CommandRegistrar
|
||||
|
||||
# Create a mock extension command file
|
||||
ext_dir = tmp_path / "extension"
|
||||
ext_dir.mkdir()
|
||||
cmd_dir = ext_dir / "commands"
|
||||
cmd_dir.mkdir()
|
||||
|
||||
cmd_file = cmd_dir / "example.md"
|
||||
cmd_file.write_text(
|
||||
"---\n"
|
||||
"description: Test command\n"
|
||||
"---\n\n"
|
||||
"Test content with $ARGUMENTS\n",
|
||||
encoding="utf-8"
|
||||
)
|
||||
|
||||
# Register with Windsurf (standard markdown agent without inject_name)
|
||||
registrar = CommandRegistrar()
|
||||
commands = [
|
||||
{
|
||||
"name": "speckit.my-extension.example",
|
||||
"file": "commands/example.md"
|
||||
}
|
||||
]
|
||||
|
||||
registrar.register_commands(
|
||||
"windsurf",
|
||||
commands,
|
||||
"test-extension",
|
||||
ext_dir,
|
||||
tmp_path
|
||||
)
|
||||
|
||||
# Windsurf uses standard markdown format without name injection.
|
||||
# The format_name callback should not be invoked for non-Forge agents.
|
||||
windsurf_cmd = tmp_path / ".windsurf" / "workflows" / "speckit.my-extension.example.md"
|
||||
assert windsurf_cmd.exists()
|
||||
|
||||
content = windsurf_cmd.read_text(encoding="utf-8")
|
||||
# Windsurf should NOT have a name field injected
|
||||
assert "name:" not in content, (
|
||||
"Windsurf should not inject name field - format_name callback should be Forge-only"
|
||||
)
|
||||
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"
|
||||
540
tests/integrations/test_integration_subcommand.py
Normal file
540
tests/integrations/test_integration_subcommand.py
Normal file
@@ -0,0 +1,540 @@
|
||||
"""Tests for ``specify integration`` subcommand (list, install, uninstall, switch)."""
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from specify_cli import app
|
||||
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
def _init_project(tmp_path, integration="copilot"):
|
||||
"""Helper: init a spec-kit project with the given integration."""
|
||||
project = tmp_path / "proj"
|
||||
project.mkdir()
|
||||
old_cwd = 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_cwd)
|
||||
assert result.exit_code == 0, f"init failed: {result.output}"
|
||||
return project
|
||||
|
||||
|
||||
# ── list ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestIntegrationList:
|
||||
def test_list_requires_speckit_project(self, tmp_path):
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(tmp_path)
|
||||
result = runner.invoke(app, ["integration", "list"])
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code != 0
|
||||
assert "Not a spec-kit project" in result.output
|
||||
|
||||
def test_list_shows_installed(self, tmp_path):
|
||||
project = _init_project(tmp_path, "copilot")
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
result = runner.invoke(app, ["integration", "list"])
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0
|
||||
assert "copilot" in result.output
|
||||
assert "installed" in result.output
|
||||
|
||||
def test_list_shows_available_integrations(self, tmp_path):
|
||||
project = _init_project(tmp_path, "copilot")
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
result = runner.invoke(app, ["integration", "list"])
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0
|
||||
# Should show multiple integrations
|
||||
assert "claude" in result.output
|
||||
assert "gemini" in result.output
|
||||
|
||||
|
||||
# ── install ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestIntegrationInstall:
|
||||
def test_install_requires_speckit_project(self, tmp_path):
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(tmp_path)
|
||||
result = runner.invoke(app, ["integration", "install", "claude"])
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code != 0
|
||||
assert "Not a spec-kit project" in result.output
|
||||
|
||||
def test_install_unknown_integration(self, tmp_path):
|
||||
project = _init_project(tmp_path)
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
result = runner.invoke(app, ["integration", "install", "nonexistent"])
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code != 0
|
||||
assert "Unknown integration" in result.output
|
||||
|
||||
def test_install_already_installed(self, tmp_path):
|
||||
project = _init_project(tmp_path, "copilot")
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
result = runner.invoke(app, ["integration", "install", "copilot"])
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0
|
||||
assert "already installed" in result.output
|
||||
assert "uninstall" in result.output
|
||||
|
||||
def test_install_different_when_one_exists(self, tmp_path):
|
||||
project = _init_project(tmp_path, "copilot")
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
result = runner.invoke(app, ["integration", "install", "claude"])
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code != 0
|
||||
assert "already installed" in result.output
|
||||
assert "uninstall" in result.output
|
||||
|
||||
def test_install_into_bare_project(self, tmp_path):
|
||||
"""Install into a project with .specify/ but no integration."""
|
||||
project = tmp_path / "bare"
|
||||
project.mkdir()
|
||||
(project / ".specify").mkdir()
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
result = runner.invoke(app, [
|
||||
"integration", "install", "claude",
|
||||
"--script", "sh",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "installed successfully" in result.output
|
||||
|
||||
# integration.json written
|
||||
data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8"))
|
||||
assert data["integration"] == "claude"
|
||||
|
||||
# Manifest created
|
||||
assert (project / ".specify" / "integrations" / "claude.manifest.json").exists()
|
||||
|
||||
# Claude uses skills directory (not commands)
|
||||
assert (project / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists()
|
||||
|
||||
def test_install_bare_project_gets_shared_infra(self, tmp_path):
|
||||
"""Installing into a bare project should create shared scripts and templates."""
|
||||
project = tmp_path / "bare"
|
||||
project.mkdir()
|
||||
(project / ".specify").mkdir()
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
result = runner.invoke(app, [
|
||||
"integration", "install", "claude",
|
||||
"--script", "sh",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0, result.output
|
||||
|
||||
# Shared infrastructure should be present
|
||||
assert (project / ".specify" / "scripts").is_dir()
|
||||
assert (project / ".specify" / "templates").is_dir()
|
||||
|
||||
|
||||
# ── uninstall ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestIntegrationUninstall:
|
||||
def test_uninstall_requires_speckit_project(self, tmp_path):
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(tmp_path)
|
||||
result = runner.invoke(app, ["integration", "uninstall"])
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code != 0
|
||||
assert "Not a spec-kit project" in result.output
|
||||
|
||||
def test_uninstall_no_integration(self, tmp_path):
|
||||
project = tmp_path / "proj"
|
||||
project.mkdir()
|
||||
(project / ".specify").mkdir()
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
result = runner.invoke(app, ["integration", "uninstall"])
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0
|
||||
assert "No integration" in result.output
|
||||
|
||||
def test_uninstall_removes_files(self, tmp_path):
|
||||
project = _init_project(tmp_path, "claude")
|
||||
# Claude uses skills directory
|
||||
assert (project / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists()
|
||||
assert (project / ".specify" / "integrations" / "claude.manifest.json").exists()
|
||||
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
result = runner.invoke(app, ["integration", "uninstall"], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0
|
||||
assert "uninstalled" in result.output
|
||||
|
||||
# Command files removed
|
||||
assert not (project / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists()
|
||||
|
||||
# Manifest removed
|
||||
assert not (project / ".specify" / "integrations" / "claude.manifest.json").exists()
|
||||
|
||||
# integration.json removed
|
||||
assert not (project / ".specify" / "integration.json").exists()
|
||||
|
||||
def test_uninstall_preserves_modified_files(self, tmp_path):
|
||||
"""Full lifecycle: install → modify → uninstall → modified file kept."""
|
||||
project = _init_project(tmp_path, "claude")
|
||||
plan_file = project / ".claude" / "skills" / "speckit-plan" / "SKILL.md"
|
||||
assert plan_file.exists()
|
||||
|
||||
# Modify a file
|
||||
plan_file.write_text("# My custom plan command\n", encoding="utf-8")
|
||||
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
result = runner.invoke(app, ["integration", "uninstall"], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0
|
||||
assert "preserved" in result.output
|
||||
|
||||
# Modified file kept
|
||||
assert plan_file.exists()
|
||||
assert plan_file.read_text(encoding="utf-8") == "# My custom plan command\n"
|
||||
|
||||
def test_uninstall_wrong_key(self, tmp_path):
|
||||
project = _init_project(tmp_path, "copilot")
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
result = runner.invoke(app, ["integration", "uninstall", "claude"])
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code != 0
|
||||
assert "not the currently installed" in result.output
|
||||
|
||||
def test_uninstall_preserves_shared_infra(self, tmp_path):
|
||||
"""Shared scripts and templates are not removed by integration uninstall."""
|
||||
project = _init_project(tmp_path, "claude")
|
||||
shared_script = project / ".specify" / "scripts" / "bash" / "common.sh"
|
||||
assert shared_script.exists()
|
||||
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
result = runner.invoke(app, ["integration", "uninstall"], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0
|
||||
|
||||
# Shared infrastructure preserved
|
||||
assert shared_script.exists()
|
||||
assert (project / ".specify" / "templates").is_dir()
|
||||
|
||||
|
||||
# ── switch ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestIntegrationSwitch:
|
||||
def test_switch_requires_speckit_project(self, tmp_path):
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(tmp_path)
|
||||
result = runner.invoke(app, ["integration", "switch", "claude"])
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code != 0
|
||||
assert "Not a spec-kit project" in result.output
|
||||
|
||||
def test_switch_unknown_target(self, tmp_path):
|
||||
project = _init_project(tmp_path)
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
result = runner.invoke(app, ["integration", "switch", "nonexistent"])
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code != 0
|
||||
assert "Unknown integration" in result.output
|
||||
|
||||
def test_switch_same_noop(self, tmp_path):
|
||||
project = _init_project(tmp_path, "copilot")
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
result = runner.invoke(app, ["integration", "switch", "copilot"])
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0
|
||||
assert "already installed" in result.output
|
||||
|
||||
def test_switch_between_integrations(self, tmp_path):
|
||||
project = _init_project(tmp_path, "claude")
|
||||
# Verify claude files exist (claude uses skills)
|
||||
assert (project / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists()
|
||||
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
result = runner.invoke(app, [
|
||||
"integration", "switch", "copilot",
|
||||
"--script", "sh",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "Switched to" in result.output
|
||||
|
||||
# Old claude files removed
|
||||
assert not (project / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists()
|
||||
|
||||
# New copilot files created
|
||||
assert (project / ".github" / "agents" / "speckit.plan.agent.md").exists()
|
||||
|
||||
# integration.json updated
|
||||
data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8"))
|
||||
assert data["integration"] == "copilot"
|
||||
|
||||
def test_switch_preserves_shared_infra(self, tmp_path):
|
||||
"""Switching preserves shared scripts, templates, and memory."""
|
||||
project = _init_project(tmp_path, "claude")
|
||||
shared_script = project / ".specify" / "scripts" / "bash" / "common.sh"
|
||||
assert shared_script.exists()
|
||||
shared_content = shared_script.read_text(encoding="utf-8")
|
||||
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
result = runner.invoke(app, [
|
||||
"integration", "switch", "copilot",
|
||||
"--script", "sh",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0
|
||||
|
||||
# Shared infra untouched
|
||||
assert shared_script.exists()
|
||||
assert shared_script.read_text(encoding="utf-8") == shared_content
|
||||
|
||||
def test_switch_from_nothing(self, tmp_path):
|
||||
"""Switch when no integration is installed should just install the target."""
|
||||
project = tmp_path / "bare"
|
||||
project.mkdir()
|
||||
(project / ".specify").mkdir()
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
result = runner.invoke(app, [
|
||||
"integration", "switch", "claude",
|
||||
"--script", "sh",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0
|
||||
assert "Switched to" in result.output
|
||||
|
||||
data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8"))
|
||||
assert data["integration"] == "claude"
|
||||
|
||||
|
||||
# ── Full lifecycle ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestIntegrationLifecycle:
|
||||
def test_install_modify_uninstall_preserves_modified(self, tmp_path):
|
||||
"""Full lifecycle: install → modify file → uninstall → verify modified file kept."""
|
||||
project = tmp_path / "lifecycle"
|
||||
project.mkdir()
|
||||
(project / ".specify").mkdir()
|
||||
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
|
||||
# Install
|
||||
result = runner.invoke(app, [
|
||||
"integration", "install", "claude",
|
||||
"--script", "sh",
|
||||
], catch_exceptions=False)
|
||||
assert result.exit_code == 0
|
||||
assert "installed successfully" in result.output
|
||||
|
||||
# Claude uses skills directory
|
||||
plan_file = project / ".claude" / "skills" / "speckit-plan" / "SKILL.md"
|
||||
assert plan_file.exists()
|
||||
|
||||
# Modify one file
|
||||
plan_file.write_text("# user customization\n", encoding="utf-8")
|
||||
|
||||
# Uninstall
|
||||
result = runner.invoke(app, ["integration", "uninstall"], catch_exceptions=False)
|
||||
assert result.exit_code == 0
|
||||
assert "preserved" in result.output
|
||||
|
||||
# Modified file kept
|
||||
assert plan_file.exists()
|
||||
assert plan_file.read_text(encoding="utf-8") == "# user customization\n"
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
|
||||
|
||||
# ── Edge-case fixes ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestScriptTypeValidation:
|
||||
def test_invalid_script_type_rejected(self, tmp_path):
|
||||
"""--script with an invalid value should fail with a clear error."""
|
||||
project = tmp_path / "proj"
|
||||
project.mkdir()
|
||||
(project / ".specify").mkdir()
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
result = runner.invoke(app, [
|
||||
"integration", "install", "claude",
|
||||
"--script", "bash",
|
||||
])
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code != 0
|
||||
assert "Invalid script type" in result.output
|
||||
|
||||
def test_valid_script_types_accepted(self, tmp_path):
|
||||
"""Both 'sh' and 'ps' should be accepted."""
|
||||
project = tmp_path / "proj"
|
||||
project.mkdir()
|
||||
(project / ".specify").mkdir()
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
result = runner.invoke(app, [
|
||||
"integration", "install", "claude",
|
||||
"--script", "sh",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0
|
||||
|
||||
|
||||
class TestParseIntegrationOptionsEqualsForm:
|
||||
def test_equals_form_parsed(self):
|
||||
"""--commands-dir=./x should be parsed the same as --commands-dir ./x."""
|
||||
from specify_cli import _parse_integration_options
|
||||
from specify_cli.integrations import get_integration
|
||||
|
||||
integration = get_integration("generic")
|
||||
assert integration is not None
|
||||
|
||||
result_space = _parse_integration_options(integration, "--commands-dir ./mydir")
|
||||
result_equals = _parse_integration_options(integration, "--commands-dir=./mydir")
|
||||
assert result_space is not None
|
||||
assert result_equals is not None
|
||||
assert result_space["commands_dir"] == "./mydir"
|
||||
assert result_equals["commands_dir"] == "./mydir"
|
||||
|
||||
|
||||
class TestUninstallNoManifestClearsInitOptions:
|
||||
def test_init_options_cleared_on_no_manifest_uninstall(self, tmp_path):
|
||||
"""When no manifest exists, uninstall should still clear init-options.json."""
|
||||
project = tmp_path / "proj"
|
||||
project.mkdir()
|
||||
(project / ".specify").mkdir()
|
||||
|
||||
# Write integration.json and init-options.json without a manifest
|
||||
int_json = project / ".specify" / "integration.json"
|
||||
int_json.write_text(json.dumps({"integration": "claude"}), encoding="utf-8")
|
||||
|
||||
opts_json = project / ".specify" / "init-options.json"
|
||||
opts_json.write_text(json.dumps({
|
||||
"integration": "claude",
|
||||
"ai": "claude",
|
||||
"ai_skills": True,
|
||||
"script": "sh",
|
||||
}), encoding="utf-8")
|
||||
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
result = runner.invoke(app, ["integration", "uninstall", "claude"])
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0
|
||||
|
||||
# init-options.json should have integration keys cleared
|
||||
opts = json.loads(opts_json.read_text(encoding="utf-8"))
|
||||
assert "integration" not in opts
|
||||
assert "ai" not in opts
|
||||
assert "ai_skills" not in opts
|
||||
# Non-integration keys preserved
|
||||
assert opts.get("script") == "sh"
|
||||
|
||||
|
||||
class TestSwitchClearsMetadataAfterTeardown:
|
||||
def test_metadata_cleared_between_phases(self, tmp_path):
|
||||
"""After a successful switch, metadata should reference the new integration."""
|
||||
project = _init_project(tmp_path, "claude")
|
||||
|
||||
# Verify initial state
|
||||
int_json = project / ".specify" / "integration.json"
|
||||
assert json.loads(int_json.read_text(encoding="utf-8"))["integration"] == "claude"
|
||||
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
# Switch to copilot — should succeed and update metadata
|
||||
result = runner.invoke(app, [
|
||||
"integration", "switch", "copilot",
|
||||
"--script", "sh",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0
|
||||
|
||||
# integration.json should reference copilot, not claude
|
||||
data = json.loads(int_json.read_text(encoding="utf-8"))
|
||||
assert data["integration"] == "copilot"
|
||||
|
||||
# init-options.json should reference copilot
|
||||
opts_json = project / ".specify" / "init-options.json"
|
||||
opts = json.loads(opts_json.read_text(encoding="utf-8"))
|
||||
assert opts.get("ai") == "copilot"
|
||||
@@ -1,11 +1,11 @@
|
||||
"""Tests for TraeIntegration."""
|
||||
|
||||
from .test_integration_base_markdown import MarkdownIntegrationTests
|
||||
from .test_integration_base_skills import SkillsIntegrationTests
|
||||
|
||||
|
||||
class TestTraeIntegration(MarkdownIntegrationTests):
|
||||
class TestTraeIntegration(SkillsIntegrationTests):
|
||||
KEY = "trae"
|
||||
FOLDER = ".trae/"
|
||||
COMMANDS_SUBDIR = "rules"
|
||||
REGISTRAR_DIR = ".trae/rules"
|
||||
CONTEXT_FILE = ".trae/rules/AGENTS.md"
|
||||
COMMANDS_SUBDIR = "skills"
|
||||
REGISTRAR_DIR = ".trae/skills"
|
||||
CONTEXT_FILE = ".trae/rules/project_rules.md"
|
||||
|
||||
@@ -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
|
||||
@@ -139,7 +154,7 @@ class TestAgentConfigConsistency:
|
||||
"""AGENT_CONFIG should include trae with correct folder and commands_subdir."""
|
||||
assert "trae" in AGENT_CONFIG
|
||||
assert AGENT_CONFIG["trae"]["folder"] == ".trae/"
|
||||
assert AGENT_CONFIG["trae"]["commands_subdir"] == "rules"
|
||||
assert AGENT_CONFIG["trae"]["commands_subdir"] == "skills"
|
||||
assert AGENT_CONFIG["trae"]["requires_cli"] is False
|
||||
assert AGENT_CONFIG["trae"]["install_url"] is None
|
||||
|
||||
@@ -151,12 +166,16 @@ class TestAgentConfigConsistency:
|
||||
trae_cfg = cfg["trae"]
|
||||
assert trae_cfg["format"] == "markdown"
|
||||
assert trae_cfg["args"] == "$ARGUMENTS"
|
||||
assert trae_cfg["extension"] == ".md"
|
||||
assert trae_cfg["extension"] == "/SKILL.md"
|
||||
|
||||
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
|
||||
|
||||
@@ -13,6 +13,7 @@ import pytest
|
||||
import json
|
||||
import tempfile
|
||||
import shutil
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timezone
|
||||
|
||||
@@ -254,17 +255,66 @@ class TestExtensionManifest:
|
||||
with pytest.raises(ValidationError, match="Invalid command name"):
|
||||
ExtensionManifest(manifest_path)
|
||||
|
||||
def test_no_commands(self, temp_dir, valid_manifest_data):
|
||||
"""Test manifest with no commands provided."""
|
||||
def test_no_commands_no_hooks(self, temp_dir, valid_manifest_data):
|
||||
"""Test manifest with no commands and no hooks provided."""
|
||||
import yaml
|
||||
|
||||
valid_manifest_data["provides"]["commands"] = []
|
||||
valid_manifest_data.pop("hooks", None)
|
||||
|
||||
manifest_path = temp_dir / "extension.yml"
|
||||
with open(manifest_path, 'w') as f:
|
||||
yaml.dump(valid_manifest_data, f)
|
||||
|
||||
with pytest.raises(ValidationError, match="must provide at least one command"):
|
||||
with pytest.raises(ValidationError, match="must provide at least one command or hook"):
|
||||
ExtensionManifest(manifest_path)
|
||||
|
||||
def test_hooks_only_extension(self, temp_dir, valid_manifest_data):
|
||||
"""Test manifest with hooks but no commands is valid."""
|
||||
import yaml
|
||||
|
||||
valid_manifest_data["provides"]["commands"] = []
|
||||
valid_manifest_data["hooks"] = {
|
||||
"after_specify": {
|
||||
"command": "speckit.test-ext.notify",
|
||||
"optional": True,
|
||||
"prompt": "Run notification?",
|
||||
}
|
||||
}
|
||||
|
||||
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.id == valid_manifest_data["extension"]["id"]
|
||||
assert len(manifest.commands) == 0
|
||||
assert len(manifest.hooks) == 1
|
||||
|
||||
def test_commands_null_rejected(self, temp_dir, valid_manifest_data):
|
||||
"""Test manifest with commands: null is rejected."""
|
||||
import yaml
|
||||
|
||||
valid_manifest_data["provides"]["commands"] = None
|
||||
|
||||
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 provides.commands"):
|
||||
ExtensionManifest(manifest_path)
|
||||
|
||||
def test_hooks_not_dict_rejected(self, temp_dir, valid_manifest_data):
|
||||
"""Test manifest with hooks as a list is rejected."""
|
||||
import yaml
|
||||
|
||||
valid_manifest_data["hooks"] = ["not", "a", "dict"]
|
||||
|
||||
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 hooks"):
|
||||
ExtensionManifest(manifest_path)
|
||||
|
||||
def test_manifest_hash(self, extension_dir):
|
||||
@@ -636,8 +686,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_rejects_alias_without_extension_namespace(self, temp_dir, project_dir):
|
||||
"""Install should reject legacy short aliases that can shadow core commands."""
|
||||
def test_install_accepts_short_alias(self, temp_dir, project_dir):
|
||||
"""Install should accept legacy short aliases for community extension compat."""
|
||||
import yaml
|
||||
|
||||
ext_dir = temp_dir / "alias-shortcut"
|
||||
@@ -668,8 +718,8 @@ class TestExtensionManager:
|
||||
(ext_dir / "commands" / "cmd.md").write_text("---\ndescription: Test\n---\n\nBody")
|
||||
|
||||
manager = ExtensionManager(project_dir)
|
||||
with pytest.raises(ValidationError, match="Invalid alias 'speckit.shortcut'"):
|
||||
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
|
||||
# Should not raise — short aliases are allowed
|
||||
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
|
||||
|
||||
def test_install_rejects_namespace_squatting(self, temp_dir, project_dir):
|
||||
"""Install should reject commands and aliases outside the extension namespace."""
|
||||
@@ -1014,6 +1064,21 @@ $ARGUMENTS
|
||||
assert "\\n" in output
|
||||
assert "\\\"\\\"\\\"" in output
|
||||
|
||||
def test_render_toml_command_preserves_multiline_description(self):
|
||||
"""Multiline descriptions should render as parseable TOML with preserved semantics."""
|
||||
from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar
|
||||
|
||||
registrar = AgentCommandRegistrar()
|
||||
output = registrar.render_toml_command(
|
||||
{"description": "first line\nsecond line\n"},
|
||||
"body",
|
||||
"extension:test-ext",
|
||||
)
|
||||
|
||||
parsed = tomllib.loads(output)
|
||||
|
||||
assert parsed["description"] == "first line\nsecond line\n"
|
||||
|
||||
def test_register_commands_for_claude(self, extension_dir, project_dir):
|
||||
"""Test registering commands for Claude agent."""
|
||||
# Create .claude directory
|
||||
@@ -2930,6 +2995,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."""
|
||||
|
||||
@@ -2865,3 +2865,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
|
||||
|
||||
@@ -4,6 +4,7 @@ Pytest tests for timestamp-based branch naming in create-new-feature.sh and comm
|
||||
Converted from tests/test_timestamp_branches.sh so they are discovered by `uv run pytest`.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
@@ -15,7 +16,15 @@ import pytest
|
||||
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"
|
||||
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"
|
||||
)
|
||||
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"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -41,6 +50,62 @@ def git_repo(tmp_path: Path) -> Path:
|
||||
return tmp_path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ext_git_repo(tmp_path: Path) -> Path:
|
||||
"""Create a temp git repo with extension scripts (for GIT_BRANCH_NAME tests)."""
|
||||
subprocess.run(["git", "init", "-q"], cwd=tmp_path, check=True)
|
||||
subprocess.run(["git", "config", "user.email", "test@example.com"], cwd=tmp_path, check=True)
|
||||
subprocess.run(["git", "config", "user.name", "Test User"], cwd=tmp_path, check=True)
|
||||
subprocess.run(["git", "commit", "--allow-empty", "-m", "init", "-q"], cwd=tmp_path, check=True)
|
||||
# Extension script needs common.sh at .specify/scripts/bash/
|
||||
specify_scripts = tmp_path / ".specify" / "scripts" / "bash"
|
||||
specify_scripts.mkdir(parents=True)
|
||||
shutil.copy(COMMON_SH, specify_scripts / "common.sh")
|
||||
# Also install core scripts for compatibility
|
||||
core_scripts = tmp_path / "scripts" / "bash"
|
||||
core_scripts.mkdir(parents=True)
|
||||
shutil.copy(COMMON_SH, core_scripts / "common.sh")
|
||||
# Copy extension script
|
||||
ext_dir = tmp_path / ".specify" / "extensions" / "git" / "scripts" / "bash"
|
||||
ext_dir.mkdir(parents=True)
|
||||
shutil.copy(EXT_CREATE_FEATURE, ext_dir / "create-new-feature.sh")
|
||||
# Also copy git-common.sh if it exists
|
||||
git_common = PROJECT_ROOT / "extensions" / "git" / "scripts" / "bash" / "git-common.sh"
|
||||
if git_common.exists():
|
||||
shutil.copy(git_common, ext_dir / "git-common.sh")
|
||||
(tmp_path / ".specify" / "templates").mkdir(parents=True, exist_ok=True)
|
||||
(tmp_path / "specs").mkdir(exist_ok=True)
|
||||
return tmp_path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ext_ps_git_repo(tmp_path: Path) -> Path:
|
||||
"""Create a temp git repo with PowerShell extension scripts."""
|
||||
subprocess.run(["git", "init", "-q"], cwd=tmp_path, check=True)
|
||||
subprocess.run(["git", "config", "user.email", "test@example.com"], cwd=tmp_path, check=True)
|
||||
subprocess.run(["git", "config", "user.name", "Test User"], cwd=tmp_path, check=True)
|
||||
subprocess.run(["git", "commit", "--allow-empty", "-m", "init", "-q"], cwd=tmp_path, check=True)
|
||||
# Install core PS scripts
|
||||
ps_dir = tmp_path / "scripts" / "powershell"
|
||||
ps_dir.mkdir(parents=True)
|
||||
common_ps = PROJECT_ROOT / "scripts" / "powershell" / "common.ps1"
|
||||
shutil.copy(common_ps, ps_dir / "common.ps1")
|
||||
# Also install at .specify/scripts/powershell/ for extension resolution
|
||||
specify_ps = tmp_path / ".specify" / "scripts" / "powershell"
|
||||
specify_ps.mkdir(parents=True)
|
||||
shutil.copy(common_ps, specify_ps / "common.ps1")
|
||||
# Copy extension script
|
||||
ext_ps = tmp_path / ".specify" / "extensions" / "git" / "scripts" / "powershell"
|
||||
ext_ps.mkdir(parents=True)
|
||||
shutil.copy(EXT_CREATE_FEATURE_PS, ext_ps / "create-new-feature.ps1")
|
||||
git_common_ps = PROJECT_ROOT / "extensions" / "git" / "scripts" / "powershell" / "git-common.ps1"
|
||||
if git_common_ps.exists():
|
||||
shutil.copy(git_common_ps, ext_ps / "git-common.ps1")
|
||||
(tmp_path / ".specify" / "templates").mkdir(parents=True, exist_ok=True)
|
||||
(tmp_path / "specs").mkdir(exist_ok=True)
|
||||
return tmp_path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def no_git_dir(tmp_path: Path) -> Path:
|
||||
"""Create a temp directory without git, but with scripts."""
|
||||
@@ -134,7 +199,7 @@ class TestSequentialBranch:
|
||||
if line.startswith("BRANCH_NAME:"):
|
||||
branch = line.split(":", 1)[1].strip()
|
||||
assert branch is not None
|
||||
assert re.match(r"^\d{3}-new-feat$", branch), f"unexpected branch: {branch}"
|
||||
assert re.match(r"^\d{3,}-new-feat$", branch), f"unexpected branch: {branch}"
|
||||
|
||||
def test_sequential_ignores_timestamp_dirs(self, git_repo: Path):
|
||||
"""Sequential numbering skips timestamp dirs when computing next number."""
|
||||
@@ -289,7 +354,7 @@ class TestE2EFlow:
|
||||
capture_output=True,
|
||||
text=True,
|
||||
).stdout.strip()
|
||||
assert re.match(r"^\d{3}-seq-feat$", branch), f"branch: {branch}"
|
||||
assert re.match(r"^\d{3,}-seq-feat$", branch), f"branch: {branch}"
|
||||
assert (git_repo / "specs" / branch).is_dir()
|
||||
val = source_and_call(f'check_feature_branch "{branch}" "true"')
|
||||
assert val.returncode == 0
|
||||
@@ -428,6 +493,43 @@ class TestAllowExistingBranch:
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
|
||||
def test_allow_existing_surfaces_checkout_error(self, git_repo: Path):
|
||||
"""Checkout failures on an existing branch should include Git's stderr."""
|
||||
shared_file = git_repo / "shared.txt"
|
||||
shared_file.write_text("base\n")
|
||||
subprocess.run(
|
||||
["git", "add", "shared.txt"],
|
||||
cwd=git_repo, check=True, capture_output=True,
|
||||
)
|
||||
subprocess.run(
|
||||
["git", "commit", "-m", "add shared file", "-q"],
|
||||
cwd=git_repo, check=True, capture_output=True,
|
||||
)
|
||||
subprocess.run(
|
||||
["git", "checkout", "-b", "010-checkout-failure"],
|
||||
cwd=git_repo, check=True, capture_output=True,
|
||||
)
|
||||
shared_file.write_text("branch version\n")
|
||||
subprocess.run(
|
||||
["git", "commit", "-am", "branch change", "-q"],
|
||||
cwd=git_repo, check=True, capture_output=True,
|
||||
)
|
||||
subprocess.run(
|
||||
["git", "checkout", "-"],
|
||||
cwd=git_repo, check=True, capture_output=True,
|
||||
)
|
||||
shared_file.write_text("uncommitted main change\n")
|
||||
|
||||
result = run_script(
|
||||
git_repo, "--allow-existing-branch", "--short-name", "checkout-failure",
|
||||
"--number", "10", "Checkout failure",
|
||||
)
|
||||
|
||||
assert result.returncode != 0, "checkout should fail with conflicting local changes"
|
||||
assert "Failed to switch to existing branch '010-checkout-failure'" in result.stderr
|
||||
assert "would be overwritten by checkout" in result.stderr
|
||||
assert "shared.txt" in result.stderr
|
||||
|
||||
|
||||
class TestAllowExistingBranchPowerShell:
|
||||
def test_powershell_supports_allow_existing_branch_flag(self):
|
||||
@@ -437,6 +539,26 @@ class TestAllowExistingBranchPowerShell:
|
||||
# Ensure the flag is referenced in script logic, not just declared
|
||||
assert "AllowExistingBranch" in contents.replace("-AllowExistingBranch", "")
|
||||
|
||||
def test_powershell_surfaces_checkout_errors(self):
|
||||
"""Static guard: PS script preserves checkout stderr on existing-branch failures."""
|
||||
contents = CREATE_FEATURE_PS.read_text(encoding="utf-8")
|
||||
assert "$switchBranchError = git checkout -q $branchName 2>&1 | Out-String" in contents
|
||||
assert "exists but could not be checked out.`n$($switchBranchError.Trim())" in contents
|
||||
|
||||
|
||||
class TestGitExtensionParity:
|
||||
def test_bash_extension_surfaces_checkout_errors(self):
|
||||
"""Static guard: git extension bash script preserves checkout stderr."""
|
||||
contents = EXT_CREATE_FEATURE.read_text(encoding="utf-8")
|
||||
assert 'switch_branch_error=$(git checkout -q "$BRANCH_NAME" 2>&1)' in contents
|
||||
assert "Failed to switch to existing branch '$BRANCH_NAME'" in contents
|
||||
|
||||
def test_powershell_extension_surfaces_checkout_errors(self):
|
||||
"""Static guard: git extension PowerShell script preserves checkout stderr."""
|
||||
contents = EXT_CREATE_FEATURE_PS.read_text(encoding="utf-8")
|
||||
assert "$switchBranchError = git checkout -q $branchName 2>&1 | Out-String" in contents
|
||||
assert "exists but could not be checked out.`n$($switchBranchError.Trim())" in contents
|
||||
|
||||
|
||||
# ── Dry-Run Tests ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -774,3 +896,262 @@ class TestPowerShellDryRun:
|
||||
assert result.returncode == 0, result.stderr
|
||||
data = json.loads(result.stdout)
|
||||
assert "DRY_RUN" not in data, f"DRY_RUN should not be in normal JSON: {data}"
|
||||
|
||||
|
||||
# ── GIT_BRANCH_NAME Override Tests ──────────────────────────────────────────
|
||||
|
||||
|
||||
class TestGitBranchNameOverrideBash:
|
||||
"""Tests for GIT_BRANCH_NAME env var override in extension create-new-feature.sh."""
|
||||
|
||||
def _run_ext(self, ext_git_repo: Path, env_extras: dict, *extra_args: str):
|
||||
script = ext_git_repo / ".specify" / "extensions" / "git" / "scripts" / "bash" / "create-new-feature.sh"
|
||||
cmd = ["bash", str(script), "--json", *extra_args, "ignored"]
|
||||
return subprocess.run(cmd, cwd=ext_git_repo, capture_output=True, text=True,
|
||||
env={**os.environ, **env_extras})
|
||||
|
||||
def test_exact_name_no_prefix(self, ext_git_repo: Path):
|
||||
"""GIT_BRANCH_NAME is used verbatim with no numeric prefix added."""
|
||||
result = self._run_ext(ext_git_repo, {"GIT_BRANCH_NAME": "my-exact-branch"})
|
||||
assert result.returncode == 0, result.stderr
|
||||
data = json.loads(result.stdout)
|
||||
assert data["BRANCH_NAME"] == "my-exact-branch"
|
||||
assert data["FEATURE_NUM"] == "my-exact-branch"
|
||||
|
||||
def test_sequential_prefix_extraction(self, ext_git_repo: Path):
|
||||
"""FEATURE_NUM extracted from sequential-style prefix (digits before dash)."""
|
||||
result = self._run_ext(ext_git_repo, {"GIT_BRANCH_NAME": "042-custom-branch"})
|
||||
assert result.returncode == 0, result.stderr
|
||||
data = json.loads(result.stdout)
|
||||
assert data["BRANCH_NAME"] == "042-custom-branch"
|
||||
assert data["FEATURE_NUM"] == "042"
|
||||
|
||||
def test_timestamp_prefix_extraction(self, ext_git_repo: Path):
|
||||
"""FEATURE_NUM extracted as full YYYYMMDD-HHMMSS for timestamp-style names."""
|
||||
result = self._run_ext(ext_git_repo, {"GIT_BRANCH_NAME": "20260407-143022-my-feature"})
|
||||
assert result.returncode == 0, result.stderr
|
||||
data = json.loads(result.stdout)
|
||||
assert data["BRANCH_NAME"] == "20260407-143022-my-feature"
|
||||
assert data["FEATURE_NUM"] == "20260407-143022"
|
||||
|
||||
def test_overlong_name_rejected(self, ext_git_repo: Path):
|
||||
"""GIT_BRANCH_NAME exceeding 244 bytes is rejected with an error."""
|
||||
long_name = "a" * 245
|
||||
result = self._run_ext(ext_git_repo, {"GIT_BRANCH_NAME": long_name})
|
||||
assert result.returncode != 0
|
||||
assert "244" in result.stderr
|
||||
|
||||
def test_dry_run_with_override(self, ext_git_repo: Path):
|
||||
"""GIT_BRANCH_NAME works with --dry-run (no branch created)."""
|
||||
result = self._run_ext(ext_git_repo, {"GIT_BRANCH_NAME": "dry-run-override"}, "--dry-run")
|
||||
assert result.returncode == 0, result.stderr
|
||||
data = json.loads(result.stdout)
|
||||
assert data["BRANCH_NAME"] == "dry-run-override"
|
||||
assert data.get("DRY_RUN") is True
|
||||
branches = subprocess.run(
|
||||
["git", "branch", "--list", "dry-run-override"],
|
||||
cwd=ext_git_repo, capture_output=True, text=True,
|
||||
)
|
||||
assert "dry-run-override" not in branches.stdout
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _has_pwsh(), reason="pwsh not installed")
|
||||
class TestGitBranchNameOverridePowerShell:
|
||||
"""Tests for GIT_BRANCH_NAME env var override in extension create-new-feature.ps1."""
|
||||
|
||||
def _run_ext(self, ext_ps_git_repo: Path, env_extras: dict):
|
||||
script = ext_ps_git_repo / ".specify" / "extensions" / "git" / "scripts" / "powershell" / "create-new-feature.ps1"
|
||||
return subprocess.run(
|
||||
["pwsh", "-NoProfile", "-File", str(script), "-Json", "ignored"],
|
||||
cwd=ext_ps_git_repo, capture_output=True, text=True,
|
||||
env={**os.environ, **env_extras},
|
||||
)
|
||||
|
||||
def test_exact_name_no_prefix(self, ext_ps_git_repo: Path):
|
||||
"""GIT_BRANCH_NAME is used verbatim with no numeric prefix added."""
|
||||
result = self._run_ext(ext_ps_git_repo, {"GIT_BRANCH_NAME": "ps-exact-branch"})
|
||||
assert result.returncode == 0, result.stderr
|
||||
data = json.loads(result.stdout)
|
||||
assert data["BRANCH_NAME"] == "ps-exact-branch"
|
||||
assert data["FEATURE_NUM"] == "ps-exact-branch"
|
||||
|
||||
def test_sequential_prefix_extraction(self, ext_ps_git_repo: Path):
|
||||
"""FEATURE_NUM extracted from sequential-style prefix."""
|
||||
result = self._run_ext(ext_ps_git_repo, {"GIT_BRANCH_NAME": "099-ps-numbered"})
|
||||
assert result.returncode == 0, result.stderr
|
||||
data = json.loads(result.stdout)
|
||||
assert data["BRANCH_NAME"] == "099-ps-numbered"
|
||||
assert data["FEATURE_NUM"] == "099"
|
||||
|
||||
def test_timestamp_prefix_extraction(self, ext_ps_git_repo: Path):
|
||||
"""FEATURE_NUM extracted as full YYYYMMDD-HHMMSS for timestamp-style names."""
|
||||
result = self._run_ext(ext_ps_git_repo, {"GIT_BRANCH_NAME": "20260407-143022-ps-feature"})
|
||||
assert result.returncode == 0, result.stderr
|
||||
data = json.loads(result.stdout)
|
||||
assert data["BRANCH_NAME"] == "20260407-143022-ps-feature"
|
||||
assert data["FEATURE_NUM"] == "20260407-143022"
|
||||
|
||||
def test_overlong_name_rejected(self, ext_ps_git_repo: Path):
|
||||
"""GIT_BRANCH_NAME exceeding 244 bytes is rejected."""
|
||||
long_name = "a" * 245
|
||||
result = self._run_ext(ext_ps_git_repo, {"GIT_BRANCH_NAME": long_name})
|
||||
assert result.returncode != 0
|
||||
assert "244" in result.stderr
|
||||
|
||||
|
||||
# ── Feature Directory Resolution Tests ───────────────────────────────────────
|
||||
|
||||
|
||||
class TestFeatureDirectoryResolution:
|
||||
"""Tests for SPECIFY_FEATURE_DIRECTORY and .specify/feature.json resolution."""
|
||||
|
||||
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"
|
||||
custom_dir.mkdir(parents=True)
|
||||
|
||||
result = subprocess.run(
|
||||
["bash", "-c", f'source "{COMMON_SH}" && get_feature_paths'],
|
||||
cwd=git_repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env={**os.environ, "SPECIFY_FEATURE_DIRECTORY": str(custom_dir)},
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert str(custom_dir) in result.stdout
|
||||
for line in result.stdout.splitlines():
|
||||
if line.startswith("FEATURE_DIR="):
|
||||
val = line.split("=", 1)[1].strip("'\"")
|
||||
assert val == str(custom_dir)
|
||||
break
|
||||
else:
|
||||
pytest.fail("FEATURE_DIR not found in output")
|
||||
|
||||
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"
|
||||
custom_dir.mkdir(parents=True)
|
||||
|
||||
feature_json = git_repo / ".specify" / "feature.json"
|
||||
feature_json.write_text(
|
||||
f'{{"feature_directory": "{custom_dir}"}}\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = subprocess.run(
|
||||
["bash", "-c", f'source "{COMMON_SH}" && get_feature_paths'],
|
||||
cwd=git_repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
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(custom_dir)
|
||||
break
|
||||
else:
|
||||
pytest.fail("FEATURE_DIR not found in output")
|
||||
|
||||
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"
|
||||
env_dir.mkdir(parents=True)
|
||||
json_dir = git_repo / "specs" / "json-feature"
|
||||
json_dir.mkdir(parents=True)
|
||||
|
||||
feature_json = git_repo / ".specify" / "feature.json"
|
||||
feature_json.write_text(
|
||||
f'{{"feature_directory": "{json_dir}"}}\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = subprocess.run(
|
||||
["bash", "-c", f'source "{COMMON_SH}" && get_feature_paths'],
|
||||
cwd=git_repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env={**os.environ, "SPECIFY_FEATURE_DIRECTORY": str(env_dir)},
|
||||
)
|
||||
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(env_dir)
|
||||
break
|
||||
else:
|
||||
pytest.fail("FEATURE_DIR not found in output")
|
||||
|
||||
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)
|
||||
spec_dir = git_repo / "specs" / "001-test-feat"
|
||||
spec_dir.mkdir(parents=True)
|
||||
|
||||
result = subprocess.run(
|
||||
["bash", "-c", f'source "{COMMON_SH}" && get_feature_paths'],
|
||||
cwd=git_repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
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 output")
|
||||
|
||||
@pytest.mark.skipif(not _has_pwsh(), reason="pwsh not installed")
|
||||
def test_ps_env_var_overrides_branch_lookup(self, git_repo: Path):
|
||||
"""PowerShell: SPECIFY_FEATURE_DIRECTORY env var takes priority."""
|
||||
common_ps = PROJECT_ROOT / "scripts" / "powershell" / "common.ps1"
|
||||
custom_dir = git_repo / "my-custom-specs" / "ps-feature"
|
||||
custom_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_DIRECTORY": str(custom_dir)},
|
||||
)
|
||||
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(custom_dir)
|
||||
break
|
||||
else:
|
||||
pytest.fail("FEATURE_DIR not found in PowerShell output")
|
||||
|
||||
@pytest.mark.skipif(not _has_pwsh(), reason="pwsh not installed")
|
||||
def test_ps_feature_json_overrides_branch_lookup(self, git_repo: Path):
|
||||
"""PowerShell: feature.json takes priority over branch-based lookup."""
|
||||
common_ps = PROJECT_ROOT / "scripts" / "powershell" / "common.ps1"
|
||||
custom_dir = git_repo / "specs" / "ps-json-feature"
|
||||
custom_dir.mkdir(parents=True)
|
||||
|
||||
feature_json = git_repo / ".specify" / "feature.json"
|
||||
feature_json.write_text(
|
||||
f'{{"feature_directory": "{custom_dir}"}}\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
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,
|
||||
)
|
||||
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(custom_dir)
|
||||
break
|
||||
else:
|
||||
pytest.fail("FEATURE_DIR not found in PowerShell output")
|
||||
|
||||
Reference in New Issue
Block a user