Skip to content

Add modular extension system#1551

Open
mbachorik wants to merge 8 commits intogithub:mainfrom
mbachorik:feature/extension-system
Open

Add modular extension system#1551
mbachorik wants to merge 8 commits intogithub:mainfrom
mbachorik:feature/extension-system

Conversation

@mbachorik
Copy link

@mbachorik mbachorik commented Feb 3, 2026

Summary

Implement a complete extension system that allows third-party developers to extend Spec Kit functionality through plugins.

  • Extension discovery and loading from local and global directories
  • YAML-based extension manifest (extension.yml) with metadata and capabilities
  • Command extensions: custom slash commands with markdown templates
  • Hook system: pre/post hooks for generate, task, and sync operations
  • Extension catalog for discovering and installing community extensions
  • SPECKIT_CATALOG_URL environment variable for organization catalog customization

Installation Methods

  • Catalog install: specify extension add <name>
  • URL install: specify extension add <name> --from <url>
  • Dev install: specify extension add --dev <path>

Extension Management Commands

  • specify extension list - List installed extensions
  • specify extension search [query] - Search catalog for extensions
  • specify extension update [name] - Check for and update extensions
  • specify extension remove <name> - Remove an extension

Organization Catalog Customization

The default catalog is intentionally empty, allowing organizations to ship their own curated extension catalogs:

# Set organization catalog URL
export SPECKIT_CATALOG_URL="https://internal.company.com/speckit/catalog.json"

# Then use normal extension commands
specify extension search
specify extension add my-extension

Documentation Included

  • RFC with design rationale and architecture decisions
  • API reference for extension developers
  • Development guide with examples
  • User guide for installing and managing extensions (includes organization customization section)
  • Publishing guide for the extension catalog

Also Included

  • Extension template for bootstrapping new extensions
  • Comprehensive test suite (39 tests)
  • Example catalog.json structure

AI Disclosure

This PR was written primarily by AI, using GitHub Copilot and Claude.

Testing

  • Manual testing: Tested on several spec-kit driven projects
  • Automated testing with AI: Created sample projects, installed extensions from catalog/remote and local sources

Sample Extension

A sample Jira extension (also written primarily by AI, using GitHub Copilot and Claude) is available at:
https://github.com/mbachorik/spec-kit-jira

Test plan

  • Manual testing with Claude Code in VS Code
  • Manual testing with GitHub Copilot in VS Code
  • Tested extension installation from catalog
  • Tested extension installation from URL
  • Tested extension installation from local directory (--dev)
  • Tested extension update command with custom catalog URL
  • Unit tests pass (39/39)

🤖 Generated with Claude Code

Implement a complete extension system that allows third-party developers
to extend Spec Kit functionality through plugins.

## Core Features
- Extension discovery and loading from local and global directories
- YAML-based extension manifest (extension.yml) with metadata and capabilities
- Command extensions: custom slash commands with markdown templates
- Hook system: pre/post hooks for generate, task, and sync operations
- Extension catalog for discovering and installing community extensions
- SPECKIT_CATALOG_URL environment variable for catalog URL override

## Installation Methods
- Catalog install: `specify extension add <name>`
- URL install: `specify extension add <name> --from <url>`
- Dev install: `specify extension add --dev <path>`

## Implementation
- ExtensionManager class for lifecycle management (load, enable, disable)
- Support for extension dependencies and version constraints
- Configuration layering (global → project → extension)
- Hook conditions for conditional execution

## Documentation
- RFC with design rationale and architecture decisions
- API reference for extension developers
- Development guide with examples
- User guide for installing and managing extensions
- Publishing guide for the extension catalog

## Included
- Extension template for bootstrapping new extensions
- Comprehensive test suite
- Example catalog.json structure

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings February 3, 2026 15:04
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR implements a comprehensive modular extension system for Spec Kit, enabling third-party developers to create plugins that extend functionality without bloating the core framework.

Changes:

  • Complete extension infrastructure with manifest validation, registry, installation/removal, and hook system
  • Extension catalog for discovery with search, caching, and metadata management
  • CLI commands for managing extensions (add, remove, list, search, info, update, enable, disable)
  • Multi-agent support for 16+ AI coding assistants with automatic command registration
  • Layered configuration system supporting defaults, project, local, and environment variable overrides
  • Comprehensive documentation suite (user guide, API reference, publishing guide, RFC)
  • Extension template and test suite with 39 unit tests

Reviewed changes

Copilot reviewed 20 out of 21 changed files in this pull request and generated 8 comments.

Show a summary per file
File Description
src/specify_cli/extensions.py Core extension system implementation (1714 lines): manifest validation, registry, manager, catalog, config, hooks
src/specify_cli/init.py CLI integration with 7 extension commands
tests/test_extensions.py Comprehensive test suite with 984 lines covering all components
pyproject.toml Version bump to 0.1.0, added dependencies (pyyaml, packaging), test configuration
extensions/template/* Complete extension template with examples and documentation
extensions/*.md Documentation suite (user guide, API reference, publishing guide, RFC)
extensions/catalog.json Initial catalog with Jira extension
CHANGELOG.md Detailed changelog documenting all new features
.gitignore Extension cache and local config exclusions

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Adds 2-level mode support (Epic → Stories only).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@mnriem mnriem self-requested a review February 3, 2026 17:18
@mnriem
Copy link
Collaborator

mnriem commented Feb 3, 2026

@mbachorik Can you make it so the catalog.json is empty and it gets populated when adding a specific extension. For organizations they could then ship their own vetted version of catalog.json? Also can you address the markdown linter errors?

- Fix Zip Slip vulnerability in ZIP extraction with path validation
- Fix keep_config option to actually preserve config files on removal
- Add URL validation for SPECKIT_CATALOG_URL (HTTPS required, localhost exception)
- Add security warning when installing from custom URLs (--from flag)
- Empty catalog.json so organizations can ship their own catalogs
- Fix markdown linter errors (MD040: add language to code blocks)
- Remove redundant import and fix unused variables in tests
- Add comment explaining empty except clause for backwards compatibility

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings February 3, 2026 20:56
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 20 out of 21 changed files in this pull request and generated 5 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

iamaeroplane and others added 2 commits February 3, 2026 22:02
- Explain why default catalog is empty (org control)
- Document how to create and host custom catalogs
- Add catalog JSON schema reference
- Include use cases: private extensions, curated catalogs, air-gapped environments
- Add examples for combining catalog with direct installation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Update test_config_backup_on_remove to use new subdirectory structure
  (.backup/test-ext/file.yml instead of .backup/test-ext-file.yml)
- Update test_full_install_and_remove_workflow to handle registered_commands
  being a dict keyed by agent name instead of a flat list

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings February 3, 2026 21:20
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 20 out of 21 changed files in this pull request and generated 2 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@mbachorik
Copy link
Author

Updates in this push

Test Fixes

Fixed two test assertions that were failing due to data structure changes:

  1. test_config_backup_on_remove: Updated to use new subdirectory structure (.backup/test-ext/file.yml instead of .backup/test-ext-file.yml)

  2. test_full_install_and_remove_workflow: Updated to handle registered_commands being a dict keyed by agent name instead of a flat list

All Tests Passing

============================== 39 passed in 0.23s ==============================

Extension Update Command Tested

Verified specify extension update works correctly:

  • Detects newer versions in catalog
  • Shows "up to date" when versions match
  • Provides manual update instructions (automatic download is TODO for future version)
  • Respects SPECKIT_CATALOG_URL environment variable for organization catalogs

- Fix localhost URL check to use parsed.hostname instead of netloc.startswith()
  This correctly handles URLs with ports like localhost:8080
- Fix YAML indentation error in config-template.yml (line 57)
- Fix double space typo in example.md (line 172)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@mbachorik
Copy link
Author

Copilot Review Feedback - Status

Addressed in this push (352bd80)

Comment Status Notes
Localhost check using netloc.startswith() ✅ Fixed Now uses parsed.hostname in ("localhost", "127.0.0.1", "::1") to correctly handle ports
YAML indentation error (config-template.yml:57) ✅ Fixed Changed from 1 space to 2 spaces
Double space typo (example.md:172) ✅ Fixed Removed extra space

Already addressed in previous commits

Comment Status Notes
Empty except clause without comment ✅ Already fixed Has explanatory comment
SPECKIT_CATALOG_URL URL injection ✅ Already fixed Validates HTTPS and shows warning for non-default catalogs
--from URL validation ✅ Already fixed Validates HTTPS and shows warning

Design decisions (not changing)

Comment Notes
Zip Slip validation The current logic is correct - validation happens for ALL paths before extractall() is called. If any path is unsafe, ValidationError is raised before extraction.
keep_config parameter Current behavior is intentional - when keep_config=True, the entire extension directory is preserved to allow seamless reinstall. The parameter name reflects user intent ("keep my config") rather than internal behavior.
Backup *-config.local.yml files Local config files are gitignored and typically contain machine-specific overrides. Backing them up could cause confusion when restoring on different machines. Users who need to preserve local configs should manually copy them.
download_extension HTTPS validation The catalog itself is already HTTPS-validated. Extension download URLs in the catalog are trusted as they come from the verified catalog source. Adding another check would be redundant for catalog installs, and --from URL installs already have HTTPS validation.

@mnriem
Copy link
Collaborator

mnriem commented Feb 4, 2026

@mbachorik Did I miss the change for catalog.json?

The main catalog.json is intentionally empty so organizations can ship
their own curated catalogs. This example file shows the expected schema
and structure for creating organization-specific catalogs.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings February 5, 2026 20:15
@mbachorik
Copy link
Author

mbachorik commented Feb 5, 2026

@mnriem Regarding your request:

catalog.json is now empty - It only contains the schema structure with no extensions:

{
  "schema_version": "1.0",
  "updated_at": "2026-02-03T00:00:00Z",
  "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.json",
  "extensions": {}
}

Added catalog.example.json - A reference file showing the expected schema for organizations creating their own catalogs. This includes two sample extension entries (Jira and Linear) demonstrating all the fields.

Organizations can:

  1. Set SPECKIT_CATALOG_URL to point to their own hosted catalog
  2. Use catalog.example.json as a template for creating their catalog
  3. The empty default catalog ensures no unexpected extensions are installed

Markdown linter issues were addressed in earlier commits.

Or do you want catalog.json to be completely empty (or non-existent in repo)?

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 21 out of 22 changed files in this pull request and generated 2 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

- Fix Zip Slip vulnerability by using relative_to() for safe path validation
- Add HTTPS validation for extension download URLs
- Backup both *-config.yml and *-config.local.yml files on remove
- Normalize boolean values to lowercase for hook condition comparisons
- Show non-default catalog warning only once per instance

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@mbachorik
Copy link
Author

Addressed Copilot Review Feedback (Round 2)

Fixed the remaining security and logic issues flagged by Copilot:

Security Fixes

  • Zip Slip vulnerability: Changed from string startswith() check to using relative_to() for proper path containment validation
  • Download URL HTTPS validation: Added requirement that extension download URLs must use HTTPS (localhost exception for testing)

Logic Fixes

  • Config backup incomplete: Now backs up both *-config.yml and *-config.local.yml files when removing extensions
  • Boolean comparison in hooks: Normalizes YAML boolean values (True/False) to lowercase ("true"/"false") for condition comparison
  • Duplicate catalog warning: Added instance flag to ensure non-default catalog warning is only shown once per execution

All 39 tests continue to pass.

@mnriem mnriem self-assigned this Feb 6, 2026
@mnriem mnriem requested review from Copilot and removed request for mnriem February 6, 2026 18:43
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 21 out of 22 changed files in this pull request and generated 8 comments.

Comments suppressed due to low confidence (2)

src/specify_cli/extensions.py:121

  • ExtensionManifest._validate() enforces a generic command name pattern, but it doesn’t ensure the command namespace matches the declaring extension.id (e.g., extension jira could provide speckit.linear.sync). That allows collisions/impersonation across extensions.

Recommendation: parse cmd["name"] segments and require the middle segment to equal ext["id"] (and validate aliases the same way).

            if not re.match(r'^speckit\.[a-z0-9-]+\.[a-z0-9-]+$', cmd["name"]):
                raise ValidationError(
                    f"Invalid command name '{cmd['name']}': "
                    "must follow pattern 'speckit.{extension}.{command}'"
                )

src/specify_cli/extensions.py:110

  • Manifest hooks are currently not validated at all. This can register hooks with command: None or pointing at non-existent commands.

Recommendation: validate hook entries (required keys, and that hook.command resolves to one of the provided command names/aliases) as part of ExtensionManifest._validate().

        # Validate provides section
        provides = self.data["provides"]
        if "commands" not in provides or not provides["commands"]:
            raise ValidationError("Extension must provide at least one command")


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

hook_executor.save_project_config(config)

console.print(f"[green]✓[/green] Extension '{extension}' disabled")
console.print(f"\nCommands will no longer be available. Hooks will not execute.")
Copy link

Copilot AI Feb 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This command prints "Commands will no longer be available", but disable only toggles registry/hook state; it does not unregister/remove the generated agent command files. The commands will remain available to agents that read commands from disk.

Recommendation: implement disable/enable by unregistering/re-registering commands (similar to ExtensionManager.remove()), or adjust the messaging/behavior to match reality.

Suggested change
console.print(f"\nCommands will no longer be available. Hooks will not execute.")
console.print(f"\nExtension commands are now disabled and hooks will not execute.")

Copilot uses AI. Check for mistakes.
Comment on lines +1938 to +1940
metadata["enabled"] = True
manager.registry.add(extension, metadata)

Copy link

Copilot AI Feb 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ExtensionRegistry.add() always overwrites installed_at. Calling it here to toggle enabled will reset the original install timestamp.

Recommendation: preserve existing installed_at when updating metadata (either in add() when it’s already present, or via a separate update() method).

Copilot uses AI. Check for mistakes.
Comment on lines +1891 to +1895
# TODO: Implement download and reinstall from URL
# For now, just show message
console.print(
f"[yellow]Note:[/yellow] Automatic update not yet implemented. "
f"Please update manually:"
Copy link

Copilot AI Feb 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

specify extension update is presented as an update command, but the implementation is a TODO and only prints manual steps.

Recommendation: either implement the download+reinstall flow (preserving config) or rename/adjust help text to reflect that it only checks for updates for now.

Copilot uses AI. Check for mistakes.
Comment on lines +1259 to +1260
return manifest_data.get("config", {}).get("defaults", {})

Copy link

Copilot AI Feb 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ConfigManager._get_extension_defaults() reads defaults from manifest_data.get("config", {}).get("defaults", {}), but the manifest schema/docs and extensions/template/extension.yml define defaults as a top-level key. As written, extension default config values will never be applied.

Recommendation: read from manifest_data.get("defaults", {}) (or support both locations for backwards compatibility).

Suggested change
return manifest_data.get("config", {}).get("defaults", {})
# Prefer top-level "defaults" key as defined in the manifest schema/template
defaults = manifest_data.get("defaults")
if isinstance(defaults, dict):
return defaults
# Backwards compatibility: support legacy "config.defaults" location
config_section = manifest_data.get("config", {})
if isinstance(config_section, dict):
legacy_defaults = config_section.get("defaults", {})
if isinstance(legacy_defaults, dict):
return legacy_defaults
return {}

Copilot uses AI. Check for mistakes.
Comment on lines +462 to +466
# Keep top-level *-config.yml and *-config.local.yml files
if child.is_file() and (
child.name.endswith("-config.yml") or
child.name.endswith("-config.local.yml")
):
Copy link

Copilot AI Feb 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Local override config naming is inconsistent: ConfigManager loads .specify/extensions/{ext}/local-config.yml, but the docs/template use {ext}-config.local.yml, and ExtensionManager.remove(..., keep_config=True) only preserves files ending with -config.local.yml. This means local overrides can be deleted unexpectedly during removal and also never loaded by ConfigManager if users follow the docs.

Recommendation: standardize on one filename (likely {ext}-config.local.yml per the docs) and update ConfigManager, removal/backup patterns, and .gitignore accordingly.

Copilot uses AI. Check for mistakes.
Comment on lines +112 to +115
for cmd in provides["commands"]:
if "name" not in cmd or "file" not in cmd:
raise ValidationError("Command missing 'name' or 'file'")

Copy link

Copilot AI Feb 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ExtensionManifest._validate() does not validate provides.commands[*].file paths. A manifest could specify an absolute path or ../ traversal, and CommandRegistrar would read files outside the extension directory.

Recommendation: enforce that file is a relative, normalized path without .. segments (e.g., Path(file).is_absolute() == False and Path(file).parts contains no ..).

This issue also appears in the following locations of the same file:

  • line 117
  • line 106

Copilot uses AI. Check for mistakes.
Comment on lines +1020 to +1022
age_seconds = (datetime.now(timezone.utc) - cached_at).total_seconds()
return age_seconds < self.CACHE_DURATION
except (json.JSONDecodeError, ValueError, KeyError):
Copy link

Copilot AI Feb 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ExtensionCatalog.is_cache_valid() can raise a TypeError when cached_at parses as a naive datetime (subtracting naive from aware). The current except clause won’t catch this, so a corrupted/legacy cache could crash catalog operations.

Recommendation: ensure cached_at is timezone-aware (assume UTC if missing tzinfo) and/or include TypeError in the exception handling.

Suggested change
age_seconds = (datetime.now(timezone.utc) - cached_at).total_seconds()
return age_seconds < self.CACHE_DURATION
except (json.JSONDecodeError, ValueError, KeyError):
# Ensure cached_at is timezone-aware; assume UTC for legacy/naive values
if cached_at.tzinfo is None:
cached_at = cached_at.replace(tzinfo=timezone.utc)
age_seconds = (datetime.now(timezone.utc) - cached_at).total_seconds()
return age_seconds < self.CACHE_DURATION
except (json.JSONDecodeError, ValueError, KeyError, TypeError):

Copilot uses AI. Check for mistakes.
console.print(f" Commands: {ext['command_count']} | Hooks: {ext['hook_count']} | Status: {'Enabled' if ext['enabled'] else 'Disabled'}")
console.print()

if available or all_extensions:
Copy link

Copilot AI Feb 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The --available / --all flags are advertised but currently don’t list catalog extensions; this block just prints an install hint.

Recommendation: when these flags are set, fetch the catalog (e.g., ExtensionCatalog.search()) and display available extensions (and optionally de-dupe against installed when --all).

Suggested change
if available or all_extensions:
if available or all_extensions:
# Show available extensions from the catalog when requested
try:
from .extensions import ExtensionCatalog # type: ignore[import]
except ImportError:
console.print("\n[yellow]Extension catalog is not available.[/yellow]")
console.print("Install an extension with:")
console.print(" [cyan]specify extension add <name>[/cyan]")
return
try:
catalog_extensions = ExtensionCatalog.search()
except Exception as exc:
console.print("\n[red]Failed to fetch extension catalog:[/red] ", str(exc))
return
# Ensure we have a list to work with
catalog_extensions = catalog_extensions or []
# Optionally de-duplicate against installed extensions when --all is used
if all_extensions and installed:
installed_names = {ext.get("name") for ext in installed if isinstance(ext, dict)}
catalog_extensions = [
ext for ext in catalog_extensions
if isinstance(ext, dict) and ext.get("name") not in installed_names
]
if catalog_extensions:
console.print("\n[bold cyan]Available Extensions:[/bold cyan]\n")
for ext in catalog_extensions:
if not isinstance(ext, dict):
continue
name = ext.get("name", "<unknown>")
version = ext.get("version", "unknown")
description = ext.get("description", "")
console.print(f" [bold]{name}[/bold] (v{version})")
if description:
console.print(f" {description}")
console.print()
else:
console.print("\n[yellow]No additional extensions available in catalog.[/yellow]")

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants