Skip to content
48 changes: 44 additions & 4 deletions src/specify_cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2735,6 +2735,38 @@ 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",
),
):
Comment thread
mbachorik marked this conversation as resolved.
"""List all available templates from the resolution stack."""
from .presets import PresetResolver

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 +3313,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 +3622,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 +3928,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
78 changes: 74 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,27 @@ def _validate(self):
"must follow pattern 'speckit.{extension}.{command}'"
)
Comment thread
mbachorik marked this conversation as resolved.

# 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'")
Comment thread
mbachorik marked this conversation as resolved.

# 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 parent traversal
file_path = script["file"]
normalized = os.path.normpath(file_path)
if os.path.isabs(normalized) or normalized.startswith(".."):
raise ValidationError(
f"Invalid script file path '{file_path}': "
"must be a relative path within the extension directory"
)
Comment thread
mbachorik marked this conversation as resolved.
Outdated

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 +208,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 +622,12 @@ 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
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 | 0o755)
Comment thread
mbachorik marked this conversation as resolved.
Outdated

# Register commands with AI agents
registered_commands = {}
if register_commands:
Expand Down Expand Up @@ -770,6 +806,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 +820,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 Down Expand Up @@ -870,6 +908,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, no filtering is applied.

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"
if not extensions_dir.is_dir():
return commands
filtered = []
Comment thread
mbachorik marked this conversation as resolved.
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
91 changes: 91 additions & 0 deletions src/specify_cli/presets.py
Original file line number Diff line number Diff line change
Expand Up @@ -1714,3 +1714,94 @@ def resolve_with_source(
continue

return {"path": resolved_str, "source": "core"}

def list_available(
self,
template_type: str = "template",
) -> List[Dict[str, str]]:
"""List all available templates of a given type with source attribution.

Walks the full priority stack and collects all discoverable templates.
For templates that exist in multiple sources, only the winning (highest
priority) source is included.

Args:
template_type: Template type ("template", "command", or "script")

Returns:
List of dicts with 'name', 'path', and 'source' keys, sorted by name.
"""
seen: set[str] = set()
results: List[Dict[str, str]] = []

# Determine file extension and subdirectory mapping
ext = ".sh" if template_type == "script" else ".md"
if template_type == "template":
subdirs = ["templates", ""]
elif template_type == "command":
subdirs = ["commands"]
elif template_type == "script":
subdirs = ["scripts"]
else:
subdirs = [""]

Comment thread
mbachorik marked this conversation as resolved.
Outdated
def _collect(directory: Path, source: str):
"""Collect template files from a directory."""
if not directory.is_dir():
return
for f in sorted(directory.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,
})

# Priority 1: Project-local overrides
if template_type == "script":
_collect(self.overrides_dir / "scripts", "project override")
else:
_collect(self.overrides_dir, "project override")
Comment thread
mbachorik marked this conversation as resolved.

# Priority 2: Installed presets (sorted by priority)
if self.presets_dir.exists():
registry = PresetRegistry(self.presets_dir)
for pack_id, metadata in registry.list_by_priority():
pack_dir = self.presets_dir / pack_id
version = metadata.get("version", "?") if metadata else "?"
source_label = f"{pack_id} v{version}"
for subdir in subdirs:
if subdir:
_collect(pack_dir / subdir, source_label)
else:
_collect(pack_dir, source_label)

# Priority 3: Extension-provided templates (sorted by priority)
for _priority, ext_id, ext_meta in self._get_all_extensions_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:
if subdir:
_collect(ext_dir / subdir, source_label)
else:
_collect(ext_dir, source_label)

# Priority 4: Core templates
if template_type == "template":
_collect(self.templates_dir, "core")
elif template_type == "command":
_collect(self.templates_dir / "commands", "core")
elif template_type == "script":
_collect(self.templates_dir / "scripts", "core")

results.sort(key=lambda x: x["name"])
return results
Loading
Loading