Files
github-spec-kit/extensions/EXTENSION-DEVELOPMENT-GUIDE.md
Seiya Kojima 4ec4635dd1 feat(extensions): per-event hook lists with priority ordering (#2798)
* feat(extensions): per-event hook lists with priority ordering

The manifest validator restricted each hook event to a single mapping,
even though HookExecutor stores entries as a list per event. This blocked
an extension from running multiple commands on one event (e.g. a
verification step plus a doc-generation step after speckit.plan), and
get_hooks_for_event returned entries in raw insertion order with no way
to influence execution order across or within extensions.

This change:

1. Validator: accept hooks.<event> as either a single mapping or a list
   of mappings. Each entry is validated individually and may carry an
   optional integer `priority` (>= 1, default 10; bool rejected).
2. Command-ref normalization: apply rename / alias->canonical rewriting
   to every entry in the list, not just the head.
3. register_hooks: expand list entries, persist `priority`, and
   purge-and-replace all entries owned by the extension on each event so a
   reinstall whose shape changed (single<->list, or a shorter list) leaves
   no orphaned entries behind.
4. get_hooks_for_event: sort enabled entries by `priority` ascending with
   a stable sort (ties keep insertion order). The existing
   normalize_priority helper is reused as the sort key so corrupted
   on-disk values fall back to the default instead of raising.

Backward compatible: existing single-mapping manifests parse and register
unchanged with priority defaulting to 10. The extension-level `priority`
used by preset/template resolution is independent of the new hook-entry
`priority`.

Implements #2378

* fix(extensions): harden register_hooks per PR review

- Skip non-dict hook entries before .get() so a manifest that bypasses
  validation can't crash register_hooks with AttributeError.
- Normalize `priority` on save via normalize_priority so the on-disk
  config stays clean, mirroring the read-side defense in
  get_hooks_for_event.
- Tests: cover the non-dict-entry skip and add encoding="utf-8" to the
  new tests' manifest writes.

* fix(extensions): purge dropped-event hook orphans on reinstall

register_hooks only purged events the new manifest still declared, so an
extension that dropped an event on reinstall left stale entries for it in
the project config. Purge this extension's entries from undeclared events
(and prune emptied events) before registering; scoped to this extension,
and a no-op for the install/update flow where unregister_hooks runs first.

* fix(extensions): reject boolean priority and complete orphan purge

- normalize_priority falls back to default for bool values
- dedup deletes duplicate commands before re-insert for last-wins ties
- register_hooks purges orphans even when all hooks are dropped

* docs(extensions): document per-event hook lists and priority

- EXTENSION-API-REFERENCE: hook event accepts a mapping or list; add
  priority field reference and last-wins dedup note
- EXTENSION-DEVELOPMENT-GUIDE: add list-form example with priority

* docs(extensions): show both single and list hook forms in schema snippet

* docs(extensions): reference DEFAULT_HOOK_PRIORITY in normalize_priority

normalize_priority hard-coded the default as the literal 10 in both its
signature and docstring, duplicating DEFAULT_HOOK_PRIORITY. Reference the
constant in the signature and drop the literal from the docstring so the
default has a single source of truth.
2026-06-08 08:03:46 -05:00

18 KiB

Extension Development Guide

A guide for creating Spec Kit extensions.


Quick Start

1. Create Extension Directory

mkdir my-extension
cd my-extension

2. 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 commands

4. 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-extension

6. Verify Installation

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:

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.

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})

Optional Fields

hooks

Integration hooks for automatic execution.

Available hook points:

  • 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

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 from provides.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 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)

---
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:

## 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:

scripts:
  sh: ../../scripts/bash/helper.sh

After registration:

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

# 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.ymldefaults)
  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:

#!/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 .extensionignore file 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.md

Pattern 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 .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 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 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/).

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:

    specify extension add --dev /path/to/extension
    
  3. Verify installation:

    specify extension list
    
  4. Test commands with your AI agent

  5. Check command registration:

    ls .claude/commands/speckit.my-ext.*
    
  6. Remove extension:

    specify 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

  1. Create repository: spec-kit-my-ext

  2. Add files:

    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:

    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:

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

Option 3: Community Reference Catalog

Submit to the community catalog for public discovery:

  1. Create a GitHub release for your extension
  2. File an issue using the Extension Submission template
  3. After review, a maintainer updates the catalog and your extension becomes available:
    • Users can browse catalog.community.json to discover your extension
    • Users copy the entry to their own catalog.json
    • Users install with: specify extension add my-ext (from their catalog)

See the Extension Publishing Guide for detailed submission instructions.


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:

# 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 with Config

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 with Hooks

Extension that runs automatically:

# extension.yml
hooks:
  after_tasks:
    command: "speckit.auto.analyze"
    optional: false  # Always run
    description: "Analyze tasks after generation"

Multiple commands on one event, ordered by priority (lower runs first):

# extension.yml
hooks:
  after_plan:
    - command: "speckit.my-ext.verify"
      priority: 5
      optional: false
      description: "Verify the plan"
    - command: "speckit.my-ext.report"
      priority: 10
      optional: true
      prompt: "Generate the report?"
      description: "Generate a report from the plan"

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 --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

  • 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:

    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! 🚀