Skip to content

feat(extensions): scripts support, command filtering, and template discovery#1964

Draft
mbachorik wants to merge 6 commits intogithub:mainfrom
mbachorik:feat/extension-parity
Draft

feat(extensions): scripts support, command filtering, and template discovery#1964
mbachorik wants to merge 6 commits intogithub:mainfrom
mbachorik:feat/extension-parity

Conversation

@mbachorik
Copy link
Contributor

@mbachorik mbachorik commented Mar 24, 2026

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.scripts alongside commands in the manifest. Includes name format validation (^[a-z0-9-]+$), path safety checks (no traversal), executable permissions on .sh files during install, and CLI display in extension list, extension add, and extension info.

  • Extension system: Add extension filtering for ext-specific commands #1848 — Command filtering: Adds _filter_commands_for_installed_extensions() to CommandRegistrar — filters extension-specific commands (speckit.<ext-id>.<cmd>) against installed extension directories. Mirrors the preset filtering logic at presets.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 in extensions.py that owns extension template resolution, source attribution, and discovery. PresetResolver now delegates its tier-3 (extension) lookups to ExtensionResolver instead of walking extension directories directly. New specify preset list-templates --type <type> CLI command for template discovery across the full 4-tier stack.

Why ExtensionResolver instead of using PresetResolver?

The PresetResolver already 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. ExtensionResolver gives extensions their own resolution/discovery API:

  • resolve(name, type) — find a template from extensions
  • resolve_with_source(name, type) — with source attribution
  • list_templates(type) — discover all templates provided by extensions

PresetResolver remains the unified resolver across all 4 tiers (overrides → presets → extensions → core) but now delegates to ExtensionResolver for the extension tier rather than owning that logic directly. Each system owns its own domain.

Closes #1846, closes #1847, closes #1848

Test plan

  • 843 tests pass (1 pre-existing failure in test_search_with_cached_data unrelated to this PR)
  • Verify specify extension add with a scripts-only extension
  • Verify specify preset list-templates outputs templates with correct source attribution
  • Verify command filtering works when a preset provides commands for an uninstalled extension

🤖 Generated with Claude Code

…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>
Copilot AI review requested due to automatic review settings March 24, 2026 21:36
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 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 .sh executable bits, and CLI surface area (list/add/info).
  • Introduce CommandRegistrar._filter_commands_for_installed_extensions() plus tests to filter speckit.<ext-id>.<cmd> commands when the target extension isn’t installed.
  • Add PresetResolver.list_available() and a new specify 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.

iamaeroplane and others added 2 commits March 25, 2026 09:11
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>
Copilot AI review requested due to automatic review settings March 25, 2026 12:08
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 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.

iamaeroplane and others added 2 commits March 25, 2026 20:45
- 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>
Copilot AI review requested due to automatic review settings March 26, 2026 15:42
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 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>
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 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"] or name/file values that aren’t strings, this will raise TypeError/KeyError (e.g., on cmd["name"] / regex) rather than a ValidationError. Validate isinstance(cmd, dict) and that name/file are 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.

Comment on lines +166 to +182
# Validate scripts
for script in scripts:
if "name" not in script or "file" not in script:
raise ValidationError("Script missing 'name' or 'file'")

# Validate script name format
if not re.match(r'^[a-z0-9-]+$', script["name"]):
raise ValidationError(
f"Invalid script name '{script['name']}': "
"must be lowercase alphanumeric with hyphens only"
)

# Validate file path safety: must be relative, no anchored/drive
# paths, and no parent traversal components
file_path = script["file"]
p = Path(file_path)
if p.is_absolute() or p.anchor:
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

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

For scripts, file_path = script["file"]; p = Path(file_path) will raise if script isn’t a dict or file isn’t a string/pathlike (e.g., file: 123). Add explicit type checks (script is dict; name/file are strings) so invalid manifests consistently raise ValidationError instead of crashing.

Copilot uses AI. Check for mistakes.
Comment on lines +1586 to +1589
# Priority 3: Extension-provided templates (delegated to ExtensionResolver)
ext_result = self._ext_resolver.resolve(template_name, template_type)
if ext_result is not None:
return ext_result
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

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

PresetResolver.resolve() still hard-codes ext = ".sh" for template_type == "script", so .ps1 scripts in overrides/presets/core will never be resolved even though list_available() (and ExtensionResolver) now discovers both .sh and .ps1. Consider trying both extensions for script resolution across all tiers (overrides/presets/extensions/core) so discovery and resolution stay consistent.

Copilot uses AI. Check for mistakes.
Comment on lines 142 to +148
provides = self.data["provides"]
if "commands" not in provides or not provides["commands"]:
raise ValidationError("Extension must provide at least one command")
commands = provides.get("commands") or []
scripts = provides.get("scripts") or []
if not isinstance(commands, list):
raise ValidationError("provides.commands must be a list")
if not isinstance(scripts, list):
raise ValidationError("provides.scripts must be a list")
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

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

provides = self.data["provides"] is assumed to be a dict, but YAML can legally set provides: null (or a non-mapping), which would currently raise an AttributeError on provides.get(...) instead of a ValidationError. Add an explicit type check for provides (and possibly extension/requires) before using .get so malformed manifests fail with a clear validation error.

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

3 participants