diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 08af65107..f1f0703ad 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -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"), @@ -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: @@ -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}/") @@ -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() diff --git a/src/specify_cli/extensions.py b/src/specify_cli/extensions.py index b26b1e931..3173ee5a6 100644 --- a/src/specify_cli/extensions.py +++ b/src/specify_cli/extensions.py @@ -116,6 +116,8 @@ def _validate(self): # Validate extension metadata ext = self.data["extension"] + if not isinstance(ext, dict): + raise ValidationError("'extension' must be a mapping") for field in ["id", "name", "version", "description"]: if field not in ext: raise ValidationError(f"Missing extension.{field}") @@ -135,18 +137,34 @@ def _validate(self): # Validate requires section requires = self.data["requires"] + if not isinstance(requires, dict): + raise ValidationError("'requires' must be a mapping") if "speckit_version" not in requires: raise ValidationError("Missing requires.speckit_version") # 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") + if not isinstance(provides, dict): + raise ValidationError("'provides' must be a mapping") + 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") + if not commands and not scripts: + raise ValidationError( + "Extension must provide at least one command or script" + ) # Validate commands - for cmd in provides["commands"]: + for cmd in commands: + if not isinstance(cmd, dict): + raise ValidationError("Each command entry must be a mapping") if "name" not in cmd or "file" not in cmd: raise ValidationError("Command missing 'name' or 'file'") + if not isinstance(cmd["name"], str) or not isinstance(cmd["file"], str): + raise ValidationError("Command 'name' and 'file' must be strings") # Validate command name format if not re.match(r'^speckit\.[a-z0-9-]+\.[a-z0-9-]+$', cmd["name"]): @@ -155,6 +173,37 @@ def _validate(self): "must follow pattern 'speckit.{extension}.{command}'" ) + # Validate scripts + for script in scripts: + if not isinstance(script, dict): + raise ValidationError("Each script entry must be a mapping") + if "name" not in script or "file" not in script: + raise ValidationError("Script missing 'name' or 'file'") + if not isinstance(script["name"], str) or not isinstance(script["file"], str): + raise ValidationError("Script 'name' and 'file' must be strings") + + # 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" + ) + @property def id(self) -> str: """Get extension ID.""" @@ -183,7 +232,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") or [] + + @property + def scripts(self) -> List[Dict[str, Any]]: + """Get list of provided scripts.""" + return self.data["provides"].get("scripts") or [] @property def hooks(self) -> Dict[str, Any]: @@ -592,6 +646,16 @@ 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, best-effort) + if os.name == "posix": + for script in manifest.scripts: + script_path = dest_dir / script["file"] + if script_path.exists() and script_path.suffix == ".sh": + try: + script_path.chmod(script_path.stat().st_mode | 0o111) + except OSError: + pass + # Register commands with AI agents registered_commands = {} if register_commands: @@ -770,6 +834,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: @@ -783,6 +848,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 }) @@ -809,6 +875,183 @@ 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, exts = 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: + base = ext_dir / subdir if subdir else ext_dir + for file_ext in exts: + candidate = base / f"{template_name}{file_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, exts = 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: + base = ext_dir / subdir if subdir else ext_dir + for file_ext in exts: + candidate = base / f"{template_name}{file_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") + + Returns: + List of dicts with 'name', 'path', and 'source' keys. + """ + subdirs, exts = 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 in exts: + 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_extensions) for a template type. + + Returns: + Tuple of (subdirs list, list of file extensions to match). + """ + if template_type == "template": + return ["templates", ""], [".md"] + elif template_type == "command": + return ["commands"], [".md"] + elif template_type == "script": + return ["scripts"], [".sh", ".ps1"] + raise ValueError( + f"Invalid template type '{template_type}': " + "must be one of 'template', 'command', 'script'" + ) + + def version_satisfies(current: str, required: str) -> bool: """Check if current version satisfies required version specifier. @@ -870,6 +1113,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.. + 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, diff --git a/src/specify_cli/presets.py b/src/specify_cli/presets.py index 24d523aa8..11f359de9 100644 --- a/src/specify_cli/presets.py +++ b/src/specify_cli/presets.py @@ -24,7 +24,7 @@ from packaging import version as pkg_version from packaging.specifiers import SpecifierSet, InvalidSpecifier -from .extensions import ExtensionRegistry, normalize_priority +from .extensions import ExtensionResolver, normalize_priority @dataclass @@ -1529,48 +1529,7 @@ def __init__(self, project_root: Path): self.presets_dir = project_root / ".specify" / "presets" self.overrides_dir = self.templates_dir / "overrides" self.extensions_dir = project_root / ".specify" / "extensions" - - def _get_all_extensions_by_priority(self) -> list[tuple[int, str, dict | None]]: - """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) - # Use keys() to track ALL extensions (including corrupted entries) without deep copy - # This prevents corrupted entries from being picked up as "unregistered" dirs - registered_extension_ids = registry.keys() - - # Get all registered extensions including disabled; we filter disabled manually below - all_registered = registry.list_by_priority(include_disabled=True) - - all_extensions: list[tuple[int, str, dict | None]] = [] - - # Only include enabled extensions in the result - for ext_id, metadata in all_registered: - # Skip disabled extensions - if not metadata.get("enabled", True): - continue - priority = normalize_priority(metadata.get("priority") if metadata else None) - all_extensions.append((priority, ext_id, metadata)) - - # Add unregistered directories with implicit priority=10 - 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)) - - # Sort by (priority, ext_id) for deterministic ordering - all_extensions.sort(key=lambda x: (x[0], x[1])) - return all_extensions + self._ext_resolver = ExtensionResolver(project_root) def resolve( self, @@ -1598,18 +1557,17 @@ def resolve( else: subdirs = [""] - # Determine file extension based on template type - ext = ".md" - if template_type == "script": - ext = ".sh" # scripts use .sh; callers can also check .ps1 + # Determine file extensions based on template type + exts = [".sh", ".ps1"] if template_type == "script" else [".md"] # Priority 1: Project-local overrides - if template_type == "script": - override = self.overrides_dir / "scripts" / f"{template_name}{ext}" - else: - override = self.overrides_dir / f"{template_name}{ext}" - if override.exists(): - return override + for file_ext in exts: + if template_type == "script": + override = self.overrides_dir / "scripts" / f"{template_name}{file_ext}" + else: + override = self.overrides_dir / f"{template_name}{file_ext}" + if override.exists(): + return override # Priority 2: Installed presets (sorted by priority — lower number wins) if self.presets_dir.exists(): @@ -1617,25 +1575,18 @@ def resolve( for pack_id, _metadata in registry.list_by_priority(): pack_dir = self.presets_dir / pack_id for subdir in subdirs: - if subdir: - candidate = pack_dir / subdir / f"{template_name}{ext}" - else: - candidate = pack_dir / f"{template_name}{ext}" - if candidate.exists(): - return candidate - - # Priority 3: Extension-provided templates (sorted by priority — lower number wins) - for _priority, ext_id, _metadata in self._get_all_extensions_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 + for file_ext in exts: + if subdir: + candidate = pack_dir / subdir / f"{template_name}{file_ext}" + else: + candidate = pack_dir / f"{template_name}{file_ext}" + if candidate.exists(): + return candidate + + # 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 # Priority 4: Core templates if template_type == "template": @@ -1647,9 +1598,10 @@ def resolve( if core.exists(): return core elif template_type == "script": - core = self.templates_dir / "scripts" / f"{template_name}{ext}" - if core.exists(): - return core + for file_ext in exts: + core = self.templates_dir / "scripts" / f"{template_name}{file_ext}" + if core.exists(): + return core return None @@ -1693,7 +1645,7 @@ def resolve_with_source( except ValueError: continue - for _priority, ext_id, ext_meta in self._get_all_extensions_by_priority(): + for _priority, ext_id, ext_meta in self._ext_resolver.get_all_by_priority(): ext_dir = self.extensions_dir / ext_id if not ext_dir.is_dir(): continue @@ -1714,3 +1666,109 @@ 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. + + Raises: + ValueError: If template_type is not one of "template", "command", "script" + """ + if template_type not in VALID_PRESET_TEMPLATE_TYPES: + raise ValueError( + f"Invalid template type '{template_type}': " + f"must be one of {sorted(VALID_PRESET_TEMPLATE_TYPES)}" + ) + + seen: set[str] = set() + results: List[Dict[str, str]] = [] + + # Determine file extensions and subdirectory mapping + exts = [".sh", ".ps1"] if template_type == "script" else [".md"] + if template_type == "template": + subdirs = ["templates", ""] + elif template_type == "command": + subdirs = ["commands"] + else: # script + subdirs = ["scripts"] + + def _name_matches_type(name: str) -> bool: + """Check if a file name matches the expected pattern for the template type. + + Commands use dot notation (e.g. speckit.specify), templates use + hyphens only (e.g. spec-template). This prevents the shared + overrides directory from leaking commands into template listings + or vice versa. Scripts live in their own subdirectory so no + filtering is needed. + """ + if template_type == "command": + return "." in name + if template_type == "template": + return "." not in name + return True + + 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 in exts: + name = f.stem + if name in seen: + continue + if not _name_matches_type(name): + continue + 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") + + # 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 (delegated to ExtensionResolver) + for entry in self._ext_resolver.list_templates(template_type): + if entry["name"] not in seen: + seen.add(entry["name"]) + results.append(entry) + + # 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 diff --git a/tests/test_extensions.py b/tests/test_extensions.py index cd0f9ba44..b0aaaa05e 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -21,6 +21,7 @@ ExtensionManifest, ExtensionRegistry, ExtensionManager, + ExtensionResolver, CommandRegistrar, ExtensionCatalog, ExtensionError, @@ -240,8 +241,8 @@ def test_invalid_command_name(self, temp_dir, valid_manifest_data): with pytest.raises(ValidationError, match="Invalid command name"): ExtensionManifest(manifest_path) - def test_no_commands(self, temp_dir, valid_manifest_data): - """Test manifest with no commands provided.""" + def test_no_commands_or_scripts(self, temp_dir, valid_manifest_data): + """Test manifest with no commands or scripts provided.""" import yaml valid_manifest_data["provides"]["commands"] = [] @@ -250,7 +251,7 @@ def test_no_commands(self, temp_dir, valid_manifest_data): with open(manifest_path, 'w') as f: yaml.dump(valid_manifest_data, f) - with pytest.raises(ValidationError, match="must provide at least one command"): + with pytest.raises(ValidationError, match="must provide at least one command or script"): ExtensionManifest(manifest_path) def test_manifest_hash(self, extension_dir): @@ -3231,3 +3232,425 @@ def test_mixed_legacy_and_new_extensions_ordering(self, temp_dir): assert result[0][0] == "ext-with-priority" assert result[1][0] == "legacy-ext" assert result[2][0] == "ext-low-priority" + + +# ===== Scripts Support Tests (#1847) ===== + +class TestScriptsSupport: + """Test extension scripts support (parity with presets).""" + + def test_manifest_with_scripts_only(self, temp_dir): + """Extension with scripts but no commands is valid.""" + import yaml + + manifest_data = { + "schema_version": "1.0", + "extension": { + "id": "scripts-only", + "name": "Scripts Only Extension", + "version": "1.0.0", + "description": "An extension with only scripts", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "scripts": [ + { + "name": "setup", + "file": "scripts/setup.sh", + "description": "Setup script", + } + ] + }, + } + + manifest_path = temp_dir / "extension.yml" + with open(manifest_path, "w") as f: + yaml.dump(manifest_data, f) + + manifest = ExtensionManifest(manifest_path) + assert len(manifest.scripts) == 1 + assert manifest.scripts[0]["name"] == "setup" + assert len(manifest.commands) == 0 + + def test_manifest_with_commands_and_scripts(self, temp_dir, valid_manifest_data): + """Extension with both commands and scripts is valid.""" + import yaml + + valid_manifest_data["provides"]["scripts"] = [ + {"name": "deploy", "file": "scripts/deploy.sh", "description": "Deploy"} + ] + + manifest_path = temp_dir / "extension.yml" + with open(manifest_path, "w") as f: + yaml.dump(valid_manifest_data, f) + + manifest = ExtensionManifest(manifest_path) + assert len(manifest.commands) == 1 + assert len(manifest.scripts) == 1 + + def test_manifest_script_name_validation(self, temp_dir): + """Invalid script names are rejected.""" + import yaml + + manifest_data = { + "schema_version": "1.0", + "extension": { + "id": "bad-scripts", + "name": "Bad Scripts", + "version": "1.0.0", + "description": "Extension with bad script name", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "scripts": [ + {"name": "Invalid_Name", "file": "scripts/bad.sh"} + ] + }, + } + + manifest_path = temp_dir / "extension.yml" + with open(manifest_path, "w") as f: + yaml.dump(manifest_data, f) + + with pytest.raises(ValidationError, match="Invalid script name"): + ExtensionManifest(manifest_path) + + def test_manifest_script_path_traversal(self, temp_dir): + """Script with path traversal is rejected.""" + import yaml + + manifest_data = { + "schema_version": "1.0", + "extension": { + "id": "bad-path", + "name": "Bad Path", + "version": "1.0.0", + "description": "Extension with path traversal", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "scripts": [ + {"name": "evil", "file": "../../etc/passwd"} + ] + }, + } + + manifest_path = temp_dir / "extension.yml" + with open(manifest_path, "w") as f: + yaml.dump(manifest_data, f) + + with pytest.raises(ValidationError, match="Invalid script file path"): + ExtensionManifest(manifest_path) + + def test_manifest_script_missing_name(self, temp_dir): + """Script missing name field is rejected.""" + import yaml + + manifest_data = { + "schema_version": "1.0", + "extension": { + "id": "no-name", + "name": "No Name", + "version": "1.0.0", + "description": "Missing script name", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "scripts": [{"file": "scripts/setup.sh"}] + }, + } + + manifest_path = temp_dir / "extension.yml" + with open(manifest_path, "w") as f: + yaml.dump(manifest_data, f) + + with pytest.raises(ValidationError, match="Script missing 'name' or 'file'"): + ExtensionManifest(manifest_path) + + def test_scripts_property_default(self, extension_dir): + """Scripts property returns empty list when no scripts defined.""" + manifest = ExtensionManifest(extension_dir / "extension.yml") + assert manifest.scripts == [] + + def test_list_installed_includes_script_count(self, temp_dir): + """list_installed output includes script_count.""" + import yaml + + project_dir = temp_dir / "project" + project_dir.mkdir() + specify_dir = project_dir / ".specify" + specify_dir.mkdir() + extensions_dir = specify_dir / "extensions" + extensions_dir.mkdir() + + # Create extension with scripts + ext_dir = extensions_dir / "scripted-ext" + ext_dir.mkdir() + scripts_dir = ext_dir / "scripts" + scripts_dir.mkdir() + (scripts_dir / "setup.sh").write_text("#!/bin/bash\necho setup") + + manifest_data = { + "schema_version": "1.0", + "extension": { + "id": "scripted-ext", + "name": "Scripted", + "version": "1.0.0", + "description": "Has scripts", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "scripts": [ + {"name": "setup", "file": "scripts/setup.sh"} + ] + }, + } + with open(ext_dir / "extension.yml", "w") as f: + yaml.dump(manifest_data, f) + + registry = ExtensionRegistry(extensions_dir) + registry.add("scripted-ext", {"version": "1.0.0", "enabled": True}) + + manager = ExtensionManager(project_dir) + installed = manager.list_installed() + + assert len(installed) == 1 + assert installed[0]["script_count"] == 1 + assert installed[0]["command_count"] == 0 + + +# ===== Command Filtering Tests (#1848) ===== + +class TestCommandFiltering: + """Test extension-specific command filtering in CommandRegistrar.""" + + def test_extension_command_skipped_when_target_missing(self, temp_dir): + """Commands targeting non-installed extensions are filtered out.""" + project_root = temp_dir / "project" + project_root.mkdir() + specify_dir = project_root / ".specify" + specify_dir.mkdir() + extensions_dir = specify_dir / "extensions" + extensions_dir.mkdir() + + commands = [ + {"name": "speckit.other-ext.cmd", "file": "commands/cmd.md"}, + ] + + filtered = CommandRegistrar._filter_commands_for_installed_extensions( + commands, project_root + ) + assert filtered == [] + + def test_extension_command_kept_when_target_installed(self, temp_dir): + """Commands targeting installed extensions pass the filter.""" + project_root = temp_dir / "project" + project_root.mkdir() + specify_dir = project_root / ".specify" + specify_dir.mkdir() + extensions_dir = specify_dir / "extensions" + extensions_dir.mkdir() + # Create the target extension directory + (extensions_dir / "other-ext").mkdir() + + commands = [ + {"name": "speckit.other-ext.cmd", "file": "commands/cmd.md"}, + ] + + filtered = CommandRegistrar._filter_commands_for_installed_extensions( + commands, project_root + ) + assert len(filtered) == 1 + assert filtered[0]["name"] == "speckit.other-ext.cmd" + + def test_core_commands_always_kept(self, temp_dir): + """Core commands (2-part names) are never filtered out.""" + project_root = temp_dir / "project" + project_root.mkdir() + specify_dir = project_root / ".specify" + specify_dir.mkdir() + extensions_dir = specify_dir / "extensions" + extensions_dir.mkdir() + + commands = [ + {"name": "speckit.specify", "file": "commands/specify.md"}, + {"name": "speckit.tasks", "file": "commands/tasks.md"}, + ] + + filtered = CommandRegistrar._filter_commands_for_installed_extensions( + commands, project_root + ) + assert len(filtered) == 2 + + def test_mixed_commands_filtered_correctly(self, temp_dir): + """Mix of core and extension commands is filtered correctly.""" + project_root = temp_dir / "project" + project_root.mkdir() + specify_dir = project_root / ".specify" + specify_dir.mkdir() + extensions_dir = specify_dir / "extensions" + extensions_dir.mkdir() + # Only ext-a is installed + (extensions_dir / "ext-a").mkdir() + + commands = [ + {"name": "speckit.specify", "file": "commands/specify.md"}, + {"name": "speckit.ext-a.run", "file": "commands/run.md"}, + {"name": "speckit.ext-b.deploy", "file": "commands/deploy.md"}, + ] + + filtered = CommandRegistrar._filter_commands_for_installed_extensions( + commands, project_root + ) + assert len(filtered) == 2 + names = [c["name"] for c in filtered] + assert "speckit.specify" in names + assert "speckit.ext-a.run" in names + assert "speckit.ext-b.deploy" not in names + + +# ===== ExtensionResolver Tests (#1846) ===== + +class TestExtensionResolver: + """Test ExtensionResolver template resolution and discovery.""" + + def test_resolve_extension_template(self, temp_dir): + """Resolves a template from an installed extension.""" + project_dir = temp_dir / "project" + specify_dir = project_dir / ".specify" + ext_dir = specify_dir / "extensions" / "my-ext" / "templates" + ext_dir.mkdir(parents=True) + (ext_dir / "custom.md").write_text("# Custom") + + registry = ExtensionRegistry(specify_dir / "extensions") + registry.add("my-ext", {"version": "1.0.0", "enabled": True, "priority": 5}) + + resolver = ExtensionResolver(project_dir) + result = resolver.resolve("custom", "template") + + assert result is not None + assert result.name == "custom.md" + + def test_resolve_returns_none_when_not_found(self, temp_dir): + """Returns None when no extension has the template.""" + project_dir = temp_dir / "project" + (project_dir / ".specify" / "extensions").mkdir(parents=True) + + resolver = ExtensionResolver(project_dir) + assert resolver.resolve("nonexistent") is None + + def test_resolve_with_source_attribution(self, temp_dir): + """resolve_with_source returns correct source label.""" + project_dir = temp_dir / "project" + ext_dir = project_dir / ".specify" / "extensions" / "docguard" / "commands" + ext_dir.mkdir(parents=True) + (ext_dir / "speckit.docguard.check.md").write_text("# Check") + + registry = ExtensionRegistry(project_dir / ".specify" / "extensions") + registry.add("docguard", {"version": "2.1.0", "enabled": True}) + + resolver = ExtensionResolver(project_dir) + result = resolver.resolve_with_source("speckit.docguard.check", "command") + + assert result is not None + assert result["source"] == "extension:docguard v2.1.0" + + def test_resolve_with_source_unregistered(self, temp_dir): + """Unregistered extension directories get '(unregistered)' label.""" + project_dir = temp_dir / "project" + ext_dir = project_dir / ".specify" / "extensions" / "loose-ext" / "templates" + ext_dir.mkdir(parents=True) + (ext_dir / "loose.md").write_text("# Loose") + + # Don't register — just have the directory + resolver = ExtensionResolver(project_dir) + result = resolver.resolve_with_source("loose", "template") + + assert result is not None + assert "unregistered" in result["source"] + + def test_list_templates_from_extensions(self, temp_dir): + """list_templates discovers templates across extensions.""" + project_dir = temp_dir / "project" + extensions_dir = project_dir / ".specify" / "extensions" + + # Extension A (priority 1) + (extensions_dir / "ext-a" / "commands").mkdir(parents=True) + (extensions_dir / "ext-a" / "commands" / "speckit.ext-a.run.md").write_text("# Run") + + # Extension B (priority 5) + (extensions_dir / "ext-b" / "commands").mkdir(parents=True) + (extensions_dir / "ext-b" / "commands" / "speckit.ext-b.deploy.md").write_text("# Deploy") + + registry = ExtensionRegistry(extensions_dir) + registry.add("ext-a", {"version": "1.0.0", "enabled": True, "priority": 1}) + registry.add("ext-b", {"version": "2.0.0", "enabled": True, "priority": 5}) + + resolver = ExtensionResolver(project_dir) + results = resolver.list_templates("command") + + assert len(results) == 2 + names = [r["name"] for r in results] + assert "speckit.ext-a.run" in names + assert "speckit.ext-b.deploy" in names + + def test_list_templates_priority_deduplication(self, temp_dir): + """Higher-priority extension wins when same template name exists.""" + project_dir = temp_dir / "project" + extensions_dir = project_dir / ".specify" / "extensions" + + # Both extensions provide "shared.md" + (extensions_dir / "ext-high" / "templates").mkdir(parents=True) + (extensions_dir / "ext-high" / "templates" / "shared.md").write_text("# High") + (extensions_dir / "ext-low" / "templates").mkdir(parents=True) + (extensions_dir / "ext-low" / "templates" / "shared.md").write_text("# Low") + + registry = ExtensionRegistry(extensions_dir) + registry.add("ext-high", {"version": "1.0.0", "enabled": True, "priority": 1}) + registry.add("ext-low", {"version": "1.0.0", "enabled": True, "priority": 10}) + + resolver = ExtensionResolver(project_dir) + results = resolver.list_templates("template") + + shared = [r for r in results if r["name"] == "shared"] + assert len(shared) == 1 + assert "ext-high" in shared[0]["source"] + + def test_list_templates_empty(self, temp_dir): + """Returns empty list when no extensions directory exists.""" + project_dir = temp_dir / "project" + (project_dir / ".specify").mkdir(parents=True) + + resolver = ExtensionResolver(project_dir) + assert resolver.list_templates("template") == [] + + def test_disabled_extension_excluded(self, temp_dir): + """Disabled extensions are not resolved.""" + project_dir = temp_dir / "project" + ext_dir = project_dir / ".specify" / "extensions" / "disabled-ext" / "templates" + ext_dir.mkdir(parents=True) + (ext_dir / "hidden.md").write_text("# Hidden") + + registry = ExtensionRegistry(project_dir / ".specify" / "extensions") + registry.add("disabled-ext", {"version": "1.0.0", "enabled": False}) + + resolver = ExtensionResolver(project_dir) + assert resolver.resolve("hidden", "template") is None + assert resolver.list_templates("template") == [] + + def test_list_scripts(self, temp_dir): + """list_templates discovers script files from extensions.""" + project_dir = temp_dir / "project" + ext_dir = project_dir / ".specify" / "extensions" / "script-ext" / "scripts" + ext_dir.mkdir(parents=True) + (ext_dir / "deploy.sh").write_text("#!/bin/bash") + + registry = ExtensionRegistry(project_dir / ".specify" / "extensions") + registry.add("script-ext", {"version": "1.0.0", "enabled": True}) + + resolver = ExtensionResolver(project_dir) + results = resolver.list_templates("script") + + assert len(results) == 1 + assert results[0]["name"] == "deploy" + assert "script-ext" in results[0]["source"] diff --git a/tests/test_presets.py b/tests/test_presets.py index 2716b73dc..10928294a 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -2391,3 +2391,134 @@ def test_disable_corrupted_registry_entry(self, project_dir, pack_dir): assert result.exit_code == 1 assert "corrupted state" in result.output.lower() + + +# ===== Template Listing / Discovery Tests (#1846) ===== + +class TestListAvailable: + """Test PresetResolver.list_available template discovery.""" + + def test_list_available_core_templates(self, project_dir): + """Discovers core templates.""" + templates_dir = project_dir / ".specify" / "templates" + templates_dir.mkdir(parents=True, exist_ok=True) + (templates_dir / "spec-template.md").write_text("# Spec") + (templates_dir / "other-template.md").write_text("# Other") + + resolver = PresetResolver(project_dir) + available = resolver.list_available("template") + + names = [e["name"] for e in available] + assert "spec-template" in names + assert "other-template" in names + assert all(e["source"] == "core" for e in available) + + def test_list_available_core_commands(self, project_dir): + """Discovers core commands.""" + commands_dir = project_dir / ".specify" / "templates" / "commands" + commands_dir.mkdir(parents=True, exist_ok=True) + (commands_dir / "speckit.specify.md").write_text("# Specify") + + resolver = PresetResolver(project_dir) + available = resolver.list_available("command") + + assert len(available) == 1 + assert available[0]["name"] == "speckit.specify" + assert available[0]["source"] == "core" + + def test_list_available_core_scripts(self, project_dir): + """Discovers core scripts.""" + scripts_dir = project_dir / ".specify" / "templates" / "scripts" + scripts_dir.mkdir(parents=True, exist_ok=True) + (scripts_dir / "setup.sh").write_text("#!/bin/bash") + + resolver = PresetResolver(project_dir) + available = resolver.list_available("script") + + assert len(available) == 1 + assert available[0]["name"] == "setup" + assert available[0]["source"] == "core" + + def test_list_available_extension_templates(self, project_dir): + """Discovers templates from extensions.""" + ext_dir = project_dir / ".specify" / "extensions" / "my-ext" + templates_dir = ext_dir / "templates" + templates_dir.mkdir(parents=True) + (templates_dir / "ext-template.md").write_text("# Ext") + + # Register in the extension registry + extensions_dir = project_dir / ".specify" / "extensions" + registry = ExtensionRegistry(extensions_dir) + registry.add("my-ext", {"version": "2.0.0", "enabled": True, "priority": 5}) + + resolver = PresetResolver(project_dir) + available = resolver.list_available("template") + + ext_entries = [e for e in available if e["name"] == "ext-template"] + assert len(ext_entries) == 1 + assert "extension:my-ext" in ext_entries[0]["source"] + assert "v2.0.0" in ext_entries[0]["source"] + + def test_list_available_higher_priority_wins(self, project_dir): + """When same template name exists in multiple sources, higher priority wins.""" + # Core template + templates_dir = project_dir / ".specify" / "templates" + templates_dir.mkdir(parents=True, exist_ok=True) + (templates_dir / "shared.md").write_text("# Core version") + + # Extension template with same name (higher priority) + ext_dir = project_dir / ".specify" / "extensions" / "override-ext" + ext_templates_dir = ext_dir / "templates" + ext_templates_dir.mkdir(parents=True) + (ext_templates_dir / "shared.md").write_text("# Extension version") + + extensions_dir = project_dir / ".specify" / "extensions" + registry = ExtensionRegistry(extensions_dir) + registry.add("override-ext", {"version": "1.0.0", "enabled": True, "priority": 5}) + + resolver = PresetResolver(project_dir) + available = resolver.list_available("template") + + # Only one entry for "shared" — extension wins over core + shared_entries = [e for e in available if e["name"] == "shared"] + assert len(shared_entries) == 1 + assert "extension:override-ext" in shared_entries[0]["source"] + + def test_list_available_override_wins_over_all(self, project_dir): + """Project overrides take highest priority.""" + # Core template + templates_dir = project_dir / ".specify" / "templates" + templates_dir.mkdir(parents=True, exist_ok=True) + (templates_dir / "target.md").write_text("# Core") + + # Override + overrides_dir = templates_dir / "overrides" + overrides_dir.mkdir(parents=True, exist_ok=True) + (overrides_dir / "target.md").write_text("# Override") + + resolver = PresetResolver(project_dir) + available = resolver.list_available("template") + + target_entries = [e for e in available if e["name"] == "target"] + assert len(target_entries) == 1 + assert target_entries[0]["source"] == "project override" + + def test_list_available_sorted_by_name(self, project_dir): + """Results are sorted alphabetically by name.""" + templates_dir = project_dir / ".specify" / "templates" + templates_dir.mkdir(parents=True, exist_ok=True) + (templates_dir / "zebra.md").write_text("# Z") + (templates_dir / "alpha.md").write_text("# A") + (templates_dir / "middle.md").write_text("# M") + + resolver = PresetResolver(project_dir) + available = resolver.list_available("template") + + names = [e["name"] for e in available] + assert names == sorted(names) + + def test_list_available_empty(self, project_dir): + """Returns empty list when no templates of the given type exist.""" + resolver = PresetResolver(project_dir) + available = resolver.list_available("script") + assert available == []