Files
github-spec-kit/extensions/EXTENSION-API-REFERENCE.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

20 KiB

Extension API Reference

Technical reference for Spec Kit extension system APIs and manifest schema.

Table of Contents

  1. Extension Manifest
  2. Python API
  3. Command File Format
  4. Configuration Schema
  5. Hook System
  6. CLI Commands

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. Each event accepts either form below.
  event_name:            # e.g., "after_specify", "after_plan", "after_tasks", "after_implement"
    command: string      # Command to execute
    priority: integer    # Optional, >= 1, default 10 (lower runs first)
    optional: boolean    # Default: true
    prompt: string       # Prompt text for optional hooks
    description: string  # Hook description
    condition: string    # Optional, condition expression
  another_event:         # Any event may instead use a list of mappings (multiple commands)
    - command: string    # Same fields as the single mapping, per entry
      priority: integer
    - command: string
      priority: integer

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)
  • Value: A single hook mapping, or a list of hook mappings to register multiple commands on one event
  • Description: Hooks that execute at lifecycle events
  • Events: Defined by core spec-kit commands
  • Ordering: Within an event, hooks run by ascending priority (integer ≥ 1, default 10; lower runs first; equal priorities keep authoring order via a stable sort)

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

  1. Extension Defaults (from extension.yml defaults section)
  2. Project Config ({extension-id}-config.yml)
  3. Local Override ({extension-id}-config.local.yml, gitignored)
  4. Environment Variables (SPECKIT_{EXTENSION}_*)

Environment Variable Pattern

Format: SPECKIT_{EXTENSION}_{KEY}

Examples:

  • SPECKIT_JIRA_PROJECT_KEY
  • SPECKIT_LINEAR_API_KEY
  • SPECKIT_GITHUB_TOKEN

Hook System

Hook Definition

Each event accepts either a single hook mapping or a list of mappings. A list registers multiple commands on the same event.

Single mapping (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

List of mappings with priority:

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"

Within a single manifest list, a repeated command is deduped as "last wins" and moved to the end, so it also breaks equal-priority ties in authoring order.

Hook Events

Standard events (defined by core):

  • before_specify - Before specification generation
  • after_specify - After specification generation
  • before_plan - Before implementation planning
  • after_plan - After implementation planning
  • before_tasks - Before task generation
  • after_tasks - After task generation
  • before_implement - Before implementation
  • after_implement - After implementation
  • before_analyze - Before cross-artifact analysis
  • after_analyze - After cross-artifact analysis
  • before_checklist - Before checklist generation
  • after_checklist - After checklist generation
  • before_clarify - Before spec clarification
  • after_clarify - After spec clarification
  • before_constitution - Before constitution update
  • after_constitution - After constitution update
  • before_taskstoissues - Before tasks-to-issues conversion
  • after_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

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