* feat: add git extension with hooks on all core commands - Create extensions/git/ with 5 commands: initialize, feature, validate, remote, commit - 18 hooks covering before/after for all 9 core commands - Scripts: create-new-feature, initialize-repo, auto-commit, git-common (bash + powershell) - Configurable: branch_numbering, init_commit_message, per-command auto-commit with custom messages - Add hooks to analyze, checklist, clarify, constitution, taskstoissues command templates - Allow hooks-only extensions (no commands required) - Bundle extension in wheel via pyproject.toml force-include - Resolve bundled extensions locally before catalog lookup - Remove planned-but-unimplemented before/after_commit hook refs - Update extension docs (API ref, dev guide, user guide) - 37 new tests covering manifest, install, all scripts (bash+pwsh), config reading, graceful degradation Stage 1: opt-in via 'specify extension add git'. No auto-install, no changes to specify.md or core git init code. Refs: #841, #1382, #1066, #1791, #1191 * fix: set git identity env vars in extension tests for CI runners * fix: address PR review comments - Fix commands property KeyError for hooks-only extensions - Fix has_git() operator precedence in git-common.sh - Align default commit message to '[Spec Kit] Initial commit' across config-template, extension.yml defaults, and both init scripts - Update README to reflect all 5 commands and 18 hooks * fix: address second round of PR review comments - Add type validation for provides.commands (must be list) and hooks (must be dict) in manifest _validate() - Tighten malformed timestamp detection in git-common.sh to catch 7-digit dates without trailing slug (e.g. 2026031-143022) - Pass REPO_ROOT to has_git/Test-HasGit in create-new-feature scripts - Fix initialize command docs: surface errors on git failures, only skip when git is not installed - Fix commit command docs: 'skips with a warning' not 'silently' - Add tests for commands:null and hooks:list rejection * fix: address third round of PR review comments - Remove scripts frontmatter from command files (CommandRegistrar rewrites ../../scripts/ to .specify/scripts/ which points at core scripts, not extension scripts) - Update speckit.git.commit command to derive event name from hook context rather than using a static example - Clarify that hook argument passthrough works via AI agent context (the agent carries conversation state including user's original feature description) * fix: address fourth round of PR review comments - Validate extension_id against ^[a-z0-9-]+$ in _locate_bundled_extension to prevent path traversal (security fix) - Move defaults under config.defaults in extension.yml to match ConfigManager._get_extension_defaults() schema - Ship git-config.yml in extension directory so it's copied during install (provides.config template isn't materialized by ExtensionManager) - Condition handling in hook templates: intentionally matches existing pattern from specify/plan/tasks/implement templates (not a new issue) * fix: add --allow-empty to git commit in initialize-repo scripts Ensures git init succeeds even on empty repos where nothing has been staged yet. * fix: resolve display names to bundled extensions before catalog download When 'specify extension add "Git Branching Workflow"' is used with a display name instead of the ID, the catalog resolver now runs first to map the name to an ID, then checks bundled extensions again with the resolved ID before falling back to network download. Also noted: EXECUTE_COMMAND_INVOCATION and condition handling match the existing pattern in specify/plan/tasks/implement templates (pre-existing, not introduced by this PR). * fix: handle before_/after_ prefixes in auto-commit message derivation - Strip both before_ and after_ prefixes when deriving command name (fixes misleading 'Auto-commit after before_plan' messages) - Include phase (before/after) in default commit messages - Clarify README config example is an override, not default behavior * fix: use portable grep -qw for word boundary in create-new-feature.sh BSD grep (macOS) doesn't support \b as a word boundary. Replace with grep -qw which is POSIX-portable. * fix: validate hook values, numeric --number, and PS warning routing - Validate each hook value is a dict with a 'command' field during manifest _validate() (prevents crash at install time) - Validate --number is a non-negative integer in bash create-new-feature (clear error instead of cryptic shell arithmetic failure) - Route PowerShell no-git warning to stderr in JSON mode so stdout stays valid JSON --------- Co-authored-by: Manfred Riem <15701806+mnriem@users.noreply.github.com>
19 KiB
Extension API Reference
Technical reference for Spec Kit extension system APIs and manifest schema.
Table of Contents
Extension Manifest
Schema Version 1.0
File: extension.yml
schema_version: "1.0" # Required
extension:
id: string # Required, pattern: ^[a-z0-9-]+$
name: string # Required, human-readable name
version: string # Required, semantic version (X.Y.Z)
description: string # Required, brief description (<200 chars)
author: string # Required
repository: string # Required, valid URL
license: string # Required (e.g., "MIT", "Apache-2.0")
homepage: string # Optional, valid URL
requires:
speckit_version: string # Required, version specifier (>=X.Y.Z)
tools: # Optional, array of tool requirements
- name: string # Tool name
version: string # Optional, version specifier
required: boolean # Optional, default: false
provides:
commands: # Required, at least one command
- name: string # Required, pattern: ^speckit\.[a-z0-9-]+\.[a-z0-9-]+$
file: string # Required, relative path to command file
description: string # Required
aliases: [string] # Optional, same pattern as name; namespace must match extension.id and must not shadow core or installed extension commands
config: # Optional, array of config files
- name: string # Config file name
template: string # Template file path
description: string
required: boolean # Default: false
hooks: # Optional, event hooks
event_name: # e.g., "after_specify", "after_plan", "after_tasks", "after_implement"
command: string # Command to execute
optional: boolean # Default: true
prompt: string # Prompt text for optional hooks
description: string # Hook description
condition: string # Optional, condition expression
tags: # Optional, array of tags (2-10 recommended)
- string
defaults: # Optional, default configuration values
key: value # Any YAML structure
Field Specifications
extension.id
- Type: string
- Pattern:
^[a-z0-9-]+$ - Description: Unique extension identifier
- Examples:
jira,linear,azure-devops - Invalid:
Jira,my_extension,extension.id
extension.version
- Type: string
- Format: Semantic versioning (X.Y.Z)
- Description: Extension version
- Examples:
1.0.0,0.9.5,2.1.3 - Invalid:
v1.0,1.0,1.0.0-beta
requires.speckit_version
- Type: string
- Format: Version specifier
- Description: Required spec-kit version range
- Examples:
>=0.1.0- Any version 0.1.0 or higher>=0.1.0,<2.0.0- Version 0.1.x or 1.x==0.1.0- Exactly 0.1.0
- Invalid:
0.1.0,>= 0.1.0(space),latest
provides.commands[].name
- Type: string
- Pattern:
^speckit\.[a-z0-9-]+\.[a-z0-9-]+$ - Description: Namespaced command name
- Format:
speckit.{extension-id}.{command-name} - Examples:
speckit.jira.specstoissues,speckit.linear.sync - Invalid:
jira.specstoissues,speckit.command,speckit.jira.CreateIssues
hooks
- Type: object
- 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
Python API
ExtensionManifest
Module: specify_cli.extensions
from specify_cli.extensions import ExtensionManifest
manifest = ExtensionManifest(Path("extension.yml"))
Properties:
manifest.id # str: Extension ID
manifest.name # str: Extension name
manifest.version # str: Version
manifest.description # str: Description
manifest.requires_speckit_version # str: Required spec-kit version
manifest.commands # List[Dict]: Command definitions
manifest.hooks # Dict: Hook definitions
Methods:
manifest.get_hash() # str: SHA256 hash of manifest file
Exceptions:
ValidationError # Invalid manifest structure
CompatibilityError # Incompatible with current spec-kit version
ExtensionRegistry
Module: specify_cli.extensions
from specify_cli.extensions import ExtensionRegistry
registry = ExtensionRegistry(extensions_dir)
Methods:
# Add extension to registry
registry.add(extension_id: str, metadata: dict)
# Remove extension from registry
registry.remove(extension_id: str)
# Get extension metadata
metadata = registry.get(extension_id: str) # Optional[dict]
# List all extensions
extensions = registry.list() # Dict[str, dict]
# Check if installed
is_installed = registry.is_installed(extension_id: str) # bool
Registry Format:
{
"schema_version": "1.0",
"extensions": {
"jira": {
"version": "1.0.0",
"source": "catalog",
"manifest_hash": "sha256...",
"enabled": true,
"registered_commands": ["speckit.jira.specstoissues", ...],
"installed_at": "2026-01-28T..."
}
}
}
ExtensionManager
Module: specify_cli.extensions
from specify_cli.extensions import ExtensionManager
manager = ExtensionManager(project_root)
Methods:
# Install from directory
manifest = manager.install_from_directory(
source_dir: Path,
speckit_version: str,
register_commands: bool = True
) # Returns: ExtensionManifest
# Install from ZIP
manifest = manager.install_from_zip(
zip_path: Path,
speckit_version: str
) # Returns: ExtensionManifest
# Remove extension
success = manager.remove(
extension_id: str,
keep_config: bool = False
) # Returns: bool
# List installed extensions
extensions = manager.list_installed() # List[Dict]
# Get extension manifest
manifest = manager.get_extension(extension_id: str) # Optional[ExtensionManifest]
# Check compatibility
manager.check_compatibility(
manifest: ExtensionManifest,
speckit_version: str
) # Raises: CompatibilityError if incompatible
CatalogEntry
Module: specify_cli.extensions
Represents a single catalog in the active catalog stack.
from specify_cli.extensions import CatalogEntry
entry = CatalogEntry(
url="https://example.com/catalog.json",
name="default",
priority=1,
install_allowed=True,
description="Built-in catalog of installable extensions",
)
Fields:
| Field | Type | Description |
|---|---|---|
url |
str |
Catalog URL (must use HTTPS, or HTTP for localhost) |
name |
str |
Human-readable catalog name |
priority |
int |
Sort order (lower = higher priority, wins on conflicts) |
install_allowed |
bool |
Whether extensions from this catalog can be installed |
description |
str |
Optional human-readable description of the catalog (default: empty) |
ExtensionCatalog
Module: specify_cli.extensions
from specify_cli.extensions import ExtensionCatalog
catalog = ExtensionCatalog(project_root)
Class attributes:
ExtensionCatalog.DEFAULT_CATALOG_URL # default catalog URL
ExtensionCatalog.COMMUNITY_CATALOG_URL # community catalog URL
Methods:
# Get the ordered list of active catalogs
entries = catalog.get_active_catalogs() # List[CatalogEntry]
# Fetch catalog (primary catalog, backward compat)
catalog_data = catalog.fetch_catalog(force_refresh: bool = False) # Dict
# Search extensions across all active catalogs
# Each result includes _catalog_name and _install_allowed
results = catalog.search(
query: Optional[str] = None,
tag: Optional[str] = None,
author: Optional[str] = None,
verified_only: bool = False
) # Returns: List[Dict] — each dict includes _catalog_name, _install_allowed
# Get extension info (searches all active catalogs)
# Returns None if not found; includes _catalog_name and _install_allowed
ext_info = catalog.get_extension_info(extension_id: str) # Optional[Dict]
# Check cache validity (primary catalog)
is_valid = catalog.is_cache_valid() # bool
# Clear all catalog caches
catalog.clear_cache()
Result annotation fields:
Each extension dict returned by search() and get_extension_info() includes:
| Field | Type | Description |
|---|---|---|
_catalog_name |
str |
Name of the source catalog |
_install_allowed |
bool |
Whether installation is allowed from this catalog |
Catalog config file (.specify/extension-catalogs.yml):
catalogs:
- name: "default"
url: "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.json"
priority: 1
install_allowed: true
description: "Built-in catalog of installable extensions"
- name: "community"
url: "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json"
priority: 2
install_allowed: false
description: "Community-contributed extensions (discovery only)"
HookExecutor
Module: specify_cli.extensions
from specify_cli.extensions import HookExecutor
hook_executor = HookExecutor(project_root)
Methods:
# Get project config
config = hook_executor.get_project_config() # Dict
# Save project config
hook_executor.save_project_config(config: Dict)
# Register hooks
hook_executor.register_hooks(manifest: ExtensionManifest)
# Unregister hooks
hook_executor.unregister_hooks(extension_id: str)
# Get hooks for event
hooks = hook_executor.get_hooks_for_event(event_name: str) # List[Dict]
# Check if hook should execute
should_run = hook_executor.should_execute_hook(hook: Dict) # bool
# Format hook message
message = hook_executor.format_hook_message(
event_name: str,
hooks: List[Dict]
) # str
CommandRegistrar
Module: specify_cli.extensions
from specify_cli.extensions import CommandRegistrar
registrar = CommandRegistrar()
Methods:
# Register commands for Claude Code
registered = registrar.register_commands_for_claude(
manifest: ExtensionManifest,
extension_dir: Path,
project_root: Path
) # Returns: List[str] (command names)
# Parse frontmatter
frontmatter, body = registrar.parse_frontmatter(content: str)
# Render frontmatter
yaml_text = registrar.render_frontmatter(frontmatter: Dict) # str
Command File Format
Universal Command Format
File: commands/{command-name}.md
---
description: "Command description"
tools:
- 'mcp-server/tool_name'
- 'other-mcp-server/other_tool'
---
# Command Title
Command documentation in Markdown.
## Prerequisites
1. Requirement 1
2. Requirement 2
## User Input
$ARGUMENTS
## Steps
### Step 1: Description
Instruction text...
\`\`\`bash
# Shell commands
\`\`\`
### Step 2: Another Step
More instructions...
## Configuration Reference
Information about configuration options.
## Notes
Additional notes and tips.
Frontmatter Fields
description: string # Required, brief command description
tools: [string] # Optional, MCP tools required
Special Variables
-
$ARGUMENTS- Placeholder for user-provided arguments -
Extension context automatically injected:
<!-- Extension: {extension-id} --> <!-- Config: .specify/extensions/{extension-id}/ -->
Configuration Schema
Extension Config File
File: .specify/extensions/{extension-id}/{extension-id}-config.yml
Extensions define their own config schema. Common patterns:
# Connection settings
connection:
url: string
api_key: string
# Project settings
project:
key: string
workspace: string
# Feature flags
features:
enabled: boolean
auto_sync: boolean
# Defaults
defaults:
labels: [string]
assignee: string
# Custom fields
field_mappings:
internal_name: "external_field_id"
Config Layers
- Extension Defaults (from
extension.ymldefaultssection) - Project Config (
{extension-id}-config.yml) - Local Override (
{extension-id}-config.local.yml, gitignored) - Environment Variables (
SPECKIT_{EXTENSION}_*)
Environment Variable Pattern
Format: SPECKIT_{EXTENSION}_{KEY}
Examples:
SPECKIT_JIRA_PROJECT_KEYSPECKIT_LINEAR_API_KEYSPECKIT_GITHUB_TOKEN
Hook System
Hook Definition
In extension.yml:
hooks:
after_tasks:
command: "speckit.jira.specstoissues"
optional: true
prompt: "Create Jira issues from tasks?"
description: "Automatically create Jira hierarchy"
condition: null
Hook Events
Standard events (defined by core):
before_specify- Before specification generationafter_specify- After specification generationbefore_plan- Before implementation planningafter_plan- After implementation planningbefore_tasks- Before task generationafter_tasks- After task generationbefore_implement- Before implementationafter_implement- After implementationbefore_analyze- Before cross-artifact analysisafter_analyze- After cross-artifact analysisbefore_checklist- Before checklist generationafter_checklist- After checklist generationbefore_clarify- Before spec clarificationafter_clarify- After spec clarificationbefore_constitution- Before constitution updateafter_constitution- After constitution updatebefore_taskstoissues- Before tasks-to-issues conversionafter_taskstoissues- After tasks-to-issues conversion
Hook Configuration
In .specify/extensions.yml:
hooks:
after_tasks:
- extension: jira
command: speckit.jira.specstoissues
enabled: true
optional: true
prompt: "Create Jira issues from tasks?"
description: "..."
condition: null
Hook Message Format
## Extension Hooks
**Optional Hook**: {extension}
Command: `/{command}`
Description: {description}
Prompt: {prompt}
To execute: `/{command}`
Or for mandatory hooks:
**Automatic Hook**: {extension}
Executing: `/{command}`
EXECUTE_COMMAND: {command}
CLI Commands
extension list
Usage: specify extension list [OPTIONS]
Options:
--available- Show available extensions from catalog--all- Show both installed and available
Output: List of installed extensions with metadata
extension catalog list
Usage: specify extension catalog list
Lists all active catalogs in the current catalog stack, showing name, description, URL, priority, and install_allowed status.
extension catalog add
Usage: specify extension catalog add URL [OPTIONS]
Options:
--name NAME- Catalog name (required)--priority INT- Priority (lower = higher priority, default: 10)--install-allowed / --no-install-allowed- Allow installs from this catalog (default: false)--description TEXT- Optional description of the catalog
Arguments:
URL- Catalog URL (must use HTTPS)
Adds a catalog entry to .specify/extension-catalogs.yml.
extension catalog remove
Usage: specify extension catalog remove NAME
Arguments:
NAME- Catalog name to remove
Removes a catalog entry from .specify/extension-catalogs.yml.
extension add
Usage: specify extension add EXTENSION [OPTIONS]
Options:
--from URL- Install from custom URL--dev PATH- Install from local directory
Arguments:
EXTENSION- Extension name or URL
Note: Extensions from catalogs with install_allowed: false cannot be installed via this command.
extension remove
Usage: specify extension remove EXTENSION [OPTIONS]
Options:
--keep-config- Preserve config files--force- Skip confirmation
Arguments:
EXTENSION- Extension ID
extension search
Usage: specify extension search [QUERY] [OPTIONS]
Searches all active catalogs simultaneously. Results include source catalog name and install_allowed status.
Options:
--tag TAG- Filter by tag--author AUTHOR- Filter by author--verified- Show only verified extensions
Arguments:
QUERY- Optional search query
extension info
Usage: specify extension info EXTENSION
Shows source catalog and install_allowed status.
Arguments:
EXTENSION- Extension ID
extension update
Usage: specify extension update [EXTENSION]
Arguments:
EXTENSION- Optional, extension ID (default: all)
extension enable
Usage: specify extension enable EXTENSION
Arguments:
EXTENSION- Extension ID
extension disable
Usage: specify extension disable EXTENSION
Arguments:
EXTENSION- Extension ID
Exceptions
ValidationError
Raised when extension manifest validation fails.
from specify_cli.extensions import ValidationError
try:
manifest = ExtensionManifest(path)
except ValidationError as e:
print(f"Invalid manifest: {e}")
CompatibilityError
Raised when extension is incompatible with current spec-kit version.
from specify_cli.extensions import CompatibilityError
try:
manager.check_compatibility(manifest, "0.1.0")
except CompatibilityError as e:
print(f"Incompatible: {e}")
ExtensionError
Base exception for all extension-related errors.
from specify_cli.extensions import ExtensionError
try:
manager.install_from_directory(path, "0.1.0")
except ExtensionError as e:
print(f"Extension error: {e}")
Version Functions
version_satisfies
Check if a version satisfies a specifier.
from specify_cli.extensions import version_satisfies
# True if 1.2.3 satisfies >=1.0.0,<2.0.0
satisfied = version_satisfies("1.2.3", ">=1.0.0,<2.0.0") # bool
File System Layout
.specify/
├── extensions/
│ ├── .registry # Extension registry (JSON)
│ ├── .cache/ # Catalog cache
│ │ ├── catalog.json
│ │ └── catalog-metadata.json
│ ├── .backup/ # Config backups
│ │ └── {ext}-{config}.yml
│ ├── {extension-id}/ # Extension directory
│ │ ├── extension.yml # Manifest
│ │ ├── {ext}-config.yml # User config
│ │ ├── {ext}-config.local.yml # Local overrides (gitignored)
│ │ ├── {ext}-config.template.yml # Template
│ │ ├── commands/ # Command files
│ │ │ └── *.md
│ │ ├── scripts/ # Helper scripts
│ │ │ └── *.sh
│ │ ├── docs/ # Documentation
│ │ └── README.md
│ └── extensions.yml # Project extension config
└── scripts/ # (existing spec-kit)
.claude/
└── commands/
└── speckit.{ext}.{cmd}.md # Registered commands
Last Updated: 2026-01-28 API Version: 1.0 Spec Kit Version: 0.1.0