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.
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.
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.
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.
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.
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.") |
There was a problem hiding this comment.
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) | ||
|
|
There was a problem hiding this comment.
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:" |
There was a problem hiding this comment.
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", {}) | ||
|
|
There was a problem hiding this comment.
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") | ||
| ): |
There was a problem hiding this comment.
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'") | ||
|
|
There was a problem hiding this comment.
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): |
There was a problem hiding this comment.
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: |
There was a problem hiding this comment.
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