Skip to content
55 changes: 51 additions & 4 deletions src/specify_cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2735,6 +2735,45 @@ def preset_resolve(
console.print(" [dim]No template with this name exists in the resolution stack[/dim]")


@preset_app.command("list-templates")
def preset_list_templates(
template_type: str = typer.Option(
"template", "--type", "-t",
help="Template type: template, command, or script",
),
):
"""List all available templates from the resolution stack."""
from .presets import PresetResolver, VALID_PRESET_TEMPLATE_TYPES

if template_type not in VALID_PRESET_TEMPLATE_TYPES:
console.print(
f"[red]Error:[/red] Invalid template type '{template_type}'. "
f"Must be one of: {', '.join(sorted(VALID_PRESET_TEMPLATE_TYPES))}"
)
raise typer.Exit(1)

project_root = Path.cwd()

specify_dir = project_root / ".specify"
if not specify_dir.exists():
console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
console.print("Run this command from a spec-kit project root")
raise typer.Exit(1)

resolver = PresetResolver(project_root)
available = resolver.list_available(template_type)

if not available:
console.print(f"[yellow]No {template_type}s found in the resolution stack[/yellow]")
return

console.print(f"\n[bold]Available {template_type}s ({len(available)}):[/bold]\n")
for entry in available:
console.print(f" [bold]{entry['name']}[/bold]")
console.print(f" [dim]Source: {entry['source']}[/dim]")
console.print(f" [dim]Path: {entry['path']}[/dim]")


@preset_app.command("info")
def preset_info(
pack_id: str = typer.Argument(..., help="Preset ID to get info about"),
Expand Down Expand Up @@ -3281,7 +3320,7 @@ def extension_list(
console.print(f" [{status_color}]{status_icon}[/{status_color}] [bold]{ext['name']}[/bold] (v{ext['version']})")
console.print(f" [dim]{ext['id']}[/dim]")
console.print(f" {ext['description']}")
console.print(f" Commands: {ext['command_count']} | Hooks: {ext['hook_count']} | Priority: {ext['priority']} | Status: {'Enabled' if ext['enabled'] else 'Disabled'}")
console.print(f" Commands: {ext['command_count']} | Scripts: {ext['script_count']} | Hooks: {ext['hook_count']} | Priority: {ext['priority']} | Status: {'Enabled' if ext['enabled'] else 'Disabled'}")
console.print()

if available or all_extensions:
Expand Down Expand Up @@ -3590,9 +3629,15 @@ def extension_add(
console.print("\n[green]✓[/green] Extension installed successfully!")
console.print(f"\n[bold]{manifest.name}[/bold] (v{manifest.version})")
console.print(f" {manifest.description}")
console.print("\n[bold cyan]Provided commands:[/bold cyan]")
for cmd in manifest.commands:
console.print(f" • {cmd['name']} - {cmd.get('description', '')}")
if manifest.commands:
console.print("\n[bold cyan]Provided commands:[/bold cyan]")
for cmd in manifest.commands:
console.print(f" • {cmd['name']} - {cmd.get('description', '')}")

if manifest.scripts:
console.print("\n[bold cyan]Provided scripts:[/bold cyan]")
for script in manifest.scripts:
console.print(f" • {script['name']} - {script.get('description', '')}")

console.print("\n[yellow]⚠[/yellow] Configuration may be required")
console.print(f" Check: .specify/extensions/{manifest.id}/")
Expand Down Expand Up @@ -3890,6 +3935,8 @@ def _print_extension_info(ext_info: dict, manager):
provides = ext_info['provides']
if provides.get('commands'):
console.print(f" • Commands: {provides['commands']}")
if provides.get('scripts'):
console.print(f" • Scripts: {provides['scripts']}")
if provides.get('hooks'):
console.print(f" • Hooks: {provides['hooks']}")
console.print()
Expand Down
257 changes: 253 additions & 4 deletions src/specify_cli/extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,11 +140,15 @@ def _validate(self):

# 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")
has_commands = "commands" in provides and provides["commands"]
has_scripts = "scripts" in provides and provides["scripts"]
if not has_commands and not has_scripts:
raise ValidationError(
"Extension must provide at least one command or script"
)

# Validate commands
for cmd in provides["commands"]:
for cmd in provides.get("commands", []):
if "name" not in cmd or "file" not in cmd:
raise ValidationError("Command missing 'name' or 'file'")

Expand All @@ -155,6 +159,33 @@ def _validate(self):
"must follow pattern 'speckit.{extension}.{command}'"
)

# Validate scripts
for script in provides.get("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:
raise ValidationError(
f"Invalid script file path '{file_path}': "
"must be a relative path within the extension directory"
)
if ".." in p.parts:
raise ValidationError(
f"Invalid script file path '{file_path}': "
"must be a relative path within the extension directory"
)

Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

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

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.

Suggested change
# 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']}'"
)

Copilot uses AI. Check for mistakes.
@property
def id(self) -> str:
"""Get extension ID."""
Expand Down Expand Up @@ -183,7 +214,12 @@ def requires_speckit_version(self) -> str:
@property
def commands(self) -> List[Dict[str, Any]]:
"""Get list of provided commands."""
return self.data["provides"]["commands"]
return self.data["provides"].get("commands", [])

@property
def scripts(self) -> List[Dict[str, Any]]:
"""Get list of provided scripts."""
return self.data["provides"].get("scripts", [])

@property
def hooks(self) -> Dict[str, Any]:
Expand Down Expand Up @@ -592,6 +628,13 @@ def install_from_directory(
ignore_fn = self._load_extensionignore(source_dir)
shutil.copytree(source_dir, dest_dir, ignore=ignore_fn)

# Set execute permissions on extension scripts (POSIX only)
if os.name == "posix":
for script in manifest.scripts:
script_path = dest_dir / script["file"]
if script_path.exists() and script_path.suffix == ".sh":
script_path.chmod(script_path.stat().st_mode | 0o111)

# Register commands with AI agents
registered_commands = {}
if register_commands:
Expand Down Expand Up @@ -770,6 +813,7 @@ def list_installed(self) -> List[Dict[str, Any]]:
"priority": normalize_priority(metadata.get("priority")),
"installed_at": metadata.get("installed_at"),
"command_count": len(manifest.commands),
"script_count": len(manifest.scripts),
"hook_count": len(manifest.hooks)
})
except ValidationError:
Expand All @@ -783,6 +827,7 @@ def list_installed(self) -> List[Dict[str, Any]]:
"priority": normalize_priority(metadata.get("priority")),
"installed_at": metadata.get("installed_at"),
"command_count": 0,
"script_count": 0,
"hook_count": 0
})

