Extension Development Guide
A guide for creating Spec Kit extensions.
Quick Start
1. Create Extension Directory
mkdir my-extension
cd my-extension2. Create extension.yml Manifest
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.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"3. Create Commands Directory
mkdir commands4. Create Command File
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"Extension Configuration
Load extension config from .specify/extensions/my-ext/my-ext-config.yml.
5. Test Locally
cd /path/to/spec-kit-project
specify extension add --dev /path/to/my-extension6. Verify Installation
specify extension list
# Should show:
# ✓ My Extension (v1.0.0)
# My custom extension
# Commands: 1 | Hooks: 1 | Status: Enabled7. Test Command
If using Claude:
claude
> /speckit.my-ext.hello worldThe 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 nameversion: Semantic version (e.g., "1.0.0")description: Short description
Optional sub-fields:
author: Extension authorrepository: Source code URLlicense: SPDX license identifierhomepage: 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.
Optional sub-fields:
commands: Array of command objectstemplates: Array of template objectsscripts: Array of script objects
hooks and events are separate top-level manifest fields (siblings of provides, not nested under it — see hooks below). At least one of provides.commands, provides.templates, provides.scripts, hooks, or events is required.
Command object:
name: Command name (must matchspeckit.\{ext-id\}.\{command\})file: Path to command file (relative to extension root)description: Command description (optional)aliases: Alternative command names (optional, array; each must matchspeckit.\{ext-id\}.\{command\})
Template object:
name: Template name (lowercase, alphanumeric, hyphens — e.g.myext-template)file: Path to template file (relative to extension root)description: Template description (optional)
Script object:
name: Script name (lowercase, alphanumeric, hyphens — e.g.myext-collect)file: Path to script file (relative to extension root)description: Script description (optional)runtimes: Runtimes the script supports (optional, array; subset ofbash,powershell,python— informational only, not used to select or invoke the script)
Extension-provided templates and scripts always resolve as replace; a manifest that includes a strategy key on one of these entries is rejected with a ValidationError. Composable strategies (wrap/prepend/append) are preset-only.
Optional Fields
hooks
Integration 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 conversion
Each event accepts a single hook object or a list of hook objects (multiple commands on one event).
Hook object:
command: Command to execute (typically fromprovides.commands, but can reference any registered command)priority: Run order within the event (integer ≥ 1, default 10; lower runs first; equal priorities keep authoring order)optional: If true, prompt user before executingprompt: Prompt text for optional hooksdescription: Hook descriptioncondition: 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)
---
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__SPECKIT_COMMAND_<NAME>__: Replaced with the invocation of another command, rendered using the active integration's separator (see Referencing other commands)
Example:
## Steps
1. Parse arguments
2. Execute logic
```bash
args="$ARGUMENTS"
echo "Running with args: $args"
```Referencing other commands
A command body is a template that Spec Kit renders once per agent. Different agents invoke commands with different surface syntax — for example /speckit.plan (dot separator) or /speckit-plan (hyphen separator). Some agents also use different prefixes in skills mode (e.g. Kimi /skill:speckit-plan, Codex/ZCode $speckit-plan). So when you reference a sibling command from a body, do not hard-code a literal invocation like /speckit.my-ext.prepare. A literal is correct for exactly one agent and breaks on the rest.
Instead use the agent-neutral token __SPECKIT_COMMAND_<NAME>__. Spec Kit resolves it to a /speckit<separator>... invocation using the active integration's invoke_separator (and integrations may post-process that further in skills output).
Encode the command name in upper case, dropping the speckit. prefix and turning each dotted segment separator into an underscore. A hyphen inside a segment is kept as a hyphen:
| Command file | Token |
|---|---|
speckit.plan.md | __SPECKIT_COMMAND_PLAN__ |
speckit.bug.fix.md | __SPECKIT_COMMAND_BUG_FIX__ |
speckit.git.commit.md | __SPECKIT_COMMAND_GIT_COMMIT__ |
speckit.agent-context.update.md | __SPECKIT_COMMAND_AGENT-CONTEXT_UPDATE__ |
The resolver maps each underscore back to the active agent's separator. An underscore separates segments and a hyphen belongs to the segment it sits in, so AGENT-CONTEXT_UPDATE is the two segments agent-context and update rather than three.
Example — a command body that points the user at the next step:
Once the assessment exists, the next step is `__SPECKIT_COMMAND_BUG_FIX__ slug=<slug>`.This renders as /speckit.bug.fix slug=<slug> for a slash-based agent, /speckit-bug-fix slug=<slug> for a skills-based agent, and so on — the author writes it once and it stays portable. The first-party bug and git extensions use this token exclusively; see extensions/bug/commands/ for working examples.
Skills mode. Token resolution runs in both paths, so the token is safe to use either way. Command files go through the command-rendering path (
CommandRegistrar). Skill bodies go through_resolve_command_ref_tokensin_register_extension_skills, which resolves the same token shape against the active skill style, so__SPECKIT_COMMAND_BUG_FIX__renders as:
Agent Rendered Codex, ZCode, Command Code $speckit-bug-fixClaude, Copilot, Cursor, Devin, Droid, Grok and the other slash agents /speckit-bug-fixKimi /skill:speckit-bug-fixAnything else falls through to the integration's own
build_command_invocation.
Script Path Rewriting
Extension commands use relative paths that get rewritten during registration:
In extension:
scripts:
sh: ../../scripts/bash/helper.shAfter registration:
scripts:
sh: .specify/scripts/bash/helper.shThis allows scripts to reference core spec-kit scripts.
Configuration Files
Config Template
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}"Config Loading
In your command, load config with layered precedence:
- Extension defaults (
extension.yml→defaults) - Project config (
.specify/extensions/my-ext/my-ext-config.yml) - Local overrides (
.specify/extensions/my-ext/my-ext-config.local.yml- gitignored) - Environment variables (
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"Excluding Files with .extensionignore
Extension 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.
Format
The file uses .gitignore-compatible patterns (one per line), powered by the pathspec library:
- Blank lines are ignored
- Lines starting with
#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/- A trailing
/restricts a pattern to directories only - Patterns containing
/(other than a trailing slash) are anchored to the extension root - Patterns without
/match at any depth in the tree !negates a previously excluded pattern (re-includes a file)- Backslashes in patterns are normalised to forward slashes for cross-platform compatibility
- The
.extensionignorefile itself is always excluded automatically
Example
# .extensionignore
# Development files
tests/
.github/
.gitignore
# Build artifacts
__pycache__/
*.pyc
dist/
# Documentation source (keep only the built README)
docs/
CONTRIBUTING.mdPattern Matching
| 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 | — |
Unsupported Features
The following .gitignore features are not applicable in this context:
- Multiple
.extensionignorefiles: Only a single file at the extension root is supported (.gitignoresupports files in subdirectories) $GIT_DIR/info/excludeandcore.excludesFile: These are Git-specific and have no equivalent here- Negation inside excluded directories: Because file copying uses
shutil.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 combinationtests/followed by!tests/important.pywill not preservetests/important.py— thetests/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/*.pycandtests/.cache/rather thantests/).
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
Create test extension
Install locally:
bashspecify extension add --dev /path/to/extensionVerify installation:
bashspecify extension listTest commands with your AI agent
Check command registration:
bashls .claude/commands/speckit.my-ext.*Remove extension:
bashspecify extension remove my-ext
Automated Testing
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}"Distribution
Option 1: GitHub Repository
Create repository:
spec-kit-my-extAdd files:
textspec-kit-my-ext/ ├── extension.yml ├── commands/ ├── scripts/ ├── docs/ ├── README.md ├── LICENSE └── CHANGELOG.mdCreate release: Tag with version (e.g.,
v1.0.0)Install from repo:
bashgit 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:
zip -r spec-kit-my-ext-1.0.0.zip extension.yml commands/ scripts/ docs/Users install with: