-
Notifications
You must be signed in to change notification settings - Fork 5.9k
Add modular extension system #1551
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
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>
There was a problem hiding this 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>
|
@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>
There was a problem hiding this 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.
- 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>
There was a problem hiding this 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.
Updates in this pushTest FixesFixed two test assertions that were failing due to data structure changes:
All Tests PassingExtension Update Command TestedVerified
|
- 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>
Copilot Review Feedback - StatusAddressed in this push (352bd80)
Already addressed in previous commits
Design decisions (not changing)
|
|
@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>
|
@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:
Markdown linter issues were addressed in earlier commits. Or do you want catalog.json to be completely empty (or non-existent in repo)? |
There was a problem hiding this 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>
Addressed Copilot Review Feedback (Round 2)Fixed the remaining security and logic issues flagged by Copilot: Security Fixes
Logic Fixes
All 39 tests continue to pass. |
There was a problem hiding this 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 declaringextension.id(e.g., extensionjiracould providespeckit.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
hooksare currently not validated at all. This can register hooks withcommand: Noneor 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.") |
Copilot
AI
Feb 6, 2026
There was a problem hiding this comment.
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.
| 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.") |
| metadata["enabled"] = True | ||
| manager.registry.add(extension, metadata) | ||
|
|
Copilot
AI
Feb 6, 2026
There was a problem hiding this comment.
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).
| # 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:" |
Copilot
AI
Feb 6, 2026
There was a problem hiding this comment.
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.
| return manifest_data.get("config", {}).get("defaults", {}) | ||
|
|
Copilot
AI
Feb 6, 2026
There was a problem hiding this comment.
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).
| 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 {} |
| # 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") | ||
| ): |
Copilot
AI
Feb 6, 2026
There was a problem hiding this comment.
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.
| for cmd in provides["commands"]: | ||
| if "name" not in cmd or "file" not in cmd: | ||
| raise ValidationError("Command missing 'name' or 'file'") | ||
|
|
Copilot
AI
Feb 6, 2026
There was a problem hiding this comment.
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
| age_seconds = (datetime.now(timezone.utc) - cached_at).total_seconds() | ||
| return age_seconds < self.CACHE_DURATION | ||
| except (json.JSONDecodeError, ValueError, KeyError): |
Copilot
AI
Feb 6, 2026
There was a problem hiding this comment.
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.
| 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): |
| 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: |
Copilot
AI
Feb 6, 2026
There was a problem hiding this comment.
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).
| 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]") |
Summary
Implement a complete extension system that allows third-party developers to extend Spec Kit functionality through plugins.
SPECKIT_CATALOG_URLenvironment variable for organization catalog customizationInstallation Methods
specify extension add <name>specify extension add <name> --from <url>specify extension add --dev <path>Extension Management Commands
specify extension list- List installed extensionsspecify extension search [query]- Search catalog for extensionsspecify extension update [name]- Check for and update extensionsspecify extension remove <name>- Remove an extensionOrganization Catalog Customization
The default catalog is intentionally empty, allowing organizations to ship their own curated extension catalogs:
Documentation Included
Also Included
AI Disclosure
Testing
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
🤖 Generated with Claude Code