extensions/EXTENSION-DEVELOPMENT-GUIDE.md
A guide for creating Spec Kit extensions.
mkdir my-extension
cd my-extension
extension.yml Manifestschema_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.my-ext.hi"] # Optional aliases, same pattern
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"
mkdir commands
File: commands/hello.md
---
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"
Load extension config from .specify/extensions/my-ext/my-ext-config.yml.
cd /path/to/spec-kit-project
specify extension add --dev /path/to/my-extension
specify extension list
# Should show:
# ✓ My Extension (v1.0.0)
# My custom extension
# Commands: 1 | Hooks: 1 | Status: Enabled
If using Claude:
claude
> /speckit.my-ext.hello world
The command will be available in .claude/commands/speckit.my-ext.hello.md.
schema_versionExtension manifest schema version. Currently: "1.0"
extensionExtension metadata block.
Required sub-fields:
id: Extension identifier (lowercase, alphanumeric, hyphens)name: Human-readable nameversion: Semantic version (e.g., "1.0.0")description: Short descriptionOptional sub-fields:
author: Extension authorrepository: Source code URLlicense: SPDX license identifierhomepage: Extension homepage URLrequiresCompatibility 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)providesWhat the extension provides.
Optional sub-fields:
commands: Array of command objects (at least one command or hook is required)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; each must match speckit.{ext-id}.{command})hooksIntegration hooks for automatic execution.
Available hook points:
before_specify / after_specify: Before/after specification generationbefore_plan / after_plan: Before/after implementation planningbefore_tasks / after_tasks: Before/after task generationbefore_implement / after_implement: Before/after implementationbefore_analyze / after_analyze: Before/after cross-artifact analysisbefore_checklist / after_checklist: Before/after checklist generationbefore_clarify / after_clarify: Before/after spec clarificationbefore_constitution / after_constitution: Before/after constitution updatebefore_taskstoissues / after_taskstoissues: Before/after tasks-to-issues conversionHook object:
command: Command to execute (typically from provides.commands, but can reference any registered command)optional: If true, prompt user before executingprompt: Prompt text for optional hooksdescription: Hook descriptioncondition: Execution condition (future)tagsArray of tags for catalog discovery.
defaultsDefault extension configuration values.
config_schemaJSON Schema for validating extension configuration.
---
description: "Command description" # Required
tools: # Optional
- 'tool-name/function'
scripts: # Optional
sh: ../../scripts/bash/helper.sh
ps: ../../scripts/powershell/helper.ps1
---
Use standard Markdown with special placeholders:
$ARGUMENTS: User-provided arguments{SCRIPT}: Replaced with script path during registrationExample:
## Steps
1. Parse arguments
2. Execute logic
```bash
args="$ARGUMENTS"
echo "Running with args: $args"
```
Extension commands use relative paths that get rewritten during registration:
In extension:
scripts:
sh: ../../scripts/bash/helper.sh
After registration:
scripts:
sh: .specify/scripts/bash/helper.sh
This allows scripts to reference core spec-kit scripts.
File: my-ext-config.template.yml
# 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}"
In your command, load config with layered precedence:
extension.yml → defaults).specify/extensions/my-ext/my-ext-config.yml).specify/extensions/my-ext/my-ext-config.local.yml - gitignored)SPECKIT_MY_EXT_*)Example loading script:
#!/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"
.extensionignoreExtension authors can create a .extensionignore file in the extension root to exclude files and folders from being copied when a user installs the extension with specify extension add. This is useful for keeping development-only files (tests, CI configs, docs source, etc.) out of the installed copy.
The file uses .gitignore-compatible patterns (one per line), powered by the pathspec library:
# are comments* matches anything except / (does not cross directory boundaries)** matches zero or more directories (e.g., docs/**/*.draft.md)? matches any single character except // restricts a pattern to directories only/ (other than a trailing slash) are anchored to the extension root/ match at any depth in the tree! negates a previously excluded pattern (re-includes a file).extensionignore file itself is always excluded automatically# .extensionignore
# Development files
tests/
.github/
.gitignore
# Build artifacts
__pycache__/
*.pyc
dist/
# Documentation source (keep only the built README)
docs/
CONTRIBUTING.md
| Pattern | Matches | Does NOT match |
|---|---|---|
*.pyc | Any .pyc file in any directory | — |
tests/ | The tests directory (and all its contents) | A file named tests |
docs/*.draft.md | docs/api.draft.md (directly inside docs/) | docs/sub/api.draft.md (nested) |
.env | The .env file at any level | — |
!README.md | Re-includes README.md even if matched by an earlier pattern | — |
docs/**/*.draft.md | docs/api.draft.md, docs/sub/api.draft.md | — |
The following .gitignore features are not applicable in this context:
.extensionignore files: Only a single file at the extension root is supported (.gitignore supports files in subdirectories)$GIT_DIR/info/exclude and core.excludesFile: These are Git-specific and have no equivalent hereshutil.copytree, excluding a directory prevents recursion into it entirely. A negation pattern cannot re-include a file inside a directory that was itself excluded. For example, the combination tests/ followed by !tests/important.py will not preserve tests/important.py — the tests/ directory is skipped at the root level and its contents are never evaluated. To work around this, exclude the directory's contents individually instead of the directory itself (e.g., tests/*.pyc and tests/.cache/ rather than tests/).^[a-z0-9-]+$my-ext, tool-123, awesome-pluginMyExt (uppercase), my_ext (underscore), my ext (space)1.0.0, 0.1.0, 2.5.31.0, v1.0.0, 1.0.0-beta^speckit\.[a-z0-9-]+\.[a-z0-9-]+$speckit.my-ext.hello, speckit.tool.cmdmy-ext.hello (missing prefix), speckit.hello (no extension namespace)commands/hello.md, commands/subdir/cmd.md/absolute/path.md, ../outside.mdCreate test extension
Install locally:
specify extension add --dev /path/to/extension
Verify installation:
specify extension list
Test commands with your AI agent
Check command registration:
ls .claude/commands/speckit.my-ext.*
Remove extension:
specify extension remove my-ext
Create tests for your extension:
# 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}"
Create repository: spec-kit-my-ext
Add files:
spec-kit-my-ext/
├── extension.yml
├── commands/
├── scripts/
├── docs/
├── README.md
├── LICENSE
└── CHANGELOG.md
Create release: Tag with version (e.g., v1.0.0)
Install from repo:
git clone https://github.com/you/spec-kit-my-ext
specify extension add --dev spec-kit-my-ext/
Create ZIP archive and host on GitHub Releases:
zip -r spec-kit-my-ext-1.0.0.zip extension.yml commands/ scripts/ docs/
Users install with:
specify extension add <extension-name> --from https://github.com/.../spec-kit-my-ext-1.0.0.zip
Submit to the community catalog for public discovery:
extensions/catalog.community.jsonREADME.md with your extensioncatalog.community.json to discover your extensioncatalog.jsonspecify extension add my-ext (from their catalog)See the Extension Publishing Guide for detailed submission instructions.
jira-integration, not ji)create-issue, sync-status)jira-config.yml)MAJOR.MINOR.PATCHSmallest possible extension:
# 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"
<!-- commands/hello.md -->
---
description: "Hello command"
---
# Hello World
```bash
echo "Hello, $ARGUMENTS!"
```
Extension using configuration:
# extension.yml
# ... metadata ...
provides:
config:
- name: "tool-config.yml"
template: "tool-config.template.yml"
required: true
# tool-config.template.yml
api_endpoint: "https://api.example.com"
timeout: 30
<!-- 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 that runs automatically:
# extension.yml
hooks:
after_tasks:
command: "speckit.auto.analyze"
optional: false # Always run
description: "Analyze tasks after generation"
Error: Invalid extension ID
Error: Extension requires spec-kit >=0.2.0
uv tool install specify-cli --force --from git+https://github.com/github/spec-kit.git. The bare specify-cli package on PyPI is a different, unrelated project — installing it without --from git+... will give you a stub CLI that does not include extension, preset, or other spec-kit commands.Error: Command file not found
Symptom: Commands don't appear in AI agent
Check:
.claude/commands/ directory exists
Extension installed successfully
Commands registered in registry:
cat .specify/extensions/.registry
Fix: Reinstall extension to trigger registration
Check:
.specify/extensions/{ext-id}/{ext-id}-config.ymlyq eval '.' config.ymlspec-kit-jira for full-featured example (Phase B)--dev flagHappy extending! 🚀