Add modular extension system (#1551)

* Add modular extension system for Spec Kit

Implement a complete extension system that allows third-party developers
to extend Spec Kit functionality through plugins.

## Core Features
- Extension discovery and loading from local and global directories
- YAML-based extension manifest (extension.yml) with metadata and capabilities
- Command extensions: custom slash commands with markdown templates
- Hook system: pre/post hooks for generate, task, and sync operations
- Extension catalog for discovering and installing community extensions
- SPECKIT_CATALOG_URL environment variable for catalog URL override

## Installation Methods
- Catalog install: `specify extension add <name>`
- URL install: `specify extension add <name> --from <url>`
- Dev install: `specify extension add --dev <path>`

## Implementation
- ExtensionManager class for lifecycle management (load, enable, disable)
- Support for extension dependencies and version constraints
- Configuration layering (global → project → extension)
- Hook conditions for conditional execution

## Documentation
- RFC with design rationale and architecture decisions
- API reference for extension developers
- Development guide with examples
- User guide for installing and managing extensions
- Publishing guide for the extension catalog

## Included
- Extension template for bootstrapping new extensions
- Comprehensive test suite
- Example catalog.json structure

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Update Jira extension to v2.1.0 in catalog

Adds 2-level mode support (Epic → Stories only).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Address PR review feedback

- Fix Zip Slip vulnerability in ZIP extraction with path validation
- Fix keep_config option to actually preserve config files on removal
- Add URL validation for SPECKIT_CATALOG_URL (HTTPS required, localhost exception)
- Add security warning when installing from custom URLs (--from flag)
- Empty catalog.json so organizations can ship their own catalogs
- Fix markdown linter errors (MD040: add language to code blocks)
- Remove redundant import and fix unused variables in tests
- Add comment explaining empty except clause for backwards compatibility

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Add comprehensive organization catalog customization docs

- Explain why default catalog is empty (org control)
- Document how to create and host custom catalogs
- Add catalog JSON schema reference
- Include use cases: private extensions, curated catalogs, air-gapped environments
- Add examples for combining catalog with direct installation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Fix test assertions for extension system data structures

- Update test_config_backup_on_remove to use new subdirectory structure
  (.backup/test-ext/file.yml instead of .backup/test-ext-file.yml)
- Update test_full_install_and_remove_workflow to handle registered_commands
  being a dict keyed by agent name instead of a flat list

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Address Copilot review feedback

- Fix localhost URL check to use parsed.hostname instead of netloc.startswith()
  This correctly handles URLs with ports like localhost:8080
- Fix YAML indentation error in config-template.yml (line 57)
- Fix double space typo in example.md (line 172)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Add catalog.example.json as reference for organizations

The main catalog.json is intentionally empty so organizations can ship
their own curated catalogs. This example file shows the expected schema
and structure for creating organization-specific catalogs.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Address remaining Copilot security and logic review feedback

- Fix Zip Slip vulnerability by using relative_to() for safe path validation
- Add HTTPS validation for extension download URLs
- Backup both *-config.yml and *-config.local.yml files on remove
- Normalize boolean values to lowercase for hook condition comparisons
- Show non-default catalog warning only once per instance

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Ignoring linter for extensions directory

---------

Co-authored-by: iamaeroplane <michal.bachorik@gmail.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Manfred Riem <manfred.riem@microsoft.com>
This commit is contained in:
Michal Bachorik
2026-02-10 21:27:20 +01:00
committed by GitHub
parent 36d97235ad
commit f14a47ea7d
24 changed files with 8925 additions and 2 deletions

View File

@@ -0,0 +1,649 @@
# Extension Development Guide
A guide for creating Spec Kit extensions.
---
## Quick Start
### 1. Create Extension Directory
```bash
mkdir my-extension
cd my-extension
```
### 2. Create `extension.yml` Manifest
```yaml
schema_version: "1.0"
extension:
id: "my-ext" # Lowercase, alphanumeric + hyphens only
name: "My Extension"
version: "1.0.0" # Semantic versioning
description: "My custom extension"
author: "Your Name"
repository: "https://github.com/you/spec-kit-my-ext"
license: "MIT"
requires:
speckit_version: ">=0.1.0" # Minimum spec-kit version
tools: # Optional: External tools required
- name: "my-tool"
required: true
version: ">=1.0.0"
commands: # Optional: Core commands needed
- "speckit.tasks"
provides:
commands:
- name: "speckit.my-ext.hello" # Must follow pattern: speckit.{ext-id}.{cmd}
file: "commands/hello.md"
description: "Say hello"
aliases: ["speckit.hello"] # Optional aliases
config: # Optional: Config files
- name: "my-ext-config.yml"
template: "my-ext-config.template.yml"
description: "Extension configuration"
required: false
hooks: # Optional: Integration hooks
after_tasks:
command: "speckit.my-ext.hello"
optional: true
prompt: "Run hello command?"
tags: # Optional: For catalog search
- "example"
- "utility"
```
### 3. Create Commands Directory
```bash
mkdir commands
```
### 4. Create Command File
**File**: `commands/hello.md`
```markdown
---
description: "Say hello command"
tools: # Optional: AI tools this command uses
- 'some-tool/function'
scripts: # Optional: Helper scripts
sh: ../../scripts/bash/helper.sh
ps: ../../scripts/powershell/helper.ps1
---
# Hello Command
This command says hello!
## User Input
$ARGUMENTS
## Steps
1. Greet the user
2. Show extension is working
```bash
echo "Hello from my extension!"
echo "Arguments: $ARGUMENTS"
```
## Extension Configuration
Load extension config from `.specify/extensions/my-ext/my-ext-config.yml`.
### 5. Test Locally
```bash
cd /path/to/spec-kit-project
specify extension add --dev /path/to/my-extension
```
### 6. Verify Installation
```bash
specify extension list
# Should show:
# ✓ My Extension (v1.0.0)
# My custom extension
# Commands: 1 | Hooks: 1 | Status: Enabled
```
### 7. Test Command
If using Claude:
```bash
claude
> /speckit.my-ext.hello world
```
The command will be available in `.claude/commands/speckit.my-ext.hello.md`.
---
## Manifest Schema Reference
### Required Fields
#### `schema_version`
Extension manifest schema version. Currently: `"1.0"`
#### `extension`
Extension metadata block.
**Required sub-fields**:
- `id`: Extension identifier (lowercase, alphanumeric, hyphens)
- `name`: Human-readable name
- `version`: Semantic version (e.g., "1.0.0")
- `description`: Short description
**Optional sub-fields**:
- `author`: Extension author
- `repository`: Source code URL
- `license`: SPDX license identifier
- `homepage`: Extension homepage URL
#### `requires`
Compatibility requirements.
**Required sub-fields**:
- `speckit_version`: Semantic version specifier (e.g., ">=0.1.0,<2.0.0")
**Optional sub-fields**:
- `tools`: External tools required (array of tool objects)
- `commands`: Core spec-kit commands needed (array of command names)
- `scripts`: Core scripts required (array of script names)
#### `provides`
What the extension provides.
**Required sub-fields**:
- `commands`: Array of command objects (must have at least one)
**Command object**:
- `name`: Command name (must match `speckit.{ext-id}.{command}`)
- `file`: Path to command file (relative to extension root)
- `description`: Command description (optional)
- `aliases`: Alternative command names (optional, array)
### Optional Fields
#### `hooks`
Integration hooks for automatic execution.
Available hook points:
- `after_tasks`: After `/speckit.tasks` completes
- `after_implement`: After `/speckit.implement` completes (future)
Hook object:
- `command`: Command to execute (must be in `provides.commands`)
- `optional`: If true, prompt user before executing
- `prompt`: Prompt text for optional hooks
- `description`: Hook description
- `condition`: Execution condition (future)
#### `tags`
Array of tags for catalog discovery.
#### `defaults`
Default extension configuration values.
#### `config_schema`
JSON Schema for validating extension configuration.
---
## Command File Format
### Frontmatter (YAML)
```yaml
---
description: "Command description" # Required
tools: # Optional
- 'tool-name/function'
scripts: # Optional
sh: ../../scripts/bash/helper.sh
ps: ../../scripts/powershell/helper.ps1
---
```
### Body (Markdown)
Use standard Markdown with special placeholders:
- `$ARGUMENTS`: User-provided arguments
- `{SCRIPT}`: Replaced with script path during registration
**Example**:
````markdown
## Steps
1. Parse arguments
2. Execute logic
```bash
args="$ARGUMENTS"
echo "Running with args: $args"
```
````
### Script Path Rewriting
Extension commands use relative paths that get rewritten during registration:
**In extension**:
```yaml
scripts:
sh: ../../scripts/bash/helper.sh
```
**After registration**:
```yaml
scripts:
sh: .specify/scripts/bash/helper.sh
```
This allows scripts to reference core spec-kit scripts.
---
## Configuration Files
### Config Template
**File**: `my-ext-config.template.yml`
```yaml
# My Extension Configuration
# Copy this to my-ext-config.yml and customize
# Example configuration
api:
endpoint: "https://api.example.com"
timeout: 30
features:
feature_a: true
feature_b: false
credentials:
# DO NOT commit credentials!
# Use environment variables instead
api_key: "${MY_EXT_API_KEY}"
```
### Config Loading
In your command, load config with layered precedence:
1. Extension defaults (`extension.yml` → `defaults`)
2. Project config (`.specify/extensions/my-ext/my-ext-config.yml`)
3. Local overrides (`.specify/extensions/my-ext/my-ext-config.local.yml` - gitignored)
4. Environment variables (`SPECKIT_MY_EXT_*`)
**Example loading script**:
```bash
#!/usr/bin/env bash
EXT_DIR=".specify/extensions/my-ext"
# Load and merge config
config=$(yq eval '.' "$EXT_DIR/my-ext-config.yml" -o=json)
# Apply env overrides
if [ -n "${SPECKIT_MY_EXT_API_KEY:-}" ]; then
config=$(echo "$config" | jq ".api.api_key = \"$SPECKIT_MY_EXT_API_KEY\"")
fi
echo "$config"
```
---
## Validation Rules
### Extension ID
- **Pattern**: `^[a-z0-9-]+$`
- **Valid**: `my-ext`, `tool-123`, `awesome-plugin`
- **Invalid**: `MyExt` (uppercase), `my_ext` (underscore), `my ext` (space)
### Extension Version
- **Format**: Semantic versioning (MAJOR.MINOR.PATCH)
- **Valid**: `1.0.0`, `0.1.0`, `2.5.3`
- **Invalid**: `1.0`, `v1.0.0`, `1.0.0-beta`
### Command Name
- **Pattern**: `^speckit\.[a-z0-9-]+\.[a-z0-9-]+$`
- **Valid**: `speckit.my-ext.hello`, `speckit.tool.cmd`
- **Invalid**: `my-ext.hello` (missing prefix), `speckit.hello` (no extension namespace)
### Command File Path
- **Must be** relative to extension root
- **Valid**: `commands/hello.md`, `commands/subdir/cmd.md`
- **Invalid**: `/absolute/path.md`, `../outside.md`
---
## Testing Extensions
### Manual Testing
1. **Create test extension**
2. **Install locally**:
```bash
specify extension add --dev /path/to/extension
```
3. **Verify installation**:
```bash
specify extension list
```
4. **Test commands** with your AI agent
5. **Check command registration**:
```bash
ls .claude/commands/speckit.my-ext.*
```
6. **Remove extension**:
```bash
specify extension remove my-ext
```
### Automated Testing
Create tests for your extension:
```python
# tests/test_my_extension.py
import pytest
from pathlib import Path
from specify_cli.extensions import ExtensionManifest
def test_manifest_valid():
"""Test extension manifest is valid."""
manifest = ExtensionManifest(Path("extension.yml"))
assert manifest.id == "my-ext"
assert len(manifest.commands) >= 1
def test_command_files_exist():
"""Test all command files exist."""
manifest = ExtensionManifest(Path("extension.yml"))
for cmd in manifest.commands:
cmd_file = Path(cmd["file"])
assert cmd_file.exists(), f"Command file not found: {cmd_file}"
```
---
## Distribution
### Option 1: GitHub Repository
1. **Create repository**: `spec-kit-my-ext`
2. **Add files**:
```text
spec-kit-my-ext/
├── extension.yml
├── commands/
├── scripts/
├── docs/
├── README.md
├── LICENSE
└── CHANGELOG.md
```
3. **Create release**: Tag with version (e.g., `v1.0.0`)
4. **Install from repo**:
```bash
git clone https://github.com/you/spec-kit-my-ext
specify extension add --dev spec-kit-my-ext/
```
### Option 2: ZIP Archive (Future)
Create ZIP archive and host on GitHub Releases:
```bash
zip -r spec-kit-my-ext-1.0.0.zip extension.yml commands/ scripts/ docs/
```
Users install with:
```bash
specify extension add --from https://github.com/.../spec-kit-my-ext-1.0.0.zip
```
### Option 3: Extension Catalog (Future)
Submit to official catalog:
1. **Fork** spec-kit repository
2. **Add entry** to `extensions/catalog.json`
3. **Create PR**
4. **After merge**, users can install with:
```bash
specify extension add my-ext # No URL needed!
```
---
## Best Practices
### Naming Conventions
- **Extension ID**: Use descriptive, hyphenated names (`jira-integration`, not `ji`)
- **Commands**: Use verb-noun pattern (`create-issue`, `sync-status`)
- **Config files**: Match extension ID (`jira-config.yml`)
### Documentation
- **README.md**: Overview, installation, usage
- **CHANGELOG.md**: Version history
- **docs/**: Detailed guides
- **Command descriptions**: Clear, concise
### Versioning
- **Follow SemVer**: `MAJOR.MINOR.PATCH`
- **MAJOR**: Breaking changes
- **MINOR**: New features
- **PATCH**: Bug fixes
### Security
- **Never commit secrets**: Use environment variables
- **Validate input**: Sanitize user arguments
- **Document permissions**: What files/APIs are accessed
### Compatibility
- **Specify version range**: Don't require exact version
- **Test with multiple versions**: Ensure compatibility
- **Graceful degradation**: Handle missing features
---
## Example Extensions
### Minimal Extension
Smallest possible extension:
```yaml
# extension.yml
schema_version: "1.0"
extension:
id: "minimal"
name: "Minimal Extension"
version: "1.0.0"
description: "Minimal example"
requires:
speckit_version: ">=0.1.0"
provides:
commands:
- name: "speckit.minimal.hello"
file: "commands/hello.md"
```
````markdown
<!-- commands/hello.md -->
---
description: "Hello command"
---
# Hello World
```bash
echo "Hello, $ARGUMENTS!"
```
````
### Extension with Config
Extension using configuration:
```yaml
# extension.yml
# ... metadata ...
provides:
config:
- name: "tool-config.yml"
template: "tool-config.template.yml"
required: true
```
```yaml
# tool-config.template.yml
api_endpoint: "https://api.example.com"
timeout: 30
```
````markdown
<!-- commands/use-config.md -->
# Use Config
Load config:
```bash
config_file=".specify/extensions/tool/tool-config.yml"
endpoint=$(yq eval '.api_endpoint' "$config_file")
echo "Using endpoint: $endpoint"
```
````
### Extension with Hooks
Extension that runs automatically:
```yaml
# extension.yml
hooks:
after_tasks:
command: "speckit.auto.analyze"
optional: false # Always run
description: "Analyze tasks after generation"
```
---
## Troubleshooting
### Extension won't install
**Error**: `Invalid extension ID`
- **Fix**: Use lowercase, alphanumeric + hyphens only
**Error**: `Extension requires spec-kit >=0.2.0`
- **Fix**: Update spec-kit with `uv tool install specify-cli --force`
**Error**: `Command file not found`
- **Fix**: Ensure command files exist at paths specified in manifest
### Commands not registered
**Symptom**: Commands don't appear in AI agent
**Check**:
1. `.claude/commands/` directory exists
2. Extension installed successfully
3. Commands registered in registry:
```bash
cat .specify/extensions/.registry
```
**Fix**: Reinstall extension to trigger registration
### Config not loading
**Check**:
1. Config file exists: `.specify/extensions/{ext-id}/{ext-id}-config.yml`
2. YAML syntax is valid: `yq eval '.' config.yml`
3. Environment variables set correctly
---
## Getting Help
- **Issues**: Report bugs at GitHub repository
- **Discussions**: Ask questions in GitHub Discussions
- **Examples**: See `spec-kit-jira` for full-featured example (Phase B)
---
## Next Steps
1. **Create your extension** following this guide
2. **Test locally** with `--dev` flag
3. **Share with community** (GitHub, catalog)
4. **Iterate** based on feedback
Happy extending! 🚀