diff --git a/scripts/bash/common.sh b/scripts/bash/common.sh index 506cc677..00e00f77 100644 --- a/scripts/bash/common.sh +++ b/scripts/bash/common.sh @@ -190,9 +190,16 @@ except Exception: local candidate="$presets_dir/$preset_id/templates/${template_name}.md" [ -f "$candidate" ] && echo "$candidate" && return 0 done <<< "$sorted_presets" + else + # python3 returned empty list — fall through to directory scan + for preset in "$presets_dir"/*/; do + [ -d "$preset" ] || continue + local candidate="$preset/templates/${template_name}.md" + [ -f "$candidate" ] && echo "$candidate" && return 0 + done fi else - # Fallback: alphabetical directory order + # Fallback: alphabetical directory order (no python3 available) for preset in "$presets_dir"/*/; do [ -d "$preset" ] || continue local candidate="$preset/templates/${template_name}.md" diff --git a/scripts/powershell/common.ps1 b/scripts/powershell/common.ps1 index 47fb69de..63d6dd63 100644 --- a/scripts/powershell/common.ps1 +++ b/scripts/powershell/common.ps1 @@ -189,7 +189,7 @@ function Resolve-Template { # Priority 3: Extension-provided templates $extDir = Join-Path $RepoRoot '.specify/extensions' if (Test-Path $extDir) { - foreach ($ext in Get-ChildItem -Path $extDir -Directory -ErrorAction SilentlyContinue) { + foreach ($ext in Get-ChildItem -Path $extDir -Directory -ErrorAction SilentlyContinue | Where-Object { $_.Name -notlike '.*' } | Sort-Object Name) { $candidate = Join-Path $ext.FullName "templates/$TemplateName.md" if (Test-Path $candidate) { return $candidate } } diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 593e2e7a..a7746fd0 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -1574,6 +1574,19 @@ def init( else: tracker.skip("git", "--no-git flag") + # Persist the CLI options so later operations (e.g. preset add) + # can adapt their behaviour without re-scanning the filesystem. + # Must be saved BEFORE preset install so _get_skills_dir() works. + save_init_options(project_path, { + "ai": selected_ai, + "ai_skills": ai_skills, + "ai_commands_dir": ai_commands_dir, + "here": here, + "preset": preset, + "script": selected_script, + "speckit_version": get_speckit_version(), + }) + # Install preset if specified if preset: try: @@ -1590,23 +1603,16 @@ def init( try: zip_path = preset_catalog.download_pack(preset) preset_manager.install_from_zip(zip_path, speckit_ver) + # Clean up downloaded ZIP to avoid cache accumulation + try: + zip_path.unlink(missing_ok=True) + except Exception: + pass except PresetError: console.print(f"[yellow]Warning:[/yellow] Preset '{preset}' not found in catalog. Skipping.") except Exception as preset_err: console.print(f"[yellow]Warning:[/yellow] Failed to install preset: {preset_err}") - # Persist the CLI options so later operations (e.g. preset add) - # can adapt their behaviour without re-scanning the filesystem. - save_init_options(project_path, { - "ai": selected_ai, - "ai_skills": ai_skills, - "ai_commands_dir": ai_commands_dir, - "here": here, - "preset": preset, - "script": selected_script, - "speckit_version": get_speckit_version(), - }) - tracker.complete("final", "project ready") except Exception as e: tracker.error("final", str(e)) @@ -1957,6 +1963,14 @@ def preset_add( console.print(f"[green]✓[/green] Preset '{manifest.name}' v{manifest.version} installed (priority {priority})") elif from_url: + # Validate URL scheme before downloading + from urllib.parse import urlparse as _urlparse + _parsed = _urlparse(from_url) + _is_localhost = _parsed.hostname in ("localhost", "127.0.0.1", "::1") + if _parsed.scheme != "https" and not (_parsed.scheme == "http" and _is_localhost): + console.print(f"[red]Error:[/red] URL must use HTTPS (got {_parsed.scheme}://). HTTP is only allowed for localhost.") + raise typer.Exit(1) + console.print(f"Installing preset from [cyan]{from_url}[/cyan]...") import urllib.request import urllib.error diff --git a/src/specify_cli/extensions.py b/src/specify_cli/extensions.py index 03cb3afa..9053db50 100644 --- a/src/specify_cli/extensions.py +++ b/src/specify_cli/extensions.py @@ -522,7 +522,7 @@ class ExtensionManager: # Unregister commands from all AI agents if registered_commands: registrar = CommandRegistrar() - registrar._registrar.unregister_commands(registered_commands, self.project_root) + registrar.unregister_commands(registered_commands, self.project_root) if keep_config: # Preserve config files, only remove non-config files @@ -714,6 +714,14 @@ class CommandRegistrar: context_note=context_note ) + def unregister_commands( + self, + registered_commands: Dict[str, List[str]], + project_root: Path + ) -> None: + """Remove previously registered command files from agent directories.""" + self._registrar.unregister_commands(registered_commands, project_root) + def register_commands_for_claude( self, manifest: ExtensionManifest, diff --git a/src/specify_cli/presets.py b/src/specify_cli/presets.py index 522fc4b5..5140865b 100644 --- a/src/specify_cli/presets.py +++ b/src/specify_cli/presets.py @@ -1137,14 +1137,16 @@ class PresetCatalog: Raises: PresetError: If catalog cannot be fetched """ + catalog_url = self.get_catalog_url() + if not force_refresh and self.is_cache_valid(): try: - return json.loads(self.cache_file.read_text()) - except json.JSONDecodeError: + metadata = json.loads(self.cache_metadata_file.read_text()) + if metadata.get("catalog_url") == catalog_url: + return json.loads(self.cache_file.read_text()) + except (json.JSONDecodeError, OSError): pass - catalog_url = self.get_catalog_url() - try: import urllib.request import urllib.error @@ -1186,6 +1188,9 @@ class PresetCatalog: ) -> List[Dict[str, Any]]: """Search catalog for presets. + Searches across all active catalogs (merged by priority) so that + community and custom catalogs are included in results. + Args: query: Search query (searches name, description, tags) tag: Filter by specific tag @@ -1195,12 +1200,11 @@ class PresetCatalog: List of matching preset metadata """ try: - catalog_data = self.fetch_catalog() + packs = self._get_merged_packs() except PresetError: return [] results = [] - packs = catalog_data.get("presets", {}) for pack_id, pack_data in packs.items(): if author and pack_data.get("author", "").lower() != author.lower(): @@ -1234,6 +1238,8 @@ class PresetCatalog: ) -> Optional[Dict[str, Any]]: """Get detailed information about a specific preset. + Searches across all active catalogs (merged by priority). + Args: pack_id: ID of the preset @@ -1241,11 +1247,10 @@ class PresetCatalog: Pack metadata or None if not found """ try: - catalog_data = self.fetch_catalog() + packs = self._get_merged_packs() except PresetError: return None - packs = catalog_data.get("presets", {}) if pack_id in packs: return {**packs[pack_id], "id": pack_id} return None @@ -1369,16 +1374,18 @@ class PresetResolver: 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 + # Priority 1: Project-local overrides - for subdir in subdirs: - if template_type == "script": - override = self.overrides_dir / "scripts" / f"{template_name}.sh" - elif subdir: - override = self.overrides_dir / f"{template_name}.md" - else: - override = self.overrides_dir / f"{template_name}.md" - if override.exists(): - return override + 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 # Priority 2: Installed presets (sorted by priority — lower number wins) if self.presets_dir.exists(): @@ -1387,11 +1394,9 @@ class PresetResolver: pack_dir = self.presets_dir / pack_id for subdir in subdirs: if subdir: - candidate = ( - pack_dir / subdir / f"{template_name}.md" - ) + candidate = pack_dir / subdir / f"{template_name}{ext}" else: - candidate = pack_dir / f"{template_name}.md" + candidate = pack_dir / f"{template_name}{ext}" if candidate.exists(): return candidate @@ -1402,13 +1407,9 @@ class PresetResolver: continue for subdir in subdirs: if subdir: - candidate = ( - ext_dir / "templates" / f"{template_name}.md" - ) + candidate = ext_dir / subdir / f"{template_name}{ext}" else: - candidate = ( - ext_dir / "templates" / f"{template_name}.md" - ) + candidate = ext_dir / "templates" / f"{template_name}{ext}" if candidate.exists(): return candidate @@ -1421,6 +1422,10 @@ class PresetResolver: core = self.templates_dir / "commands" / f"{template_name}.md" if core.exists(): return core + elif template_type == "script": + core = self.templates_dir / "scripts" / f"{template_name}{ext}" + if core.exists(): + return core return None @@ -1438,52 +1443,34 @@ class PresetResolver: Returns: Dictionary with 'path' and 'source' keys, or None if not found """ - # Priority 1: Project-local overrides - override = self.overrides_dir / f"{template_name}.md" - if override.exists(): - return {"path": str(override), "source": "project override"} + # Delegate to resolve() for the actual lookup, then determine source + resolved = self.resolve(template_name, template_type) + if resolved is None: + return None - # Priority 2: Installed presets (sorted by priority — lower number wins) - if self.presets_dir.exists(): + resolved_str = str(resolved) + + # Determine source attribution + if str(self.overrides_dir) in resolved_str: + return {"path": resolved_str, "source": "project override"} + + if str(self.presets_dir) in resolved_str and 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 - # Check templates/ subdirectory first, then root - for subdir in ["templates", "commands", "scripts", ""]: - if subdir: - candidate = ( - pack_dir / subdir / f"{template_name}.md" - ) - else: - candidate = pack_dir / f"{template_name}.md" - if candidate.exists(): - meta = registry.get(pack_id) - version = meta.get("version", "?") if meta else "?" - return { - "path": str(candidate), - "source": f"{pack_id} v{version}", - } - - # Priority 3: Extension-provided templates - if self.extensions_dir.exists(): - for ext_dir in sorted(self.extensions_dir.iterdir()): - if not ext_dir.is_dir() or ext_dir.name.startswith("."): - continue - candidate = ext_dir / "templates" / f"{template_name}.md" - if candidate.exists(): + if f"/{pack_id}/" in resolved_str: + meta = registry.get(pack_id) + version = meta.get("version", "?") if meta else "?" return { - "path": str(candidate), + "path": resolved_str, + "source": f"{pack_id} v{version}", + } + + if str(self.extensions_dir) in resolved_str and self.extensions_dir.exists(): + for ext_dir in sorted(self.extensions_dir.iterdir()): + if ext_dir.is_dir() and f"/{ext_dir.name}/" in resolved_str: + return { + "path": resolved_str, "source": f"extension:{ext_dir.name}", } - # Priority 4: Core templates - core = self.templates_dir / f"{template_name}.md" - if core.exists(): - return {"path": str(core), "source": "core"} - - # Also check commands subdirectory for core - core_cmd = self.templates_dir / "commands" / f"{template_name}.md" - if core_cmd.exists(): - return {"path": str(core_cmd), "source": "core"} - - return None + return {"path": resolved_str, "source": "core"}