Expand All @@ -809,6 +854,178 @@ def get_extension(self, extension_id: str) -> Optional[ExtensionManifest]:
return None


class ExtensionResolver:
"""Resolves and discovers templates provided by installed extensions.

Handles priority-based ordering of extensions, template resolution,
and source attribution for extension-provided templates.

This class owns the extension tier of the template resolution stack.
PresetResolver delegates to it for extension lookups rather than
walking extension directories directly.
"""

def __init__(self, project_root: Path):
self.project_root = project_root
self.extensions_dir = project_root / ".specify" / "extensions"

def get_all_by_priority(self) -> List[tuple]:
"""Build unified list of registered and unregistered extensions sorted by priority.

Registered extensions use their stored priority; unregistered directories
get implicit priority=10. Results are sorted by (priority, ext_id) for
deterministic ordering.

Returns:
List of (priority, ext_id, metadata_or_none) tuples sorted by priority.
"""
if not self.extensions_dir.exists():
return []

registry = ExtensionRegistry(self.extensions_dir)
registered_extension_ids = registry.keys()
all_registered = registry.list_by_priority(include_disabled=True)

all_extensions: list[tuple[int, str, dict | None]] = []

for ext_id, metadata in all_registered:
if not metadata.get("enabled", True):
continue
priority = normalize_priority(metadata.get("priority") if metadata else None)
all_extensions.append((priority, ext_id, metadata))

for ext_dir in self.extensions_dir.iterdir():
if not ext_dir.is_dir() or ext_dir.name.startswith("."):
continue
if ext_dir.name not in registered_extension_ids:
all_extensions.append((10, ext_dir.name, None))

all_extensions.sort(key=lambda x: (x[0], x[1]))
return all_extensions

def resolve(
self,
template_name: str,
template_type: str = "template",
) -> Optional[Path]:
"""Resolve a template name to its file path within extensions.

Args:
template_name: Template name (e.g., "spec-template")
template_type: Template type ("template", "command", or "script")

Returns:
Path to the resolved template file, or None if not found
"""
subdirs, ext = self._type_config(template_type)

