feat(extensions): scripts support, command filtering, and template discovery - #1964
feat(extensions): scripts support, command filtering, and template discovery#1964mbachorik wants to merge 9 commits into
Conversation
…e discovery Bring extension system to parity with presets across three areas: - github#1847: Extensions now support `provides.scripts` alongside commands, with name format and path safety validation, executable permissions, and CLI display in list/add/info. - github#1848: Add `_filter_commands_for_installed_extensions()` to CommandRegistrar for cross-boundary command filtering (matching the preset filtering at presets.py:518-529). - github#1846: Add `list_available()` to PresetResolver for template discovery across the 4-tier priority stack with source attribution, and a new `specify preset list-templates` CLI command. Closes github#1846, closes github#1847, closes github#1848 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR extends Spec Kit’s extension and preset ecosystems to improve parity and discoverability: extensions can now declare scripts, commands can be filtered based on installed extensions, and presets gain a resolver-backed template discovery/listing command.
Changes:
- Add extension manifest + manager support for
provides.scripts, including validation, install-time.shexecutable bits, and CLI surface area (list/add/info). - Introduce
CommandRegistrar._filter_commands_for_installed_extensions()plus tests to filterspeckit.<ext-id>.<cmd>commands when the target extension isn’t installed. - Add
PresetResolver.list_available()and a newspecify preset list-templates --type <...>command, with template discovery tests.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
src/specify_cli/extensions.py |
Adds scripts support to extension manifests/installs and introduces command filtering utility. |
src/specify_cli/presets.py |
Adds PresetResolver.list_available() for priority-stack template discovery with sources. |
src/specify_cli/__init__.py |
Adds preset list-templates CLI command and surfaces script counts in extension CLI output. |
tests/test_extensions.py |
Updates manifest validation tests and adds coverage for scripts support + command filtering utility. |
tests/test_presets.py |
Adds tests for PresetResolver.list_available() behavior (priority, sources, sorting). |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Move extension template resolution and discovery into a dedicated ExtensionResolver class in extensions.py. PresetResolver now delegates its tier-3 (extension) lookups to ExtensionResolver instead of walking extension directories directly. This gives extensions their own resolution/discovery API without coupling them to the preset system. PresetResolver remains the unified resolver across all 4 tiers but no longer owns extension-specific logic. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Use Path.anchor to reject drive-relative/UNC paths in script validation, not just os.path.isabs + normpath - chmod only adds execute bits (0o111) and is gated to POSIX - Command filter treats missing extensions dir as empty (filters out all extension-scoped commands), matching preset behavior - list_available() rejects unsupported template_type with ValueError - CLI list-templates validates --type before calling resolver Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated 2 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- Make chmod best-effort (try/except OSError) so permission edge cases don't abort extension installation - Filter overrides by name pattern in list_available(): commands must contain dots, templates must not, preventing cross-contamination in the shared overrides directory Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
After the ExtensionResolver refactor, PresetResolver no longer uses ExtensionRegistry directly — that dependency moved into ExtensionResolver. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated 3 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- Coerce None values from provides.commands/scripts via `or []` and validate they are lists, preventing TypeError on null YAML values - Discover both .sh and .ps1 scripts in ExtensionResolver and PresetResolver.list_available() instead of only .sh - Remove unused ExtensionRegistry import (ruff F401) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated 3 comments.
Comments suppressed due to low confidence (1)
src/specify_cli/extensions.py:160
- The commands/scripts validators assume each entry is a dict with string fields. If a manifest has
provides.commands: ["foo"]orname/filevalues that aren’t strings, this will raiseTypeError/KeyError(e.g., oncmd["name"]/ regex) rather than aValidationError. Validateisinstance(cmd, dict)and thatname/fileare strings before running regex/path checks.
# Validate commands
for cmd in commands:
if "name" not in cmd or "file" not in cmd:
raise ValidationError("Command missing 'name' or 'file'")
# Validate command name format
if not re.match(r'^speckit\.[a-z0-9-]+\.[a-z0-9-]+$', cmd["name"]):
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- Add type checks for extension/requires/provides top-level fields (must be mappings, not null) to raise ValidationError instead of AttributeError on malformed manifests - Add type checks for script entries (must be dict with string name/file) to prevent TypeError on non-dict or non-string values - PresetResolver.resolve() now tries both .sh and .ps1 for scripts across all tiers, consistent with list_available() discovery Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated 2 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- Add type checks for command entries (must be dict with string name/file), mirroring the script validation added earlier - ExtensionResolver._type_config raises ValueError for unsupported template types instead of silently falling back Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated 4 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- Stricter name validation in list_available(): templates must match ^[a-z0-9-]+$ (excludes README.md etc.), commands must match ^speckit\.[a-z0-9.-]+$, scripts must match ^[a-z0-9-]+$ - Script deduplication in list_available() and ExtensionResolver .list_templates() now prefers .sh over .ps1 when both exist, matching resolve() extension order - ExtensionResolver.list_templates() adds same name validation and script dedup logic - Type checks for extension.id/name/version/description (must be strings) and requires.speckit_version (must be string) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated 3 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| f"Invalid script file path '{file_path}': " | ||
| "must be a relative path within the extension directory" | ||
| ) | ||
|
|
There was a problem hiding this comment.
ExtensionManifest validates that provides.scripts[*].file is relative and non-traversing, but it doesn’t enforce that the file name/extension matches the script name (or even that it ends in .sh/.ps1). Since ExtensionResolver.resolve(..., template_type='script') and list_templates('script') rely on {template_name}.sh|.ps1 naming, a manifest like name: setup + file: scripts/setup-v2.sh will install/list as setup but will never resolve/discover as setup. Consider validating that the script file is under scripts/ and that its stem equals script['name'] with an allowed suffix (.sh/.ps1) to keep manifest display, resolution, and discovery consistent.
| # Enforce resolver/discovery conventions: | |
| # - script files must live under "scripts/" | |
| # - file name stem must match the script name | |
| # - extension must be one of the allowed script types | |
| if not p.parts or p.parts[0] != "scripts": | |
| raise ValidationError( | |
| f"Invalid script file path '{file_path}': " | |
| "script files must be located under the 'scripts/' directory" | |
| ) | |
| if p.suffix not in {".sh", ".ps1"}: | |
| raise ValidationError( | |
| f"Invalid script file path '{file_path}': " | |
| "script files must have a '.sh' or '.ps1' extension" | |
| ) | |
| if p.stem != script["name"]: | |
| raise ValidationError( | |
| f"Invalid script file path '{file_path}': " | |
| f"file name (without extension) must match script name '{script['name']}'" | |
| ) |
| """List all templates of a given type provided by extensions. | ||
|
|
||
| Returns templates sorted by extension priority, then alphabetically. | ||
|
|
||
| Args: | ||
| template_type: Template type ("template", "command", or "script") | ||
|
|
There was a problem hiding this comment.
ExtensionResolver.list_templates() docstring promises results are sorted by extension priority then alphabetically, but the implementation returns templates in “scan order” (subdir order + filesystem order) and never sorts results before returning. This can produce non-alphabetical output for template type (because both templates/ and extension root are scanned). Either sort results (e.g., by (priority, name)) before returning or adjust the docstring to match the actual ordering guarantee.
| @@ -1598,44 +1557,36 @@ def resolve( | |||
| else: | |||
| subdirs = [""] | |||
There was a problem hiding this comment.
PresetResolver.resolve() still accepts unknown template_type values via the else: subdirs = [''] fallback, which means callers can accidentally resolve arbitrary *.md files from preset/extension roots. Since list_available() (and ExtensionResolver._type_config) now reject unsupported types, it would be more consistent (and safer) for resolve() to validate template_type against VALID_PRESET_TEMPLATE_TYPES and raise ValueError for invalid values.
| subdirs = [""] | |
| raise ValueError(f"Unsupported template_type: {template_type!r}") |
|
This pull request has been automatically marked as stale because it has not had any activity for 150 days. It will be closed in 30 days if no further activity occurs. |
Summary
Brings the extension system to parity with presets across three areas:
Extension system: Add template type diversity (scripts support) #1847 — Scripts support: Extensions now accept
provides.scriptsalongside commands in the manifest. Includes name format validation (^[a-z0-9-]+$), path safety checks (no traversal), executable permissions on.shfiles during install, and CLI display inextension list,extension add, andextension info.Extension system: Add extension filtering for ext-specific commands #1848 — Command filtering: Adds
_filter_commands_for_installed_extensions()toCommandRegistrar— filters extension-specific commands (speckit.<ext-id>.<cmd>) against installed extension directories. Mirrors the preset filtering logic atpresets.py:518-529. Available as a utility for cross-boundary filtering (e.g. when presets provide commands for extensions that may not be installed). Not applied during extension self-registration since all commands in an extension's own manifest are always valid.Extension system: Add template resolution system #1846 — Template resolution & discovery: Introduces
ExtensionResolver— a dedicated class inextensions.pythat owns extension template resolution, source attribution, and discovery.PresetResolvernow delegates its tier-3 (extension) lookups toExtensionResolverinstead of walking extension directories directly. Newspecify preset list-templates --type <type>CLI command for template discovery across the full 4-tier stack.Why ExtensionResolver instead of using PresetResolver?
The
PresetResolveralready had extension logic baked in (tier 3 of its resolution stack), but this meant extensions had to go through the preset system to discover their own templates — mixing concerns.ExtensionResolvergives extensions their own resolution/discovery API:resolve(name, type)— find a template from extensionsresolve_with_source(name, type)— with source attributionlist_templates(type)— discover all templates provided by extensionsPresetResolverremains the unified resolver across all 4 tiers (overrides → presets → extensions → core) but now delegates toExtensionResolverfor the extension tier rather than owning that logic directly. Each system owns its own domain.Closes #1846, closes #1847, closes #1848
Test plan
test_search_with_cached_dataunrelated to this PR)specify extension addwith a scripts-only extensionspecify preset list-templatesoutputs templates with correct source attribution🤖 Generated with Claude Code