for _priority, ext_id, _metadata in self.get_all_by_priority():
ext_dir = self.extensions_dir / ext_id
if not ext_dir.is_dir():
continue
for subdir in subdirs:
if subdir:
candidate = ext_dir / subdir / f"{template_name}{ext}"
else:
candidate = ext_dir / f"{template_name}{ext}"
if candidate.exists():
return candidate

return None

def resolve_with_source(
self,
template_name: str,
template_type: str = "template",
) -> Optional[Dict[str, str]]:
"""Resolve a template name and return source attribution.

Args:
template_name: Template name (e.g., "spec-template")
template_type: Template type ("template", "command", or "script")

Returns:
Dictionary with 'path' and 'source' keys, or None if not found
"""
subdirs, ext = self._type_config(template_type)

for _priority, ext_id, ext_meta in self.get_all_by_priority():
ext_dir = self.extensions_dir / ext_id
if not ext_dir.is_dir():
continue
for subdir in subdirs:
if subdir:
candidate = ext_dir / subdir / f"{template_name}{ext}"
else:
candidate = ext_dir / f"{template_name}{ext}"
if candidate.exists():
if ext_meta:
version = ext_meta.get("version", "?")
source = f"extension:{ext_id} v{version}"
else:
source = f"extension:{ext_id} (unregistered)"
return {"path": str(candidate), "source": source}

return None

def list_templates(
self,
template_type: str = "template",
) -> List[Dict[str, str]]:
"""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")

Comment on lines +998 to +1004
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
Returns:
List of dicts with 'name', 'path', and 'source' keys.
"""
subdirs, ext = self._type_config(template_type)
results: List[Dict[str, str]] = []
seen: set[str] = set()

for _priority, ext_id, ext_meta in self.get_all_by_priority():
ext_dir = self.extensions_dir / ext_id
if not ext_dir.is_dir():
continue

if ext_meta:
version = ext_meta.get("version", "?")
source_label = f"extension:{ext_id} v{version}"
else:
source_label = f"extension:{ext_id} (unregistered)"

for subdir in subdirs:
scan_dir = ext_dir / subdir if subdir else ext_dir
if not scan_dir.is_dir():
continue
for f in sorted(scan_dir.iterdir()):
if f.is_file() and f.suffix == ext:
name = f.stem
if name not in seen:
seen.add(name)
results.append({
"name": name,
"path": str(f),
"source": source_label,
})

return results

@staticmethod
def _type_config(template_type: str) -> tuple:
"""Return (subdirs, file_extension) for a template type."""
if template_type == "template":
return ["templates", ""], ".md"
elif template_type == "command":
return ["commands"], ".md"
elif template_type == "script":
return ["scripts"], ".sh"
return [""], ".md"


def version_satisfies(current: str, required: str) -> bool:
"""Check if current version satisfies required version specifier.

Expand Down Expand Up @@ -870,6 +1087,38 @@ def _render_toml_command(self, frontmatter, body, ext_id):
context_lines = f"# Extension: {ext_id}\n# Config: .specify/extensions/{ext_id}/\n"
return base.rstrip("\n") + "\n" + context_lines

@staticmethod
def _filter_commands_for_installed_extensions(
commands: List[Dict[str, Any]],
project_root: Path,
) -> List[Dict[str, Any]]:
"""Filter out commands targeting extensions that are not installed.

Command names follow the pattern: speckit.<ext-id>.<cmd-name>
Core commands (e.g. speckit.specify) have only two parts — always kept.
Extension-specific commands are only kept if the target extension
directory exists under .specify/extensions/.

If the extensions directory does not exist, it is treated as empty
and all extension-scoped commands are filtered out (matching the
preset filtering behavior at presets.py:518-529).

Note: This method is not applied during extension self-registration
(all commands in an extension's own manifest are always registered).
It is designed for cross-boundary filtering, e.g. when presets provide
commands for extensions that may not be installed.
"""
extensions_dir = project_root / ".specify" / "extensions"
filtered = []
for cmd in commands:
parts = cmd["name"].split(".")
if len(parts) >= 3 and parts[0] == "speckit":
ext_id = parts[1]
if not (extensions_dir / ext_id).is_dir():
continue
filtered.append(cmd)
return filtered

def register_commands_for_agent(
self,
agent_name: str,
Expand Down
Loading
Loading