diff --git a/design/cli.md b/design/cli.md index 0829df74f1..b38dfec09f 100644 --- a/design/cli.md +++ b/design/cli.md @@ -249,6 +249,25 @@ the domain has the same name as that CLI namespace. The nested directory is reserved for `test_command_.py` suites that exercise its actual subcommands. +When a domain implementation is split into private modules, mirror those +boundaries in its tests, dropping the source module's leading underscore: + +| Preset implementation | Mirrored test under `tests/specify_cli/presets/` | +| --- | --- | +| `_manifest.py` | `test_manifest.py` | +| `_registry.py` | `test_registry.py` | +| `_catalog.py` | `test_catalog.py` | +| `_resolver.py` | `test_resolver.py` | +| `_manager.py` | `test_manager.py` | +| `_manager_commands.py` | `test_manager_commands.py` | +| `_manager_skills.py` | `test_manager_skills.py` | + +`test_manager_commands.py` exercises domain command-artifact behavior, not a +registered CLI handler. The `test_command_*.py` suites continue to cover the +CLI surface. `test_catalog.py` belongs at the parent preset package level; +`catalog/test_command_*.py` covers the nested catalog CLI. Package export +compatibility is covered separately by `test_domain_exports.py`. + Not every test is a command test, even when it belongs in the mirrored package tree: @@ -264,6 +283,10 @@ tree: Moving tests must preserve coverage rather than duplicating it. Run both the new command-focused suites and the legacy suites from which tests were moved. +For domain splits, also run all new domain suites and any remaining cross-domain +tests in the legacy file. Compare full-suite collection before and after the +move: the count must not decrease, and every existing parametrized test case +must remain represented. A matching total alone does not prove preservation. ## Reference layout diff --git a/src/specify_cli/presets/__init__.py b/src/specify_cli/presets/__init__.py index eff1e68159..2d325530f0 100644 --- a/src/specify_cli/presets/__init__.py +++ b/src/specify_cli/presets/__init__.py @@ -1,6259 +1,64 @@ -""" -Preset Manager for Spec Kit +"""Preset domain exports; ``_commands.py`` registers the CLI handlers. -Handles installation, removal, and management of Spec Kit presets. -Presets are self-contained, versioned collections of templates -(artifact, command, and script templates) that can be installed to -customize the Spec-Driven Development workflow. +Handlers live in ``command_*.py`` and ``catalog/``. Domain implementations +live in private modules; package-level names preserve existing internal imports. """ -import copy -import json -import hashlib -import os -import tempfile -import shutil -from dataclasses import dataclass -from pathlib import Path -from typing import TYPE_CHECKING, Optional, Dict, List, Any, Union, Set - -if TYPE_CHECKING: - from ..agents import CommandRegistrar -from datetime import datetime, timezone -import re - -import yaml -from packaging import version as pkg_version -from packaging.specifiers import SpecifierSet, InvalidSpecifier - from .._download_security import ( - archive_format_from_name, - archive_suffix, - MAX_JSON_CATALOG_BYTES, - build_safe_download_path, - detect_archive_format, - is_https_or_localhost_http, - read_response_limited, - safe_extract_archive, + MAX_JSON_CATALOG_BYTES as MAX_JSON_CATALOG_BYTES, ) -from ..extensions import REINSTALL_COMMAND, ExtensionRegistry, normalize_priority -from .._init_options import ( - MISSING_INIT_OPTIONS_FILE, - is_ai_skills_enabled, - load_init_options, - resolve_active_agent_for_registration, +from .._download_security import ( + read_response_limited as read_response_limited, ) -from .._invocation_style import get_invocation_prefix -from ..integrations.base import IntegrationBase -from .._utils import dump_frontmatter, version_satisfies -from ..shared_infra import ( - _ensure_safe_shared_destination, - _ensure_safe_shared_directory, - _write_shared_bytes, - _write_shared_text, - verify_archive_sha256, +from ..extensions import ExtensionRegistry as ExtensionRegistry +from ..shared_infra import verify_archive_sha256 as verify_archive_sha256 +from ._catalog import PresetCatalog as PresetCatalog +from ._catalog import PresetCatalogEntry as PresetCatalogEntry +from ._manager import ( + _CONSTITUTION_PROVENANCE_FILE as _CONSTITUTION_PROVENANCE_FILE, ) - - -_CONSTITUTION_PROVENANCE_FILE = ".constitution-template.json" -_CONSTITUTION_SYNC_PRESET_ID = "constitution-sync" - - -def _content_sha256(content: bytes) -> str: - return hashlib.sha256(content).hexdigest() - - -def _is_comparable_version(value: str) -> bool: - """Return whether a recorded version can be evaluated against a specifier. - - ``version_satisfies()`` answers "does not satisfy" for an unparseable - version, which is indistinguishable from a genuine mismatch. Callers that - need to tell those apart check here first. - """ - try: - pkg_version.Version(value) - except pkg_version.InvalidVersion: - return False - return True - - -def _constitution_is_generated( - project_root: Path, - memory_constitution: Path, - resolver: "PresetResolver", -) -> bool: - """Return whether the live constitution is an unchanged generated file.""" - _ensure_safe_shared_destination(project_root, memory_constitution) - content = memory_constitution.read_bytes() - provenance = memory_constitution.parent / _CONSTITUTION_PROVENANCE_FILE - _ensure_safe_shared_destination(project_root, provenance) - - if provenance.exists(): - try: - metadata = json.loads(provenance.read_text(encoding="utf-8")) - except (json.JSONDecodeError, UnicodeDecodeError): - return False - return ( - isinstance(metadata, dict) - and metadata.get("sha256") == _content_sha256(content) - ) - - # Older projects have no provenance sidecar. Only the immutable bundled or - # source-checkout core template is safe to treat as generated. - core = resolver._find_bundled_core( - "constitution-template", "template", ".md" - ) - return core is not None and core.read_bytes() == content - - -def _constitution_provenance_matches_preset( - project_root: Path, - memory_constitution: Path, - pack_id: str, - pack_version: str, -) -> bool: - """Return whether provenance identifies a preset as the materialized source.""" - provenance = memory_constitution.parent / _CONSTITUTION_PROVENANCE_FILE - if not provenance.parent.exists(): - return False - _ensure_safe_shared_destination(project_root, provenance) - if not provenance.exists(): - return False - try: - metadata = json.loads(provenance.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError, UnicodeDecodeError): - return False - return ( - isinstance(metadata, dict) - and metadata.get("source") == f"{pack_id} v{pack_version}" - ) - - -def _materialize_constitution_template( - project_root: Path, - memory_constitution: Path, -) -> str | None: - """Materialize constitution-template content into memory/constitution.md. - - Returns: - "copied" when the winning layer is ``replace`` and the source file is - copied verbatim; "composed" when a composing strategy is materialized - via ``resolve_content``; ``None`` when no constitution template resolves. - """ - resolver = PresetResolver(project_root) - layers = resolver.collect_all_layers("constitution-template", "template") - if not layers: - return None - - top_layer = layers[0] - if top_layer["strategy"] == "replace": - content = top_layer["path"].read_bytes() - result = "copied" - else: - composed_content = resolver.resolve_content("constitution-template", "template") - if composed_content is None: - return None - content = composed_content.encode("utf-8") - result = "composed" - - _ensure_safe_shared_directory(project_root, memory_constitution.parent) - _write_shared_bytes(project_root, memory_constitution, content) - provenance = memory_constitution.parent / _CONSTITUTION_PROVENANCE_FILE - _write_shared_text( - project_root, - provenance, - json.dumps( - { - "sha256": _content_sha256(content), - "source": top_layer["source"], - }, - indent=2, - ) - + "\n", - ) - return result - - -def _substitute_core_template( - body: str, - cmd_name: str, - project_root: "Path", - registrar: "CommandRegistrar", -) -> "tuple[str, dict]": - """Substitute {CORE_TEMPLATE} with the body of the installed core command template. - - Args: - body: Preset command body (may contain {CORE_TEMPLATE} placeholder). - cmd_name: Full command name (e.g. "speckit.git.feature" or "speckit.specify"). - project_root: Project root path. - registrar: CommandRegistrar instance for parse_frontmatter. - - Returns: - A tuple of (body, core_frontmatter) where body has {CORE_TEMPLATE} replaced - by the core template body and core_frontmatter holds the core template's parsed - frontmatter (so callers can inherit scripts/agent_scripts from it). Both are - unchanged / empty when the placeholder is absent or the core template file does - not exist or cannot be read. - """ - if "{CORE_TEMPLATE}" not in body: - return body, {} - - # Derive the short name (strip "speckit." prefix) used by core command templates. - short_name = cmd_name - if short_name.startswith("speckit."): - short_name = short_name[len("speckit."):] - - resolver = PresetResolver(project_root) - # Resolution order for the core template: - # 1. resolve_core(cmd_name) — covers tier-1 project overrides and tier-3/4 - # name-based lookup (file named .md). Checked first so that a - # local override always wins, even for extension commands. - # 2. resolve_extension_command_via_manifest(cmd_name) — manifest-based tier-3 - # fallback for extension commands whose file is named differently from the - # command name (e.g. speckit.selftest.extension → commands/selftest.md). - # 3. resolve_core(short_name) — core template fallback using the unprefixed - # name (e.g. specify → templates/commands/specify.md). - # resolve_core() skips installed presets (tier 2) to prevent accidental nesting - # where another preset's wrap output is mistaken for the real core. - core_file = ( - resolver.resolve_core(cmd_name, "command") - or resolver.resolve_extension_command_via_manifest(cmd_name) - or resolver.resolve_core(short_name, "command") - ) - if core_file is None: - return body, {} - - # Treat an unreadable/undecodable core template like a missing one so a - # single corrupted project override cannot crash command registration — - # the wrap-strategy callers already skip an unreadable preset source with - # a warning (CommandRegistrar.register_pack). - try: - core_content = core_file.read_text(encoding="utf-8") - except (OSError, UnicodeDecodeError) as exc: - import warnings - - warnings.warn( - f"Ignoring core template for command '{cmd_name}': could not read " - f"'{core_file.name}' ({exc.__class__.__name__}: {exc}).", - stacklevel=2, - ) - return body, {} - - core_frontmatter, core_body = registrar.parse_frontmatter(core_content) - return body.replace("{CORE_TEMPLATE}", core_body), core_frontmatter - - -@dataclass -class PresetCatalogEntry: - """Represents a single entry in the preset catalog stack.""" - url: str - name: str - priority: int - install_allowed: bool - description: str = "" - - -class PresetError(Exception): - """Base exception for preset-related errors.""" - pass - - -class PresetValidationError(PresetError): - """Raised when preset manifest validation fails.""" - pass - - -class PresetCompatibilityError(PresetError): - """Raised when preset is incompatible with current environment.""" - pass - - -VALID_PRESET_TEMPLATE_TYPES = {"template", "command", "script"} -VALID_PRESET_STRATEGIES = {"replace", "prepend", "append", "wrap"} -# Scripts only support replace and wrap (prepend/append don't make semantic sense for executable code) -VALID_SCRIPT_STRATEGIES = {"replace", "wrap"} - - -class PresetManifest: - """Represents and validates a preset manifest (preset.yml).""" - - SCHEMA_VERSION = "1.0" - REQUIRED_FIELDS = ["schema_version", "preset", "requires", "provides"] - - def __init__(self, manifest_path: Path): - """Load and validate preset manifest. - - Args: - manifest_path: Path to preset.yml file - - Raises: - PresetValidationError: If manifest is invalid - """ - self.path = manifest_path - self.data = self._load_yaml(manifest_path) - self._validate() - - def _load_yaml(self, path: Path) -> dict: - """Load YAML file safely.""" - try: - with open(path, 'r', encoding='utf-8') as f: - data = yaml.safe_load(f) - except yaml.YAMLError as e: - raise PresetValidationError(f"Invalid YAML in {path}: {e}") - except FileNotFoundError: - raise PresetValidationError(f"Manifest not found: {path}") - except UnicodeDecodeError as e: - raise PresetValidationError( - f"Manifest is not valid UTF-8: {path} ({e.reason} at byte {e.start})" - ) - except OSError as e: - raise PresetValidationError(f"Could not read manifest {path}: {e}") - if data is None: - return {} - if not isinstance(data, dict): - raise PresetValidationError( - f"Manifest must be a YAML mapping, got {type(data).__name__}: {path}" - ) - return data - - def _validate(self): - """Validate manifest structure and required fields.""" - # Check required top-level fields - for field in self.REQUIRED_FIELDS: - if field not in self.data: - raise PresetValidationError(f"Missing required field: {field}") - - # Validate schema version - if self.data["schema_version"] != self.SCHEMA_VERSION: - raise PresetValidationError( - f"Unsupported schema version: {self.data['schema_version']} " - f"(expected {self.SCHEMA_VERSION})" - ) - - for section in ("preset", "requires", "provides"): - if not isinstance(self.data[section], dict): - raise PresetValidationError( - f"Invalid {section}: expected a mapping" - ) - - # Validate preset metadata - pack = self.data["preset"] - # Check presence AND type: the format/version checks below feed these - # values straight to ``re.match`` and ``packaging.Version``, both of - # which raise a bare TypeError on a non-string. YAML makes that an easy - # authoring slip -- unquoted ``version: 1.0`` parses as a float and - # ``id: 2`` as an int -- and TypeError is not a PresetValidationError, - # so it escapes every caller that already handles a malformed manifest - # (see list_installed()'s "Corrupted preset" fallback, which catches - # PresetValidationError only, making one bad preset exit ``specify - # preset list`` with a raw traceback and hide the healthy ones). - # Mirrors the sibling IntegrationDescriptor, which already type-checks - # the same four fields. - for field in ["id", "name", "version", "description"]: - if field not in pack: - raise PresetValidationError(f"Missing preset.{field}") - if not isinstance(pack[field], str): - raise PresetValidationError( - f"Invalid preset.{field}: expected a string, " - f"got {type(pack[field]).__name__}" - ) - - # Validate pack ID format - if not re.match(r'^[a-z0-9-]+$', pack["id"]): - raise PresetValidationError( - f"Invalid preset ID '{pack['id']}': " - "must be lowercase alphanumeric with hyphens only" - ) - - # Validate semantic version - try: - pkg_version.Version(pack["version"]) - except pkg_version.InvalidVersion: - raise PresetValidationError(f"Invalid version: {pack['version']}") - - # Validate requires section - requires = self.data["requires"] - if "speckit_version" not in requires: - raise PresetValidationError("Missing requires.speckit_version") - # Presence alone is not enough: check_compatibility() feeds this value to - # ``SpecifierSet(required)``, guarded only by ``except InvalidSpecifier``, - # which a non-string escapes two different ways. A float/int/bool/None - # raises TypeError from the constructor, while a list or dict is an - # *iterable*, so SpecifierSet accepts it and the failure surfaces much - # later as ``AttributeError: 'str' object has no attribute 'filter'`` from - # inside .contains(). Neither is a PresetCompatibilityError, so both - # bypass the CLI's "Compatibility Error" handler and exit 1 with a raw - # traceback naming no field. An unquoted ``speckit_version: 1.0`` is an - # easy YAML slip. Mirrors the sibling IntegrationDescriptor, which already - # requires a non-empty string here. - if ( - not isinstance(requires["speckit_version"], str) - or not requires["speckit_version"].strip() - ): - raise PresetValidationError( - "Invalid requires.speckit_version: expected a non-empty string, " - f"got {type(requires['speckit_version']).__name__}" - ) - - # Validate the optional extension dependency list. A preset that - # overrides commands calling into an extension is inert without it, and - # until now the only place that could be said was the README -- see - # issue #4231. Absent means "no dependencies", so every existing preset - # stays valid. - if "extensions" in requires: - self._validate_requires_extensions(requires["extensions"]) - - # Validate provides section - provides = self.data["provides"] - if "templates" not in provides: - raise PresetValidationError( - "Preset must provide at least one template" - ) - - # Validate templates. Guard the container and each entry's shape so a - # malformed third-party preset.yml (e.g. ``templates: 5`` or - # ``templates: [null]``) raises a clean PresetValidationError the - # install handler already catches, instead of a raw TypeError - # ('int'/'NoneType' object is not iterable) that escapes to an - # unhandled traceback. Mirrors the sibling ExtensionManifest guards. - # - # Order matters: the container's TYPE is checked before its emptiness, - # so a FALSY non-list (``templates: 0``/``false``/``null``/``''``/``{}``) - # reports the accurate type error rather than the misleading "must - # provide at least one template". An empty list still reports the - # latter, since that genuinely is a list with no templates. - templates = provides["templates"] - if not isinstance(templates, list): - raise PresetValidationError( - "Invalid provides.templates: expected a list" - ) - if not templates: - raise PresetValidationError( - "Preset must provide at least one template" - ) - seen_name_types: set[tuple[str, str]] = set() - for tmpl in templates: - if not isinstance(tmpl, dict): - raise PresetValidationError( - "Each template entry in 'provides.templates' must be a mapping" - ) - if "type" not in tmpl or "name" not in tmpl or "file" not in tmpl: - raise PresetValidationError( - "Template missing 'type', 'name', or 'file'" - ) - - # 'name' feeds re.match and 'file' feeds os.path.normpath below; - # both raise a bare TypeError on a non-string, which is not a - # PresetValidationError and so escapes the callers that handle a - # malformed manifest. The sibling extension manifest already - # rejects a non-string command 'file' via - # relative_extension_path_violation(). - for field in ("type", "name", "file"): - if not isinstance(tmpl[field], str): - raise PresetValidationError( - f"Invalid template {field}: expected a string, " - f"got {type(tmpl[field]).__name__}" - ) - - if tmpl["type"] not in VALID_PRESET_TEMPLATE_TYPES: - raise PresetValidationError( - f"Invalid template type '{tmpl['type']}': " - f"must be one of {sorted(VALID_PRESET_TEMPLATE_TYPES)}" - ) - - # PresetResolver._manifest_declared_template returns the first - # 'provides.templates' entry matching a given (name, type) pair, so - # a later duplicate would be silently unreachable while still being - # counted by PresetManifest.templates. Reject at validation time - # instead, mirroring the sibling fix for ExtensionManifest's - # provides.templates/scripts (#4016). - name_type = (tmpl["name"], tmpl["type"]) - if name_type in seen_name_types: - raise PresetValidationError( - f"Duplicate template name '{tmpl['name']}' of type " - f"'{tmpl['type']}' in 'provides.templates'" - ) - seen_name_types.add(name_type) - - # Validate file path safety: must be relative, no parent traversal - file_path = tmpl["file"] - normalized = os.path.normpath(file_path) - if os.path.isabs(normalized) or normalized.startswith(".."): - raise PresetValidationError( - f"Invalid template file path '{file_path}': " - "must be a relative path within the preset directory" - ) - - # Validate strategy field (optional, defaults to "replace") - strategy = tmpl.get("strategy", "replace") - if not isinstance(strategy, str): - raise PresetValidationError( - f"Invalid strategy value: must be a string, " - f"got {type(strategy).__name__}" - ) - strategy = strategy.lower() - # Persist normalized value so downstream code sees lowercase - if "strategy" in tmpl: - tmpl["strategy"] = strategy - if strategy not in VALID_PRESET_STRATEGIES: - raise PresetValidationError( - f"Invalid strategy '{strategy}': " - f"must be one of {sorted(VALID_PRESET_STRATEGIES)}" - ) - if tmpl["type"] == "script" and strategy not in VALID_SCRIPT_STRATEGIES: - raise PresetValidationError( - f"Invalid strategy '{strategy}' for script: " - f"scripts only support {sorted(VALID_SCRIPT_STRATEGIES)}" - ) - - # Validate template name format - if tmpl["type"] == "command": - # Commands use dot notation (e.g. speckit.specify) - if not re.match(r'^[a-z0-9.-]+$', tmpl["name"]): - raise PresetValidationError( - f"Invalid command name '{tmpl['name']}': " - "must be lowercase alphanumeric with hyphens and dots only" - ) - else: - if not re.match(r'^[a-z0-9-]+$', tmpl["name"]): - raise PresetValidationError( - f"Invalid template name '{tmpl['name']}': " - "must be lowercase alphanumeric with hyphens only" - ) - - @property - def id(self) -> str: - """Get preset ID.""" - return self.data["preset"]["id"] - - @property - def name(self) -> str: - """Get preset name.""" - return self.data["preset"]["name"] - - @property - def version(self) -> str: - """Get preset version.""" - return self.data["preset"]["version"] - - @property - def description(self) -> str: - """Get preset description.""" - return self.data["preset"]["description"] - - @property - def author(self) -> str: - """Get preset author.""" - return self.data["preset"].get("author", "") - - @staticmethod - def _validate_requires_extensions(declared: Any) -> None: - """Validate the optional ``requires.extensions`` list. - - Accepts either a bare extension id or a mapping carrying an optional - version specifier and an optional ``required`` flag: - - .. code-block:: yaml - - requires: - extensions: - - speckit-inventory - - id: other-ext - version: ">=1.2.0" - required: false - - Raises: - PresetValidationError: If the list or any entry is malformed. - """ - if not isinstance(declared, list): - raise PresetValidationError( - "Invalid requires.extensions: expected a list, " - f"got {type(declared).__name__}" - ) - - for index, entry in enumerate(declared): - label = f"requires.extensions[{index}]" - - if isinstance(entry, str): - entry = {"id": entry} - elif not isinstance(entry, dict): - raise PresetValidationError( - f"Invalid {label}: expected a string or a mapping, " - f"got {type(entry).__name__}" - ) - - if "id" not in entry: - raise PresetValidationError(f"Missing {label}.id") - extension_id = entry["id"] - if not isinstance(extension_id, str): - raise PresetValidationError( - f"Invalid {label}.id: expected a string, " - f"got {type(extension_id).__name__}" - ) - # Same id shape the extension loader enforces, so a dependency can - # never name something that could not be installed in the first - # place. fullmatch rather than match with an anchored pattern: `$` - # also matches before a trailing newline, so "demo-ext\n" would - # otherwise validate here while PresetResolver._is_safe_registry_id - # (which uses fullmatch) rejects it, and the newline would land in - # a suggested command. - if not re.fullmatch(r'[a-z0-9-]+', extension_id): - raise PresetValidationError( - f"Invalid {label}.id {extension_id!r}: " - "must be lowercase alphanumeric with hyphens only" - ) - - if "version" in entry: - constraint = entry["version"] - # Mirrors the requires.speckit_version reasoning: a non-string - # escapes InvalidSpecifier two ways -- scalars raise TypeError - # from the constructor, and a list/dict is iterable so it - # constructs and only fails later inside .contains(). - if not isinstance(constraint, str) or not constraint.strip(): - raise PresetValidationError( - f"Invalid {label}.version: expected a non-empty string, " - f"got {type(constraint).__name__}" - ) - try: - SpecifierSet(constraint) - except InvalidSpecifier: - raise PresetValidationError( - f"Invalid {label}.version '{constraint}': " - "not a valid version specifier" - ) - - if "required" in entry and not isinstance(entry["required"], bool): - raise PresetValidationError( - f"Invalid {label}.required: expected a boolean, " - f"got {type(entry['required']).__name__}" - ) - - @property - def requires_speckit_version(self) -> str: - """Get required spec-kit version range.""" - return self.data["requires"]["speckit_version"] - - @property - def requires_extensions(self) -> List[Dict[str, Any]]: - """Get declared extension dependencies, normalized to mappings. - - Returns: - One entry per dependency with ``id``, ``version`` (``None`` when - unconstrained), and ``required`` (defaulting to ``True``). Empty - when the manifest declares no dependencies. - """ - declared = self.data["requires"].get("extensions") - if not isinstance(declared, list): - return [] - - normalized: List[Dict[str, Any]] = [] - for entry in declared: - if isinstance(entry, str): - entry = {"id": entry} - if not isinstance(entry, dict) or not isinstance(entry.get("id"), str): - continue - normalized.append( - { - "id": entry["id"], - "version": entry.get("version"), - "required": entry.get("required", True), - } - ) - return normalized - - @property - def templates(self) -> List[Dict[str, Any]]: - """Get list of provided templates.""" - return self.data["provides"]["templates"] - - @property - def tags(self) -> List[str]: - """Get preset tags.""" - return self.data.get("tags", []) - - def get_hash(self) -> str: - """Calculate SHA256 hash of manifest file.""" - h = hashlib.sha256() - with open(self.path, 'rb') as f: - for chunk in iter(lambda: f.read(8192), b""): - h.update(chunk) - return f"sha256:{h.hexdigest()}" - - -class PresetRegistry: - """Manages the registry of installed presets.""" - - REGISTRY_FILE = ".registry" - SCHEMA_VERSION = "1.0" - - def __init__(self, packs_dir: Path): - """Initialize registry. - - Args: - packs_dir: Path to .specify/presets/ directory - """ - self.packs_dir = packs_dir - self.registry_path = packs_dir / self.REGISTRY_FILE - self.data = self._load() - - def _load(self) -> dict: - """Load registry from disk.""" - if not self.registry_path.exists(): - return { - "schema_version": self.SCHEMA_VERSION, - "presets": {} - } - - try: - with open(self.registry_path, 'r', encoding='utf-8') as f: - data = json.load(f) - # Validate loaded data is a dict (handles corrupted registry files) - if not isinstance(data, dict): - return { - "schema_version": self.SCHEMA_VERSION, - "presets": {} - } - # Normalize presets field (handles corrupted presets value) - if not isinstance(data.get("presets"), dict): - data["presets"] = {} - return data - except (json.JSONDecodeError, UnicodeDecodeError, FileNotFoundError): - # Corrupted or missing registry, start fresh. A registry whose - # bytes cannot be decoded as UTF-8 is the same corruption class - # as malformed JSON — only the exception type differs. OSError is - # deliberately not caught: the data may be intact on disk, and - # starting fresh would let a later _save() wipe it. - return { - "schema_version": self.SCHEMA_VERSION, - "presets": {} - } - - def _save(self): - """Save registry to disk.""" - self.packs_dir.mkdir(parents=True, exist_ok=True) - with open(self.registry_path, 'w', encoding='utf-8') as f: - json.dump(self.data, f, indent=2) - - def add(self, pack_id: str, metadata: dict): - """Add preset to registry. - - Args: - pack_id: Preset ID - metadata: Pack metadata (version, source, etc.) - """ - self.data["presets"][pack_id] = { - **copy.deepcopy(metadata), - "installed_at": datetime.now(timezone.utc).isoformat() - } - self._save() - - def remove(self, pack_id: str): - """Remove preset from registry. - - Args: - pack_id: Preset ID - """ - packs = self.data.get("presets") - if not isinstance(packs, dict): - return - if pack_id in packs: - del packs[pack_id] - self._save() - - def update(self, pack_id: str, updates: dict): - """Update preset metadata in registry. - - Merges the provided updates with the existing entry, preserving any - fields not specified. The installed_at timestamp is always preserved - from the original entry. - - Args: - pack_id: Preset ID - updates: Partial metadata to merge into existing metadata - - Raises: - KeyError: If preset is not installed - """ - packs = self.data.get("presets") - if not isinstance(packs, dict) or pack_id not in packs: - raise KeyError(f"Preset '{pack_id}' not found in registry") - existing = packs[pack_id] - # Handle corrupted registry entries (e.g., string/list instead of dict) - if not isinstance(existing, dict): - existing = {} - # Merge: existing fields preserved, new fields override (deep copy to prevent caller mutation) - merged = {**existing, **copy.deepcopy(updates)} - # Always preserve original installed_at based on key existence, not truthiness, - # to handle cases where the field exists but may be falsy (legacy/corruption) - if "installed_at" in existing: - merged["installed_at"] = existing["installed_at"] - else: - # If not present in existing, explicitly remove from merged if caller provided it - merged.pop("installed_at", None) - packs[pack_id] = merged - self._save() - - def restore(self, pack_id: str, metadata: dict): - """Restore preset metadata to registry without modifying timestamps. - - Use this method for rollback scenarios where you have a complete backup - of the registry entry (including installed_at) and want to restore it - exactly as it was. - - Args: - pack_id: Preset ID - metadata: Complete preset metadata including installed_at - - Raises: - ValueError: If metadata is None or not a dict - """ - if metadata is None or not isinstance(metadata, dict): - raise ValueError(f"Cannot restore '{pack_id}': metadata must be a dict") - # Ensure presets dict exists (handle corrupted registry) - if not isinstance(self.data.get("presets"), dict): - self.data["presets"] = {} - self.data["presets"][pack_id] = copy.deepcopy(metadata) - self._save() - - def get(self, pack_id: str) -> Optional[dict]: - """Get preset metadata from registry. - - Returns a deep copy to prevent callers from accidentally mutating - nested internal registry state without going through the write path. - - Args: - pack_id: Preset ID - - Returns: - Deep copy of preset metadata, or None if not found or corrupted - """ - packs = self.data.get("presets") - if not isinstance(packs, dict): - return None - entry = packs.get(pack_id) - # Return None for missing or corrupted (non-dict) entries - if entry is None or not isinstance(entry, dict): - return None - return copy.deepcopy(entry) - - def list(self) -> Dict[str, dict]: - """Get all installed presets with valid metadata. - - Returns a deep copy of presets with dict metadata only. - Corrupted entries (non-dict values) are filtered out. - - Returns: - Dictionary of pack_id -> metadata (deep copies), empty dict if corrupted - """ - packs = self.data.get("presets", {}) or {} - if not isinstance(packs, dict): - return {} - # Filter to only valid dict entries to match type contract - return { - pack_id: copy.deepcopy(meta) - for pack_id, meta in packs.items() - if isinstance(meta, dict) - } - - def keys(self) -> set: - """Get all preset IDs including corrupted entries. - - Lightweight method that returns IDs without deep-copying metadata. - Use this when you only need to check which presets are tracked. - - Returns: - Set of preset IDs (includes corrupted entries) - """ - packs = self.data.get("presets", {}) or {} - if not isinstance(packs, dict): - return set() - return set(packs.keys()) - - def list_by_priority(self, include_disabled: bool = False) -> List[tuple]: - """Get all installed presets sorted by priority. - - Lower priority number = higher precedence (checked first). - Presets with equal priority are sorted alphabetically by ID - for deterministic ordering. - - Args: - include_disabled: If True, include disabled presets. Default False. - - Returns: - List of (pack_id, metadata_copy) tuples sorted by priority. - Metadata is deep-copied to prevent accidental mutation. - """ - packs = self.data.get("presets", {}) or {} - if not isinstance(packs, dict): - packs = {} - sortable_packs = [] - for pack_id, meta in packs.items(): - if not isinstance(meta, dict): - continue - # Skip disabled presets unless explicitly requested - if not include_disabled and not meta.get("enabled", True): - continue - metadata_copy = copy.deepcopy(meta) - metadata_copy["priority"] = normalize_priority(metadata_copy.get("priority", 10)) - sortable_packs.append((pack_id, metadata_copy)) - return sorted( - sortable_packs, - key=lambda item: (item[1]["priority"], item[0]), - ) - - def is_installed(self, pack_id: str) -> bool: - """Check if preset is installed. - - Args: - pack_id: Preset ID - - Returns: - True if pack is installed, False if not or registry corrupted - """ - packs = self.data.get("presets") - if not isinstance(packs, dict): - return False - return pack_id in packs - - -class PresetManager: - """Manages preset lifecycle: installation, removal, updates.""" - - def __init__(self, project_root: Path): - """Initialize preset manager. - - Args: - project_root: Path to project root directory - """ - self.project_root = project_root - self.presets_dir = project_root / ".specify" / "presets" - self.registry = PresetRegistry(self.presets_dir) - - def check_compatibility( - self, - manifest: PresetManifest, - speckit_version: str - ) -> bool: - """Check if preset is compatible with current spec-kit version. - - Args: - manifest: Preset manifest - speckit_version: Current spec-kit version - - Returns: - True if compatible - - Raises: - PresetCompatibilityError: If pack is incompatible - """ - required = manifest.requires_speckit_version - # Defense in depth: the manifest validator now rejects a non-string - # requires.speckit_version, but this method is public and also reachable - # with a hand-built manifest object. ``InvalidSpecifier`` alone does not - # cover a non-string -- scalars raise TypeError from the constructor, and - # a list/dict is iterable so it constructs here and only breaks inside - # .contains(). Reject up front so this always reports a - # PresetCompatibilityError. - if not isinstance(required, str): - raise PresetCompatibilityError( - "Invalid version specifier: expected a string, got " - f"{type(required).__name__} ({required!r})" - ) - try: - SpecifierSet(required) # Just to validate - except InvalidSpecifier: - raise PresetCompatibilityError(f"Invalid version specifier: {required}") - - if not version_satisfies(speckit_version, required): - raise PresetCompatibilityError( - f"Preset requires spec-kit {required}, " - f"but {speckit_version} is installed.\n" - f"Upgrade spec-kit with: {REINSTALL_COMMAND}" - ) - - return True - - def find_unmet_extension_dependencies( - self, - manifest: PresetManifest - ) -> List[Dict[str, Any]]: - """Find declared extension dependencies that are not satisfied. - - Reports rather than raises. A preset whose overrides call into an - extension is written to degrade safely -- without the extension the - core workflow still runs -- so a missing dependency is a warning, not - an install failure. See issue #4231. - - Args: - manifest: Preset manifest to inspect - - Returns: - One entry per unsatisfied dependency, each with ``id``, the - requested ``version`` specifier (``None`` when unconstrained), the - ``installed`` version (``None`` when absent or unusable), and a - ``reason`` of ``"missing"``, ``"corrupt"``, ``"stale"``, - ``"disabled"``, or ``"version"``. Optional dependencies - (``required: false``) are never reported. - - An unreadable registry yields no results rather than raising, since - this runs after the install has already succeeded. - - A registry version that cannot be parsed is treated as - uncomparable, not as a mismatch: the extension is installed and - usable, and only its recorded version is unreadable. An extension - present on disk but absent from the registry is likewise treated as - satisfied, because resolution admits unregistered directories. - """ - # Defense in depth, mirroring check_compatibility(): this method is - # public and also reachable with a hand-built manifest object that - # predates this field. A manifest without it declares nothing. - candidates = getattr(manifest, "requires_extensions", None) - if not isinstance(candidates, list): - return [] - - # Collapse exact repeats so a manifest naming the same dependency twice - # warns once. Two entries for one id with *different* constraints are - # kept, since both genuinely have to hold. - declared: List[Dict[str, Any]] = [] - seen: Set[tuple] = set() - for dep in candidates: - if not isinstance(dep, dict) or not dep.get("required", True): - continue - key = (dep.get("id"), dep.get("version")) - if key in seen: - continue - seen.add(key) - declared.append(dep) - if not declared: - return [] - - extensions_dir = self.project_root / ".specify" / "extensions" - try: - registry = ExtensionRegistry(extensions_dir) - registered_ids = registry.keys() - registry_corrupt = registry.is_corrupt() - except OSError: - # Both reads can raise: _load() recovers from malformed content but - # deliberately lets OSError through, and is_corrupt() re-reads the - # file. This check runs *after* the install has completed, and - # preset_add only handles preset-domain errors, so letting that - # escape would turn a finished install into a traceback over a - # warning. An unreadable registry simply cannot be inspected. - return [] - - unmet: List[Dict[str, Any]] = [] - - for dep in declared: - metadata = registry.get(dep["id"]) - if metadata is None: - # An absent registry entry does not mean the extension is - # unusable. _get_all_extensions_by_priority() admits a safe - # on-disk directory as an unregistered extension at implicit - # priority 10, so it resolves and the preset works -- but only - # when the registry is readable, since a corrupt one makes that - # path fail closed and contribute nothing. - # - # get() returns None for a corrupted (non-dict) entry as well as - # an absent one, but keys() retains corrupted ids -- both so - # resolution does not re-admit their directories as - # unregistered, and because is_installed() still counts them, so - # a plain `extension add` would be refused as already installed. - # That is a different state from absent, and needs a different - # remedy. - if dep["id"] in registered_ids: - unmet.append({**dep, "installed": None, "reason": "corrupt"}) - continue - if ( - (extensions_dir / dep["id"]).is_dir() - and PresetResolver._is_safe_registry_id(dep["id"]) - and not registry_corrupt - ): - # Unregistered means no recorded version, so a constraint - # cannot be evaluated -- uncomparable, not unsatisfied. - continue - unmet.append({**dep, "installed": None, "reason": "missing"}) - continue - - installed_version = metadata.get("version") - installed_version = ( - installed_version if isinstance(installed_version, str) else None - ) - - # A registry entry is not proof the extension can contribute. If - # its directory is gone, PresetResolver skips it outright (both - # template lookup and layer collection guard on ``is_dir()``), so - # the preset is as inert as if it were never installed -- but the - # surviving entry would otherwise read as satisfied. - if not (extensions_dir / dep["id"]).is_dir(): - unmet.append( - {**dep, "installed": installed_version, "reason": "stale"} - ) - continue - - # A disabled extension is registered but contributes nothing: - # resolution skips it (see _collect_extension_layers), so the - # preset is just as inert as if it were absent. Report it before - # any version check -- enabling it is the prerequisite, and the - # version may well be fine once it is. - if not metadata.get("enabled", True): - unmet.append( - {**dep, "installed": installed_version, "reason": "disabled"} - ) - continue - - constraint = dep["version"] - if not constraint: - continue - - # A version that cannot be compared is not a mismatch. Absent or - # non-string is one way to be unusable; an unparseable string such - # as "unknown" is another, and version_satisfies() cannot tell them - # apart -- it catches InvalidVersion and returns False, which would - # report a mismatch against a version nobody can evaluate. Check - # parseability up front so only real comparisons reach the warning. - if installed_version is None or not _is_comparable_version(installed_version): - continue - if not version_satisfies(installed_version, constraint): - unmet.append( - {**dep, "installed": installed_version, "reason": "version"} - ) - - return unmet - - def _register_commands( - self, - manifest: PresetManifest, - preset_dir: Path - ) -> Dict[str, List[str]]: - """Register preset command overrides with all detected AI agents. - - Scans the preset's templates for type "command", reads each command - file, and writes it to every detected agent directory using the - CommandRegistrar from the agents module. - - When a command uses a composition strategy (prepend, append, wrap), - the content is composed with the lower-priority command before - registration. - - Args: - manifest: Preset manifest - preset_dir: Installed preset directory - - Returns: - Dictionary mapping agent names to lists of registered command names - """ - command_templates = [ - t for t in manifest.templates if t.get("type") == "command" - ] - if not command_templates: - return {} - - # A preset command template always ships its own body, so it is - # self-contained and scaffolds regardless of whether any similarly - # named extension is installed. Namespaced names (speckit..) - # are treated exactly like short names (speckit.) — they are NOT - # filtered out just because ``.specify/extensions//`` is absent. - # The only command that cannot be materialized is a composition - # (prepend/append/wrap) with no base layer to compose onto; that case - # is handled per-command below (warn + skip), not by dropping names up - # front. - # Handle composition strategies: resolve composed content for non-replace commands - resolver = PresetResolver(self.project_root) - composed_dir = None - commands_to_register = [] - for cmd in command_templates: - strategy = cmd.get("strategy", "replace") - if strategy != "replace": - # Only pre-compose if this preset is the top composing layer. - # If a higher-priority replace already wins, skip composition - # here — reconciliation will write the correct content. - layers = resolver.collect_all_layers(cmd["name"], "command") - top_layer_is_ours = ( - layers and layers[0]["path"].is_relative_to(preset_dir) - ) - if top_layer_is_ours: - composed = resolver.resolve_content(cmd["name"], "command") - if composed is not None: - if composed_dir is None: - composed_dir = preset_dir / ".composed" - composed_dir.mkdir(parents=True, exist_ok=True) - composed_file = composed_dir / f"{cmd['name']}.md" - composed_file.write_text(composed, encoding="utf-8") - commands_to_register.append({ - **cmd, - "file": f".composed/{cmd['name']}.md", - }) - else: - # No base layer to compose onto (e.g. the command it - # would wrap comes from an extension that isn't - # installed). Warn and skip this single command rather - # than aborting the whole install — mirrors the - # "composed is None" branch in - # _reconcile_composed_commands so command-mode and - # reconciliation behave identically. - import warnings - warnings.warn( - f"Command '{cmd['name']}' uses '{strategy}' " - f"strategy but no base command layer exists to " - f"compose onto; skipping. Provide a lower-priority " - f"preset, extension, or core command for it before " - f"using composition strategies.", - stacklevel=2, - ) - continue - else: - # Not the top layer — register raw file; reconciliation - # will overwrite with the correct composed/winning content. - # Note: CommandRegistrar may process frontmatter strategy: wrap - # from the raw file (legacy compat), but reconciliation runs - # immediately after install and corrects the final output. - commands_to_register.append(cmd) - else: - commands_to_register.append(cmd) - - try: - from ..agents import CommandRegistrar - except ImportError: - return {} - - registrar = CommandRegistrar() - - # Single-active rule (#2948): preset command overrides register for - # the active integration only. A project without a recorded active - # integration (init-options.json does not exist at all — a legacy - # pre-init-options layout or direct library use) falls back to - # detection-based registration for all agents. A recorded key with - # no registrar config (e.g. "generic") naturally yields no matches - # via only_agent instead of falling back. - # - # An init-options.json that exists but is corrupted, unreadable, or - # has a malformed/empty "ai" value must not be treated the same as - # "no file" — that would silently reintroduce all-agent - # registration. Fail closed (register nothing) instead. - resolved_agent = resolve_active_agent_for_registration(self.project_root) - if resolved_agent is MISSING_INIT_OPTIONS_FILE: - active_agent = None - elif resolved_agent is None: - return {} - else: - active_agent = resolved_agent - # Mirror the extension path's ai_skills guard: when the active - # agent is a command-backed integration (extension != "/SKILL.md") - # running in skills mode, its preset command overrides render as - # skills via _register_skills, not as command files. Command-mode - # and skills-mode artifacts are mutually exclusive — writing both - # (e.g. `integration use copilot` with `--skills`) leaves a stale - # command file alongside the SKILL.md that is actually active. - init_options = load_init_options(self.project_root) - agent_config = registrar.AGENT_CONFIGS.get(active_agent) - if ( - agent_config - and is_ai_skills_enabled(init_options) - and agent_config.get("extension") != "/SKILL.md" - ): - return {} - - return registrar.register_commands_for_all_agents( - commands_to_register, - manifest.id, - preset_dir, - self.project_root, - create_missing_active_skills_dir=True, - only_agent=active_agent, - ) - - def register_enabled_presets_for_agent(self, agent_name: str) -> None: - """Re-register enabled presets' command overrides and skills for ``agent_name``. - - Mirrors ``ExtensionManager.register_enabled_extensions_for_agent`` for - presets (#2948): ``integration use`` / ``switch`` call this for the - newly active agent so a preset installed while a different - integration was active gets rescaffolded on activation, instead of - writing artifacts for inactive integrations at install time. - ``_register_commands`` / ``_register_skills`` already resolve the - active integration from init-options themselves, so this re-runs them - for every enabled preset and merges the fresh result for - ``agent_name`` into its stored registry metadata. - - Presets are processed in *reverse* priority order (lowest-precedence - first). Each pass overwrites the same target command/skill files, so - writing the highest-precedence preset last is what makes it win when - two enabled presets override the same command — matching the - priority stack documented for ``list_by_priority()``. - """ - if not agent_name: - return - - # Resolve once: whether agent_name is a command-backed integration - # (extension != "/SKILL.md") currently running in skills mode, or - # vice versa. Native skill-only agents (extension == "/SKILL.md", - # e.g. claude/codex) have no command/skill toggle at all — both - # registered_commands and registered_skills legitimately co-exist - # for them by design, so this restriction only applies to - # command-backed integrations. - try: - from ..agents import CommandRegistrar - - agent_config = CommandRegistrar().AGENT_CONFIGS.get(agent_name) - except ImportError: - agent_config = None - is_command_backed = bool(agent_config) and agent_config.get("extension") != "/SKILL.md" - ai_skills_now = is_command_backed and is_ai_skills_enabled( - load_init_options(self.project_root) - ) - - resolver = PresetResolver(self.project_root) - affected_cmd_names: set = set() - presets_by_priority = list(self.registry.list_by_priority()) - winning_pack_by_command: Dict[str, str] = {} - winning_source_by_command: Dict[str, Path] = {} - project_override_commands: set[str] = set() - for candidate_pack_id, _candidate_metadata in presets_by_priority: - candidate_manifest = resolver._get_manifest( - self.presets_dir / candidate_pack_id - ) - if candidate_manifest is None: - continue - for template in candidate_manifest.templates: - command_name = template.get("name") - if ( - template.get("type") == "command" - and isinstance(command_name, str) - ): - if ( - resolver.overrides_dir / f"{command_name}.md" - ).is_file(): - project_override_commands.add(command_name) - winning_pack_by_command.setdefault( - command_name, candidate_pack_id - ) - source_file = template.get("file") - if isinstance(source_file, str): - winning_source_by_command.setdefault( - command_name, - self.presets_dir - / candidate_pack_id - / source_file, - ) - - pending_command_cleanups: List[ - tuple[ - str, - Dict[str, List[str]], - List[str], - Dict[str, str], - ] - ] = [] - successful_skill_replacements: set[tuple[str, str]] = set() - pending_skill_cleanups: List[ - tuple[ - str, - Path, - Dict[str, List[str]], - List[str], - Dict[str, str], - ] - ] = [] - successful_command_replacements: set[tuple[str, str]] = set() - for pack_id, metadata in reversed(presets_by_priority): - pack_dir = self.presets_dir / pack_id - manifest = resolver._get_manifest(pack_dir) - if manifest is None: - continue - - # Registration can write one command and then fail on a later - # template. Record names first so final reconciliation can repair - # any partial writes even when _register_commands never returns. - for tmpl in manifest.templates: - name = tmpl.get("name") - if tmpl.get("type") == "command" and isinstance(name, str): - affected_cmd_names.add(name) - - # Isolate per-preset failures: one preset that fails to register - # must not abort registration of the remaining enabled presets. - try: - registered_commands = self._register_commands(manifest, pack_dir) - registered_command_names = set( - registered_commands.get(agent_name) or [] - ) - for tmpl in manifest.templates: - if tmpl.get("type") != "command": - continue - primary_name = tmpl.get("name") - if ( - isinstance(primary_name, str) - and primary_name in registered_command_names - ): - successful_command_replacements.add( - (pack_id, primary_name) - ) - existing_commands = metadata.get("registered_commands", {}) - if not isinstance(existing_commands, dict): - existing_commands = {} - merged_commands = copy.deepcopy(existing_commands) - # Toggled command -> skills for this same agent: - # _register_commands's ai_skills guard just made this a - # no-op, but the command file this preset wrote while - # command mode was active is still on disk and still - # tracked. Do NOT unregister it yet — _register_skills() - # below is an independently fallible replacement step, and - # deleting the old artifact before it succeeds would leave - # neither the old command file nor a new skill file if - # skills registration raises. The old artifact is only - # removed after the skills phase below completes without - # raising, preserving command/skill mutual exclusion while - # never leaving a transient failure with nothing in place - # (#2948). - stale_command_names: Optional[List[str]] = None - if registered_commands.get(agent_name): - existing_names = merged_commands.get(agent_name, []) - merged_commands[agent_name] = existing_names + [ - name - for name in registered_commands[agent_name] - if name not in existing_names - ] - elif ai_skills_now and merged_commands.get(agent_name): - stale_command_names = merged_commands[agent_name] - # Persist the commands phase immediately, mirroring - # install_from_directory(): _register_skills is an - # independently fallible phase, and if it raises, the files - # the commands phase already wrote to disk must still be - # tracked so preset removal can clean them up (#2948). - if merged_commands != existing_commands: - self.registry.update(pack_id, {"registered_commands": merged_commands}) - - registered_skills = self._register_skills(manifest, pack_dir) - replaced_skill_names = set(registered_skills.get(agent_name) or []) - for tmpl in manifest.templates: - if tmpl.get("type") != "command": - continue - primary_name = tmpl.get("name") - if not isinstance(primary_name, str): - continue - modern_name, legacy_name = self._skill_names_for_command( - primary_name - ) - if ( - modern_name in replaced_skill_names - or legacy_name in replaced_skill_names - ): - successful_skill_replacements.add( - (pack_id, primary_name) - ) - raw_existing_skills = metadata.get("registered_skills") - if isinstance(raw_existing_skills, list) and raw_existing_skills: - # Legacy flat-list value: don't assume agent_name wrote - # every name (the first post-upgrade operation may be a - # direct switch to a different skill-mode agent) — - # infer real ownership from on-disk provenance instead - # (#2948). - existing_skills = self._infer_legacy_skill_provenance( - [n for n in raw_existing_skills if isinstance(n, str)], - pack_id, - fallback_agent=agent_name, - ) - else: - existing_skills = self._normalize_registered_skills( - raw_existing_skills, fallback_agent=agent_name - ) - merged_skills = copy.deepcopy(existing_skills) - if registered_skills.get(agent_name): - existing_names = merged_skills.get(agent_name, []) - merged_skills[agent_name] = existing_names + [ - name - for name in registered_skills[agent_name] - if name not in existing_names - ] - elif is_command_backed and not ai_skills_now and merged_skills.get(agent_name): - # Mirror image: toggled skills -> command for this same - # agent. _get_skills_dir() no longer resolves a skills - # directory once ai_skills is off, so _register_skills - # is a no-op — but the SKILL.md this preset wrote while - # skills mode was active is still tracked and still on - # disk. Restore/remove it narrowly for this agent. This - # direction is already register-new-then-remove-old: - # _register_commands (the replacement) ran unconditionally - # above and only reaches here once it has already - # succeeded — but that call can still have returned - # empty or partial results (missing source template, - # safety-validation skip, corrupted manifest), so only - # retire the subset of stale skills whose corresponding - # command name was actually returned for this agent; - # anything unreplaced stays tracked and on disk (#2948). - stale_skill_names = merged_skills[agent_name] - skill_to_primary: Dict[str, str] = {} - for tmpl in manifest.templates: - if tmpl.get("type") != "command": - continue - primary_name = tmpl.get("name") - if not isinstance(primary_name, str): - continue - modern_name, legacy_name = self._skill_names_for_command( - primary_name - ) - skill_to_primary[modern_name] = primary_name - skill_to_primary[legacy_name] = primary_name - pending_skill_cleanups.append( - ( - pack_id, - pack_dir, - merged_skills, - stale_skill_names, - skill_to_primary, - ) - ) - # A legacy flat-list registered_skills value (predating - # per-agent provenance) must migrate to the dict format on - # disk even when the rescaffolded names are unchanged from - # what the list already held — comparing only the - # *normalized* forms would otherwise treat that as a no-op - # and leave the raw un-migrated list in the registry, which - # later removal/switch handling treats as legacy - # best-effort (restoring only the currently active agent's - # directory) instead of per-agent provenance (#2948). - needs_migration = ( - isinstance(raw_existing_skills, list) and raw_existing_skills - ) - if merged_skills != existing_skills or needs_migration: - self.registry.update(pack_id, {"registered_skills": merged_skills}) - - # The skills phase above completed without raising, but a - # non-raising result can still be empty or partial (missing - # source template, safety-validation skip, corrupted - # manifest) — retiring every stale command purely on "did - # not raise" would delete a command whose replacement skill - # never actually landed, leaving neither artifact. Only - # retire the subset of stale commands whose corresponding - # skill name was actually returned for this agent; anything - # unreplaced stays tracked and on disk (#2948). - if stale_command_names: - # Commands may carry aliases (CommandRegistrar.register_ - # commands() tracks and returns primary + alias names - # flattened together into one list), but _register_ - # skills() only ever renders/returns the *primary* - # command name's skill — running an alias's own name - # through _skill_names_for_command() never matches - # anything real, so an alias would stay tracked/on-disk - # forever even after its primary's skill replacement - # landed. Map each stale name back to its template's - # primary via the manifest so the whole primary+alias - # group is retired or kept together, based solely on - # whether the *primary*'s skill replacement actually - # landed (#2948). - alias_to_primary: Dict[str, str] = {} - for tmpl in manifest.templates: - if tmpl.get("type") != "command": - continue - primary_name = tmpl.get("name") - if not isinstance(primary_name, str): - continue - for alias in tmpl.get("aliases", []): - if isinstance(alias, str): - alias_to_primary[alias] = primary_name - - pending_command_cleanups.append( - ( - pack_id, - merged_commands, - stale_command_names, - alias_to_primary, - ) - ) - except Exception as pack_err: - from .. import _print_cli_warning - - _print_cli_warning( - "register preset artifacts for", - "preset", - pack_id, - pack_err, - continuing="Continuing with the remaining presets.", - ) - continue - - # Registration writes each preset's raw layer. Reconcile before - # retiring opposite-mode artifacts so project overrides and composed - # winners are materialized first, and so cleanup runs last instead of - # being undone by skill reconciliation. - reconciled_commands: set[str] = set() - reconciled_skills: set[str] = set() - if affected_cmd_names: - try: - reconciled_commands = self._reconcile_composed_commands( - list(affected_cmd_names), target_agent=agent_name - ) - reconciled_skills = self._reconcile_skills( - list(affected_cmd_names), target_agent=agent_name - ) - except Exception as exc: - import warnings - - warnings.warn( - f"Post-rescaffold reconciliation failed for '{agent_name}': " - f"{exc}. Agent command files may be stale; re-run " - f"'specify integration use {agent_name}' or reinstall " - f"affected presets to refresh.", - stacklevel=2, - ) - - successfully_replaced_winners = { - command_name - for command_name, winning_pack_id in winning_pack_by_command.items() - if command_name not in project_override_commands - and ( - (winning_pack_id, command_name) - in successful_skill_replacements - or ( - command_name in reconciled_skills - and command_name in winning_source_by_command - and winning_source_by_command[command_name].is_file() - ) - ) - } - successfully_replaced_winners.update( - project_override_commands & reconciled_skills - ) - - for ( - pack_id, - merged_commands, - stale_command_names, - alias_to_primary, - ) in pending_command_cleanups: - fully_replaced = [ - command_name - for command_name in stale_command_names - if alias_to_primary.get(command_name, command_name) - in successfully_replaced_winners - ] - if not fully_replaced: - continue - remaining_stale = [ - command_name - for command_name in stale_command_names - if command_name not in fully_replaced - ] - self._unregister_commands({agent_name: fully_replaced}) - if remaining_stale: - merged_commands[agent_name] = remaining_stale - else: - merged_commands.pop(agent_name, None) - self.registry.update( - pack_id, {"registered_commands": merged_commands} - ) - - successfully_replaced_command_winners = { - command_name - for command_name, winning_pack_id in winning_pack_by_command.items() - if command_name not in project_override_commands - and ( - (winning_pack_id, command_name) - in successful_command_replacements - or ( - command_name in reconciled_commands - and command_name in winning_source_by_command - and winning_source_by_command[command_name].is_file() - ) - ) - } - successfully_replaced_command_winners.update( - project_override_commands & reconciled_commands - ) - - # Skill restoration walks the priority stack, so retire stale layers - # from highest to lowest. The last cleanup then restores the true - # non-preset fallback (or removes the skill) rather than cycling back - # to a lower-priority preset. - for ( - pack_id, - pack_dir, - merged_skills, - stale_skill_names, - skill_to_primary, - ) in reversed(pending_skill_cleanups): - fully_replaced = [ - skill_name - for skill_name in stale_skill_names - if skill_to_primary.get(skill_name) - in successfully_replaced_command_winners - ] - if not fully_replaced: - continue - remaining_stale = [ - skill_name - for skill_name in stale_skill_names - if skill_name not in fully_replaced - ] - override_sources = { - skill_name: f"override:{skill_to_primary[skill_name]}" - for skill_name in fully_replaced - if skill_name in skill_to_primary - } - self._unregister_skills( - {agent_name: fully_replaced}, - pack_dir, - additional_owned_sources=override_sources, - ) - if remaining_stale: - merged_skills[agent_name] = remaining_stale - else: - merged_skills.pop(agent_name, None) - self.registry.update( - pack_id, {"registered_skills": merged_skills} - ) - - def unregister_agent_artifacts(self, agent_name: str) -> None: - """Remove ``agent_name``'s tracked preset command/skill artifacts. - - Mirrors ``ExtensionManager.unregister_agent_artifacts()`` (#2948): - used by ``integration switch`` when deactivating the previous - integration, so a preset's command overrides and skill mirrors - written for that agent don't linger as orphans in its directory - once a different (possibly not-yet-installed) integration becomes - active — including custom preset commands and files the registrar - would otherwise skip as user-modified. - - Scoped strictly to ``agent_name``: only that agent's own tracked - artifacts and registry entries are touched. Other agents' files, - tracking, and preset packs themselves are left untouched, and no - priority-stack reconciliation runs — this is agent-scoped cleanup - only, not preset removal. - """ - if not agent_name: - return - - try: - from ..agents import CommandRegistrar - - registrar = CommandRegistrar() - agent_config = registrar.AGENT_CONFIGS.get(agent_name) - except ImportError: - registrar = None - agent_config = None - if agent_config is None or registrar is None: - return - - for pack_id, metadata in list(self.registry.list().items()): - updates: Dict[str, Any] = {} - - raw_skills = metadata.get("registered_skills", []) - if isinstance(raw_skills, list) and raw_skills: - # Legacy flat-list value predating per-agent provenance: - # infer real ownership from on-disk markers before removing - # anything, so only agent_name's actual share is unregistered - # and the rest migrates to per-agent form instead of either - # guessing every name belongs to agent_name or blindly - # leaving other agents' shares unrecoverable (#2948). - registered_skills_all = self._infer_legacy_skill_provenance( - [n for n in raw_skills if isinstance(n, str)], - pack_id, - fallback_agent=agent_name, - ) - skills_migrated = True - elif isinstance(raw_skills, dict): - registered_skills_all = copy.deepcopy(raw_skills) - skills_migrated = False - else: - registered_skills_all = {} - skills_migrated = False - - registered_commands = metadata.get("registered_commands", {}) - if not isinstance(registered_commands, dict): - registered_commands = {} - - agent_command_names = [ - n for n in registered_commands.get(agent_name, []) if isinstance(n, str) - ] - - # Native SKILL.md agents (claude/codex/agy/…) materialize their - # preset override in _register_commands(), tracked under - # registered_commands, not registered_skills — see - # _register_skills()'s own docstring ("Native skill agents … - # materialize brand-new preset skills in _register_commands()"). - # A legacy flat-list registered_skills value predating that - # split can still attribute the very same on-disk file to this - # agent via provenance inference; unregistering through both - # paths would double-process the identical directory (delete - # via the commands path, then no-op "restore" via the skills - # path since the directory is already gone). Mirror remove()'s - # own coordination: whenever this agent's artifact is already - # handled via registered_commands, never additionally treat it - # as a registered_skills entry for the same agent. - native_skills_entry_removed = False - if agent_command_names and agent_config.get("extension") == "/SKILL.md": - native_skills_entry_removed = agent_name in registered_skills_all - registered_skills_all.pop(agent_name, None) - - if agent_command_names: - command_names_to_unregister = agent_command_names - if agent_config.get("extension") == "/SKILL.md": - agent_output = registrar._resolve_agent_dir( - agent_name, agent_config, self.project_root - ) - shared_names: set[str] = set() - for other_agent, other_names in registered_commands.items(): - if ( - other_agent == agent_name - or not isinstance(other_names, list) - ): - continue - other_config = registrar.AGENT_CONFIGS.get(other_agent) - if ( - not other_config - or other_config.get("extension") != "/SKILL.md" - ): - continue - other_output = registrar._resolve_agent_dir( - other_agent, other_config, self.project_root - ) - if other_output == agent_output: - shared_names.update( - name - for name in other_names - if isinstance(name, str) - ) - command_names_to_unregister = [ - name - for name in agent_command_names - if name not in shared_names - ] - if command_names_to_unregister: - self._unregister_commands( - {agent_name: command_names_to_unregister} - ) - new_registered_commands = copy.deepcopy(registered_commands) - new_registered_commands.pop(agent_name, None) - updates["registered_commands"] = new_registered_commands - - agent_skill_names = registered_skills_all.get(agent_name) or [] - if ( - agent_skill_names - or skills_migrated - or native_skills_entry_removed - ): - if agent_skill_names: - self._delete_agent_preset_skills( - agent_name, agent_skill_names, pack_id - ) - remaining = { - other_agent: names - for other_agent, names in registered_skills_all.items() - if other_agent != agent_name - } - updates["registered_skills"] = remaining - - if updates: - self.registry.update(pack_id, updates) - - def _unregister_commands(self, registered_commands: Dict[str, List[str]]) -> None: - """Remove previously registered command files from agent directories. - - Args: - registered_commands: Dict mapping agent names to command name lists - """ - try: - from ..agents import CommandRegistrar - except ImportError: - return - - registrar = CommandRegistrar() - registrar.unregister_commands(registered_commands, self.project_root) - - def _merge_pack_registered_commands( - self, pack_id: str, written: Optional[Dict[str, List[str]]] - ) -> None: - """Merge actually-written agent command registrations into a preset's metadata. - - Reconciliation (``_reconcile_composed_commands``) can write a - preset's content into an agent directory the preset never wrote to - before — most notably a historical (currently inactive) agent - supplied via ``extra_agents`` when a higher-priority preset is - removed. If that write isn't reflected back into the winning - preset's own ``registered_commands``, the registry silently lies - about which directories the preset owns: a later removal of this - same preset only cleans up the agents it already knew about, - orphaning the directory reconciliation just wrote to on its behalf - (#2948). - - Args: - pack_id: The preset whose metadata should be updated. - written: ``{agent_name: [cmd_name, ...]}`` actually written by - the reconciliation call just made, exactly mirroring - ``CommandRegistrar.register_commands_for_non_skill_agents``'s - return value. A falsy value is a no-op. - """ - if not written: - return - metadata = self.registry.get(pack_id) - if metadata is None: - return # pack_id no longer installed (e.g. removed mid-loop) - existing_commands = metadata.get("registered_commands", {}) - if not isinstance(existing_commands, dict): - existing_commands = {} - merged_commands = copy.deepcopy(existing_commands) - changed = False - for agent_name, cmd_names in written.items(): - if not cmd_names: - continue - existing_names = merged_commands.get(agent_name, []) - new_names = [n for n in cmd_names if n not in existing_names] - if new_names: - merged_commands[agent_name] = existing_names + new_names - changed = True - if changed: - self.registry.update(pack_id, {"registered_commands": merged_commands}) - - def _merge_extension_registered_commands( - self, extension_id: str, written: Optional[Dict[str, List[str]]] - ) -> None: - """Merge reconciliation writes into an extension's registry entry.""" - if not written: - return - registry = ExtensionRegistry(self.project_root / ".specify" / "extensions") - metadata = registry.get(extension_id) - if metadata is None: - return - existing_commands = metadata.get("registered_commands", {}) - if not isinstance(existing_commands, dict): - existing_commands = {} - merged_commands = copy.deepcopy(existing_commands) - changed = False - for agent_name, cmd_names in written.items(): - existing_names = merged_commands.get(agent_name, []) - new_names = [name for name in cmd_names if name not in existing_names] - if new_names: - merged_commands[agent_name] = existing_names + new_names - changed = True - if changed: - registry.update(extension_id, {"registered_commands": merged_commands}) - - def _reconcile_composed_commands( - self, - command_names: List[str], - extra_agents: Optional[Set[str]] = None, - target_agent: Optional[str] = None, - ) -> Set[str]: - """Re-resolve and re-register composed commands from the full stack. - - After install or remove, recompute the effective content for each - command name that participates in composition, and write the winning - content to the agent directories. This ensures command files always - reflect the current priority stack rather than depending on - install/remove order. - - Single-active rule (#2948): non-skill command-file registration - performed by this pass is restricted to the active integration, the - same as ``_register_commands``. Without this, reconciliation after - install/remove would write command files for every detected - non-skill agent even though registration itself is active-only, - leaving inactive integrations with artifacts that are never - recorded in ``registered_commands`` (and therefore never cleaned up - on removal). - - Args: - command_names: List of command names to reconcile - extra_agents: Additional agent names to also reconcile besides - the currently active one. Populated by ``remove()`` with the - historical agents a just-removed preset's - ``registered_commands`` actually targeted, so a surviving - lower-priority preset's content is restored there too — not - only for the currently active agent (#2948). Install/use - callers omit this, preserving pure active-only behavior. - target_agent: If set, report only command names written for this - agent. Other callers receive the union of all written names. - - Returns: - Command names successfully written by this reconciliation pass. - """ - if not command_names: - return set() - - # Every preset-owned command name flows through unchanged. Names are - # NOT filtered by the ``speckit..`` shape: a self-contained - # preset command scaffolds whether or not a like-named extension is - # installed (parity with _register_commands), and a name whose base - # layer has disappeared must still reach the loop below so its now - # uncomposable stale file gets unregistered. The loop already skips - # names that resolve to no layers at all (``if not layers: continue``). - try: - from ..agents import CommandRegistrar - except ImportError: - return set() - - resolver = PresetResolver(self.project_root) - registrar = CommandRegistrar() - reconciled_commands: set[str] = set() - - def record_written(written: Dict[str, List[str]]) -> None: - if target_agent is not None: - reconciled_commands.update(written.get(target_agent, [])) - else: - for names in written.values(): - reconciled_commands.update(names) - - # Resolve the active-only restriction once. MISSING_INIT_OPTIONS_FILE - # (legacy pre-init-options project) keeps the pre-#2948 fallback of - # registering every detected non-skill agent; a corrupted/malformed - # init-options.json fails closed via a sentinel that matches no real - # agent name instead of silently falling back to "no restriction". - resolved_agent = resolve_active_agent_for_registration(self.project_root) - if resolved_agent is MISSING_INIT_OPTIONS_FILE: - only_agent: Optional[str] = None - elif resolved_agent is None: - only_agent = "" - else: - only_agent = resolved_agent - # Mirror _register_commands's ai_skills guard: a command-backed - # active agent running in skills mode renders preset/extension - # overrides as skills, not command files, so this non-skill - # command reconciliation pass must not target it either. - agent_config = registrar.AGENT_CONFIGS.get(only_agent) - if ( - agent_config - and is_ai_skills_enabled(load_init_options(self.project_root)) - and agent_config.get("extension") != "/SKILL.md" - ): - only_agent = "" - - # The active agent's participation is decided exclusively by the - # only_agent guard above (which encodes the ai_skills mode). A - # partially failed command→skills toggle can leave the active agent - # behind in extra_agents via its stale registered_commands entry, - # and register_commands_for_non_skill_agents admits every - # extra_agents member even when only_agent excludes the agent — - # recreating a command file for an agent now running in skills - # mode. Never re-admit the active agent through the - # historical-agents side channel (#2948). - if extra_agents and isinstance(resolved_agent, str): - extra_agents = set(extra_agents) - {resolved_agent} - - # Cache registry and manifests outside the loop to avoid - # repeated filesystem reads for each command name. - presets_by_priority = list(self.registry.list_by_priority()) - - for cmd_name in command_names: - layers = resolver.collect_all_layers(cmd_name, "command") - if not layers: - continue - - # If the top layer is replace, it wins entirely — lower layers - # are irrelevant regardless of their strategies. - top_is_replace = layers[0]["strategy"] == "replace" - has_composition = not top_is_replace and any( - layer["strategy"] != "replace" for layer in layers - ) - if not has_composition: - # Pure replace — the top layer wins. - top_layer = layers[0] - top_path = top_layer["path"] - # Try to find which preset owns this layer - registered = False - for pack_id, _meta in presets_by_priority: - pack_dir = self.presets_dir / pack_id - if top_path.is_relative_to(pack_dir): - manifest = resolver._get_manifest(pack_dir) - if manifest: - for tmpl in manifest.templates: - if tmpl.get("name") == cmd_name and tmpl.get("type") == "command": - written = self._register_for_non_skill_agents( - registrar, [tmpl], manifest.id, pack_dir, - only_agent=only_agent, extra_agents=extra_agents, - ) - record_written(written) - self._merge_pack_registered_commands(manifest.id, written) - registered = True - break - break - if not registered: - # Top layer is a non-preset source (extension, core, or - # project override). Register directly from the layer path. - source = layers[0]["source"] - extension_id = None - written: Dict[str, List[str]] = {} - if source.startswith("extension:"): - # Use extension's own registration to preserve context formatting - extension_id = source.split(":", 1)[1].split(" ", 1)[0] - ext_dir = ( - self.project_root / ".specify" / "extensions" / extension_id - ) - ext_manifest_path = ext_dir / "extension.yml" - if ext_manifest_path.exists(): - try: - from ..extensions import ExtensionManifest - ext_manifest = ExtensionManifest(ext_manifest_path) - # Filter to only the command being reconciled - matching_cmds = [ - c for c in ext_manifest.commands - if c.get("name") == cmd_name - ] - if matching_cmds: - written = registrar.register_commands_for_non_skill_agents( - matching_cmds, extension_id, ext_dir, - self.project_root, - context_note=f"\n\n\n", - extension_id=extension_id, - only_agent=only_agent, - extra_agents=extra_agents, - ) - record_written(written) - registered = True - except (ImportError, FileNotFoundError, OSError): - # Extension registration failed; fall back to - # generic path-based registration below. - pass - if not registered: - source_id = extension_id or source - written = self._register_command_from_path( - registrar, cmd_name, top_path, - source_id=source_id, - only_agent=only_agent, extra_agents=extra_agents, - ) - record_written(written) - if extension_id: - self._merge_extension_registered_commands( - extension_id, written - ) - else: - # Composed command — resolve from full stack - composed = resolver.resolve_content(cmd_name, "command") - if composed is None: - # Composition no longer possible (e.g. base layer removed). - # Unregister any stale command file from non-skill agents. - import warnings - warnings.warn( - f"Cannot compose command '{cmd_name}': no base layer. " - f"Stale command files may remain.", - stacklevel=2, - ) - registrar._ensure_configs() - # Include aliases from the top layer's manifest - cmd_names_to_unregister = [cmd_name] - for _pid, _meta in presets_by_priority: - _pd = self.presets_dir / _pid - _m = resolver._get_manifest(_pd) - if _m: - for _t in _m.templates: - if _t.get("name") == cmd_name and _t.get("type") == "command": - for alias in _t.get("aliases", []): - if isinstance(alias, str): - cmd_names_to_unregister.append(alias) - break - # Mirror the active-only restriction used elsewhere in - # this pass: without it, unregistering a stale composed - # command would touch every non-skill agent's directory, - # deleting historical artifacts from integrations that - # were never active when this preset registered (#2948). - registrar.unregister_commands( - { - agent: cmd_names_to_unregister - for agent in registrar.AGENT_CONFIGS - if registrar.AGENT_CONFIGS[agent].get("extension") != "/SKILL.md" - and ( - only_agent is None - or agent == only_agent - or agent in (extra_agents or ()) - ) - }, - self.project_root, - ) - continue - - # Write to the highest-priority preset's .composed dir - registered = False - for pack_id, _meta in presets_by_priority: - pack_dir = self.presets_dir / pack_id - manifest = resolver._get_manifest(pack_dir) - if not manifest: - continue - for tmpl in manifest.templates: - if tmpl.get("name") == cmd_name and tmpl.get("type") == "command": - composed_dir = pack_dir / ".composed" - composed_dir.mkdir(parents=True, exist_ok=True) - composed_file = composed_dir / f"{cmd_name}.md" - composed_file.write_text(composed, encoding="utf-8") - written = self._register_for_non_skill_agents( - registrar, - [{**tmpl, "file": f".composed/{cmd_name}.md"}], - manifest.id, pack_dir, - only_agent=only_agent, extra_agents=extra_agents, - ) - record_written(written) - self._merge_pack_registered_commands(manifest.id, written) - registered = True - break - else: - continue - break - if not registered: - # No preset owns this composed command — write to a - # shared .composed dir and register from the top layer. - shared_composed = self.presets_dir / ".composed" - shared_composed.mkdir(parents=True, exist_ok=True) - composed_file = shared_composed / f"{cmd_name}.md" - composed_file.write_text(composed, encoding="utf-8") - source = layers[0]["source"] - if source.startswith("extension:"): - source_id = source.split(":", 1)[1].split(" ", 1)[0] - else: - source_id = source - written = self._register_command_from_path( - registrar, cmd_name, composed_file, - source_id=source_id, - only_agent=only_agent, extra_agents=extra_agents, - ) - record_written(written) - if source.startswith("extension:"): - self._merge_extension_registered_commands( - source_id, written - ) - - return reconciled_commands - - def _register_command_from_path( - self, - registrar: Any, - cmd_name: str, - cmd_path: Path, - source_id: str = "reconciled", - only_agent: Optional[str] = None, - extra_agents: Optional[Set[str]] = None, - ) -> Dict[str, List[str]]: - """Register a single command from a file path (non-preset source). - - Used by reconciliation when the winning layer is an extension, - core template, or project override rather than a preset. - - Args: - registrar: CommandRegistrar instance - cmd_name: Command name - cmd_path: Path to the command file - source_id: Source attribution for rendered output - only_agent: If set, restrict registration to this single agent (#2948). - extra_agents: Additional agent names to register for besides - ``only_agent`` (post-removal reconciliation only, #2948). - - Returns: - ``{agent_name: [cmd_name, ...]}`` for every agent this call - actually registered the command for (empty if the source path - doesn't exist or nothing was written). - """ - if not cmd_path.exists(): - return {} - cmd_tmpl: Dict[str, Any] = { - "name": cmd_name, - "type": "command", - "file": cmd_path.name, - } - # Load aliases from extension manifest when the winning layer is an extension - if source_id and not source_id.startswith("preset:"): - try: - from ..extensions import ExtensionManifest - for ext_dir in (self.project_root / ".specify" / "extensions").iterdir(): - if not ext_dir.is_dir(): - continue - if cmd_path.is_relative_to(ext_dir): - manifest_path = ext_dir / "extension.yml" - if manifest_path.exists(): - ext_manifest = ExtensionManifest(manifest_path) - for cmd in ext_manifest.commands: - if cmd.get("name") == cmd_name: - aliases = cmd.get("aliases", []) - if isinstance(aliases, list) and aliases: - cmd_tmpl["aliases"] = aliases - break - break - except Exception: - pass # best-effort alias loading - return self._register_for_non_skill_agents( - registrar, [cmd_tmpl], source_id, cmd_path.parent, - only_agent=only_agent, extra_agents=extra_agents, - ) - - def _register_for_non_skill_agents( - self, - registrar: Any, - commands: List[Dict[str, Any]], - source_id: str, - source_dir: Path, - only_agent: Optional[str] = None, - extra_agents: Optional[Set[str]] = None, - ) -> Dict[str, List[str]]: - """Register commands for non-skill agents during reconciliation. - - Skill-based agents (``/SKILL.md`` layout) are handled separately: - - On removal: ``_unregister_skills()`` restores from core/extension, - then ``_reconcile_skills()`` re-runs ``_register_skills()`` for the - next winning preset so SKILL.md files get proper frontmatter and - descriptions. - - On install: ``_register_skills()`` writes formatted SKILL.md, then - ``_reconcile_skills()`` ensures the actual priority winner is used. - - Writing raw command content to skill agents would produce invalid - SKILL.md files (missing skill frontmatter, descriptions, etc.). - - Args: - only_agent: If set, restrict registration to this single agent, - matching the active-only rule applied by ``_register_commands`` - (#2948). - extra_agents: Additional agent names to register for besides - ``only_agent``. Used by post-removal reconciliation to also - restore surviving content into historical agent directories - a just-removed preset actually wrote to (#2948). - - Returns: - ``{agent_name: [cmd_name, ...]}`` for every agent this call - actually registered a command for, mirroring - ``CommandRegistrar.register_commands_for_non_skill_agents``'s - return value so callers can merge it into a preset's own - ``registered_commands`` tracking (#2948). - """ - return registrar.register_commands_for_non_skill_agents( - commands, source_id, source_dir, self.project_root, - only_agent=only_agent, extra_agents=extra_agents, - ) - - class _FilteredManifest: - """Wrapper that exposes only selected command templates from a manifest. - - Used by _reconcile_skills to avoid overwriting skills for commands - that aren't being reconciled. - """ - - def __init__(self, manifest: "PresetManifest", cmd_names: set): - self._manifest = manifest - self._cmd_names = cmd_names - - def __getattr__(self, name: str): - return getattr(self._manifest, name) - - @property - def templates(self) -> List[Dict[str, Any]]: - return [ - t for t in self._manifest.templates - if t.get("name") in self._cmd_names - ] - - def _merge_pack_registered_skills( - self, pack_id: str, written: Optional[Dict[str, List[str]]] - ) -> None: - """Merge actually-written agent skill registrations into a preset's metadata. - - Mirrors :meth:`_merge_pack_registered_commands` for the skills - side: ``_reconcile_skills`` can render a preset's SKILL.md content - into an agent directory the preset never wrote to before — most - notably a historical (currently inactive) agent restored via - ``extra_skills_dirs`` when a higher-priority preset is removed. If - that write isn't reflected back into the winning preset's own - ``registered_skills``, a later removal of this same preset only - cleans up the agents it already knew about, orphaning the skill - directory reconciliation just wrote to on its behalf (#2948). - - Args: - pack_id: The preset whose metadata should be updated. - written: ``{agent_name: [skill_name, ...]}`` actually written - by the ``_register_skills`` call just made. A falsy value - is a no-op. - """ - if not written: - return - metadata = self.registry.get(pack_id) - if metadata is None: - return # pack_id no longer installed (e.g. removed mid-loop) - raw_existing_skills = metadata.get("registered_skills") - if isinstance(raw_existing_skills, list) and raw_existing_skills: - # Legacy flat-list value: infer real per-agent ownership from - # on-disk provenance rather than guessing (#2948). - fallback_agent = next(iter(written)) if written else None - existing_skills = self._infer_legacy_skill_provenance( - [n for n in raw_existing_skills if isinstance(n, str)], - pack_id, - fallback_agent=fallback_agent, - ) - else: - existing_skills = self._normalize_registered_skills(raw_existing_skills) - merged_skills = copy.deepcopy(existing_skills) - changed = ( - isinstance(raw_existing_skills, list) and bool(raw_existing_skills) - ) - for agent_name, skill_names in written.items(): - if not skill_names: - continue - existing_names = merged_skills.get(agent_name, []) - new_names = [n for n in skill_names if n not in existing_names] - if new_names: - merged_skills[agent_name] = existing_names + new_names - changed = True - if changed: - self.registry.update(pack_id, {"registered_skills": merged_skills}) - - def _reconcile_skills( - self, - command_names: List[str], - extra_skills_dirs: Optional[ - Dict[Path, tuple[Optional[str], List[str]]] - ] = None, - target_agent: Optional[str] = None, - ) -> Set[str]: - """Re-register skills for commands whose winning layer changed. - - After a preset is removed, finds the next preset in the priority - stack that provides each command and re-runs skill registration - for that preset so SKILL.md files reflect the current winner. - - Args: - command_names: List of command names to reconcile skills for - extra_skills_dirs: Additional - ``{skills_dir: (renderer_agent, managed_skill_names)}`` - entries restored by ``_unregister_skills``. Reconciliation - is limited to the names actually managed in each directory. - target_agent: If set, report only command names written for this - agent. Other callers receive the union of all written names. - - Returns: - Command names whose skill output was successfully written. - """ - if not command_names: - return set() - - # Preset-owned command names are not filtered by the - # ``speckit..`` shape here either: a self-contained preset - # command renders its skill whether or not a like-named extension is - # installed. The per-name loop below skips anything that doesn't - # resolve to a managed skill directory. - resolver = PresetResolver(self.project_root) - active_skills_dir = self._get_skills_dir() - - from .. import load_init_options - - init_opts = load_init_options(self.project_root) - active_ai = init_opts.get("ai") if isinstance(init_opts, dict) else None - if not isinstance(active_ai, str) or not active_ai: - active_ai = None - - # Cache registry once to avoid repeated filesystem reads - presets_by_priority = list(self.registry.list_by_priority()) - - # Group command names by winning preset to batch _register_skills calls - # while only registering skills for the specific commands being - # reconciled. This resolution (which preset/content wins) is - # directory-independent, so it's computed once and then applied to - # every affected directory below. - preset_cmds: Dict[str, List[str]] = {} - non_preset_skills: List[tuple] = [] - managed_skill_names: set = set() - reconciled_skill_commands: set[str] = set() - - for cmd_name in command_names: - layers = resolver.collect_all_layers(cmd_name, "command") - if not layers: - continue - - skill_name, legacy_skill_name = self._skill_names_for_command( - cmd_name - ) - candidate_skill_names = {skill_name, legacy_skill_name} - # Track whether any preset previously registered this skill - # (i.e., it was actively managed), so a not-yet-existing skill - # dir can be re-created per affected directory below. - for _pid, meta in presets_by_priority: - if not isinstance(meta, dict): - continue - recorded = meta.get("registered_skills", []) - if isinstance(recorded, dict): - recorded_names = { - name - for names in recorded.values() - if isinstance(names, list) - for name in names - } - elif isinstance(recorded, list): - recorded_names = set(recorded) - else: - recorded_names = set() - recorded_candidates = ( - candidate_skill_names & recorded_names - ) - if recorded_candidates: - managed_skill_names.update(recorded_candidates) - - top_path = layers[0]["path"] - # Find the preset that owns the winning layer - found_preset = False - for pack_id, _meta in presets_by_priority: - pack_dir = self.presets_dir / pack_id - if top_path.is_relative_to(pack_dir): - preset_cmds.setdefault(pack_id, []).append(cmd_name) - found_preset = True - break - if not found_preset: - # Winner is a non-preset source (core/extension/override). - # Track the winning layer path for skill restoration. - non_preset_skills.append((skill_name, cmd_name, layers[0])) - - core_ext_skills = [s for s in non_preset_skills if s[2]["source"] != "project override"] - override_skills = [s for s in non_preset_skills if s[2]["source"] == "project override"] - - def apply_to_dir( - skills_dir: Path, - dir_agent: Optional[str], - *, - is_active: bool, - managed_names: Optional[Set[str]] = None, - ) -> None: - dir_managed_names = ( - managed_skill_names if managed_names is None else managed_names - ) - # Restore skills for commands whose winner is non-preset. - # _unregister_skills_in_dir can rmtree the skill dir, so - # overrides must be handled directly (create dir + write) - # without that call. - dir_core_ext_names = [ - candidate - for _skill_name, cmd_name, _top_layer in core_ext_skills - for candidate in self._skill_names_for_command(cmd_name) - if candidate in dir_managed_names - ] - if dir_core_ext_names: - self._unregister_skills_in_dir( - dir_core_ext_names, - skills_dir, - dir_agent, - restore_from_bundled_core=True, - ) - - for _skill_name, cmd_name, top_layer in override_skills: - target_skill_names = [ - name - for name in self._skill_names_for_command(cmd_name) - if name in dir_managed_names - ] - if not target_skill_names: - continue - try: - from ..agents import CommandRegistrar - from .. import SKILL_DESCRIPTIONS - from ..shared_infra import _write_shared_text - registrar = CommandRegistrar() - content = top_layer["path"].read_text(encoding="utf-8") - fm, body = registrar.parse_frontmatter(content) - short_name = cmd_name - if short_name.startswith("speckit."): - short_name = short_name[len("speckit."):] - desc = fm.get("description", "") or SKILL_DESCRIPTIONS.get( - short_name.replace(".", "-"), - f"Command: {short_name}", - ) - selected_ai = dir_agent if isinstance(dir_agent, str) else "" - if selected_ai: - body = registrar.resolve_skill_placeholders( - selected_ai, fm, body, self.project_root - ) - body = self._resolve_skill_command_refs( - body, registrar, selected_ai, self.project_root - ) - from ..integrations import get_integration - integration = get_integration(selected_ai) if selected_ai else None - skill_title = self._skill_title_from_command(cmd_name) - wrote_override = False - for target_skill_name in target_skill_names: - skill_subdir = skills_dir / target_skill_name - # Same symlink guard as _register_skills's - # registration path (#2948). - if not self._validate_skill_subdir( - skill_subdir, - create=True, - skills_root=skills_dir, - ): - continue - fm_data = registrar.build_skill_frontmatter( - selected_ai, - target_skill_name, - desc, - f"override:{cmd_name}", - ) - registrar.apply_argument_hint( - fm, fm_data, integration - ) - fm_text = dump_frontmatter(fm_data) - skill_content = ( - f"---\n{fm_text}\n---\n\n" - f"# Speckit {skill_title} Skill\n\n{body}\n" - ) - if integration is not None and hasattr( - integration, "post_process_skill_content" - ): - skill_content = ( - integration.post_process_skill_content( - skill_content - ) - ) - _write_shared_text( - skills_dir, - skill_subdir / "SKILL.md", - skill_content, - ) - wrote_override = True - if ( - wrote_override - and ( - target_agent is None - or dir_agent == target_agent - ) - ): - reconciled_skill_commands.add(cmd_name) - except Exception: - pass # best-effort override skill restoration - - # Register skills only for the specific commands being - # reconciled, not all commands in each winning preset's - # manifest. - for pack_id, cmds in preset_cmds.items(): - dir_cmds = [ - cmd - for cmd in cmds - if any( - name in dir_managed_names - for name in self._skill_names_for_command(cmd) - ) - ] - if not dir_cmds: - continue - pack_dir = self.presets_dir / pack_id - manifest_path = pack_dir / "preset.yml" - if not manifest_path.exists(): - continue - try: - manifest = PresetManifest(manifest_path) - except PresetValidationError: - continue - cmds_set = set(dir_cmds) - filtered_manifest = self._FilteredManifest(manifest, cmds_set) - # Not dead code: _register_skills only *overwrites* skill - # subdirectories that already exist (plus brand-new ones for - # the active ai_skills agent). For a restore into a - # historical directory, _unregister_skills has just deleted - # the retiring preset's subdirectory, so pre-create the - # tracked (dir_managed_names) subdirectories here — under - # the same symlink guard — or the surviving preset's - # override would be silently skipped (#2948). - for cmd_name in dir_cmds: - for skill_name in self._skill_names_for_command(cmd_name): - if skill_name not in dir_managed_names: - continue - skill_subdir = skills_dir / skill_name - if not self._validate_skill_subdir( - skill_subdir, - create=True, - skills_root=skills_dir, - ): - continue - if is_active: - # Preserve exact prior behaviour for the currently - # active directory (including the ability to create - # brand-new skill subdirectories when ai_skills is on). - written = self._register_skills(filtered_manifest, pack_dir) - else: - written = self._register_skills( - filtered_manifest, pack_dir, - target_dir=skills_dir, target_agent=dir_agent or "", - ) - if target_agent is None: - written_names = { - name - for names in written.values() - for name in names - } - else: - written_names = set(written.get(target_agent, [])) - for cmd_name in dir_cmds: - if written_names.intersection( - self._skill_names_for_command(cmd_name) - ): - reconciled_skill_commands.add(cmd_name) - # The winning preset may not have previously written to - # this directory's agent (most notably a historical agent - # reconciliation just restored content into via - # extra_skills_dirs). If that write isn't merged back into - # the preset's own registered_skills, its registry entry - # silently lies about which directories it owns and a - # later removal of this same preset orphans the directory - # reconciliation just wrote to on its behalf (#2948). - self._merge_pack_registered_skills(pack_id, written) - - extra_dirs = extra_skills_dirs or {} - if active_skills_dir: - active_provenance = extra_dirs.get(active_skills_dir) - if extra_skills_dirs is None or active_provenance: - apply_to_dir( - active_skills_dir, - active_ai, - is_active=True, - managed_names=( - set(active_provenance[1]) - if active_provenance - else None - ), - ) - - for extra_dir, (extra_agent, extra_names) in extra_dirs.items(): - if extra_dir == active_skills_dir: - continue # already reconciled above as the active directory - apply_to_dir( - extra_dir, - extra_agent, - is_active=False, - managed_names=set(extra_names), - ) - - return reconciled_skill_commands - - def _resolve_agent_skills_dir(self, agent_name: str) -> Path: - """Resolve the real skill output directory for an integration.""" - from .. import _get_skills_dir as _project_skills_dir - from ..agents import CommandRegistrar - - registrar = CommandRegistrar() - agent_config = registrar.AGENT_CONFIGS.get(agent_name) - if agent_config and agent_config.get("extension") == "/SKILL.md": - return registrar._resolve_agent_dir( - agent_name, agent_config, self.project_root - ) - return _project_skills_dir(self.project_root, agent_name) - - def _skills_validation_root(self, skills_dir: Path) -> Optional[Path]: - """Return the trusted root containing a project or user skill dir.""" - for root in (self.project_root, Path.home()): - if skills_dir.is_relative_to(root): - return root - return None - - def _get_skills_dir(self) -> Optional[Path]: - """Return the active skills directory for preset skill overrides. - - Uses :func:`resolve_active_skills_dir` for activation/detection, - then resolves native skill agents through the registrar's output - directory so integrations such as Hermes write to their global - skills path rather than their project-local detection marker. - - Returns ``None`` (instead of raising) when the directory cannot - be created due to symlink, containment, or permission issues so - that callers can fall back gracefully. - """ - from .. import ( - _print_cli_warning, - load_init_options, - resolve_active_skills_dir, - ) - from ..shared_infra import _ensure_safe_shared_directory - try: - skills_dir = resolve_active_skills_dir(self.project_root) - except (ValueError, OSError) as exc: - _print_cli_warning( - "resolve", "skills directory", None, exc, - continuing="Continuing without skill registration.", - ) - return None - if skills_dir is None: - return None - - opts = load_init_options(self.project_root) - selected_ai = opts.get("ai") if isinstance(opts, dict) else None - if not isinstance(selected_ai, str) or not selected_ai: - return skills_dir - - agent_skills_dir = self._resolve_agent_skills_dir(selected_ai) - if agent_skills_dir == skills_dir: - return skills_dir - - validation_root = self._skills_validation_root(agent_skills_dir) - if validation_root is None: - _print_cli_warning( - "resolve", - "skills directory", - str(agent_skills_dir), - ValueError("skills directory is outside trusted roots"), - continuing="Continuing without skill registration.", - ) - return None - try: - _ensure_safe_shared_directory( - validation_root, - agent_skills_dir, - context="preset skills directory", - ) - except (ValueError, OSError) as exc: - _print_cli_warning( - "resolve", "skills directory", str(agent_skills_dir), exc, - continuing="Continuing without skill registration.", - ) - return None - return agent_skills_dir - - @staticmethod - def _skill_names_for_command(cmd_name: str) -> tuple[str, str]: - """Return the modern and legacy skill directory names for a command.""" - raw_short_name = cmd_name - if raw_short_name.startswith("speckit."): - raw_short_name = raw_short_name[len("speckit."):] - - modern_skill_name = f"speckit-{raw_short_name.replace('.', '-')}" - legacy_skill_name = f"speckit.{raw_short_name}" - return modern_skill_name, legacy_skill_name - - @staticmethod - def _skill_title_from_command(cmd_name: str) -> str: - """Return a human-friendly title for a skill command name.""" - title_name = cmd_name - if title_name.startswith("speckit."): - title_name = title_name[len("speckit."):] - return title_name.replace(".", " ").replace("-", " ").title() - - @staticmethod - def _resolve_skill_command_refs( - body: str, - registrar: "CommandRegistrar", - selected_ai: str, - project_root: "Path | None" = None, - ) -> str: - """Render ``__SPECKIT_COMMAND_*__`` tokens in a skill body as invocations. - - Looks up the agent's invoke separator and rewrites each - ``__SPECKIT_COMMAND___`` placeholder into the matching - agent-native invocation -- ``/speckit-`` or ``$speckit-`` for - a ``-`` separator, ``/speckit.`` for ``.``, or - ``/skill:speckit-`` for skill-colon agents (e.g. Kimi) -- the - same rendering the command layer applies via - ``CommandRegistrar.register_commands()``. - - For dual-layout agents (e.g. Bob) the separator depends on the - project's persisted skills state, so -- when *project_root* is provided - -- the separator is resolved from the integration via - ``invoke_separator_for_mode`` rather than the single static - ``AGENT_CONFIGS`` value. - """ - separator = None - if project_root is not None and isinstance(selected_ai, str): - try: - from .. import load_init_options - from ..integrations import get_integration - - integration = get_integration(selected_ai) - if integration is not None: - separator = integration.invoke_separator_for_mode( - is_ai_skills_enabled(load_init_options(project_root)) - ) - except Exception: - separator = None - if separator is None: - separator = registrar.AGENT_CONFIGS.get(selected_ai, {}).get( - "invoke_separator", "." - ) - prefix = get_invocation_prefix(selected_ai, separator == "-") - return IntegrationBase.resolve_command_refs(body, separator, prefix) - - def _build_extension_skill_restore_index(self) -> Dict[str, Dict[str, Any]]: - """Index extension-backed skill restore data by skill directory name.""" - from ..extensions import ExtensionManifest, ValidationError - - resolver = PresetResolver(self.project_root) - extensions_dir = self.project_root / ".specify" / "extensions" - restore_index: Dict[str, Dict[str, Any]] = {} - - for _priority, ext_id, _metadata in resolver._get_all_extensions_by_priority(): - ext_dir = extensions_dir / ext_id - manifest_path = ext_dir / "extension.yml" - if not manifest_path.is_file(): - continue - - try: - manifest = ExtensionManifest(manifest_path) - except (ValidationError, TypeError, AttributeError): - continue - - ext_root = ext_dir.resolve() - for cmd_info in manifest.commands: - cmd_name = cmd_info.get("name") - cmd_file_rel = cmd_info.get("file") - if not isinstance(cmd_name, str) or not isinstance(cmd_file_rel, str): - continue - - cmd_path = Path(cmd_file_rel) - if cmd_path.is_absolute(): - continue - - try: - source_file = (ext_root / cmd_path).resolve() - source_file.relative_to(ext_root) - except (OSError, ValueError): - continue - - if not source_file.is_file(): - continue - - restore_info = { - "command_name": cmd_name, - "source_file": source_file, - "source": f"extension:{manifest.id}", - "author": manifest.data["extension"].get("author"), - "extension_id": manifest.id, - "extension_dir": ext_root, - } - modern_skill_name, legacy_skill_name = self._skill_names_for_command(cmd_name) - restore_index.setdefault(modern_skill_name, restore_info) - if legacy_skill_name != modern_skill_name: - restore_index.setdefault(legacy_skill_name, restore_info) - - return restore_index - - def _register_skills( - self, - manifest: "PresetManifest", - preset_dir: Path, - *, - target_dir: Optional[Path] = None, - target_agent: Optional[str] = None, - ) -> Dict[str, List[str]]: - """Generate SKILL.md files for preset command overrides. - - For every command template in the preset, checks whether a - corresponding skill already exists in any detected skills - directory. If so, the skill is overwritten with content derived - from the preset's command file. This ensures that presets that - override commands also propagate to the agentskills.io skill - layer when skills mode was used during project initialisation. - - Args: - manifest: Preset manifest. - preset_dir: Installed preset directory. - target_dir: Explicit skills directory to render into, instead - of resolving the currently active one. Used by - ``_reconcile_skills`` to restore a surviving preset's - override into a historical (currently inactive) agent's - directory that removal of a higher-priority preset just - reverted (#2948). - target_agent: Explicit agent name to render for, paired with - ``target_dir``. When set, skills are only ever restored - into already-tracked directories/names — brand-new skill - subdirectories are never created for a non-active, - explicitly targeted directory (that creation path is only - meaningful for the currently active agent). - - Returns: - ``{agent_name: [skill_name, ...]}`` for the single active - agent skills were written for (empty if none were written), - matching the shape ``registered_commands`` already uses so the - two can be tracked/restored consistently (#2948). - """ - command_templates = [ - t for t in manifest.templates if t.get("type") == "command" - ] - if not command_templates: - return {} - - # Preset command templates are self-contained and render as skills - # regardless of whether a like-named extension is installed — the same - # rule _register_commands() uses. No ``speckit..`` name-shape - # filtering; the per-command loop below skips anything without a target - # skill directory. - skills_dir = target_dir if target_dir is not None else self._get_skills_dir() - if not skills_dir: - return {} - - resolver = PresetResolver(self.project_root) - - from .. import SKILL_DESCRIPTIONS, load_init_options - from ..agents import CommandRegistrar - from ..integrations import get_integration - from ..shared_infra import _write_shared_text - - init_opts = load_init_options(self.project_root) - if not isinstance(init_opts, dict): - init_opts = {} - selected_ai = target_agent if target_agent is not None else init_opts.get("ai") - if not isinstance(selected_ai, str) or not selected_ai: - return {} - # A target_dir/target_agent call reconciles an explicitly-known, - # already-tracked directory (see _reconcile_skills) rather than the - # currently active agent, so ai_skills_enabled must not be derived - # from the *current* project-wide toggle for that other agent — it - # only controls whether brand-new skill subdirectories may be - # created below, which is only meaningful for the active agent. - ai_skills_enabled = target_agent is None and is_ai_skills_enabled(init_opts) - registrar = CommandRegistrar() - integration = get_integration(selected_ai) - agent_config = registrar.AGENT_CONFIGS.get(selected_ai, {}) - # Native skill agents (e.g. codex/kimi/agy/trae) materialize brand-new - # preset skills in _register_commands() because their detected agent - # directory is already the skills directory. This flag is only for - # command-backed agents that also mirror commands into skills. - create_missing_skills = ai_skills_enabled and agent_config.get("extension") != "/SKILL.md" - - written: List[str] = [] - - for cmd_tmpl in command_templates: - cmd_name = cmd_tmpl["name"] - cmd_file_rel = cmd_tmpl["file"] - source_file = preset_dir / cmd_file_rel - if not source_file.exists(): - continue - - # Use composed content if available (written by _register_commands - # for commands with non-replace strategies), otherwise the original. - composed_file = preset_dir / ".composed" / f"{cmd_name}.md" - if composed_file.exists(): - source_file = composed_file - - # Derive the short command name (e.g. "specify" from "speckit.specify") - raw_short_name = cmd_name - if raw_short_name.startswith("speckit."): - raw_short_name = raw_short_name[len("speckit."):] - short_name = raw_short_name.replace(".", "-") - skill_name, legacy_skill_name = self._skill_names_for_command(cmd_name) - skill_title = self._skill_title_from_command(cmd_name) - - # Only overwrite skills that already exist under skills_dir, - # including Kimi native skills when ai_skills is false. - # If both modern and legacy directories exist, update both. - target_skill_names: List[str] = [] - if (skills_dir / skill_name).is_dir(): - target_skill_names.append(skill_name) - if legacy_skill_name != skill_name and (skills_dir / legacy_skill_name).is_dir(): - target_skill_names.append(legacy_skill_name) - if not target_skill_names and create_missing_skills: - missing_skill_dir = skills_dir / skill_name - if not missing_skill_dir.exists(): - target_skill_names.append(skill_name) - if not target_skill_names: - continue - - # Parse the command file - content = source_file.read_text(encoding="utf-8") - frontmatter, body = registrar.parse_frontmatter(content) - - # A composition-strategy command (wrap/prepend/append) needs a - # base layer to compose onto. When _register_commands produced no - # composed file for it and the stack still has no base - # (resolve_content is None) — e.g. the command it wraps comes from - # an extension that isn't installed — rendering the raw preset - # fragment as a skill would emit broken output: a literal - # {CORE_TEMPLATE} for wrap, or only the preset's own fragment for - # prepend/append. Skip it here too so command mode and skills mode - # agree (mirrors _register_commands, which skips the same command). - # _register_commands already warned for this command in the same - # pass, so the skip is silent here to avoid a duplicate warning. - effective_strategy = ( - cmd_tmpl.get("strategy") - or frontmatter.get("strategy") - or "replace" - ) - if ( - effective_strategy != "replace" - and not composed_file.exists() - and resolver.resolve_content(cmd_name, "command") is None - ): - continue - - if frontmatter.get("strategy") == "wrap": - body, core_frontmatter = _substitute_core_template(body, cmd_name, self.project_root, registrar) - frontmatter = dict(frontmatter) - for key in ("scripts", "agent_scripts", "argument-hint"): - if key not in frontmatter and key in core_frontmatter: - frontmatter[key] = core_frontmatter[key] - - original_desc = frontmatter.get("description", "") - enhanced_desc = original_desc or SKILL_DESCRIPTIONS.get( - short_name, - f"Spec-kit workflow command: {short_name}", - ) - frontmatter = dict(frontmatter) - frontmatter["description"] = enhanced_desc - body = registrar.resolve_skill_placeholders( - selected_ai, frontmatter, body, self.project_root - ) - body = self._resolve_skill_command_refs(body, registrar, selected_ai, self.project_root) - - for target_skill_name in target_skill_names: - skill_subdir = skills_dir / target_skill_name - if skill_subdir.exists() and not skill_subdir.is_dir(): - continue - # Validate (and create, if missing) the skill's own - # subdirectory under the same symlink guard as its parent — - # is_dir() above follows symlinks, so a symlinked subdir - # with a real parent would otherwise slip through and have - # SKILL.md written through it to an arbitrary location (#2948). - if not self._validate_skill_subdir( - skill_subdir, create=True, skills_root=skills_dir - ): - continue - frontmatter_data = registrar.build_skill_frontmatter( - selected_ai, - target_skill_name, - enhanced_desc, - f"preset:{manifest.id}", - ) - registrar.apply_argument_hint(frontmatter, frontmatter_data, integration) - frontmatter_text = dump_frontmatter(frontmatter_data) - skill_content = ( - f"---\n" - f"{frontmatter_text}\n" - f"---\n\n" - f"# Speckit {skill_title} Skill\n\n" - f"{body}\n" - ) - if integration is not None and hasattr(integration, "post_process_skill_content"): - skill_content = integration.post_process_skill_content( - skill_content - ) - - skill_file = skill_subdir / "SKILL.md" - _write_shared_text( - skills_dir, skill_file, skill_content - ) - written.append(target_skill_name) - self._merge_pack_registered_skills( - manifest.id, {selected_ai: [target_skill_name]} - ) - - return {selected_ai: written} if written else {} - - def _infer_legacy_skill_provenance( - self, skill_names: List[str], pack_id: str, fallback_agent: str - ) -> Dict[str, List[str]]: - """Infer per-agent ownership of a legacy flat-list ``registered_skills`` value. - - Pre-#2948 registries recorded ``registered_skills`` as a flat list - with no record of which agent directory each name was actually - written under. Blindly attributing every name to ``fallback_agent`` - (the agent currently being processed) loses the real writer whenever - the *first* operation after upgrading is a direct switch to a - *different* agent — e.g. a legacy Copilot override (written while - Copilot was active with ``ai_skills`` enabled) followed directly by - ``integration use claude``, with no intervening rescaffold for - Copilot — permanently orphaning Copilot's override on later - removal. - - Every project-local configured integration's skills directory is probed (via - the same safe, symlink-validated helpers used for - restore/removal), not only agents whose registrar config is - statically ``/SKILL.md``-only: a command-backed agent (e.g. - Copilot, whose command extension is ``.agent.md``) renders its - preset overrides as ``SKILL.md`` files exactly like a native - skill-only agent whenever it was the active agent with - ``ai_skills`` enabled, so excluding it would miss real, - preset-owned provenance and misattribute it to whichever agent - happens to be processed first. Each directory is probed for a - ``SKILL.md`` whose frontmatter records this exact preset as the - owner (``metadata.source == "preset:"``, the same marker - :meth:`_register_skills` writes) — this marker check is what keeps - the broadened probe from falsely attributing ownership to an - agent's directory that never actually held this preset's override - (e.g. a command-mode agent that never rendered skills, or an - unrelated skill of the same name). A name can legitimately be - found under more than one agent's directory — the preset may have - been active while the user switched between several agents before - provenance tracking existed — so every matching agent is recorded, - not just the first. Names that can't be matched to any directory - (e.g. the file was deleted out of band) fall back to - ``fallback_agent``, preserving the previous best-effort behaviour - for the unrecoverable case. - """ - from ..agents import CommandRegistrar - - registrar = CommandRegistrar() - candidate_agents = sorted(registrar.AGENT_CONFIGS) - - # Multiple agent names can resolve to the same physical directory - # (e.g. agy/amp/codex/zed all use .agents/skills); group by - # directory so each is probed once and attributed to a single - # deterministic canonical agent name, matching the tie-break - # already used by _unregister_skills's directory grouping. Deliberately - # keep the unresolved path (matching what _safe_skills_dir_for_agent - # already validated) rather than calling .resolve() here: on macOS - # /var is itself a symlink to /private/var, so resolving would make - # this path diverge from self.project_root's own resolution state - # and make every subsequent containment check in - # _validate_skill_subdir() spuriously fail. - dir_to_agents: Dict[Path, List[str]] = {} - for agent_name in candidate_agents: - skills_dir = self._safe_skills_dir_for_agent(agent_name) - if skills_dir is None: - continue - # Only project-local skills directories are eligible: the - # legacy provenance markers don't record which project owns a - # skill under a home directory, so deletion stays restricted - # to the project root. Revisit if provenance ever records the - # owning project. - if not Path(os.path.abspath(skills_dir)).is_relative_to( - Path(os.path.abspath(self.project_root)) - ): - continue - dir_to_agents.setdefault(skills_dir, []).append(agent_name) - - marker = f"preset:{pack_id}" - # Filter unsafe names once, up front, rather than only inside the - # matching loop: any name skipped there would otherwise still - # land in "unmatched" below and get blindly attributed to - # fallback_agent anyway, defeating the guard entirely (#2948). - safe_skill_names = [ - name for name in skill_names if self._is_safe_registry_skill_name(name) - ] - inferred: Dict[str, List[str]] = {} - matched_names: set = set() - for resolved_dir, agents in dir_to_agents.items(): - canonical_agent = fallback_agent if fallback_agent in agents else sorted(agents)[0] - for name in safe_skill_names: - skill_subdir = resolved_dir / name - if not self._validate_skill_subdir( - skill_subdir, create=False, skills_root=resolved_dir - ): - continue - skill_file = skill_subdir / "SKILL.md" - if not skill_file.is_file(): - continue - try: - content = skill_file.read_text(encoding="utf-8") - except (OSError, UnicodeDecodeError): - continue - frontmatter, _ = registrar.parse_frontmatter(content) - skill_metadata = frontmatter.get("metadata") - source = ( - skill_metadata.get("source") - if isinstance(skill_metadata, dict) - else None - ) - if source == marker: - inferred.setdefault(canonical_agent, []).append(name) - matched_names.add(name) - - unmatched = [name for name in safe_skill_names if name not in matched_names] - if unmatched and fallback_agent: - fallback_names = inferred.setdefault(fallback_agent, []) - for name in unmatched: - if name not in fallback_names: - fallback_names.append(name) - - return inferred - - @staticmethod - def _normalize_registered_skills( - value: Any, fallback_agent: Optional[str] = None - ) -> Dict[str, List[str]]: - """Normalize a ``registered_skills`` registry value to per-agent form. - - The registry stores ``registered_skills`` as ``Dict[str, List[str]]`` - (agent name -> skill names actually written for that agent), - mirroring ``registered_commands``. Older registries predate that - provenance and stored a flat ``List[str]`` with no record of which - agent directory the names were written under; since that can't be - recovered, ``fallback_agent`` (when given) attributes the legacy - list to the agent currently being processed so the format - self-migrates on the next write. Without a fallback agent, legacy - lists are dropped rather than guessed at. - - Callers that can identify the owning preset (i.e. have a - ``pack_id``) should prefer :meth:`_infer_legacy_skill_provenance` - for a legacy flat-list value instead, which probes on-disk - provenance rather than assuming ``fallback_agent`` wrote every name. - """ - if isinstance(value, dict): - return { - agent: list(names) - for agent, names in value.items() - if isinstance(agent, str) and isinstance(names, list) - } - if isinstance(value, list) and value and fallback_agent: - return {fallback_agent: [n for n in value if isinstance(n, str)]} - return {} - - def _safe_skills_dir_for_agent(self, agent_name: str) -> Optional[Path]: - """Resolve ``agent_name``'s skills directory, validated for safety. - - Unlike :meth:`_get_skills_dir` (which resolves only the *currently - active* integration via init-options), this resolves an arbitrary - agent's directory from persisted provenance so a preset's skill - registrations can be restored/cleaned up under an agent that isn't - currently active. The candidate directory is validated through the - project's shared symlink/containment guard before any file in it is - touched; directories that don't exist or fail validation are - skipped rather than raising. - """ - from ..agents import CommandRegistrar - from ..shared_infra import _ensure_safe_shared_directory - - if agent_name not in CommandRegistrar.AGENT_CONFIGS: - return None - skills_dir = self._resolve_agent_skills_dir(agent_name) - validation_root = self._skills_validation_root(skills_dir) - if validation_root is None: - return None - try: - _ensure_safe_shared_directory( - validation_root, skills_dir, - create=False, context="preset skills directory", - ) - except (ValueError, OSError): - return None - return skills_dir - - @staticmethod - def _is_safe_registry_skill_name(name: Any) -> bool: - """Validate a registry-provided skill name is a single safe path component. - - ``registered_skills`` entries are persisted registry data, not - derived from the current preset manifest, so a corrupted or - maliciously edited registry could contain an absolute path, a - multi-segment path (containing ``/`` or ``\\``), or a traversal - component (``"."``/``".."``) instead of a plain skill directory - name. Any of these — if joined directly onto a skills directory — - can escape the intended skill subtree while still resolving to a - location inside the project root, which is enough to pass the - parent-directory containment/symlink check alone (#2948). This - centralizes the single boundary check every preset cleanup and - provenance loop that consumes registry-provided skill names must - apply before ever constructing a path from one. - """ - if not isinstance(name, str) or not name: - return False - if name in (".", ".."): - return False - candidate = Path(name) - if candidate.is_absolute(): - return False - if len(candidate.parts) != 1: - return False - if candidate.name != name: - return False - return True - - def _validate_skill_subdir( - self, - skill_subdir: Path, - *, - create: bool, - skills_root: Optional[Path] = None, - ) -> bool: - """Validate a single skill's subdirectory is symlink-free. - - Unlike :meth:`_safe_skills_dir_for_agent` (which only validates the - *parent* skills directory), this validates the skill's own - subdirectory — e.g. ``.claude/skills/speckit-specify`` — so a - symlink planted at that level (with a safe parent) can't be used to - write or delete through to a location outside the project. Shared by - both the registration path (``create=True``, so a missing directory - is created component-by-component under the same guard) and the - restore/removal path (``create=False``, so a missing directory is - left for the caller's own existence check to skip). Returns - ``False`` rather than raising when the path escapes the project - root or crosses a symlink. ``skills_root`` supplies the trusted - agent output boundary for native global skill integrations such as - Hermes; project-local callers default to ``self.project_root``. - """ - from ..shared_infra import _ensure_safe_shared_directory, _validate_safe_shared_directory - - validation_root = skills_root or self.project_root - if validation_root.is_symlink(): - return False - try: - if create: - _ensure_safe_shared_directory( - validation_root, skill_subdir, - create=True, context="preset skill directory", - ) - else: - _validate_safe_shared_directory( - validation_root, skill_subdir - ) - except (ValueError, OSError): - return False - return True - - def _unregister_skills( - self, - registered_skills: Union[Dict[str, List[str]], List[str]], - preset_dir: Union[Path, str], - *, - additional_owned_sources: Optional[Dict[str, str]] = None, - restore_from_bundled_core: bool = False, - ) -> Dict[Path, tuple[Optional[str], List[str]]]: - """Restore original SKILL.md files after a preset is removed. - - For each skill that was overridden by the preset, attempts to - regenerate the skill from the core command template. If no core - template exists, the skill directory is removed. - - Args: - restore_from_bundled_core: When True, a missing project-local - core template (the common case — ``specify init`` never - populates ``.specify/templates/commands``) falls back to - the bundled core_pack/repo-root templates so the skill is - restored instead of deleted (#3928). Callers that are - retiring a skill because its command now renders elsewhere - (a command file superseding it) must leave this False so - the skill is removed rather than resurrected with core - content that would duplicate the winning command. - - ``registered_skills`` records exactly which agent directories this - preset actually wrote to (see :meth:`_register_skills`), so removal - restores precisely those directories rather than guessing at every - skill-mode agent that happens to exist on disk. Each directory is - re-resolved and safety-validated at removal time (see - :meth:`_safe_skills_dir_for_agent`) since it may belong to an agent - that isn't currently active. - - Args: - registered_skills: Per-agent skill names written by the preset - (``{agent_name: [skill_name, ...]}``), or a legacy flat - ``List[str]`` from a registry written before this - provenance tracking existed. - preset_dir: The preset's installed directory (may already be deleted). - additional_owned_sources: Generated non-preset source markers - that this cleanup may also replace for specific skill names. - - Returns: - ``{skills_dir: (renderer_agent, managed_skill_names)}`` for - every directory and skill name actually restored or removed. - """ - if not registered_skills: - return {} - - pack_id = preset_dir if isinstance(preset_dir, str) else preset_dir.name - - if isinstance(registered_skills, dict): - from .. import load_init_options - - init_opts = load_init_options(self.project_root) - active_agent = init_opts.get("ai") if isinstance(init_opts, dict) else None - if not isinstance(active_agent, str) or not active_agent: - active_agent = None - - # Multiple integration keys can share the same physical - # directory (e.g. agy/codex/zed all resolve to - # ``.agents/skills``). Restoring that directory once per - # recorded agent would have each pass's agent-specific - # rendering (frontmatter, post-processing) overwrite the - # previous one, with whichever agent is iterated *last* silently - # winning regardless of which agent is actually active. Group - # provenance by resolved directory so each physical directory is - # restored exactly once, using the active agent's renderer when - # it shares that directory (otherwise any recorded owner, - # chosen deterministically). - groups: Dict[Path, Dict[str, Any]] = {} - for agent_name, skill_names in registered_skills.items(): - if not skill_names: - continue - skills_dir = self._safe_skills_dir_for_agent(agent_name) - if skills_dir is None: - continue - group = groups.setdefault(skills_dir, {"agents": [], "names": []}) - group["agents"].append(agent_name) - for name in skill_names: - if ( - self._is_safe_registry_skill_name(name) - and name not in group["names"] - ): - group["names"].append(name) - - restored: Dict[Path, tuple[Optional[str], List[str]]] = {} - for skills_dir, group in groups.items(): - agents = group["agents"] - renderer_agent = ( - active_agent if active_agent in agents else sorted(agents)[0] - ) - mutated_names = self._unregister_skills_in_dir( - group["names"], - skills_dir, - renderer_agent, - pack_id=pack_id, - additional_owned_sources=additional_owned_sources, - restore_from_bundled_core=restore_from_bundled_core, - ) - if mutated_names: - restored[skills_dir] = ( - renderer_agent, - mutated_names, - ) - return restored - - # Legacy flat-list format: no record of which agent directory these - # names were written under, so best-effort restore is limited to the - # currently active agent's directory (the pre-provenance behaviour). - skills_dir = self._get_skills_dir() - if not skills_dir: - return {} - from .. import load_init_options - - init_opts = load_init_options(self.project_root) - if not isinstance(init_opts, dict): - init_opts = {} - selected_ai = init_opts.get("ai") - selected_ai = selected_ai if isinstance(selected_ai, str) else None - safe_names = [ - name - for name in registered_skills - if self._is_safe_registry_skill_name(name) - ] - mutated_names = self._unregister_skills_in_dir( - safe_names, - skills_dir, - selected_ai, - pack_id=pack_id, - additional_owned_sources=additional_owned_sources, - restore_from_bundled_core=restore_from_bundled_core, - ) - return ( - {skills_dir: (selected_ai, mutated_names)} - if mutated_names - else {} - ) - - def _delete_agent_preset_skills( - self, agent_name: str, skill_names: List[str], pack_id: str - ) -> None: - """Delete still-preset-owned skills when an agent is deactivated.""" - skills_dir = self._safe_skills_dir_for_agent(agent_name) - if skills_dir is None: - return - - from ..agents import CommandRegistrar - - registrar = CommandRegistrar() - marker = f"preset:{pack_id}" - override_sources: Dict[str, str] = {} - manifest = PresetResolver(self.project_root)._get_manifest( - self.presets_dir / pack_id - ) - if manifest is not None: - for template in manifest.templates: - command_name = template.get("name") - if ( - template.get("type") == "command" - and isinstance(command_name, str) - ): - for skill_name in self._skill_names_for_command( - command_name - ): - override_sources[skill_name] = ( - f"override:{command_name}" - ) - for skill_name in skill_names: - if not self._is_safe_registry_skill_name(skill_name): - continue - skill_subdir = skills_dir / skill_name - if not self._validate_skill_subdir( - skill_subdir, create=False, skills_root=skills_dir - ): - continue - skill_file = skill_subdir / "SKILL.md" - if not skill_file.is_file(): - continue - try: - content = skill_file.read_text(encoding="utf-8") - except (OSError, UnicodeDecodeError): - continue - frontmatter, _ = registrar.parse_frontmatter(content) - metadata = frontmatter.get("metadata") - source = ( - metadata.get("source") - if isinstance(metadata, dict) - else None - ) - owned_sources = {marker} - override_source = override_sources.get(skill_name) - if override_source: - owned_sources.add(override_source) - if source in owned_sources: - shutil.rmtree(skill_subdir) - - @staticmethod - def _warn_unrestored_skill( - skill_name: str, source_file: Path, exc: BaseException - ) -> None: - """Warn that a skill kept preset content because its restore source is unreadable. - - Skipping the restore is the safe recovery — the alternative branch - deletes the skill outright — but it is still a partial removal: the - preset directory and registry entry go away while this ``SKILL.md`` - keeps the removed preset's content, and reconciliation never revisits - it because the name is left out of ``mutated_names``. Name the skill - and the source so the condition is actionable instead of silent. - """ - import warnings - - warnings.warn( - f"Skill '{skill_name}' still contains the removed preset's content: " - f"its restore source '{source_file}' could not be read " - f"({exc.__class__.__name__}: {exc}). The skill was left in place " - f"rather than deleted. Fix or remove that file and re-run " - f"'specify preset add'/'specify preset remove' to refresh it.", - stacklevel=2, - ) - - def _unregister_skills_in_dir( - self, - skill_names: List[str], - skills_dir: Path, - selected_ai: Optional[str], - *, - pack_id: Optional[str] = None, - additional_owned_sources: Optional[Dict[str, str]] = None, - restore_from_bundled_core: bool = False, - ) -> List[str]: - """Restore original SKILL.md files within a single skills directory. - - Args: - skill_names: List of skill names written by the preset. - skills_dir: The skills directory to restore within. - selected_ai: The agent name that owns ``skills_dir``, used for - placeholder resolution and argument-hint formatting. - additional_owned_sources: Generated non-preset source markers - accepted as owned for specific skill names. - restore_from_bundled_core: See ``_unregister_skills``. - - Returns: - Skill names whose files were restored or removed. - """ - from .. import SKILL_DESCRIPTIONS - from ..agents import CommandRegistrar - from ..integrations import get_integration - from ..shared_infra import _write_shared_text - - # Locate core command templates from the project's installed templates - core_templates_dir = self.project_root / ".specify" / "templates" / "commands" - registrar = CommandRegistrar() - integration = get_integration(selected_ai) if isinstance(selected_ai, str) else None - extension_restore_index = self._build_extension_skill_restore_index() - mutated_names: List[str] = [] - - for skill_name in skill_names: - # Guard against a corrupted/malicious registry entry: a - # registered_skills name is persisted data, not derived from - # the current manifest, so it must be validated as a single, - # relative, non-"."/".." path component before ever being - # joined onto skills_dir. Without this, an absolute name - # discards skills_dir entirely (Path's "/" operator drops the - # left side for an absolute right side) or a multi-component - # name containing ".." can resolve to a different, unrelated - # directory that still happens to be inside the project root - # — passing the containment-only symlink guard below and - # letting removal overwrite/delete it (#2948). - if not self._is_safe_registry_skill_name(skill_name): - continue - - # Derive command name from skill name (speckit-specify -> specify) - short_name = skill_name - if short_name.startswith("speckit-"): - short_name = short_name[len("speckit-"):] - elif short_name.startswith("speckit."): - short_name = short_name[len("speckit."):] - - skill_subdir = skills_dir / skill_name - skill_file = skill_subdir / "SKILL.md" - if not skill_subdir.is_dir(): - continue - # is_dir() follows symlinks, so a symlinked skill subdirectory - # (with a safe, non-symlinked parent) would otherwise slip past - # _safe_skills_dir_for_agent's parent-only check and have - # write_text/rmtree operate through it (#2948). - if not self._validate_skill_subdir( - skill_subdir, create=False, skills_root=skills_dir - ): - continue - if not skill_file.is_file(): - # Only manage directories that contain the expected skill entrypoint. - continue - if pack_id is not None: - try: - current_content = skill_file.read_text(encoding="utf-8") - except (OSError, UnicodeDecodeError): - continue - current_frontmatter, _ = registrar.parse_frontmatter(current_content) - current_metadata = current_frontmatter.get("metadata") - current_source = ( - current_metadata.get("source") - if isinstance(current_metadata, dict) - else None - ) - owned_sources = {f"preset:{pack_id}"} - if additional_owned_sources: - additional_source = additional_owned_sources.get( - skill_name - ) - if additional_source: - owned_sources.add(additional_source) - if current_source not in owned_sources: - continue - - extension_restore = extension_restore_index.get(skill_name) - - # Try to find the core command template. Project-local overrides - # in core_templates_dir take precedence, but that directory is - # rarely populated — the real core commands ship in the bundled - # core_pack (wheel install) or the repo-root templates/ tree - # (source checkout). Callers that want a genuine restore (a - # preset was removed outright, not superseded by another - # renderer) opt into that fallback via restore_from_bundled_core - # so the skill is restored instead of deleted (#3928). An - # installed extension providing a core-named command resolves - # ahead of bundled core elsewhere, so skip the bundled fallback - # when an extension restore exists — otherwise it would win - # over the higher-priority extension layer below. - core_file = core_templates_dir / f"{short_name}.md" - if ( - not core_file.exists() - and restore_from_bundled_core - and extension_restore is None - ): - from .. import _locate_core_pack, _repo_root - - _core_pack = _locate_core_pack() - if _core_pack is not None: - core_file = _core_pack / "commands" / f"{short_name}.md" - else: - core_file = _repo_root() / "templates" / "commands" / f"{short_name}.md" - if not core_file.exists(): - core_file = None - - if core_file: - # Restore from core template. An unreadable/undecodable - # source cannot produce restored content, so leave the - # existing skill untouched rather than leaking a raw - # OSError/UnicodeDecodeError out of `preset remove` — and - # rather than falling through to the rmtree below, which - # would delete a skill precisely when its replacement - # cannot be generated. Matches the `continue` guards above - # (unsafe name, missing subdir, foreign owner), which also - # skip without recording the name as mutated. - try: - content = core_file.read_text(encoding="utf-8") - except (OSError, UnicodeDecodeError) as exc: - self._warn_unrestored_skill(skill_name, core_file, exc) - continue - frontmatter, body = registrar.parse_frontmatter(content) - if isinstance(selected_ai, str): - body = registrar.resolve_skill_placeholders( - selected_ai, frontmatter, body, self.project_root - ) - body = self._resolve_skill_command_refs( - body, registrar, selected_ai, self.project_root - ) - - original_desc = frontmatter.get("description", "") - enhanced_desc = original_desc or SKILL_DESCRIPTIONS.get( - short_name, - f"Spec-kit workflow command: {short_name}", - ) - - frontmatter_data = registrar.build_skill_frontmatter( - selected_ai if isinstance(selected_ai, str) else "", - skill_name, - enhanced_desc, - f"templates/commands/{short_name}.md", - ) - registrar.apply_argument_hint(frontmatter, frontmatter_data, integration) - frontmatter_text = dump_frontmatter(frontmatter_data) - skill_title = self._skill_title_from_command(short_name) - skill_content = ( - f"---\n" - f"{frontmatter_text}\n" - f"---\n\n" - f"# Speckit {skill_title} Skill\n\n" - f"{body}\n" - ) - if integration is not None and hasattr(integration, "post_process_skill_content"): - skill_content = integration.post_process_skill_content( - skill_content - ) - _write_shared_text(skills_dir, skill_file, skill_content) - mutated_names.append(skill_name) - continue - - if extension_restore: - # Same boundary as the core-template branch above: an - # unreadable extension source leaves the skill in place - # instead of crashing or being deleted. - try: - content = extension_restore["source_file"].read_text(encoding="utf-8") - except (OSError, UnicodeDecodeError) as exc: - self._warn_unrestored_skill( - skill_name, extension_restore["source_file"], exc - ) - continue - frontmatter, body = registrar.parse_frontmatter(content) - # Mirror the register-time rewrite (#2101): resolve - # extension-relative subdir references (agents/, - # knowledge-base/, etc.) to their installed location before - # the generic placeholder resolution below, otherwise - # restoring after a preset override removal would leave - # bare, unresolvable paths in the skill body. - body = registrar.rewrite_extension_paths( - body, - extension_restore["extension_id"], - extension_restore["extension_dir"], - ) - if isinstance(selected_ai, str): - body = registrar.resolve_skill_placeholders( - selected_ai, frontmatter, body, self.project_root - ) - body = self._resolve_skill_command_refs( - body, registrar, selected_ai, self.project_root - ) - - command_name = extension_restore["command_name"] - title_name = self._skill_title_from_command(command_name) - - frontmatter_data = registrar.build_skill_frontmatter( - selected_ai if isinstance(selected_ai, str) else "", - skill_name, - frontmatter.get("description", f"Extension command: {command_name}"), - extension_restore["source"], - author=extension_restore.get("author", "github-spec-kit"), - ) - registrar.apply_argument_hint(frontmatter, frontmatter_data, integration) - frontmatter_text = dump_frontmatter(frontmatter_data) - skill_content = ( - f"---\n" - f"{frontmatter_text}\n" - f"---\n\n" - f"# {title_name} Skill\n\n" - f"{body}\n" - ) - if integration is not None and hasattr(integration, "post_process_skill_content"): - skill_content = integration.post_process_skill_content( - skill_content - ) - _write_shared_text(skills_dir, skill_file, skill_content) - mutated_names.append(skill_name) - else: - # No core or extension template — remove the skill entirely - shutil.rmtree(skill_subdir) - mutated_names.append(skill_name) - - return mutated_names - - def install_from_directory( - self, - source_dir: Path, - speckit_version: str, - priority: int = 10, - force: bool = False, - *, - catalog_name: str | None = None, - ) -> PresetManifest: - """Install preset from a local directory. - - Args: - source_dir: Path to preset directory - speckit_version: Current spec-kit version - priority: Resolution priority (lower = higher precedence, default 10) - force: If True and the preset is already installed, remove it first - - Returns: - Installed preset manifest - - Raises: - PresetValidationError: If manifest is invalid or priority is invalid - PresetCompatibilityError: If pack is incompatible - """ - # Validate priority - if priority < 1: - raise PresetValidationError("Priority must be a positive integer (1 or higher)") - - manifest_path = source_dir / "preset.yml" - manifest = PresetManifest(manifest_path) - - self.check_compatibility(manifest, speckit_version) - - if self.registry.is_installed(manifest.id): - if not force: - raise PresetError( - f"Preset '{manifest.id}' is already installed. " - f"Use 'specify preset remove {manifest.id}' first." - ) - self.remove(manifest.id) - - dest_dir = self.presets_dir / manifest.id - if dest_dir.exists(): - shutil.rmtree(dest_dir) - - shutil.copytree(source_dir, dest_dir) - - # Pre-register the preset so that composition resolution can see it - # in the priority stack when resolving composed command content. - normalized_catalog_name = ( - catalog_name.strip() if isinstance(catalog_name, str) else "" - ) - source = ( - {"kind": "catalog", "catalog": normalized_catalog_name} - if normalized_catalog_name - else "local" - ) - self.registry.add(manifest.id, { - "version": manifest.version, - "source": source, - "manifest_hash": manifest.get_hash(), - "enabled": True, - "priority": priority, - "registered_commands": {}, - "registered_skills": {}, - }) - - registered_commands: Dict[str, List[str]] = {} - registered_skills: Dict[str, List[str]] = {} - try: - # Register command overrides with AI agents and persist the result - # immediately so cleanup can recover even if installation stops - # before later phases complete. - registered_commands = self._register_commands(manifest, dest_dir) - self.registry.update(manifest.id, { - "registered_commands": registered_commands, - }) - - # Update corresponding skills when skills mode was previously used - # and persist that result as well. - registered_skills = self._register_skills(manifest, dest_dir) - self.registry.update(manifest.id, { - "registered_skills": registered_skills, - }) - except Exception: - # Roll back all side effects. _register_skills persists each - # successful write immediately, so reload that partial map when - # a later template fails before the call can return. - if registered_commands: - self._unregister_commands(registered_commands) - persisted_metadata = self.registry.get(manifest.id) or {} - persisted_skills = persisted_metadata.get( - "registered_skills", registered_skills - ) - if persisted_skills: - self._unregister_skills( - persisted_skills, dest_dir, restore_from_bundled_core=True - ) - try: - if dest_dir.exists(): - shutil.rmtree(dest_dir) - except OSError: - pass # best-effort cleanup; don't mask the original error - self.registry.remove(manifest.id) - raise - - # Reconcile all affected commands from the full priority stack so that - # install order doesn't determine the winning command file. - cmd_names = [ - t["name"] - for t in manifest.templates - if t.get("type") == "command" - ] - if cmd_names: - try: - self._reconcile_composed_commands(cmd_names) - self._reconcile_skills(cmd_names) - except Exception as exc: - import warnings - warnings.warn( - f"Post-install reconciliation failed for {manifest.id}: {exc}. " - f"Agent command files may not reflect the current priority stack.", - stacklevel=2, - ) - - # Materialize constitution-template changes only for projects that opt - # into the constitution-sync preset. The core /constitution command - # resolves this template on demand; constitution-sync preserves the - # previous install-time behavior for teams that want reviewed snapshots. - self._seed_constitution_from_preset(manifest, dest_dir) - - return manifest - - def _seed_constitution_from_preset( - self, manifest: PresetManifest, preset_dir: Path - ) -> None: - """Seed memory/constitution.md when constitution-sync opts into snapshots. - - Installing constitution-sync itself materializes the currently resolved - stack. Later preset installs only reconcile when they provide a - ``constitution-template``. Authored constitutions are never overwritten. - """ - provides_constitution = manifest.id == _CONSTITUTION_SYNC_PRESET_ID or any( - t.get("type") == "template" and t.get("name") == "constitution-template" - for t in manifest.templates - ) or any( - (preset_dir / relative_path).is_file() - for relative_path in ( - "templates/constitution-template.md", - "constitution-template.md", - ) - ) - if not provides_constitution: - return - - self.reconcile_constitution( - f"Failed to seed constitution from preset {manifest.id}", - create_if_missing=True, - ) - - def reconcile_constitution( - self, failure_context: str, *, create_if_missing: bool = False - ) -> None: - """Reconcile an opted-in generated constitution without failing a change.""" - try: - self._reconcile_constitution(create_if_missing=create_if_missing) - except (OSError, UnicodeDecodeError, PresetValidationError, ValueError) as exc: - import warnings - - warnings.warn( - f"{failure_context}: {exc}.", - stacklevel=2, - ) - - def _reconcile_constitution(self, *, create_if_missing: bool = False) -> None: - """Materialize the winning layer when constitution-sync is enabled.""" - sync_metadata = self.registry.get(_CONSTITUTION_SYNC_PRESET_ID) - if sync_metadata is None or not sync_metadata.get("enabled", True): - return - - memory_constitution = ( - self.project_root / ".specify" / "memory" / "constitution.md" - ) - if not memory_constitution.exists() and not create_if_missing: - return - resolver = PresetResolver(self.project_root) - if memory_constitution.exists() and not _constitution_is_generated( - self.project_root, memory_constitution, resolver - ): - return - _materialize_constitution_template(self.project_root, memory_constitution) - - def install_from_archive( - self, - archive_path: Path, - speckit_version: str, - priority: int = 10, - force: bool = False, - *, - catalog_name: str | None = None, - ) -> PresetManifest: - """Install a preset from a supported archive. - - Args: - archive_path: Path to a .zip, .tar.gz, or .tgz archive - speckit_version: Current spec-kit version - priority: Resolution priority (lower = higher precedence, default 10) - force: If True and the preset is already installed, remove it first - - Returns: - Installed preset manifest - - Raises: - PresetValidationError: If manifest is invalid or priority is invalid - PresetCompatibilityError: If pack is incompatible - """ - # Validate priority early - if priority < 1: - raise PresetValidationError("Priority must be a positive integer (1 or higher)") - - with tempfile.TemporaryDirectory() as tmpdir: - temp_path = Path(tmpdir) - - safe_extract_archive( - archive_path, - temp_path, - error_type=PresetValidationError, - ) - - pack_dir = temp_path - manifest_path = pack_dir / "preset.yml" - - if not manifest_path.exists(): - subdirs = [d for d in temp_path.iterdir() if d.is_dir()] - if len(subdirs) == 1: - pack_dir = subdirs[0] - manifest_path = pack_dir / "preset.yml" - - if not manifest_path.exists(): - raise PresetValidationError( - "No preset.yml found in archive" - ) - - return self.install_from_directory( - pack_dir, - speckit_version, - priority, - force=force, - catalog_name=catalog_name, - ) - - def install_from_zip( - self, - zip_path: Path, - speckit_version: str, - priority: int = 10, - force: bool = False, - *, - catalog_name: str | None = None, - ) -> PresetManifest: - """Backward-compatible wrapper for archive installation.""" - return self.install_from_archive( - zip_path, - speckit_version, - priority, - force=force, - catalog_name=catalog_name, - ) - - def remove(self, pack_id: str) -> bool: - """Remove an installed preset. - - Args: - pack_id: Preset ID - - Returns: - True if pack was removed - """ - if not self.registry.is_installed(pack_id): - return False - - metadata = self.registry.get(pack_id) - # Restore original skills when preset is removed - registered_skills = metadata.get("registered_skills", []) if metadata else [] - if isinstance(registered_skills, list) and registered_skills: - # Legacy flat-list registries predate per-agent provenance - # tracking. Migration to the per-agent dict form previously - # only happened during a rescaffold (register_enabled_presets_ - # for_agent); if the *first* post-upgrade operation is instead - # `preset remove` (no intervening use/upgrade), the legacy - # branch of _unregister_skills restores only the currently - # active agent's directory, leaving this preset's overrides in - # every previously active agent's directory orphaned. Infer - # real per-agent ownership from the on-disk preset marker now, - # while pack_id is still known, and hand the resulting mapping - # through the same dict-based cleanup path already used for - # non-legacy registries (#2948). - from .. import load_init_options - - init_opts = load_init_options(self.project_root) - fallback_agent = init_opts.get("ai") if isinstance(init_opts, dict) else None - if not isinstance(fallback_agent, str): - fallback_agent = "" - registered_skills = self._infer_legacy_skill_provenance( - [name for name in registered_skills if isinstance(name, str)], - pack_id, - fallback_agent=fallback_agent, - ) - registered_commands = metadata.get("registered_commands", {}) if metadata else {} - pack_dir = self.presets_dir / pack_id - - # Record which historical agents this preset's registered_commands - # actually targeted, *before* any filtering below, so post-removal - # reconciliation can restore a surviving lower-priority preset's - # override into every one of those directories too — not only the - # currently active agent's. Without this, removing a preset that - # was rendered under a previously-active (now inactive) agent - # deletes that agent's command file via _unregister_commands below, - # but active-only reconciliation would only recreate the surviving - # winner for the current agent, leaving the inactive integration - # with a missing/stale file (#2948). - try: - from ..agents import CommandRegistrar as _CommandRegistrarForScope - except ImportError: - _CommandRegistrarForScope = None - affected_command_agents = { - agent_name - for agent_name in registered_commands - if _CommandRegistrarForScope is None - or _CommandRegistrarForScope.AGENT_CONFIGS.get(agent_name, {}).get("extension") != "/SKILL.md" - } - - # Collect ALL command names before filtering for reconciliation, - # so commands registered only for skill-based agents are also - # reconciled. Every command-type template's primary name is added - # unconditionally (not just aliases) since ai_skills-mode presets - # never populate registered_commands for command-backed - # integrations (see _register_commands's ai_skills guard) — without - # this, removing a skills-mode preset that overrides a command no - # other preset registered "the normal way" would skip reconciliation - # entirely and _unregister_skills would restore core/extension - # content instead of a surviving lower-priority preset's override. - removed_cmd_names = set() - removed_constitution = any( - path.exists() - for path in ( - pack_dir / "templates" / "constitution-template.md", - pack_dir / "constitution-template.md", - ) - ) - if metadata and isinstance(metadata.get("version"), str): - memory_constitution = ( - self.project_root / ".specify" / "memory" / "constitution.md" - ) - removed_constitution = removed_constitution or ( - _constitution_provenance_matches_preset( - self.project_root, - memory_constitution, - pack_id, - metadata["version"], - ) - ) - for cmd_names in registered_commands.values(): - removed_cmd_names.update(cmd_names) - manifest_path = pack_dir / "preset.yml" - if manifest_path.exists(): - try: - manifest = PresetManifest(manifest_path) - for tmpl in manifest.templates: - if ( - tmpl.get("type") == "template" - and tmpl.get("name") == "constitution-template" - ): - removed_constitution = True - if tmpl.get("type") == "command": - name = tmpl.get("name") - if isinstance(name, str): - removed_cmd_names.add(name) - for alias in tmpl.get("aliases", []): - if isinstance(alias, str): - removed_cmd_names.add(alias) - except PresetValidationError: - # Invalid manifest — skip alias extraction; primary command - # names from registered_commands are still unregistered. - pass - - affected_skill_dirs: Dict[ - Path, tuple[Optional[str], List[str]] - ] = {} - if registered_skills: - restorable_skills = registered_skills - # A skill tracked for a command-backed agent whose ai_skills is - # now off is a leftover from a partially failed skills→command - # toggle. Restoring it via _unregister_skills (and letting - # _reconcile_skills reapply a surviving lower preset through - # extra_skills_dirs) would hand the active command-mode agent a - # skill artifact it must not have — its current representation - # is the command file handled via registered_commands above. - # Delete the preset-owned skill instead and keep its directory - # out of restoration/reconciliation entirely (#2948). Inactive - # agents' entries still restore as before. - # The legacy branch above locally imports load_init_options, - # shadowing the module-level name for this whole function. - from .._init_options import load_init_options as _load_init_options - - resolved_active = resolve_active_agent_for_registration( - self.project_root - ) - if ( - isinstance(registered_skills, dict) - and isinstance(resolved_active, str) - and resolved_active in registered_skills - and _CommandRegistrarForScope is not None - and _CommandRegistrarForScope.AGENT_CONFIGS.get( - resolved_active, {} - ).get("extension") != "/SKILL.md" - and not is_ai_skills_enabled( - _load_init_options(self.project_root) - ) - ): - raw_names = registered_skills.get(resolved_active) - stale_names = [ - name - for name in ( - raw_names if isinstance(raw_names, list) else [] - ) - if isinstance(name, str) - ] - restorable_skills = { - agent_name: names - for agent_name, names in registered_skills.items() - if agent_name != resolved_active - } - if stale_names: - self._delete_agent_preset_skills( - resolved_active, stale_names, pack_id - ) - override_sources = { - skill_name: f"override:{command_name}" - for command_name in removed_cmd_names - for skill_name in self._skill_names_for_command(command_name) - } - affected_skill_dirs = self._unregister_skills( - restorable_skills, - pack_dir, - additional_owned_sources=override_sources, - restore_from_bundled_core=True, - ) - try: - from ..agents import CommandRegistrar - except ImportError: - CommandRegistrar = None - if CommandRegistrar is not None: - skill_coverage = ( - registered_skills - if isinstance(registered_skills, dict) - else {} - ) - commands_to_unregister: Dict[str, List[str]] = {} - for agent_name, cmd_names in registered_commands.items(): - is_native_skill_agent = ( - CommandRegistrar.AGENT_CONFIGS.get( - agent_name, {} - ).get("extension") - == "/SKILL.md" - ) - if not is_native_skill_agent: - commands_to_unregister[agent_name] = cmd_names - continue - - raw_skill_names = skill_coverage.get(agent_name, []) - covered_skill_names = { - name - for name in ( - raw_skill_names - if isinstance(raw_skill_names, list) - else [] - ) - if isinstance(name, str) - } - uncovered_commands = [ - cmd_name - for cmd_name in cmd_names - if not isinstance(cmd_name, str) - or covered_skill_names.isdisjoint( - self._skill_names_for_command(cmd_name) - ) - ] - if uncovered_commands: - commands_to_unregister[agent_name] = ( - uncovered_commands - ) - registered_commands = commands_to_unregister - - # Unregister non-skill command files from AI agents. - if registered_commands: - self._unregister_commands(registered_commands) - - if pack_dir.exists(): - shutil.rmtree(pack_dir) - - self.registry.remove(pack_id) - - # Reconcile: if other presets still provide these commands, - # re-resolve from the remaining stack so the next layer takes effect. - if removed_cmd_names: - try: - self._reconcile_composed_commands( - list(removed_cmd_names), extra_agents=affected_command_agents - ) - self._reconcile_skills( - list(removed_cmd_names), extra_skills_dirs=affected_skill_dirs - ) - except Exception as exc: - import warnings - warnings.warn( - f"Post-removal reconciliation failed for {pack_id}: {exc}. " - f"Agent command files may be stale; reinstall affected presets " - f"or run 'specify preset add' to refresh.", - stacklevel=2, - ) - - if removed_constitution: - try: - self._reconcile_constitution() - except (OSError, UnicodeDecodeError, PresetValidationError, ValueError) as exc: - import warnings - - warnings.warn( - f"Post-removal constitution reconciliation failed for {pack_id}: " - f"{exc}. The live constitution may be stale.", - stacklevel=2, - ) - - return True - - def list_installed(self) -> List[Dict[str, Any]]: - """List all installed presets with metadata. - - Returns: - List of preset metadata dictionaries - """ - result = [] - - for pack_id, metadata in self.registry.list().items(): - # Ensure metadata is a dictionary to avoid AttributeError when using .get() - if not isinstance(metadata, dict): - metadata = {} - pack_dir = self.presets_dir / pack_id - manifest_path = pack_dir / "preset.yml" - - try: - manifest = PresetManifest(manifest_path) - provided_counts = {"commands": 0, "templates": 0, "scripts": 0, "hooks": 0} - for template in manifest.templates: - provided_counts[f"{template['type']}s"] += 1 - author = manifest.author - result.append({ - "id": pack_id, - "name": manifest.name, - "version": metadata.get("version", manifest.version), - "description": manifest.description, - "enabled": metadata.get("enabled", True), - "installed_at": metadata.get("installed_at"), - "template_count": len(manifest.templates), - "tags": manifest.tags, - "priority": normalize_priority(metadata.get("priority")), - "_json_author": author if isinstance(author, str) and author else None, - "_json_source": metadata.get("source"), - "_json_provides": provided_counts, - }) - except PresetValidationError: - result.append({ - "id": pack_id, - "name": pack_id, - "version": metadata.get("version", "unknown"), - "description": "⚠️ Corrupted preset", - "enabled": False, - "installed_at": metadata.get("installed_at"), - "template_count": 0, - "tags": [], - "priority": normalize_priority(metadata.get("priority")), - "_json_author": None, - "_json_source": metadata.get("source"), - "_json_provides": {"commands": 0, "templates": 0, "scripts": 0, "hooks": 0}, - }) - - return result - - def get_pack(self, pack_id: str) -> Optional[PresetManifest]: - """Get manifest for an installed preset. - - Args: - pack_id: Preset ID - - Returns: - Preset manifest or None if not installed - """ - if not self.registry.is_installed(pack_id): - return None - - pack_dir = self.presets_dir / pack_id - manifest_path = pack_dir / "preset.yml" - - try: - return PresetManifest(manifest_path) - except PresetValidationError: - return None - - -class PresetCatalog: - """Manages preset catalog fetching, caching, and searching. - - Supports multi-catalog stacks with priority-based resolution, - mirroring the extension catalog system. - """ - - DEFAULT_CATALOG_URL = "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.json" - COMMUNITY_CATALOG_URL = "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.community.json" - CACHE_DURATION = 3600 # 1 hour in seconds - - def __init__(self, project_root: Path): - """Initialize preset catalog manager. - - Args: - project_root: Root directory of the spec-kit project - """ - self.project_root = project_root - self.presets_dir = project_root / ".specify" / "presets" - self.cache_dir = self.presets_dir / ".cache" - self.cache_file = self.cache_dir / "catalog.json" - self.cache_metadata_file = self.cache_dir / "catalog-metadata.json" - - def _validate_catalog_url(self, url: str) -> None: - """Validate that a catalog URL uses HTTPS (localhost HTTP allowed). - - Args: - url: URL to validate - - Raises: - PresetValidationError: If URL is invalid or uses non-HTTPS scheme - """ - from urllib.parse import urlparse - - try: - parsed = urlparse(url) - hostname = parsed.hostname - # Accessing ``port`` performs urllib's syntax/range validation; - # ``hostname`` alone does not, so a non-numeric or out-of-range - # port would otherwise pass validation here and only fail later, - # at fetch time, as a raw error this function does not translate - # into PresetValidationError. Mirrors specify_cli.catalogs and - # bundler/services/adapters.py's copy of this same guard. - _ = parsed.port - except ValueError: - raise PresetValidationError(f"Catalog URL is malformed: {url}") from None - is_localhost = hostname in ("localhost", "127.0.0.1", "::1") - if parsed.scheme != "https" and not ( - parsed.scheme == "http" and is_localhost - ): - raise PresetValidationError( - f"Catalog URL must use HTTPS (got {parsed.scheme}://). " - "HTTP is only allowed for localhost." - ) - # Check hostname, not netloc: netloc is truthy for host-less URLs like - # "https://:8080" or "https://user@", so the host guarantee this error - # promises would not actually hold. hostname is None in those cases (#3209). - if not hostname: - raise PresetValidationError( - "Catalog URL must be a valid URL with a host." - ) - - def _make_request(self, url: str): - """Build a urllib Request, adding auth headers when a provider matches. - - Delegates to :func:`specify_cli.authentication.http.build_request`. - """ - from specify_cli.authentication.http import build_request - return build_request(url) - - def _open_url( - self, - url: str, - timeout: int = 10, - extra_headers: Optional[Dict[str, str]] = None, - redirect_validator=None, - ): - """Open a URL with provider-based auth, trying each configured provider. - - Delegates to :func:`specify_cli.authentication.http.open_url`. - *redirect_validator*, when provided, is invoked as ``(old_url, new_url)`` - before EACH redirect hop, so an HTTPS host guarantee can be enforced on - every intermediate URL, not just the terminal one. - """ - from specify_cli.authentication.http import open_url - return open_url( - url, - timeout, - extra_headers=extra_headers, - redirect_validator=redirect_validator, - ) - - def _resolve_github_release_asset_api_url( - self, - download_url: str, - timeout: int = 60, - ) -> Optional[str]: - """Resolve a GitHub release asset URL to its REST API asset URL. - - Passes the ``github`` provider hosts from ``auth.json`` so GitHub - Enterprise Server release assets resolve via ``/api/v3``. - """ - from specify_cli.authentication.github_http import resolve_github_release_asset_api_url - from specify_cli.authentication.http import github_provider_hosts - - return resolve_github_release_asset_api_url( - download_url, - self._open_url, - timeout=timeout, - github_hosts=github_provider_hosts(), - ) - - def _validate_catalog_payload(self, catalog_data: Any, url: str) -> None: - """Validate a parsed preset-catalog payload's shape. - - Applied to both network-fetched and cache-loaded payloads so a - once-poisoned cache (older spec-kit version, manual edit, upstream - served a bad payload before the network-side guards were added) - cannot re-crash ``_get_merged_packs`` on subsequent calls. - - Checking only key presence would let a payload like - ``{"presets": []}`` or ``{"presets": null}`` slip through here and - then crash with ``AttributeError: 'list' object has no attribute - 'items'`` deep inside ``_get_merged_packs``. The sibling - integration catalog reader already guards both the root object and - the nested mapping (see ``integrations/catalog.py``); the preset - catalog must stay consistent so a malformed payload surfaces as - the user-facing ``Invalid preset catalog format`` error instead of - a raw Python traceback. - - Args: - catalog_data: Parsed JSON payload from the catalog source. - url: Source URL — used in the error message so the user can - tell which catalog in a multi-catalog stack is malformed. - - Raises: - PresetError: If the payload's shape is invalid. - """ - if not isinstance(catalog_data, dict): - raise PresetError( - f"Invalid preset catalog format from {url}: " - "expected a JSON object" - ) - if ( - "schema_version" not in catalog_data - or "presets" not in catalog_data - ): - raise PresetError(f"Invalid preset catalog format from {url}") - if not isinstance(catalog_data.get("presets"), dict): - raise PresetError( - f"Invalid preset catalog format from {url}: " - "'presets' must be a JSON object" - ) - - def _load_catalog_config(self, config_path: Path) -> Optional[List[PresetCatalogEntry]]: - """Load catalog stack configuration from a YAML file. - - Args: - config_path: Path to preset-catalogs.yml - - Returns: - Ordered list of PresetCatalogEntry objects, or None if file - doesn't exist or contains no valid catalog entries. - - Raises: - PresetValidationError: If any catalog entry has an invalid URL, - the file cannot be parsed, or a priority value is invalid. - """ - if not config_path.exists(): - return None - try: - data = yaml.safe_load(config_path.read_text(encoding="utf-8")) - except (yaml.YAMLError, OSError, UnicodeError) as e: - raise PresetValidationError( - f"Failed to read catalog config {config_path}: {e}" - ) - # Do NOT coerce with ``or {}`` here: that also turns a FALSY - # non-mapping top level (``[]``, ``false``, ``0``, ``''``) into ``{}`` - # and silently swallows it, while a TRUTHY non-mapping (``5``, a bare - # list) correctly raises below. Only an empty document/explicit - # ``null`` means "no document". - if data is None: - return None - if not isinstance(data, dict): - raise PresetValidationError( - f"Invalid catalog config {config_path}: expected a mapping at root, got {type(data).__name__}" - ) - # Same asymmetry one nesting level down: the shape check has to run - # BEFORE the emptiness check, or a FALSY non-list ``catalogs`` value - # (``{}``, ``''``, ``0``, ``false``) is silently swallowed as "no - # catalogs" while a TRUTHY non-list (``catalogs: "not-a-list"``) - # correctly raises. An absent key or an explicit ``catalogs: null`` - # both keep their existing "nothing configured here" behavior. - catalogs_data = data.get("catalogs") - if catalogs_data is None: - return None - if not isinstance(catalogs_data, list): - raise PresetValidationError( - f"Invalid catalog config: 'catalogs' must be a list, got {type(catalogs_data).__name__}" - ) - if not catalogs_data: - return None - entries: List[PresetCatalogEntry] = [] - for idx, item in enumerate(catalogs_data): - if not isinstance(item, dict): - raise PresetValidationError( - f"Invalid catalog entry at index {idx}: expected a mapping, got {type(item).__name__}" - ) - url = str(item.get("url", "")).strip() - if not url: - continue - self._validate_catalog_url(url) - raw_priority = item.get("priority", idx + 1) - # Reject bools explicitly: ``bool`` is a subclass of ``int`` so - # ``int(True)`` silently returns 1, which would let a YAML - # ``priority: true`` slip through as a valid priority of 1. The - # sibling integration-catalog reader in ``catalogs.py`` already - # guards this; mirror the check here so the three catalog - # validators stay consistent. - if isinstance(raw_priority, bool): - raise PresetValidationError( - f"Invalid priority for catalog '{item.get('name', idx + 1)}': " - f"expected integer, got {raw_priority!r}" - ) - try: - priority = int(raw_priority) - except (TypeError, ValueError, OverflowError): - # OverflowError: int(float("inf")) — a YAML ``priority: .inf`` - # would otherwise escape as an uncaught traceback instead of the - # clean validation error (mirrors catalogs.py). - raise PresetValidationError( - f"Invalid priority for catalog '{item.get('name', idx + 1)}': " - f"expected integer, got {raw_priority!r}" - ) - raw_install = item.get("install_allowed", False) - if isinstance(raw_install, str): - install_allowed = raw_install.strip().lower() in ("true", "yes", "1") - else: - install_allowed = bool(raw_install) - raw_name = item.get("name") - name = str(raw_name).strip() if raw_name is not None else "" - if not name: - name = f"catalog-{len(entries) + 1}" - - entries.append(PresetCatalogEntry( - url=url, - name=name, - priority=priority, - install_allowed=install_allowed, - description=str(item.get("description", "")), - )) - entries.sort(key=lambda e: e.priority) - return entries if entries else None - - def get_active_catalogs(self) -> List[PresetCatalogEntry]: - """Get the ordered list of active preset catalogs. - - Resolution order: - 1. SPECKIT_PRESET_CATALOG_URL env var — single catalog replacing all defaults - 2. Project-level .specify/preset-catalogs.yml - 3. User-level ~/.specify/preset-catalogs.yml - 4. Built-in default stack (default + community) - - Returns: - List of PresetCatalogEntry objects sorted by priority (ascending) - - Raises: - PresetValidationError: If a catalog URL is invalid - """ - import sys - - # 1. SPECKIT_PRESET_CATALOG_URL env var replaces all defaults - if env_value := os.environ.get("SPECKIT_PRESET_CATALOG_URL"): - catalog_url = env_value.strip() - self._validate_catalog_url(catalog_url) - if catalog_url != self.DEFAULT_CATALOG_URL: - if not getattr(self, "_non_default_catalog_warning_shown", False): - print( - "Warning: Using non-default preset catalog. " - "Only use catalogs from sources you trust.", - file=sys.stderr, - ) - self._non_default_catalog_warning_shown = True - return [PresetCatalogEntry(url=catalog_url, name="custom", priority=1, install_allowed=True, description="Custom catalog via SPECKIT_PRESET_CATALOG_URL")] - - # 2. Project-level config overrides all defaults - project_config_path = self.project_root / ".specify" / "preset-catalogs.yml" - catalogs = self._load_catalog_config(project_config_path) - if catalogs is not None: - return catalogs - - # 3. User-level config - user_config_path = Path.home() / ".specify" / "preset-catalogs.yml" - catalogs = self._load_catalog_config(user_config_path) - if catalogs is not None: - return catalogs - - # 4. Built-in default stack - return [ - PresetCatalogEntry(url=self.DEFAULT_CATALOG_URL, name="default", priority=1, install_allowed=True, description="Built-in catalog of installable presets"), - PresetCatalogEntry(url=self.COMMUNITY_CATALOG_URL, name="community", priority=2, install_allowed=False, description="Community-contributed presets (discovery only)"), - ] - - def get_catalog_url(self) -> str: - """Get the primary catalog URL. - - Returns the URL of the highest-priority catalog. Kept for backward - compatibility. Use get_active_catalogs() for full multi-catalog support. - - Returns: - URL of the primary catalog - """ - active = self.get_active_catalogs() - return active[0].url if active else self.DEFAULT_CATALOG_URL - - def _get_cache_paths(self, url: str): - """Get cache file paths for a given catalog URL. - - For the DEFAULT_CATALOG_URL, uses legacy cache files for backward - compatibility. For all other URLs, uses URL-hash-based cache files. - - Returns: - Tuple of (cache_file_path, cache_metadata_path) - """ - if url == self.DEFAULT_CATALOG_URL: - return self.cache_file, self.cache_metadata_file - url_hash = hashlib.sha256(url.encode()).hexdigest()[:16] - return ( - self.cache_dir / f"catalog-{url_hash}.json", - self.cache_dir / f"catalog-{url_hash}-metadata.json", - ) - - def _is_url_cache_valid(self, url: str) -> bool: - """Check if cached catalog for a specific URL is still valid.""" - cache_file, metadata_file = self._get_cache_paths(url) - if not cache_file.exists() or not metadata_file.exists(): - return False - try: - metadata = json.loads(metadata_file.read_text(encoding="utf-8")) - cached_at = datetime.fromisoformat(metadata.get("cached_at", "")) - if cached_at.tzinfo is None: - cached_at = cached_at.replace(tzinfo=timezone.utc) - age_seconds = ( - datetime.now(timezone.utc) - cached_at - ).total_seconds() - return age_seconds < self.CACHE_DURATION - except ( - json.JSONDecodeError, - OSError, - UnicodeError, - ValueError, - KeyError, - TypeError, - AttributeError, - ): - # Cache validity is best-effort: invalid/missing fields, an - # unreadable metadata file (permissions / disk), a wrongly - # encoded one (written by a tool using the system locale - # codec), or a metadata payload that parses to a non-mapping - # like ``[]`` or ``"oops"`` (so ``metadata.get(...)`` raises - # ``AttributeError``) all degrade to "cache invalid" so the - # caller falls through to a network refetch instead of - # crashing. - return False - - def _fetch_single_catalog(self, entry: PresetCatalogEntry, force_refresh: bool = False) -> Dict[str, Any]: - """Fetch a single catalog with per-URL caching. - - Args: - entry: PresetCatalogEntry describing the catalog to fetch - force_refresh: If True, bypass cache - - Returns: - Catalog data dictionary - - Raises: - PresetError: If catalog cannot be fetched - """ - cache_file, metadata_file = self._get_cache_paths(entry.url) - - # Use cache if valid. A previously-cached payload must clear the - # same shape checks as a freshly-fetched one — otherwise a once- - # poisoned cache would re-crash on every invocation despite the - # cache being "valid" by age. If validation fails on the cached - # read, fall through to the network fetch path so the cache gets - # refreshed. - if not force_refresh and self._is_url_cache_valid(entry.url): - try: - cached_data = json.loads(cache_file.read_text(encoding="utf-8")) - self._validate_catalog_payload(cached_data, entry.url) - return cached_data - except (json.JSONDecodeError, OSError, UnicodeError, PresetError): - # Cache is best-effort: a JSON-decode failure, an OS-level - # read failure (permissions / disk / handle limit), or a - # text-encoding failure on a cache file written by an - # older client all fall through to the network fetch path. - # Only the network failure is surfaced to the caller. - pass - - try: - # Validate EVERY redirect hop (not just the terminal URL): an - # https -> http -> attacker-controlled-https chain would pass a - # final-URL-only check while the insecure intermediate hop lets a - # network attacker rewrite the next redirect. redirect_validator runs - # before each hop; the final geturl() check is retained as a - # belt-and-braces guard. Mirrors bundler/services/adapters.py. - def _validate_redirect(_old_url: str, new_url: str) -> None: - self._validate_catalog_url(new_url) - - with self._open_url( - entry.url, timeout=10, redirect_validator=_validate_redirect - ) as response: - final_url = response.geturl() - if final_url != entry.url: - self._validate_catalog_url(final_url) - catalog_data = json.loads( - read_response_limited( - response, - max_bytes=MAX_JSON_CATALOG_BYTES, - error_type=PresetError, - label=f"preset catalog {entry.url}", - ) - ) - - self._validate_catalog_payload(catalog_data, entry.url) - - # Both files are written explicitly as UTF-8 to match the - # ``read_text(encoding="utf-8")`` on the read side and the - # ``integrations/catalog.py`` precedent. Without this, - # platforms whose default encoding isn't UTF-8 would write - # locale-encoded bytes the read path can't decode, forcing an - # unnecessary refetch on every invocation. The write itself - # is best-effort like the read side: an unwritable cache dir - # (read-only checkout, permissions) must not be re-raised as - # a ``PresetError`` for a payload that was already fetched - # and validated. - try: - self.cache_dir.mkdir(parents=True, exist_ok=True) - cache_file.write_text( - json.dumps(catalog_data, indent=2), encoding="utf-8" - ) - metadata = { - "cached_at": datetime.now(timezone.utc).isoformat(), - "catalog_url": entry.url, - } - metadata_file.write_text( - json.dumps(metadata, indent=2), encoding="utf-8" - ) - except OSError: - pass # Cache is best-effort; proceed with fetched data - - return catalog_data - - except (ImportError, Exception) as e: - if isinstance(e, PresetError): - raise - raise PresetError( - f"Failed to fetch preset catalog from {entry.url}: {e}" - ) - - def _get_merged_packs(self, force_refresh: bool = False) -> Dict[str, Dict[str, Any]]: - """Fetch and merge presets from all active catalogs. - - Higher-priority catalogs (lower priority number) win on ID conflicts. - - Returns: - Merged dictionary of pack_id -> pack_data - """ - active_catalogs = self.get_active_catalogs() - merged: Dict[str, Dict[str, Any]] = {} - - for entry in reversed(active_catalogs): - try: - data = self._fetch_single_catalog(entry, force_refresh) - for pack_id, pack_data in data.get("presets", {}).items(): - # Per-entry guard: ``_fetch_single_catalog`` already - # validates that ``data["presets"]`` is a mapping, but it - # does not (and should not) validate every entry shape - # there — one malformed entry shouldn't poison an - # otherwise valid catalog. Skip non-mapping entries here - # so a payload like ``{"presets": {"foo": [], "bar": - # {...}}}`` still merges the valid entries without - # crashing on ``**pack_data``. Mirrors - # ``integrations/catalog.py:245``. - if not isinstance(pack_data, dict): - continue - pack_data_with_catalog = {**pack_data, "_catalog_name": entry.name, "_install_allowed": entry.install_allowed} - merged[pack_id] = pack_data_with_catalog - except PresetError: - continue - - return merged - - def is_cache_valid(self) -> bool: - """Check if cached catalog is still valid. - - Returns ``False`` for any read/decoding failure on the metadata - file (missing fields, malformed JSON, permissions / disk errors, - wrong text encoding) so callers fall through to a network refetch - instead of crashing. Treating cache validity as best-effort - matches the contract used by ``_is_url_cache_valid`` above. - - Returns: - True if cache exists and is within cache duration - """ - if not self.cache_file.exists() or not self.cache_metadata_file.exists(): - return False - - try: - metadata = json.loads( - self.cache_metadata_file.read_text(encoding="utf-8") - ) - cached_at = datetime.fromisoformat(metadata.get("cached_at", "")) - if cached_at.tzinfo is None: - cached_at = cached_at.replace(tzinfo=timezone.utc) - age_seconds = ( - datetime.now(timezone.utc) - cached_at - ).total_seconds() - return age_seconds < self.CACHE_DURATION - except ( - json.JSONDecodeError, - OSError, - UnicodeError, - ValueError, - KeyError, - TypeError, - AttributeError, - ): - # ``AttributeError`` covers the case where the metadata file - # parses to a non-mapping (``[]``, ``"oops"``, ``42``) so - # ``metadata.get(...)`` would otherwise crash. All decode / - # shape failures degrade to "cache invalid" so the caller - # falls through to a network refetch. - return False - - def fetch_catalog(self, force_refresh: bool = False) -> Dict[str, Any]: - """Fetch preset catalog from URL or cache. - - Args: - force_refresh: If True, bypass cache and fetch from network - - Returns: - Catalog data dictionary - - Raises: - PresetError: If catalog cannot be fetched - """ - catalog_url = self.get_catalog_url() - - # Match the ``_fetch_single_catalog`` cache contract: a poisoned - # or unreadable cache silently falls through to a network refetch - # rather than crashing the caller. ``_validate_catalog_payload`` - # is reused here so a cache written by an older client - # (pre-validation) is rejected and refreshed instead of returning - # the stale malformed payload. - if not force_refresh and self.is_cache_valid(): - try: - metadata = json.loads( - self.cache_metadata_file.read_text(encoding="utf-8") - ) - if metadata.get("catalog_url") == catalog_url: - cached_data = json.loads( - self.cache_file.read_text(encoding="utf-8") - ) - self._validate_catalog_payload(cached_data, catalog_url) - return cached_data - except (json.JSONDecodeError, OSError, UnicodeError, PresetError): - # Cache is corrupt, unreadable, or fails the shape check; - # fall through to network fetch. - pass - - try: - # Same redirect hardening as _fetch_single_catalog: validate every - # redirect hop AND the final URL so this legacy single-catalog path - # is not vulnerable to an HTTPS->HTTP redirected payload either. - def _validate_redirect(_old_url: str, new_url: str) -> None: - self._validate_catalog_url(new_url) - - with self._open_url( - catalog_url, timeout=10, redirect_validator=_validate_redirect - ) as response: - final_url = response.geturl() - if final_url != catalog_url: - self._validate_catalog_url(final_url) - catalog_data = json.loads( - read_response_limited( - response, - max_bytes=MAX_JSON_CATALOG_BYTES, - error_type=PresetError, - label=f"preset catalog {catalog_url}", - ) - ) - - # Validate catalog structure. Reuses the same helper as - # ``_fetch_single_catalog`` so all three branches (root type, - # missing keys, nested-mapping type) stay consistent. - self._validate_catalog_payload(catalog_data, catalog_url) - - # Save to cache. Explicit UTF-8 on both writes mirrors the - # ``read_text(encoding="utf-8")`` on the read side and the - # ``integrations/catalog.py`` precedent — otherwise platforms - # whose default encoding isn't UTF-8 would write - # locale-encoded bytes the read path can't decode, forcing an - # unnecessary refetch on every invocation. Like the read - # side, the write is best-effort: an unwritable cache dir - # must not be re-raised as a ``PresetError`` for a payload - # that was already fetched and validated. - try: - self.cache_dir.mkdir(parents=True, exist_ok=True) - self.cache_file.write_text( - json.dumps(catalog_data, indent=2), encoding="utf-8" - ) - - metadata = { - "cached_at": datetime.now(timezone.utc).isoformat(), - "catalog_url": catalog_url, - } - self.cache_metadata_file.write_text( - json.dumps(metadata, indent=2), encoding="utf-8" - ) - except OSError: - pass # Cache is best-effort; proceed with fetched data - - return catalog_data - - except (ImportError, Exception) as e: - if isinstance(e, PresetError): - raise - raise PresetError( - f"Failed to fetch preset catalog from {catalog_url}: {e}" - ) - - def search( - self, - query: Optional[str] = None, - tag: Optional[str] = None, - author: Optional[str] = None, - ) -> 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 - author: Filter by author name - - Returns: - List of matching preset metadata - """ - try: - packs = self._get_merged_packs() - except PresetError: - return [] - - results = [] - - for pack_id, pack_data in packs.items(): - if author: - author_val = pack_data.get("author", "") - if not isinstance(author_val, str): - author_val = str(author_val) if author_val is not None else "" - if author_val.lower() != author.lower(): - continue - - if tag: - raw_tags = pack_data.get("tags", []) - tags_list = raw_tags if isinstance(raw_tags, list) else [] - if tag.lower() not in [ - str(t).lower() for t in tags_list - ]: - continue - - if query: - query_lower = query.lower() - raw_tags = pack_data.get("tags", []) - tags_list = raw_tags if isinstance(raw_tags, list) else [] - name_val = pack_data.get("name", "") - desc_val = pack_data.get("description", "") - searchable_text = " ".join( - [ - str(name_val) if name_val is not None else "", - str(desc_val) if desc_val is not None else "", - pack_id, - ] - + [str(t) for t in tags_list] - ).lower() - - if query_lower not in searchable_text: - continue - - results.append({**pack_data, "id": pack_id}) - - return results - - def get_pack_info( - self, pack_id: str - ) -> 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 - - Returns: - Pack metadata or None if not found - """ - try: - packs = self._get_merged_packs() - except PresetError: - return None - - if pack_id in packs: - return {**packs[pack_id], "id": pack_id} - return None - - def download_pack( - self, pack_id: str, target_dir: Optional[Path] = None - ) -> Path: - """Download a preset archive from a catalog. - - Args: - pack_id: ID of the preset to download - target_dir: Directory to save the archive - - Returns: - Path to the downloaded archive - - Raises: - PresetError: If pack not found or download fails - """ - import urllib.error - - pack_info = self.get_pack_info(pack_id) - if not pack_info: - raise PresetError( - f"Preset '{pack_id}' not found in catalog" - ) - - # Bundled presets without a download URL must be installed locally - if pack_info.get("bundled") and not pack_info.get("download_url"): - from ..extensions import REINSTALL_COMMAND - raise PresetError( - f"Preset '{pack_id}' is bundled with spec-kit and has no download URL. " - f"It should be installed from the local package. " - f"Use 'specify preset add {pack_id}' to install from the bundled package, " - f"or reinstall spec-kit if the bundled files are missing: {REINSTALL_COMMAND}" - ) - - if not pack_info.get("_install_allowed", True): - catalog_name = pack_info.get("_catalog_name", "unknown") - raise PresetError( - f"Preset '{pack_id}' is from the '{catalog_name}' catalog which does not allow installation. " - f"Use --from with the preset's repository URL instead." - ) - - download_url = pack_info.get("download_url") - if not download_url: - raise PresetError( - f"Preset '{pack_id}' has no download URL" - ) - if not isinstance(download_url, str): - raise PresetError( - f"Preset download URL is malformed: {download_url}" - ) - - from urllib.parse import urlparse - - # A malformed authority (e.g. an unterminated IPv6 bracket - # "https://[::1") makes urlparse / hostname access raise ValueError. - # The download_url comes from catalog payload data, so surface a clean - # PresetError rather than leaking a raw ValueError past the command - # handler (which only catches PresetError). Mirrors catalogs (#3435) - # and workflows/catalog.py (#3484). - try: - parsed = urlparse(download_url) - hostname = parsed.hostname - parsed.port - except ValueError: - raise PresetError( - f"Preset download URL is malformed: {download_url}" - ) from None - if not hostname: - raise PresetError( - f"Preset download URL is malformed: {download_url}" - ) - if not is_https_or_localhost_http(download_url): - raise PresetError( - f"Preset download URL must use HTTPS: {download_url}" - ) - - if target_dir is None: - target_dir = self.cache_dir / "downloads" - target_dir = Path(target_dir) - version = pack_info.get("version", "unknown") - declared_format = archive_format_from_name(download_url) - build_safe_download_path( - target_dir, - pack_id, - version, - error_type=PresetError, - label="preset", - suffix=archive_suffix(declared_format or "tar.gz"), - ) - target_dir.mkdir(parents=True, exist_ok=True) - - original_download_url = download_url - extra_headers = None - resolved_download_url = self._resolve_github_release_asset_api_url(download_url) - if resolved_download_url: - download_url = resolved_download_url - extra_headers = {"Accept": "application/octet-stream"} - - staging_path: Path | None = None - try: - with self._open_url(download_url, timeout=60, extra_headers=extra_headers) as response: - archive_data = read_response_limited( - response, - error_type=PresetError, - label=f"preset '{pack_id}' download", - ) - final_url = ( - response.geturl() - if hasattr(response, "geturl") - else download_url - ) - content_type = ( - response.getheader("Content-Type") - if hasattr(response, "getheader") - else None - ) - - verify_archive_sha256( - archive_data, pack_info.get("sha256"), pack_id, PresetError - ) - - with tempfile.NamedTemporaryFile( - prefix="preset-download-", - suffix=".archive", - dir=target_dir, - delete=False, - ) as staging_file: - staging_path = Path(staging_file.name) - staging_file.write(archive_data) - archive_format = detect_archive_format( - staging_path, - source_name=( - final_url - if archive_format_from_name(final_url) is not None - else original_download_url - ), - content_type=content_type, - error_type=PresetError, - ) - archive_path = build_safe_download_path( - target_dir, - pack_id, - version, - error_type=PresetError, - label="preset", - suffix=archive_suffix(archive_format), - ) - os.replace(staging_path, archive_path) - staging_path = None - return archive_path - - except urllib.error.URLError as e: - raise PresetError( - f"Failed to download preset from {download_url}: {e}" - ) - except IOError as e: - raise PresetError(f"Failed to save preset archive: {e}") - finally: - if staging_path is not None: - staging_path.unlink(missing_ok=True) - - def clear_cache(self): - """Clear all catalog cache files, including per-URL hashed caches.""" - if self.cache_dir.exists(): - for f in self.cache_dir.iterdir(): - if f.is_file() and f.name.startswith("catalog"): - f.unlink(missing_ok=True) - - -class PresetResolver: - """Resolves template names to file paths using a priority stack. - - Resolution order: - 1. .specify/templates/overrides/ - Project-local overrides - 2. .specify/presets// - Installed presets - 3. .specify/extensions//templates/ - Extension-provided templates - 4. .specify/templates/ - Core templates (shipped with Spec Kit) - """ - - def __init__(self, project_root: Path): - """Initialize preset resolver. - - Args: - project_root: Path to project root directory - """ - self.project_root = project_root - self.templates_dir = project_root / ".specify" / "templates" - self.presets_dir = project_root / ".specify" / "presets" - self.overrides_dir = self.templates_dir / "overrides" - self.extensions_dir = project_root / ".specify" / "extensions" - self._manifest_cache: Dict[str, Optional["PresetManifest"]] = {} - - def _get_manifest(self, pack_dir: Path) -> Optional["PresetManifest"]: - """Get a cached preset manifest, parsing it on first access.""" - key = str(pack_dir) - if key not in self._manifest_cache: - manifest_path = pack_dir / "preset.yml" - if manifest_path.exists(): - try: - self._manifest_cache[key] = PresetManifest(manifest_path) - except PresetValidationError: - self._manifest_cache[key] = None - else: - self._manifest_cache[key] = None - return self._manifest_cache[key] - - @staticmethod - def _is_safe_registry_id(value: object) -> bool: - return isinstance(value, str) and re.fullmatch(r"[a-z0-9-]+", value) is not None - - def _get_all_presets_by_priority(self) -> List[tuple[str, dict]]: - registry = PresetRegistry(self.presets_dir) - return [ - (pack_id, metadata) - for pack_id, metadata in registry.list_by_priority() - if self._is_safe_registry_id(pack_id) - ] - - def _manifest_declared_template( - self, pack_dir: Path, template_name: str, template_type: str - ) -> tuple[dict | None, Path | None]: - """Resolve a preset's manifest-declared template entry and usable file. - - Returns ``(entry, candidate)``: - - ``entry`` is the matching ``provides.templates`` mapping, or ``None`` if - the manifest is absent or does not list this ``(name, type)``. - - ``candidate`` is the declared ``file:`` resolved under ``pack_dir`` IFF - it is a regular file (``is_file()``); ``None`` otherwise — a missing, - empty, or non-file (e.g. directory) declaration yields ``(entry, None)``. - - The manifest is authoritative: when it declares a template (``entry`` is - not ``None``) but the file is unusable (``candidate`` is ``None``), - callers must NOT fall back to the convention lookup — that would mask a - typo or pick up an undeclared file. Shared by ``resolve()`` and - ``collect_all_layers()`` so their manifest-first resolution cannot - silently diverge again (the divergence this fix addressed). - """ - manifest = self._get_manifest(pack_dir) - if not manifest: - return None, None - for tmpl in manifest.templates: - if tmpl.get("name") == template_name and tmpl.get("type") == template_type: - file_path = tmpl.get("file") - if file_path: - manifest_candidate = pack_dir / file_path - return tmpl, ( - manifest_candidate if manifest_candidate.is_file() else None - ) - return tmpl, None - return None, None - - def _extension_manifest_declared_template( - self, ext_dir: Path, template_name: str, template_type: str - ) -> tuple[dict | None, Path | None]: - """Resolve an extension's manifest-declared command/template/script entry and usable file. - - Mirrors ``_manifest_declared_template`` (for presets): returns ``(entry, candidate)`` - where ``entry`` is the matching ``provides.`` mapping, or ``None`` if the - extension has no (valid) manifest or doesn't declare this ``(name, type)``. - ``candidate`` is the declared ``file:`` resolved under ``ext_dir`` IFF it is a - regular file that stays within ``ext_dir`` (guards against path traversal via a - malformed manifest, mirroring ``resolve_extension_command_via_manifest``); - ``None`` otherwise. - - The manifest is authoritative: when ``entry`` is not ``None`` but ``candidate`` is - ``None``, callers must NOT fall back to convention-based lookup — that would mask - a typo or pick up an undeclared file. Shared by ``resolve()`` and - ``collect_all_layers()`` so their manifest-first resolution cannot silently - diverge (the divergence flagged in review on #4012). - """ - if template_type not in ("command", "template", "script"): - return None, None - ext_manifest_path = ext_dir / "extension.yml" - if not ext_manifest_path.exists(): - return None, None - from ..extensions import ExtensionManifest, ValidationError as ExtValidationError - - try: - ext_manifest = ExtensionManifest(ext_manifest_path) - except (ExtValidationError, yaml.YAMLError, OSError, TypeError, AttributeError): - return None, None - if template_type == "command": - entries = ext_manifest.commands - elif template_type == "template": - entries = ext_manifest.templates - else: - entries = ext_manifest.scripts - for entry in entries: - if entry.get("name") != template_name: - continue - file_rel = entry.get("file") - if not file_rel: - return entry, None - rel_path = Path(file_rel) - if rel_path.is_absolute(): - return entry, None - candidate = ext_dir / rel_path - try: - # Resolve only for the containment check, not for the - # returned path -- resolving the returned path would follow - # symlinks in ext_dir's ancestors (e.g. a symlinked tmp dir - # on macOS) and diverge from the unresolved paths convention - # lookup returns for the same directory. - candidate.resolve().relative_to(ext_dir.resolve()) # raises ValueError if outside - except (OSError, ValueError): - return entry, None - return entry, (candidate if candidate.is_file() else None) - return None, None - - 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) - # Fail closed on a corrupt registry. ExtensionRegistry._load() recovers - # by normalizing an unreadable registry to an empty mapping, which would - # otherwise cause the directory scan below to admit every on-disk - # directory as an unregistered, enabled extension — a fail-open path - # that could supply constitution content from an invalid registry state. - if registry.is_corrupt(): - raise PresetValidationError( - f"Invalid extension registry {registry.registry_path}: " - "refusing to enumerate extensions" - ) - # 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: - if not self._is_safe_registry_id(ext_id): - continue - # 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 not self._is_safe_registry_id(ext_dir.name): - 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 - - @staticmethod - def _core_stem(template_name: str) -> Optional[str]: - """Extract the stem for core command lookup. - - Commands use dot notation (e.g. ``speckit.specify``), but core - command files are named by stem (e.g. ``specify.md``). Returns - the stem if *template_name* follows the ``speckit.`` pattern, - or ``None`` otherwise. - """ - if template_name.startswith("speckit."): - return template_name[len("speckit."):] - return None - - def resolve( - self, - template_name: str, - template_type: str = "template", - skip_presets: bool = False, - ) -> Optional[Path]: - """Resolve a template name to its file path. - - Walks the priority stack and returns the first match. - - Args: - template_name: Template name (e.g., "spec-template") - template_type: Template type ("template", "command", or "script") - skip_presets: When True, skip tier 2 (installed presets). Use - resolve_core() as the preferred caller-facing API for this. - - Returns: - Path to the resolved template file, or None if not found - """ - # Determine subdirectory based on template type - if template_type == "template": - subdirs = ["templates", ""] - elif template_type == "command": - subdirs = ["commands"] - elif template_type == "script": - subdirs = ["scripts"] - 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 - 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 not skip_presets and self.presets_dir.exists(): - for pack_id, _metadata in self._get_all_presets_by_priority(): - pack_dir = self.presets_dir / pack_id - # The preset manifest is authoritative: if it declares this - # template with an explicit ``file:``, resolve to that path — - # and do NOT fall back to convention when it's missing, to - # avoid masking typos or picking up an undeclared file. Only - # when the manifest is absent or doesn't list this template do - # we use the convention-based subdir lookup. Mirrors - # collect_all_layers()/resolve_content() so resolve() and - # resolve_with_source() agree with them instead of returning - # the core template (or a stray convention file). - entry, manifest_candidate = self._manifest_declared_template( - pack_dir, template_name, template_type - ) - if manifest_candidate is not None: - return manifest_candidate - if entry is not None: - # Manifest declares this template but the file is missing, - # non-file (e.g. a directory), or an empty/falsey ``file`` - # value. The manifest is authoritative, so skip this pack's - # convention fallback rather than mask a typo — mirrors - # collect_all_layers(). - continue - 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 - # The extension manifest is authoritative, same as preset manifests - # above: check it before convention-based lookup so a declared entry - # at a non-conventional path wins over a stale conventional file. - entry, manifest_candidate = self._extension_manifest_declared_template( - ext_dir, template_name, template_type - ) - if manifest_candidate is not None: - return manifest_candidate - if entry is not None: - 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 - - # Priority 4: Core templates - if template_type == "template": - core = self.templates_dir / f"{template_name}.md" - if core.exists(): - return core - elif template_type == "command": - core = self.templates_dir / "commands" / f"{template_name}.md" - if core.exists(): - return core - # Fallback: speckit. → .md - stem = self._core_stem(template_name) - if stem: - core = self.templates_dir / "commands" / f"{stem}.md" - if core.exists(): - return core - elif template_type == "script": - core = self.templates_dir / "scripts" / f"{template_name}{ext}" - if core.exists(): - return core - - # Priority 5: Bundled core_pack (wheel install) or repo-root templates - # (source-checkout / editable install). This is the canonical home for - # speckit's built-in command/template files and must always be checked - # so that strategy:wrap presets can locate {CORE_TEMPLATE}. - from specify_cli import _locate_core_pack, _repo_root # local import to avoid cycles - _core_pack = _locate_core_pack() - if _core_pack is not None: - # Wheel install path - if template_type == "template": - candidate = _core_pack / "templates" / f"{template_name}.md" - elif template_type == "command": - candidate = _core_pack / "commands" / f"{template_name}.md" - if not candidate.exists(): - stem = self._core_stem(template_name) - if stem: - candidate = _core_pack / "commands" / f"{stem}.md" - elif template_type == "script": - candidate = _core_pack / "scripts" / f"{template_name}{ext}" - else: - candidate = _core_pack / f"{template_name}.md" - if candidate.exists(): - return candidate - else: - # Source-checkout / editable install: templates live at repo root - repo_root = _repo_root() - if template_type == "template": - candidate = repo_root / "templates" / f"{template_name}.md" - elif template_type == "command": - candidate = repo_root / "templates" / "commands" / f"{template_name}.md" - if not candidate.exists(): - stem = self._core_stem(template_name) - if stem: - candidate = repo_root / "templates" / "commands" / f"{stem}.md" - elif template_type == "script": - candidate = repo_root / "scripts" / f"{template_name}{ext}" - else: - candidate = repo_root / f"{template_name}.md" - if candidate.exists(): - return candidate - - return None - - def resolve_core( - self, - template_name: str, - template_type: str = "template", - ) -> Optional[Path]: - """Resolve while skipping installed presets (tier 2). - - Searches tiers 1, 3, 4, and 5 (bundled core_pack / repo-root fallback). - Use when resolving {CORE_TEMPLATE} to guarantee the result is actual - base content, never another preset's wrap output. - """ - return self.resolve(template_name, template_type, skip_presets=True) - - def resolve_extension_command_via_manifest(self, cmd_name: str) -> Optional[Path]: - """Resolve an extension command by consulting installed extension manifests. - - Walks installed extension directories in priority order, loads each - extension.yml via ExtensionManifest, and looks up the command by its - declared name to find the actual file path. This is necessary because - the manifest's ``provides.commands[].file`` field is authoritative and - may differ from the command name - (e.g. ``speckit.selftest.extension`` → ``commands/selftest.md``). - - Returns None if no manifest maps the given command name, so the caller - can fall back to the name-based lookup. - """ - if not self.extensions_dir.exists(): - return None - - from ..extensions import ExtensionManifest, ValidationError - - for _priority, ext_id, _metadata in self._get_all_extensions_by_priority(): - ext_dir = self.extensions_dir / ext_id - manifest_path = ext_dir / "extension.yml" - if not manifest_path.is_file(): - continue - try: - manifest = ExtensionManifest(manifest_path) - except (ValidationError, OSError, TypeError, AttributeError): - continue - for cmd_info in manifest.commands: - if cmd_info.get("name") != cmd_name: - continue - file_rel = cmd_info.get("file") - if not file_rel: - continue - # Mirror the containment check in ExtensionManager to guard against - # path traversal via a malformed manifest (e.g. file: ../../AGENTS.md). - cmd_path = Path(file_rel) - if cmd_path.is_absolute(): - continue - try: - ext_root = ext_dir.resolve() - candidate = (ext_root / cmd_path).resolve() - candidate.relative_to(ext_root) # raises ValueError if outside - except (OSError, ValueError): - continue - if candidate.is_file(): - 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 - """ - # Delegate to resolve() for the actual lookup, then determine source - resolved = self.resolve(template_name, template_type) - if resolved is None: - return None - - 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(): - for pack_id, metadata in self._get_all_presets_by_priority(): - pack_dir = self.presets_dir / pack_id - try: - resolved.relative_to(pack_dir) - version = metadata.get("version", "?") - return { - "path": resolved_str, - "source": f"{pack_id} v{version}", - } - except ValueError: - continue - - 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 - try: - resolved.relative_to(ext_dir) - if ext_meta: - version = ext_meta.get("version", "?") - return { - "path": resolved_str, - "source": f"extension:{ext_id} v{version}", - } - else: - return { - "path": resolved_str, - "source": f"extension:{ext_id} (unregistered)", - } - except ValueError: - continue - - return {"path": resolved_str, "source": "core"} - - def collect_all_layers( - self, - template_name: str, - template_type: str = "template", - ) -> List[Dict[str, Any]]: - """Collect all layers in the priority stack for a template. - - Returns layers from highest priority (checked first) to lowest priority. - Each layer is a dict with 'path', 'source', and 'strategy' keys. - - Args: - template_name: Template name (e.g., "spec-template") - template_type: Template type ("template", "command", or "script") - - Returns: - List of layer dicts ordered highest-to-lowest priority. - """ - if template_type == "template": - subdirs = ["templates", ""] - elif template_type == "command": - subdirs = ["commands"] - elif template_type == "script": - subdirs = ["scripts"] - else: - subdirs = [""] - - ext = ".md" - if template_type == "script": - ext = ".sh" - - layers: List[Dict[str, Any]] = [] - - def _find_in_subdirs(base_dir: Path) -> Optional[Path]: - for subdir in subdirs: - if subdir: - candidate = base_dir / subdir / f"{template_name}{ext}" - else: - candidate = base_dir / f"{template_name}{ext}" - if candidate.exists(): - return candidate - return None - - # Priority 1: Project-local overrides (always "replace" strategy) - 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(): - layers.append({ - "path": override, - "source": "project override", - "strategy": "replace", - }) - - # Priority 2: Installed presets (sorted by priority — lower number = higher precedence) - if self.presets_dir.exists(): - for pack_id, metadata in self._get_all_presets_by_priority(): - pack_dir = self.presets_dir / pack_id - # Read strategy and manifest file path from preset manifest - strategy = "replace" - manifest_has_strategy = False - entry, manifest_candidate = self._manifest_declared_template( - pack_dir, template_name, template_type - ) - if entry is not None: - strategy = entry.get("strategy", "replace") - manifest_has_strategy = "strategy" in entry - # Use the manifest's declared file when it's a usable regular file; - # only fall back to convention-based lookup when the manifest - # doesn't list this template at all, so preset.yml stays - # authoritative (a declared-but-unusable file skips convention — - # parity with resolve()). - candidate = None - if manifest_candidate is not None: - candidate = manifest_candidate - elif entry is None: - candidate = _find_in_subdirs(pack_dir) - if candidate: - # Legacy fallback: if manifest doesn't explicitly declare a - # strategy, check the command file's frontmatter for any valid - # strategy. Skip when the manifest entry includes strategy key - # (even if it's "replace") to avoid overriding explicit declarations. - if not manifest_has_strategy and strategy == "replace" and template_type == "command": - try: - cmd_content = candidate.read_text(encoding="utf-8") - lines = cmd_content.splitlines(keepends=True) - if lines and lines[0].rstrip("\r\n") == "---": - fence_end = -1 - for fi, fline in enumerate(lines[1:], start=1): - if fline.rstrip("\r\n") == "---": - fence_end = fi - break - if fence_end > 0: - fm_text = "".join(lines[1:fence_end]) - fm_data = yaml.safe_load(fm_text) - if isinstance(fm_data, dict): - fm_strategy = fm_data.get("strategy") - if isinstance(fm_strategy, str) and fm_strategy.lower() in VALID_PRESET_STRATEGIES: - strategy = fm_strategy.lower() - except (UnicodeDecodeError, yaml.YAMLError, OSError): - # Best-effort legacy frontmatter parsing: keep default - # strategy ("replace") when content is unreadable/invalid. - pass - version = metadata.get("version", "?") if metadata else "?" - layers.append({ - "path": candidate, - "source": f"{pack_id} v{version}", - "strategy": strategy, - }) - - # Priority 3: Extension-provided templates (always "replace") - 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 - # The extension manifest is authoritative, same as preset manifests - # above: check it before convention-based lookup so a declared entry - # at a non-conventional path wins over a stale conventional file, and - # a declared-but-missing file isn't silently masked by convention. - entry, candidate = self._extension_manifest_declared_template( - ext_dir, template_name, template_type - ) - if entry is None: - candidate = _find_in_subdirs(ext_dir) - if candidate: - if ext_meta: - version = ext_meta.get("version", "?") - source = f"extension:{ext_id} v{version}" - else: - source = f"extension:{ext_id} (unregistered)" - layers.append({ - "path": candidate, - "source": source, - "strategy": "replace", - "extension_id": ext_id, - "extension_dir": ext_dir, - }) - - # Priority 4: Core templates (always "replace") - core = None - if template_type == "template": - c = self.templates_dir / f"{template_name}.md" - if c.exists(): - core = c - elif template_type == "command": - c = self.templates_dir / "commands" / f"{template_name}.md" - if c.exists(): - core = c - else: - # Fallback: speckit. → .md - stem = self._core_stem(template_name) - if stem: - c = self.templates_dir / "commands" / f"{stem}.md" - if c.exists(): - core = c - elif template_type == "script": - c = self.templates_dir / "scripts" / f"{template_name}{ext}" - if c.exists(): - core = c - if core: - layers.append({ - "path": core, - "source": "core", - "strategy": "replace", - }) - else: - # Priority 5: Bundled core_pack (wheel install) or repo-root - # templates (source-checkout), matching resolve()'s tier-5 fallback. - bundled = self._find_bundled_core(template_name, template_type, ext) - if bundled: - layers.append({ - "path": bundled, - "source": "core (bundled)", - "strategy": "replace", - }) - - return layers - - def _find_bundled_core( - self, - template_name: str, - template_type: str, - ext: str, - ) -> Optional[Path]: - """Find a core template from the bundled pack or source checkout. - - Mirrors the tier-5 fallback logic in ``resolve()`` so that - ``collect_all_layers()`` can locate base layers even when - ``.specify/templates/`` doesn't contain the core file. - """ - try: - from specify_cli import _locate_core_pack, _repo_root - except ImportError: - return None - - stem = self._core_stem(template_name) - names = [template_name] - if stem and stem != template_name: - names.append(stem) - - core_pack = _locate_core_pack() - if core_pack is not None: - for name in names: - if template_type == "template": - c = core_pack / "templates" / f"{name}.md" - elif template_type == "command": - c = core_pack / "commands" / f"{name}.md" - elif template_type == "script": - c = core_pack / "scripts" / f"{name}{ext}" - else: - c = core_pack / f"{name}.md" - if c.exists(): - return c - else: - repo_root = _repo_root() - for name in names: - if template_type == "template": - c = repo_root / "templates" / f"{name}.md" - elif template_type == "command": - c = repo_root / "templates" / "commands" / f"{name}.md" - elif template_type == "script": - c = repo_root / "scripts" / f"{name}{ext}" - else: - c = repo_root / f"{name}.md" - if c.exists(): - return c - return None - - def resolve_content( - self, - template_name: str, - template_type: str = "template", - ) -> Optional[str]: - """Resolve a template name and return composed content. - - Walks the priority stack and composes content using strategies: - - replace (default): highest-priority content wins entirely - - prepend: content is placed before lower-priority content - - append: content is placed after lower-priority content - - wrap: content contains {CORE_TEMPLATE} placeholder replaced - with lower-priority content (or $CORE_SCRIPT for scripts) - - Composition is recursive — multiple composing presets chain. - - Args: - template_name: Template name (e.g., "spec-template") - template_type: Template type ("template", "command", or "script") - - Returns: - Composed content string, or None if not found - """ - layers = self.collect_all_layers(template_name, template_type) - if not layers: - return None - - def _read_layer_content(layer: Dict[str, Any]) -> Optional[str]: - """Read a layer's raw text, rewriting extension-relative subdir - references (agents/, knowledge-base/, etc.) to their installed - location when the layer is extension-provided (#2101). - - Extension layers are always inserted with strategy "replace" - (see collect_all_layers), so a layer only ever needs this - rewrite when it wins outright above or serves as the - composition base below — never as a mid-stack composing - (append/prepend/wrap) layer. - - Returns None when the layer cannot be read or decoded: - collect_all_layers deliberately keeps a non-UTF-8 legacy layer - (with its "replace" default) so unrelated commands still - resolve, so the same tolerance must apply here — the documented - contract is "Composed content string, or None if not found", - not a raw UnicodeDecodeError at composition time. - """ - try: - text = layer["path"].read_text(encoding="utf-8") - except (OSError, UnicodeDecodeError): - return None - extension_id = layer.get("extension_id") - extension_dir = layer.get("extension_dir") - if extension_id and extension_dir: - from ..agents import CommandRegistrar - - text = CommandRegistrar.rewrite_extension_paths( - text, extension_id, extension_dir - ) - return text - - # If the top (highest-priority) layer is replace, it wins entirely — - # lower layers are irrelevant regardless of their strategies. - if layers[0]["strategy"] == "replace": - return _read_layer_content(layers[0]) - - # Composition: build content bottom-up from the effective base. - # The base is the nearest replace layer scanning from highest priority - # downward. Only layers above the base contribute to composition. - # - # layers is ordered highest-priority first. We process in reverse. - reversed_layers = list(reversed(layers)) - - # Find the effective base: scan from highest priority (layers[0]) downward - # to find the nearest replace layer. Only compose layers above that base. - # layers is highest-priority first; reversed_layers is lowest first. - base_layer_idx = None # index in layers[] (highest-priority first) - for idx, layer in enumerate(layers): - if layer["strategy"] == "replace": - base_layer_idx = idx - break - - if base_layer_idx is None: - return None # no replace base found - - # Convert to reversed_layers index - base_reversed_idx = len(layers) - 1 - base_layer_idx - content = _read_layer_content(layers[base_layer_idx]) - if content is None: - return None - # Compose only the layers above the base (higher priority = lower index in layers, - # higher index in reversed_layers). Process bottom-up from base+1. - start_idx = base_reversed_idx + 1 - - # For command composition, strip frontmatter from each layer to avoid - # leaking YAML metadata into the composed body. The highest-priority - # layer's frontmatter will be reattached at the end. - is_command = template_type == "command" - top_frontmatter_text = None - base_frontmatter_text = None - - def _split_frontmatter(text: str) -> tuple: - """Return (frontmatter_block_with_fences, body) or (None, text). - - Uses line-based fence detection (fence must be ``---`` on its - own line) to avoid false matches on ``---`` inside YAML values. - """ - lines = text.splitlines(keepends=True) - if not lines or lines[0].rstrip("\r\n") != "---": - return None, text - - fence_end = -1 - for i, line in enumerate(lines[1:], start=1): - if line.rstrip("\r\n") == "---": - fence_end = i - break - - if fence_end == -1: - return None, text - - fm_block = "".join(lines[:fence_end + 1]).rstrip("\r\n") - body = "".join(lines[fence_end + 1:]) - return fm_block, body - - if is_command: - fm, body = _split_frontmatter(content) - if fm: - top_frontmatter_text = fm - base_frontmatter_text = fm - content = body - - # Apply composition layers from bottom to top - for layer in reversed_layers[start_idx:]: - try: - layer_content = layer["path"].read_text(encoding="utf-8") - except (OSError, UnicodeDecodeError): - # Same tolerance as _read_layer_content: an unreadable layer - # means the composed result cannot be produced. - return None - strategy = layer["strategy"] - - if is_command: - fm, layer_body = _split_frontmatter(layer_content) - layer_content = layer_body - # Track the highest-priority frontmatter seen; - # replace layers reset both top and base frontmatter since - # they replace the entire command including metadata. - if strategy == "replace": - top_frontmatter_text = fm - base_frontmatter_text = fm - elif fm: - top_frontmatter_text = fm - - if strategy == "replace": - content = layer_content - elif strategy == "prepend": - content = layer_content + "\n\n" + content - elif strategy == "append": - content = content + "\n\n" + layer_content - elif strategy == "wrap": - if template_type == "script": - placeholder = "$CORE_SCRIPT" - else: - placeholder = "{CORE_TEMPLATE}" - if placeholder not in layer_content: - raise PresetValidationError( - f"Wrap strategy in '{layer['source']}' is missing " - f"the {placeholder} placeholder. The wrapper must " - f"contain {placeholder} to indicate where the " - f"lower-priority content should be inserted." - ) - content = layer_content.replace(placeholder, content) - - # Reattach the highest-priority frontmatter for commands, - # inheriting scripts/agent_scripts from the base if missing - # and stripping the strategy key (internal-only, not for agent output). - if is_command and top_frontmatter_text: - def _parse_fm_yaml(fm_block: str) -> dict: - """Parse YAML from a frontmatter block (with --- fences).""" - lines = fm_block.splitlines() - # Parse only interior lines (between --- fences) - if len(lines) >= 2: - yaml_lines = lines[1:-1] - else: - yaml_lines = [] - try: - return yaml.safe_load("\n".join(yaml_lines)) or {} - except yaml.YAMLError: - return {} - - top_fm = _parse_fm_yaml(top_frontmatter_text) - - # Inherit scripts/agent_scripts from base frontmatter if missing - if base_frontmatter_text and base_frontmatter_text != top_frontmatter_text: - base_fm = _parse_fm_yaml(base_frontmatter_text) - for key in ("scripts", "agent_scripts", "argument-hint"): - if key not in top_fm and key in base_fm: - top_fm[key] = base_fm[key] - - # Strip strategy key — it's an internal composition directive, - # not meant for rendered agent command files - top_fm.pop("strategy", None) - - if top_fm: - top_frontmatter_text = ( - "---\n" - + dump_frontmatter(top_fm) - + "\n---" - ) - else: - # Empty frontmatter — omit rather than emitting {} - top_frontmatter_text = None - - if top_frontmatter_text: - content = top_frontmatter_text + "\n\n" + content - - return content +from ._manager import ( + _CONSTITUTION_SYNC_PRESET_ID as _CONSTITUTION_SYNC_PRESET_ID, +) +from ._manager import ( + PresetManager as PresetManager, +) +from ._manager import ( + _constitution_is_generated as _constitution_is_generated, +) +from ._manager import ( + _constitution_provenance_matches_preset as _constitution_provenance_matches_preset, +) +from ._manager import ( + _content_sha256 as _content_sha256, +) +from ._manager import ( + _is_comparable_version as _is_comparable_version, +) +from ._manager import ( + _materialize_constitution_template as _materialize_constitution_template, +) +from ._manager_commands import _substitute_core_template as _substitute_core_template +from ._manifest import ( + VALID_PRESET_STRATEGIES as VALID_PRESET_STRATEGIES, +) +from ._manifest import ( + VALID_PRESET_TEMPLATE_TYPES as VALID_PRESET_TEMPLATE_TYPES, +) +from ._manifest import ( + VALID_SCRIPT_STRATEGIES as VALID_SCRIPT_STRATEGIES, +) +from ._manifest import ( + PresetCompatibilityError as PresetCompatibilityError, +) +from ._manifest import ( + PresetError as PresetError, +) +from ._manifest import ( + PresetManifest as PresetManifest, +) +from ._manifest import ( + PresetValidationError as PresetValidationError, +) +from ._registry import PresetRegistry as PresetRegistry +from ._resolver import PresetResolver as PresetResolver diff --git a/src/specify_cli/presets/_catalog.py b/src/specify_cli/presets/_catalog.py new file mode 100644 index 0000000000..a4768cc22d --- /dev/null +++ b/src/specify_cli/presets/_catalog.py @@ -0,0 +1,926 @@ +"""Preset catalog retrieval and downloads (private domain implementation).""" + +import hashlib +import json +import os +import tempfile +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Dict, List, Optional + +import yaml + +from .._download_security import ( + archive_format_from_name, + archive_suffix, + build_safe_download_path, + detect_archive_format, + is_https_or_localhost_http, +) +from ._manifest import PresetError, PresetValidationError + + +@dataclass +class PresetCatalogEntry: + """Represents a single entry in the preset catalog stack.""" + url: str + name: str + priority: int + install_allowed: bool + description: str = "" + + +class PresetCatalog: + """Manages preset catalog fetching, caching, and searching. + + Supports multi-catalog stacks with priority-based resolution, + mirroring the extension catalog system. + """ + + DEFAULT_CATALOG_URL = "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.json" + COMMUNITY_CATALOG_URL = "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.community.json" + CACHE_DURATION = 3600 # 1 hour in seconds + + def __init__(self, project_root: Path): + """Initialize preset catalog manager. + + Args: + project_root: Root directory of the spec-kit project + """ + self.project_root = project_root + self.presets_dir = project_root / ".specify" / "presets" + self.cache_dir = self.presets_dir / ".cache" + self.cache_file = self.cache_dir / "catalog.json" + self.cache_metadata_file = self.cache_dir / "catalog-metadata.json" + + def _validate_catalog_url(self, url: str) -> None: + """Validate that a catalog URL uses HTTPS (localhost HTTP allowed). + + Args: + url: URL to validate + + Raises: + PresetValidationError: If URL is invalid or uses non-HTTPS scheme + """ + from urllib.parse import urlparse + + try: + parsed = urlparse(url) + hostname = parsed.hostname + # Accessing ``port`` performs urllib's syntax/range validation; + # ``hostname`` alone does not, so a non-numeric or out-of-range + # port would otherwise pass validation here and only fail later, + # at fetch time, as a raw error this function does not translate + # into PresetValidationError. Mirrors specify_cli.catalogs and + # bundler/services/adapters.py's copy of this same guard. + _ = parsed.port + except ValueError: + raise PresetValidationError(f"Catalog URL is malformed: {url}") from None + is_localhost = hostname in ("localhost", "127.0.0.1", "::1") + if parsed.scheme != "https" and not ( + parsed.scheme == "http" and is_localhost + ): + raise PresetValidationError( + f"Catalog URL must use HTTPS (got {parsed.scheme}://). " + "HTTP is only allowed for localhost." + ) + # Check hostname, not netloc: netloc is truthy for host-less URLs like + # "https://:8080" or "https://user@", so the host guarantee this error + # promises would not actually hold. hostname is None in those cases (#3209). + if not hostname: + raise PresetValidationError( + "Catalog URL must be a valid URL with a host." + ) + + def _make_request(self, url: str): + """Build a urllib Request, adding auth headers when a provider matches. + + Delegates to :func:`specify_cli.authentication.http.build_request`. + """ + from specify_cli.authentication.http import build_request + return build_request(url) + + def _open_url( + self, + url: str, + timeout: int = 10, + extra_headers: Optional[Dict[str, str]] = None, + redirect_validator=None, + ): + """Open a URL with provider-based auth, trying each configured provider. + + Delegates to :func:`specify_cli.authentication.http.open_url`. + *redirect_validator*, when provided, is invoked as ``(old_url, new_url)`` + before EACH redirect hop, so an HTTPS host guarantee can be enforced on + every intermediate URL, not just the terminal one. + """ + from specify_cli.authentication.http import open_url + return open_url( + url, + timeout, + extra_headers=extra_headers, + redirect_validator=redirect_validator, + ) + + def _resolve_github_release_asset_api_url( + self, + download_url: str, + timeout: int = 60, + ) -> Optional[str]: + """Resolve a GitHub release asset URL to its REST API asset URL. + + Passes the ``github`` provider hosts from ``auth.json`` so GitHub + Enterprise Server release assets resolve via ``/api/v3``. + """ + from specify_cli.authentication.github_http import ( + resolve_github_release_asset_api_url, + ) + from specify_cli.authentication.http import github_provider_hosts + + return resolve_github_release_asset_api_url( + download_url, + self._open_url, + timeout=timeout, + github_hosts=github_provider_hosts(), + ) + + def _validate_catalog_payload(self, catalog_data: Any, url: str) -> None: + """Validate a parsed preset-catalog payload's shape. + + Applied to both network-fetched and cache-loaded payloads so a + once-poisoned cache (older spec-kit version, manual edit, upstream + served a bad payload before the network-side guards were added) + cannot re-crash ``_get_merged_packs`` on subsequent calls. + + Checking only key presence would let a payload like + ``{"presets": []}`` or ``{"presets": null}`` slip through here and + then crash with ``AttributeError: 'list' object has no attribute + 'items'`` deep inside ``_get_merged_packs``. The sibling + integration catalog reader already guards both the root object and + the nested mapping (see ``integrations/catalog.py``); the preset + catalog must stay consistent so a malformed payload surfaces as + the user-facing ``Invalid preset catalog format`` error instead of + a raw Python traceback. + + Args: + catalog_data: Parsed JSON payload from the catalog source. + url: Source URL — used in the error message so the user can + tell which catalog in a multi-catalog stack is malformed. + + Raises: + PresetError: If the payload's shape is invalid. + """ + if not isinstance(catalog_data, dict): + raise PresetError( + f"Invalid preset catalog format from {url}: " + "expected a JSON object" + ) + if ( + "schema_version" not in catalog_data + or "presets" not in catalog_data + ): + raise PresetError(f"Invalid preset catalog format from {url}") + if not isinstance(catalog_data.get("presets"), dict): + raise PresetError( + f"Invalid preset catalog format from {url}: " + "'presets' must be a JSON object" + ) + + def _load_catalog_config(self, config_path: Path) -> Optional[List[PresetCatalogEntry]]: + """Load catalog stack configuration from a YAML file. + + Args: + config_path: Path to preset-catalogs.yml + + Returns: + Ordered list of PresetCatalogEntry objects, or None if file + doesn't exist or contains no valid catalog entries. + + Raises: + PresetValidationError: If any catalog entry has an invalid URL, + the file cannot be parsed, or a priority value is invalid. + """ + if not config_path.exists(): + return None + try: + data = yaml.safe_load(config_path.read_text(encoding="utf-8")) + except (yaml.YAMLError, OSError, UnicodeError) as e: + raise PresetValidationError( + f"Failed to read catalog config {config_path}: {e}" + ) + # Do NOT coerce with ``or {}`` here: that also turns a FALSY + # non-mapping top level (``[]``, ``false``, ``0``, ``''``) into ``{}`` + # and silently swallows it, while a TRUTHY non-mapping (``5``, a bare + # list) correctly raises below. Only an empty document/explicit + # ``null`` means "no document". + if data is None: + return None + if not isinstance(data, dict): + raise PresetValidationError( + f"Invalid catalog config {config_path}: expected a mapping at root, got {type(data).__name__}" + ) + # Same asymmetry one nesting level down: the shape check has to run + # BEFORE the emptiness check, or a FALSY non-list ``catalogs`` value + # (``{}``, ``''``, ``0``, ``false``) is silently swallowed as "no + # catalogs" while a TRUTHY non-list (``catalogs: "not-a-list"``) + # correctly raises. An absent key or an explicit ``catalogs: null`` + # both keep their existing "nothing configured here" behavior. + catalogs_data = data.get("catalogs") + if catalogs_data is None: + return None + if not isinstance(catalogs_data, list): + raise PresetValidationError( + f"Invalid catalog config: 'catalogs' must be a list, got {type(catalogs_data).__name__}" + ) + if not catalogs_data: + return None + entries: List[PresetCatalogEntry] = [] + for idx, item in enumerate(catalogs_data): + if not isinstance(item, dict): + raise PresetValidationError( + f"Invalid catalog entry at index {idx}: expected a mapping, got {type(item).__name__}" + ) + url = str(item.get("url", "")).strip() + if not url: + continue + self._validate_catalog_url(url) + raw_priority = item.get("priority", idx + 1) + # Reject bools explicitly: ``bool`` is a subclass of ``int`` so + # ``int(True)`` silently returns 1, which would let a YAML + # ``priority: true`` slip through as a valid priority of 1. The + # sibling integration-catalog reader in ``catalogs.py`` already + # guards this; mirror the check here so the three catalog + # validators stay consistent. + if isinstance(raw_priority, bool): + raise PresetValidationError( + f"Invalid priority for catalog '{item.get('name', idx + 1)}': " + f"expected integer, got {raw_priority!r}" + ) + try: + priority = int(raw_priority) + except (TypeError, ValueError, OverflowError): + # OverflowError: int(float("inf")) — a YAML ``priority: .inf`` + # would otherwise escape as an uncaught traceback instead of the + # clean validation error (mirrors catalogs.py). + raise PresetValidationError( + f"Invalid priority for catalog '{item.get('name', idx + 1)}': " + f"expected integer, got {raw_priority!r}" + ) + raw_install = item.get("install_allowed", False) + if isinstance(raw_install, str): + install_allowed = raw_install.strip().lower() in ("true", "yes", "1") + else: + install_allowed = bool(raw_install) + raw_name = item.get("name") + name = str(raw_name).strip() if raw_name is not None else "" + if not name: + name = f"catalog-{len(entries) + 1}" + + entries.append(PresetCatalogEntry( + url=url, + name=name, + priority=priority, + install_allowed=install_allowed, + description=str(item.get("description", "")), + )) + entries.sort(key=lambda e: e.priority) + return entries if entries else None + + def get_active_catalogs(self) -> List[PresetCatalogEntry]: + """Get the ordered list of active preset catalogs. + + Resolution order: + 1. SPECKIT_PRESET_CATALOG_URL env var — single catalog replacing all defaults + 2. Project-level .specify/preset-catalogs.yml + 3. User-level ~/.specify/preset-catalogs.yml + 4. Built-in default stack (default + community) + + Returns: + List of PresetCatalogEntry objects sorted by priority (ascending) + + Raises: + PresetValidationError: If a catalog URL is invalid + """ + import sys + + # 1. SPECKIT_PRESET_CATALOG_URL env var replaces all defaults + if env_value := os.environ.get("SPECKIT_PRESET_CATALOG_URL"): + catalog_url = env_value.strip() + self._validate_catalog_url(catalog_url) + if catalog_url != self.DEFAULT_CATALOG_URL: + if not getattr(self, "_non_default_catalog_warning_shown", False): + print( + "Warning: Using non-default preset catalog. " + "Only use catalogs from sources you trust.", + file=sys.stderr, + ) + self._non_default_catalog_warning_shown = True + return [PresetCatalogEntry(url=catalog_url, name="custom", priority=1, install_allowed=True, description="Custom catalog via SPECKIT_PRESET_CATALOG_URL")] + + # 2. Project-level config overrides all defaults + project_config_path = self.project_root / ".specify" / "preset-catalogs.yml" + catalogs = self._load_catalog_config(project_config_path) + if catalogs is not None: + return catalogs + + # 3. User-level config + user_config_path = Path.home() / ".specify" / "preset-catalogs.yml" + catalogs = self._load_catalog_config(user_config_path) + if catalogs is not None: + return catalogs + + # 4. Built-in default stack + return [ + PresetCatalogEntry(url=self.DEFAULT_CATALOG_URL, name="default", priority=1, install_allowed=True, description="Built-in catalog of installable presets"), + PresetCatalogEntry(url=self.COMMUNITY_CATALOG_URL, name="community", priority=2, install_allowed=False, description="Community-contributed presets (discovery only)"), + ] + + def get_catalog_url(self) -> str: + """Get the primary catalog URL. + + Returns the URL of the highest-priority catalog. Kept for backward + compatibility. Use get_active_catalogs() for full multi-catalog support. + + Returns: + URL of the primary catalog + """ + active = self.get_active_catalogs() + return active[0].url if active else self.DEFAULT_CATALOG_URL + + def _get_cache_paths(self, url: str): + """Get cache file paths for a given catalog URL. + + For the DEFAULT_CATALOG_URL, uses legacy cache files for backward + compatibility. For all other URLs, uses URL-hash-based cache files. + + Returns: + Tuple of (cache_file_path, cache_metadata_path) + """ + if url == self.DEFAULT_CATALOG_URL: + return self.cache_file, self.cache_metadata_file + url_hash = hashlib.sha256(url.encode()).hexdigest()[:16] + return ( + self.cache_dir / f"catalog-{url_hash}.json", + self.cache_dir / f"catalog-{url_hash}-metadata.json", + ) + + def _is_url_cache_valid(self, url: str) -> bool: + """Check if cached catalog for a specific URL is still valid.""" + cache_file, metadata_file = self._get_cache_paths(url) + if not cache_file.exists() or not metadata_file.exists(): + return False + try: + metadata = json.loads(metadata_file.read_text(encoding="utf-8")) + cached_at = datetime.fromisoformat(metadata.get("cached_at", "")) + if cached_at.tzinfo is None: + cached_at = cached_at.replace(tzinfo=timezone.utc) + age_seconds = ( + datetime.now(timezone.utc) - cached_at + ).total_seconds() + return age_seconds < self.CACHE_DURATION + except ( + json.JSONDecodeError, + OSError, + UnicodeError, + ValueError, + KeyError, + TypeError, + AttributeError, + ): + # Cache validity is best-effort: invalid/missing fields, an + # unreadable metadata file (permissions / disk), a wrongly + # encoded one (written by a tool using the system locale + # codec), or a metadata payload that parses to a non-mapping + # like ``[]`` or ``"oops"`` (so ``metadata.get(...)`` raises + # ``AttributeError``) all degrade to "cache invalid" so the + # caller falls through to a network refetch instead of + # crashing. + return False + + def _fetch_single_catalog(self, entry: PresetCatalogEntry, force_refresh: bool = False) -> Dict[str, Any]: + """Fetch a single catalog with per-URL caching. + + Args: + entry: PresetCatalogEntry describing the catalog to fetch + force_refresh: If True, bypass cache + + Returns: + Catalog data dictionary + + Raises: + PresetError: If catalog cannot be fetched + """ + # Honor the established package-level patch points during extraction. + from . import MAX_JSON_CATALOG_BYTES, read_response_limited + + cache_file, metadata_file = self._get_cache_paths(entry.url) + + # Use cache if valid. A previously-cached payload must clear the + # same shape checks as a freshly-fetched one — otherwise a once- + # poisoned cache would re-crash on every invocation despite the + # cache being "valid" by age. If validation fails on the cached + # read, fall through to the network fetch path so the cache gets + # refreshed. + if not force_refresh and self._is_url_cache_valid(entry.url): + try: + cached_data = json.loads(cache_file.read_text(encoding="utf-8")) + self._validate_catalog_payload(cached_data, entry.url) + return cached_data + except (json.JSONDecodeError, OSError, UnicodeError, PresetError): + # Cache is best-effort: a JSON-decode failure, an OS-level + # read failure (permissions / disk / handle limit), or a + # text-encoding failure on a cache file written by an + # older client all fall through to the network fetch path. + # Only the network failure is surfaced to the caller. + pass + + try: + # Validate EVERY redirect hop (not just the terminal URL): an + # https -> http -> attacker-controlled-https chain would pass a + # final-URL-only check while the insecure intermediate hop lets a + # network attacker rewrite the next redirect. redirect_validator runs + # before each hop; the final geturl() check is retained as a + # belt-and-braces guard. Mirrors bundler/services/adapters.py. + def _validate_redirect(_old_url: str, new_url: str) -> None: + self._validate_catalog_url(new_url) + + with self._open_url( + entry.url, timeout=10, redirect_validator=_validate_redirect + ) as response: + final_url = response.geturl() + if final_url != entry.url: + self._validate_catalog_url(final_url) + catalog_data = json.loads( + read_response_limited( + response, + max_bytes=MAX_JSON_CATALOG_BYTES, + error_type=PresetError, + label=f"preset catalog {entry.url}", + ) + ) + + self._validate_catalog_payload(catalog_data, entry.url) + + # Both files are written explicitly as UTF-8 to match the + # ``read_text(encoding="utf-8")`` on the read side and the + # ``integrations/catalog.py`` precedent. Without this, + # platforms whose default encoding isn't UTF-8 would write + # locale-encoded bytes the read path can't decode, forcing an + # unnecessary refetch on every invocation. The write itself + # is best-effort like the read side: an unwritable cache dir + # (read-only checkout, permissions) must not be re-raised as + # a ``PresetError`` for a payload that was already fetched + # and validated. + try: + self.cache_dir.mkdir(parents=True, exist_ok=True) + cache_file.write_text( + json.dumps(catalog_data, indent=2), encoding="utf-8" + ) + metadata = { + "cached_at": datetime.now(timezone.utc).isoformat(), + "catalog_url": entry.url, + } + metadata_file.write_text( + json.dumps(metadata, indent=2), encoding="utf-8" + ) + except OSError: + pass # Cache is best-effort; proceed with fetched data + + return catalog_data + + except (ImportError, Exception) as e: + if isinstance(e, PresetError): + raise + raise PresetError( + f"Failed to fetch preset catalog from {entry.url}: {e}" + ) + + def _get_merged_packs(self, force_refresh: bool = False) -> Dict[str, Dict[str, Any]]: + """Fetch and merge presets from all active catalogs. + + Higher-priority catalogs (lower priority number) win on ID conflicts. + + Returns: + Merged dictionary of pack_id -> pack_data + """ + active_catalogs = self.get_active_catalogs() + merged: Dict[str, Dict[str, Any]] = {} + + for entry in reversed(active_catalogs): + try: + data = self._fetch_single_catalog(entry, force_refresh) + for pack_id, pack_data in data.get("presets", {}).items(): + # Per-entry guard: ``_fetch_single_catalog`` already + # validates that ``data["presets"]`` is a mapping, but it + # does not (and should not) validate every entry shape + # there — one malformed entry shouldn't poison an + # otherwise valid catalog. Skip non-mapping entries here + # so a payload like ``{"presets": {"foo": [], "bar": + # {...}}}`` still merges the valid entries without + # crashing on ``**pack_data``. Mirrors + # ``integrations/catalog.py:245``. + if not isinstance(pack_data, dict): + continue + pack_data_with_catalog = {**pack_data, "_catalog_name": entry.name, "_install_allowed": entry.install_allowed} + merged[pack_id] = pack_data_with_catalog + except PresetError: + continue + + return merged + + def is_cache_valid(self) -> bool: + """Check if cached catalog is still valid. + + Returns ``False`` for any read/decoding failure on the metadata + file (missing fields, malformed JSON, permissions / disk errors, + wrong text encoding) so callers fall through to a network refetch + instead of crashing. Treating cache validity as best-effort + matches the contract used by ``_is_url_cache_valid`` above. + + Returns: + True if cache exists and is within cache duration + """ + if not self.cache_file.exists() or not self.cache_metadata_file.exists(): + return False + + try: + metadata = json.loads( + self.cache_metadata_file.read_text(encoding="utf-8") + ) + cached_at = datetime.fromisoformat(metadata.get("cached_at", "")) + if cached_at.tzinfo is None: + cached_at = cached_at.replace(tzinfo=timezone.utc) + age_seconds = ( + datetime.now(timezone.utc) - cached_at + ).total_seconds() + return age_seconds < self.CACHE_DURATION + except ( + json.JSONDecodeError, + OSError, + UnicodeError, + ValueError, + KeyError, + TypeError, + AttributeError, + ): + # ``AttributeError`` covers the case where the metadata file + # parses to a non-mapping (``[]``, ``"oops"``, ``42``) so + # ``metadata.get(...)`` would otherwise crash. All decode / + # shape failures degrade to "cache invalid" so the caller + # falls through to a network refetch. + return False + + def fetch_catalog(self, force_refresh: bool = False) -> Dict[str, Any]: + """Fetch preset catalog from URL or cache. + + Args: + force_refresh: If True, bypass cache and fetch from network + + Returns: + Catalog data dictionary + + Raises: + PresetError: If catalog cannot be fetched + """ + from . import MAX_JSON_CATALOG_BYTES, read_response_limited + + catalog_url = self.get_catalog_url() + + # Match the ``_fetch_single_catalog`` cache contract: a poisoned + # or unreadable cache silently falls through to a network refetch + # rather than crashing the caller. ``_validate_catalog_payload`` + # is reused here so a cache written by an older client + # (pre-validation) is rejected and refreshed instead of returning + # the stale malformed payload. + if not force_refresh and self.is_cache_valid(): + try: + metadata = json.loads( + self.cache_metadata_file.read_text(encoding="utf-8") + ) + if metadata.get("catalog_url") == catalog_url: + cached_data = json.loads( + self.cache_file.read_text(encoding="utf-8") + ) + self._validate_catalog_payload(cached_data, catalog_url) + return cached_data + except (json.JSONDecodeError, OSError, UnicodeError, PresetError): + # Cache is corrupt, unreadable, or fails the shape check; + # fall through to network fetch. + pass + + try: + # Same redirect hardening as _fetch_single_catalog: validate every + # redirect hop AND the final URL so this legacy single-catalog path + # is not vulnerable to an HTTPS->HTTP redirected payload either. + def _validate_redirect(_old_url: str, new_url: str) -> None: + self._validate_catalog_url(new_url) + + with self._open_url( + catalog_url, timeout=10, redirect_validator=_validate_redirect + ) as response: + final_url = response.geturl() + if final_url != catalog_url: + self._validate_catalog_url(final_url) + catalog_data = json.loads( + read_response_limited( + response, + max_bytes=MAX_JSON_CATALOG_BYTES, + error_type=PresetError, + label=f"preset catalog {catalog_url}", + ) + ) + + # Validate catalog structure. Reuses the same helper as + # ``_fetch_single_catalog`` so all three branches (root type, + # missing keys, nested-mapping type) stay consistent. + self._validate_catalog_payload(catalog_data, catalog_url) + + # Save to cache. Explicit UTF-8 on both writes mirrors the + # ``read_text(encoding="utf-8")`` on the read side and the + # ``integrations/catalog.py`` precedent — otherwise platforms + # whose default encoding isn't UTF-8 would write + # locale-encoded bytes the read path can't decode, forcing an + # unnecessary refetch on every invocation. Like the read + # side, the write is best-effort: an unwritable cache dir + # must not be re-raised as a ``PresetError`` for a payload + # that was already fetched and validated. + try: + self.cache_dir.mkdir(parents=True, exist_ok=True) + self.cache_file.write_text( + json.dumps(catalog_data, indent=2), encoding="utf-8" + ) + + metadata = { + "cached_at": datetime.now(timezone.utc).isoformat(), + "catalog_url": catalog_url, + } + self.cache_metadata_file.write_text( + json.dumps(metadata, indent=2), encoding="utf-8" + ) + except OSError: + pass # Cache is best-effort; proceed with fetched data + + return catalog_data + + except (ImportError, Exception) as e: + if isinstance(e, PresetError): + raise + raise PresetError( + f"Failed to fetch preset catalog from {catalog_url}: {e}" + ) + + def search( + self, + query: Optional[str] = None, + tag: Optional[str] = None, + author: Optional[str] = None, + ) -> 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 + author: Filter by author name + + Returns: + List of matching preset metadata + """ + try: + packs = self._get_merged_packs() + except PresetError: + return [] + + results = [] + + for pack_id, pack_data in packs.items(): + if author: + author_val = pack_data.get("author", "") + if not isinstance(author_val, str): + author_val = str(author_val) if author_val is not None else "" + if author_val.lower() != author.lower(): + continue + + if tag: + raw_tags = pack_data.get("tags", []) + tags_list = raw_tags if isinstance(raw_tags, list) else [] + if tag.lower() not in [ + str(t).lower() for t in tags_list + ]: + continue + + if query: + query_lower = query.lower() + raw_tags = pack_data.get("tags", []) + tags_list = raw_tags if isinstance(raw_tags, list) else [] + name_val = pack_data.get("name", "") + desc_val = pack_data.get("description", "") + searchable_text = " ".join( + [ + str(name_val) if name_val is not None else "", + str(desc_val) if desc_val is not None else "", + pack_id, + ] + + [str(t) for t in tags_list] + ).lower() + + if query_lower not in searchable_text: + continue + + results.append({**pack_data, "id": pack_id}) + + return results + + def get_pack_info( + self, pack_id: str + ) -> 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 + + Returns: + Pack metadata or None if not found + """ + try: + packs = self._get_merged_packs() + except PresetError: + return None + + if pack_id in packs: + return {**packs[pack_id], "id": pack_id} + return None + + def download_pack( + self, pack_id: str, target_dir: Optional[Path] = None + ) -> Path: + """Download a preset archive from a catalog. + + Args: + pack_id: ID of the preset to download + target_dir: Directory to save the archive + + Returns: + Path to the downloaded archive + + Raises: + PresetError: If pack not found or download fails + """ + import urllib.error + + from . import read_response_limited, verify_archive_sha256 + + pack_info = self.get_pack_info(pack_id) + if not pack_info: + raise PresetError( + f"Preset '{pack_id}' not found in catalog" + ) + + # Bundled presets without a download URL must be installed locally + if pack_info.get("bundled") and not pack_info.get("download_url"): + from ..extensions import REINSTALL_COMMAND + raise PresetError( + f"Preset '{pack_id}' is bundled with spec-kit and has no download URL. " + f"It should be installed from the local package. " + f"Use 'specify preset add {pack_id}' to install from the bundled package, " + f"or reinstall spec-kit if the bundled files are missing: {REINSTALL_COMMAND}" + ) + + if not pack_info.get("_install_allowed", True): + catalog_name = pack_info.get("_catalog_name", "unknown") + raise PresetError( + f"Preset '{pack_id}' is from the '{catalog_name}' catalog which does not allow installation. " + f"Use --from with the preset's repository URL instead." + ) + + download_url = pack_info.get("download_url") + if not download_url: + raise PresetError( + f"Preset '{pack_id}' has no download URL" + ) + if not isinstance(download_url, str): + raise PresetError( + f"Preset download URL is malformed: {download_url}" + ) + + from urllib.parse import urlparse + + # A malformed authority (e.g. an unterminated IPv6 bracket + # "https://[::1") makes urlparse / hostname access raise ValueError. + # The download_url comes from catalog payload data, so surface a clean + # PresetError rather than leaking a raw ValueError past the command + # handler (which only catches PresetError). Mirrors catalogs (#3435) + # and workflows/catalog.py (#3484). + try: + parsed = urlparse(download_url) + hostname = parsed.hostname + parsed.port + except ValueError: + raise PresetError( + f"Preset download URL is malformed: {download_url}" + ) from None + if not hostname: + raise PresetError( + f"Preset download URL is malformed: {download_url}" + ) + if not is_https_or_localhost_http(download_url): + raise PresetError( + f"Preset download URL must use HTTPS: {download_url}" + ) + + if target_dir is None: + target_dir = self.cache_dir / "downloads" + target_dir = Path(target_dir) + version = pack_info.get("version", "unknown") + declared_format = archive_format_from_name(download_url) + build_safe_download_path( + target_dir, + pack_id, + version, + error_type=PresetError, + label="preset", + suffix=archive_suffix(declared_format or "tar.gz"), + ) + target_dir.mkdir(parents=True, exist_ok=True) + + original_download_url = download_url + extra_headers = None + resolved_download_url = self._resolve_github_release_asset_api_url(download_url) + if resolved_download_url: + download_url = resolved_download_url + extra_headers = {"Accept": "application/octet-stream"} + + staging_path: Path | None = None + try: + with self._open_url(download_url, timeout=60, extra_headers=extra_headers) as response: + archive_data = read_response_limited( + response, + error_type=PresetError, + label=f"preset '{pack_id}' download", + ) + final_url = ( + response.geturl() + if hasattr(response, "geturl") + else download_url + ) + content_type = ( + response.getheader("Content-Type") + if hasattr(response, "getheader") + else None + ) + + verify_archive_sha256( + archive_data, pack_info.get("sha256"), pack_id, PresetError + ) + + with tempfile.NamedTemporaryFile( + prefix="preset-download-", + suffix=".archive", + dir=target_dir, + delete=False, + ) as staging_file: + staging_path = Path(staging_file.name) + staging_file.write(archive_data) + archive_format = detect_archive_format( + staging_path, + source_name=( + final_url + if archive_format_from_name(final_url) is not None + else original_download_url + ), + content_type=content_type, + error_type=PresetError, + ) + archive_path = build_safe_download_path( + target_dir, + pack_id, + version, + error_type=PresetError, + label="preset", + suffix=archive_suffix(archive_format), + ) + os.replace(staging_path, archive_path) + staging_path = None + return archive_path + + except urllib.error.URLError as e: + raise PresetError( + f"Failed to download preset from {download_url}: {e}" + ) + except IOError as e: + raise PresetError(f"Failed to save preset archive: {e}") + finally: + if staging_path is not None: + staging_path.unlink(missing_ok=True) + + def clear_cache(self): + """Clear all catalog cache files, including per-URL hashed caches.""" + if self.cache_dir.exists(): + for f in self.cache_dir.iterdir(): + if f.is_file() and f.name.startswith("catalog"): + f.unlink(missing_ok=True) diff --git a/src/specify_cli/presets/_manager.py b/src/specify_cli/presets/_manager.py new file mode 100644 index 0000000000..33274dfdfc --- /dev/null +++ b/src/specify_cli/presets/_manager.py @@ -0,0 +1,969 @@ +"""Preset installation, removal, and lifecycle coordination (private).""" + +import hashlib +import json +import shutil +import tempfile +from pathlib import Path +from typing import Any, Dict, List, Optional, Set + +from packaging import version as pkg_version +from packaging.specifiers import InvalidSpecifier, SpecifierSet + +from .._download_security import safe_extract_archive +from .._init_options import is_ai_skills_enabled, resolve_active_agent_for_registration +from .._utils import version_satisfies +from ..extensions import REINSTALL_COMMAND, normalize_priority +from ..shared_infra import ( + _ensure_safe_shared_destination, + _ensure_safe_shared_directory, + _write_shared_bytes, + _write_shared_text, +) +from ._manager_commands import _PresetCommandMethods +from ._manager_skills import _PresetSkillMethods +from ._manifest import ( + PresetCompatibilityError, + PresetError, + PresetManifest, + PresetValidationError, +) +from ._registry import PresetRegistry +from ._resolver import PresetResolver + +_CONSTITUTION_PROVENANCE_FILE = ".constitution-template.json" +_CONSTITUTION_SYNC_PRESET_ID = "constitution-sync" + + +def _content_sha256(content: bytes) -> str: + return hashlib.sha256(content).hexdigest() + + +def _is_comparable_version(value: str) -> bool: + """Return whether a recorded version can be evaluated against a specifier. + + ``version_satisfies()`` answers "does not satisfy" for an unparseable + version, which is indistinguishable from a genuine mismatch. Callers that + need to tell those apart check here first. + """ + try: + pkg_version.Version(value) + except pkg_version.InvalidVersion: + return False + return True + + +def _constitution_is_generated( + project_root: Path, + memory_constitution: Path, + resolver: "PresetResolver", +) -> bool: + """Return whether the live constitution is an unchanged generated file.""" + _ensure_safe_shared_destination(project_root, memory_constitution) + content = memory_constitution.read_bytes() + provenance = memory_constitution.parent / _CONSTITUTION_PROVENANCE_FILE + _ensure_safe_shared_destination(project_root, provenance) + + if provenance.exists(): + try: + metadata = json.loads(provenance.read_text(encoding="utf-8")) + except (json.JSONDecodeError, UnicodeDecodeError): + return False + return ( + isinstance(metadata, dict) + and metadata.get("sha256") == _content_sha256(content) + ) + + # Older projects have no provenance sidecar. Only the immutable bundled or + # source-checkout core template is safe to treat as generated. + core = resolver._find_bundled_core( + "constitution-template", "template", ".md" + ) + return core is not None and core.read_bytes() == content + + +def _constitution_provenance_matches_preset( + project_root: Path, + memory_constitution: Path, + pack_id: str, + pack_version: str, +) -> bool: + """Return whether provenance identifies a preset as the materialized source.""" + provenance = memory_constitution.parent / _CONSTITUTION_PROVENANCE_FILE + if not provenance.parent.exists(): + return False + _ensure_safe_shared_destination(project_root, provenance) + if not provenance.exists(): + return False + try: + metadata = json.loads(provenance.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError, UnicodeDecodeError): + return False + return ( + isinstance(metadata, dict) + and metadata.get("source") == f"{pack_id} v{pack_version}" + ) + + +def _materialize_constitution_template( + project_root: Path, + memory_constitution: Path, +) -> str | None: + """Materialize constitution-template content into memory/constitution.md. + + Returns: + "copied" when the winning layer is ``replace`` and the source file is + copied verbatim; "composed" when a composing strategy is materialized + via ``resolve_content``; ``None`` when no constitution template resolves. + """ + resolver = PresetResolver(project_root) + layers = resolver.collect_all_layers("constitution-template", "template") + if not layers: + return None + + top_layer = layers[0] + if top_layer["strategy"] == "replace": + content = top_layer["path"].read_bytes() + result = "copied" + else: + composed_content = resolver.resolve_content("constitution-template", "template") + if composed_content is None: + return None + content = composed_content.encode("utf-8") + result = "composed" + + _ensure_safe_shared_directory(project_root, memory_constitution.parent) + _write_shared_bytes(project_root, memory_constitution, content) + provenance = memory_constitution.parent / _CONSTITUTION_PROVENANCE_FILE + _write_shared_text( + project_root, + provenance, + json.dumps( + { + "sha256": _content_sha256(content), + "source": top_layer["source"], + }, + indent=2, + ) + + "\n", + ) + return result + + +class PresetManager(_PresetCommandMethods, _PresetSkillMethods): + """Manages preset lifecycle: installation, removal, updates.""" + + def __init__(self, project_root: Path): + """Initialize preset manager. + + Args: + project_root: Path to project root directory + """ + self.project_root = project_root + self.presets_dir = project_root / ".specify" / "presets" + self.registry = PresetRegistry(self.presets_dir) + + def check_compatibility( + self, + manifest: PresetManifest, + speckit_version: str + ) -> bool: + """Check if preset is compatible with current spec-kit version. + + Args: + manifest: Preset manifest + speckit_version: Current spec-kit version + + Returns: + True if compatible + + Raises: + PresetCompatibilityError: If pack is incompatible + """ + required = manifest.requires_speckit_version + # Defense in depth: the manifest validator now rejects a non-string + # requires.speckit_version, but this method is public and also reachable + # with a hand-built manifest object. ``InvalidSpecifier`` alone does not + # cover a non-string -- scalars raise TypeError from the constructor, and + # a list/dict is iterable so it constructs here and only breaks inside + # .contains(). Reject up front so this always reports a + # PresetCompatibilityError. + if not isinstance(required, str): + raise PresetCompatibilityError( + "Invalid version specifier: expected a string, got " + f"{type(required).__name__} ({required!r})" + ) + try: + SpecifierSet(required) # Just to validate + except InvalidSpecifier: + raise PresetCompatibilityError(f"Invalid version specifier: {required}") + + if not version_satisfies(speckit_version, required): + raise PresetCompatibilityError( + f"Preset requires spec-kit {required}, " + f"but {speckit_version} is installed.\n" + f"Upgrade spec-kit with: {REINSTALL_COMMAND}" + ) + + return True + + def find_unmet_extension_dependencies( + self, + manifest: PresetManifest + ) -> List[Dict[str, Any]]: + """Find declared extension dependencies that are not satisfied. + + Reports rather than raises. A preset whose overrides call into an + extension is written to degrade safely -- without the extension the + core workflow still runs -- so a missing dependency is a warning, not + an install failure. See issue #4231. + + Args: + manifest: Preset manifest to inspect + + Returns: + One entry per unsatisfied dependency, each with ``id``, the + requested ``version`` specifier (``None`` when unconstrained), the + ``installed`` version (``None`` when absent or unusable), and a + ``reason`` of ``"missing"``, ``"corrupt"``, ``"stale"``, + ``"disabled"``, or ``"version"``. Optional dependencies + (``required: false``) are never reported. + + An unreadable registry yields no results rather than raising, since + this runs after the install has already succeeded. + + A registry version that cannot be parsed is treated as + uncomparable, not as a mismatch: the extension is installed and + usable, and only its recorded version is unreadable. An extension + present on disk but absent from the registry is likewise treated as + satisfied, because resolution admits unregistered directories. + """ + # Defense in depth, mirroring check_compatibility(): this method is + # public and also reachable with a hand-built manifest object that + # predates this field. A manifest without it declares nothing. + candidates = getattr(manifest, "requires_extensions", None) + if not isinstance(candidates, list): + return [] + + # Collapse exact repeats so a manifest naming the same dependency twice + # warns once. Two entries for one id with *different* constraints are + # kept, since both genuinely have to hold. + declared: List[Dict[str, Any]] = [] + seen: Set[tuple] = set() + for dep in candidates: + if not isinstance(dep, dict) or not dep.get("required", True): + continue + key = (dep.get("id"), dep.get("version")) + if key in seen: + continue + seen.add(key) + declared.append(dep) + if not declared: + return [] + + extensions_dir = self.project_root / ".specify" / "extensions" + try: + from . import ExtensionRegistry + + registry = ExtensionRegistry(extensions_dir) + registered_ids = registry.keys() + registry_corrupt = registry.is_corrupt() + except OSError: + # Both reads can raise: _load() recovers from malformed content but + # deliberately lets OSError through, and is_corrupt() re-reads the + # file. This check runs *after* the install has completed, and + # preset_add only handles preset-domain errors, so letting that + # escape would turn a finished install into a traceback over a + # warning. An unreadable registry simply cannot be inspected. + return [] + + unmet: List[Dict[str, Any]] = [] + + for dep in declared: + metadata = registry.get(dep["id"]) + if metadata is None: + # An absent registry entry does not mean the extension is + # unusable. _get_all_extensions_by_priority() admits a safe + # on-disk directory as an unregistered extension at implicit + # priority 10, so it resolves and the preset works -- but only + # when the registry is readable, since a corrupt one makes that + # path fail closed and contribute nothing. + # + # get() returns None for a corrupted (non-dict) entry as well as + # an absent one, but keys() retains corrupted ids -- both so + # resolution does not re-admit their directories as + # unregistered, and because is_installed() still counts them, so + # a plain `extension add` would be refused as already installed. + # That is a different state from absent, and needs a different + # remedy. + if dep["id"] in registered_ids: + unmet.append({**dep, "installed": None, "reason": "corrupt"}) + continue + if ( + (extensions_dir / dep["id"]).is_dir() + and PresetResolver._is_safe_registry_id(dep["id"]) + and not registry_corrupt + ): + # Unregistered means no recorded version, so a constraint + # cannot be evaluated -- uncomparable, not unsatisfied. + continue + unmet.append({**dep, "installed": None, "reason": "missing"}) + continue + + installed_version = metadata.get("version") + installed_version = ( + installed_version if isinstance(installed_version, str) else None + ) + + # A registry entry is not proof the extension can contribute. If + # its directory is gone, PresetResolver skips it outright (both + # template lookup and layer collection guard on ``is_dir()``), so + # the preset is as inert as if it were never installed -- but the + # surviving entry would otherwise read as satisfied. + if not (extensions_dir / dep["id"]).is_dir(): + unmet.append( + {**dep, "installed": installed_version, "reason": "stale"} + ) + continue + + # A disabled extension is registered but contributes nothing: + # resolution skips it (see _collect_extension_layers), so the + # preset is just as inert as if it were absent. Report it before + # any version check -- enabling it is the prerequisite, and the + # version may well be fine once it is. + if not metadata.get("enabled", True): + unmet.append( + {**dep, "installed": installed_version, "reason": "disabled"} + ) + continue + + constraint = dep["version"] + if not constraint: + continue + + # A version that cannot be compared is not a mismatch. Absent or + # non-string is one way to be unusable; an unparseable string such + # as "unknown" is another, and version_satisfies() cannot tell them + # apart -- it catches InvalidVersion and returns False, which would + # report a mismatch against a version nobody can evaluate. Check + # parseability up front so only real comparisons reach the warning. + if installed_version is None or not _is_comparable_version(installed_version): + continue + if not version_satisfies(installed_version, constraint): + unmet.append( + {**dep, "installed": installed_version, "reason": "version"} + ) + + return unmet + + def install_from_directory( + self, + source_dir: Path, + speckit_version: str, + priority: int = 10, + force: bool = False, + *, + catalog_name: str | None = None, + ) -> PresetManifest: + """Install preset from a local directory. + + Args: + source_dir: Path to preset directory + speckit_version: Current spec-kit version + priority: Resolution priority (lower = higher precedence, default 10) + force: If True and the preset is already installed, remove it first + + Returns: + Installed preset manifest + + Raises: + PresetValidationError: If manifest is invalid or priority is invalid + PresetCompatibilityError: If pack is incompatible + """ + # Validate priority + if priority < 1: + raise PresetValidationError("Priority must be a positive integer (1 or higher)") + + manifest_path = source_dir / "preset.yml" + manifest = PresetManifest(manifest_path) + + self.check_compatibility(manifest, speckit_version) + + if self.registry.is_installed(manifest.id): + if not force: + raise PresetError( + f"Preset '{manifest.id}' is already installed. " + f"Use 'specify preset remove {manifest.id}' first." + ) + self.remove(manifest.id) + + dest_dir = self.presets_dir / manifest.id + if dest_dir.exists(): + shutil.rmtree(dest_dir) + + shutil.copytree(source_dir, dest_dir) + + # Pre-register the preset so that composition resolution can see it + # in the priority stack when resolving composed command content. + normalized_catalog_name = ( + catalog_name.strip() if isinstance(catalog_name, str) else "" + ) + source = ( + {"kind": "catalog", "catalog": normalized_catalog_name} + if normalized_catalog_name + else "local" + ) + self.registry.add(manifest.id, { + "version": manifest.version, + "source": source, + "manifest_hash": manifest.get_hash(), + "enabled": True, + "priority": priority, + "registered_commands": {}, + "registered_skills": {}, + }) + + registered_commands: Dict[str, List[str]] = {} + registered_skills: Dict[str, List[str]] = {} + try: + # Register command overrides with AI agents and persist the result + # immediately so cleanup can recover even if installation stops + # before later phases complete. + registered_commands = self._register_commands(manifest, dest_dir) + self.registry.update(manifest.id, { + "registered_commands": registered_commands, + }) + + # Update corresponding skills when skills mode was previously used + # and persist that result as well. + registered_skills = self._register_skills(manifest, dest_dir) + self.registry.update(manifest.id, { + "registered_skills": registered_skills, + }) + except Exception: + # Roll back all side effects. _register_skills persists each + # successful write immediately, so reload that partial map when + # a later template fails before the call can return. + if registered_commands: + self._unregister_commands(registered_commands) + persisted_metadata = self.registry.get(manifest.id) or {} + persisted_skills = persisted_metadata.get( + "registered_skills", registered_skills + ) + if persisted_skills: + self._unregister_skills( + persisted_skills, dest_dir, restore_from_bundled_core=True + ) + try: + if dest_dir.exists(): + shutil.rmtree(dest_dir) + except OSError: + pass # best-effort cleanup; don't mask the original error + self.registry.remove(manifest.id) + raise + + # Reconcile all affected commands from the full priority stack so that + # install order doesn't determine the winning command file. + cmd_names = [ + t["name"] + for t in manifest.templates + if t.get("type") == "command" + ] + if cmd_names: + try: + self._reconcile_composed_commands(cmd_names) + self._reconcile_skills(cmd_names) + except Exception as exc: + import warnings + warnings.warn( + f"Post-install reconciliation failed for {manifest.id}: {exc}. " + f"Agent command files may not reflect the current priority stack.", + stacklevel=2, + ) + + # TODO: constitution-sync is a named preset with core-owned side effects. + # Give synchronization an explicit owner without changing its opt-in + # behavior or overwriting authored constitutions. + # Materialize constitution-template changes only for projects that opt + # into the constitution-sync preset. The core /constitution command + # resolves this template on demand; constitution-sync preserves the + # previous install-time behavior for teams that want reviewed snapshots. + self._seed_constitution_from_preset(manifest, dest_dir) + + return manifest + + def _seed_constitution_from_preset( + self, manifest: PresetManifest, preset_dir: Path + ) -> None: + """Seed memory/constitution.md when constitution-sync opts into snapshots. + + Installing constitution-sync itself materializes the currently resolved + stack. Later preset installs only reconcile when they provide a + ``constitution-template``. Authored constitutions are never overwritten. + """ + provides_constitution = manifest.id == _CONSTITUTION_SYNC_PRESET_ID or any( + t.get("type") == "template" and t.get("name") == "constitution-template" + for t in manifest.templates + ) or any( + (preset_dir / relative_path).is_file() + for relative_path in ( + "templates/constitution-template.md", + "constitution-template.md", + ) + ) + if not provides_constitution: + return + + self.reconcile_constitution( + f"Failed to seed constitution from preset {manifest.id}", + create_if_missing=True, + ) + + def reconcile_constitution( + self, failure_context: str, *, create_if_missing: bool = False + ) -> None: + """Reconcile an opted-in generated constitution without failing a change.""" + try: + self._reconcile_constitution(create_if_missing=create_if_missing) + except (OSError, UnicodeDecodeError, PresetValidationError, ValueError) as exc: + import warnings + + warnings.warn( + f"{failure_context}: {exc}.", + stacklevel=2, + ) + + def _reconcile_constitution(self, *, create_if_missing: bool = False) -> None: + """Materialize the winning layer when constitution-sync is enabled.""" + sync_metadata = self.registry.get(_CONSTITUTION_SYNC_PRESET_ID) + if sync_metadata is None or not sync_metadata.get("enabled", True): + return + + memory_constitution = ( + self.project_root / ".specify" / "memory" / "constitution.md" + ) + if not memory_constitution.exists() and not create_if_missing: + return + resolver = PresetResolver(self.project_root) + if memory_constitution.exists() and not _constitution_is_generated( + self.project_root, memory_constitution, resolver + ): + return + _materialize_constitution_template(self.project_root, memory_constitution) + + def install_from_archive( + self, + archive_path: Path, + speckit_version: str, + priority: int = 10, + force: bool = False, + *, + catalog_name: str | None = None, + ) -> PresetManifest: + """Install a preset from a supported archive. + + Args: + archive_path: Path to a .zip, .tar.gz, or .tgz archive + speckit_version: Current spec-kit version + priority: Resolution priority (lower = higher precedence, default 10) + force: If True and the preset is already installed, remove it first + + Returns: + Installed preset manifest + + Raises: + PresetValidationError: If manifest is invalid or priority is invalid + PresetCompatibilityError: If pack is incompatible + """ + # Validate priority early + if priority < 1: + raise PresetValidationError("Priority must be a positive integer (1 or higher)") + + with tempfile.TemporaryDirectory() as tmpdir: + temp_path = Path(tmpdir) + + safe_extract_archive( + archive_path, + temp_path, + error_type=PresetValidationError, + ) + + pack_dir = temp_path + manifest_path = pack_dir / "preset.yml" + + if not manifest_path.exists(): + subdirs = [d for d in temp_path.iterdir() if d.is_dir()] + if len(subdirs) == 1: + pack_dir = subdirs[0] + manifest_path = pack_dir / "preset.yml" + + if not manifest_path.exists(): + raise PresetValidationError( + "No preset.yml found in archive" + ) + + return self.install_from_directory( + pack_dir, + speckit_version, + priority, + force=force, + catalog_name=catalog_name, + ) + + def install_from_zip( + self, + zip_path: Path, + speckit_version: str, + priority: int = 10, + force: bool = False, + *, + catalog_name: str | None = None, + ) -> PresetManifest: + """Backward-compatible wrapper for archive installation.""" + return self.install_from_archive( + zip_path, + speckit_version, + priority, + force=force, + catalog_name=catalog_name, + ) + + def remove(self, pack_id: str) -> bool: + """Remove an installed preset. + + Args: + pack_id: Preset ID + + Returns: + True if pack was removed + """ + if not self.registry.is_installed(pack_id): + return False + + metadata = self.registry.get(pack_id) + # Restore original skills when preset is removed + registered_skills = metadata.get("registered_skills", []) if metadata else [] + if isinstance(registered_skills, list) and registered_skills: + # Legacy flat-list registries predate per-agent provenance + # tracking. Migration to the per-agent dict form previously + # only happened during a rescaffold (register_enabled_presets_ + # for_agent); if the *first* post-upgrade operation is instead + # `preset remove` (no intervening use/upgrade), the legacy + # branch of _unregister_skills restores only the currently + # active agent's directory, leaving this preset's overrides in + # every previously active agent's directory orphaned. Infer + # real per-agent ownership from the on-disk preset marker now, + # while pack_id is still known, and hand the resulting mapping + # through the same dict-based cleanup path already used for + # non-legacy registries (#2948). + from .. import load_init_options + + init_opts = load_init_options(self.project_root) + fallback_agent = init_opts.get("ai") if isinstance(init_opts, dict) else None + if not isinstance(fallback_agent, str): + fallback_agent = "" + registered_skills = self._infer_legacy_skill_provenance( + [name for name in registered_skills if isinstance(name, str)], + pack_id, + fallback_agent=fallback_agent, + ) + registered_commands = metadata.get("registered_commands", {}) if metadata else {} + pack_dir = self.presets_dir / pack_id + + # Record which historical agents this preset's registered_commands + # actually targeted, *before* any filtering below, so post-removal + # reconciliation can restore a surviving lower-priority preset's + # override into every one of those directories too — not only the + # currently active agent's. Without this, removing a preset that + # was rendered under a previously-active (now inactive) agent + # deletes that agent's command file via _unregister_commands below, + # but active-only reconciliation would only recreate the surviving + # winner for the current agent, leaving the inactive integration + # with a missing/stale file (#2948). + try: + from ..agents import CommandRegistrar as _CommandRegistrarForScope + except ImportError: + _CommandRegistrarForScope = None + affected_command_agents = { + agent_name + for agent_name in registered_commands + if _CommandRegistrarForScope is None + or _CommandRegistrarForScope.AGENT_CONFIGS.get(agent_name, {}).get("extension") != "/SKILL.md" + } + + # Collect ALL command names before filtering for reconciliation, + # so commands registered only for skill-based agents are also + # reconciled. Every command-type template's primary name is added + # unconditionally (not just aliases) since ai_skills-mode presets + # never populate registered_commands for command-backed + # integrations (see _register_commands's ai_skills guard) — without + # this, removing a skills-mode preset that overrides a command no + # other preset registered "the normal way" would skip reconciliation + # entirely and _unregister_skills would restore core/extension + # content instead of a surviving lower-priority preset's override. + removed_cmd_names = set() + removed_constitution = any( + path.exists() + for path in ( + pack_dir / "templates" / "constitution-template.md", + pack_dir / "constitution-template.md", + ) + ) + if metadata and isinstance(metadata.get("version"), str): + memory_constitution = ( + self.project_root / ".specify" / "memory" / "constitution.md" + ) + removed_constitution = removed_constitution or ( + _constitution_provenance_matches_preset( + self.project_root, + memory_constitution, + pack_id, + metadata["version"], + ) + ) + for cmd_names in registered_commands.values(): + removed_cmd_names.update(cmd_names) + manifest_path = pack_dir / "preset.yml" + if manifest_path.exists(): + try: + manifest = PresetManifest(manifest_path) + for tmpl in manifest.templates: + if ( + tmpl.get("type") == "template" + and tmpl.get("name") == "constitution-template" + ): + removed_constitution = True + if tmpl.get("type") == "command": + name = tmpl.get("name") + if isinstance(name, str): + removed_cmd_names.add(name) + for alias in tmpl.get("aliases", []): + if isinstance(alias, str): + removed_cmd_names.add(alias) + except PresetValidationError: + # Invalid manifest — skip alias extraction; primary command + # names from registered_commands are still unregistered. + pass + + affected_skill_dirs: Dict[ + Path, tuple[Optional[str], List[str]] + ] = {} + if registered_skills: + restorable_skills = registered_skills + # A skill tracked for a command-backed agent whose ai_skills is + # now off is a leftover from a partially failed skills→command + # toggle. Restoring it via _unregister_skills (and letting + # _reconcile_skills reapply a surviving lower preset through + # extra_skills_dirs) would hand the active command-mode agent a + # skill artifact it must not have — its current representation + # is the command file handled via registered_commands above. + # Delete the preset-owned skill instead and keep its directory + # out of restoration/reconciliation entirely (#2948). Inactive + # agents' entries still restore as before. + # The legacy branch above locally imports load_init_options, + # shadowing the module-level name for this whole function. + from .._init_options import load_init_options as _load_init_options + + resolved_active = resolve_active_agent_for_registration( + self.project_root + ) + if ( + isinstance(registered_skills, dict) + and isinstance(resolved_active, str) + and resolved_active in registered_skills + and _CommandRegistrarForScope is not None + and _CommandRegistrarForScope.AGENT_CONFIGS.get( + resolved_active, {} + ).get("extension") != "/SKILL.md" + and not is_ai_skills_enabled( + _load_init_options(self.project_root) + ) + ): + raw_names = registered_skills.get(resolved_active) + stale_names = [ + name + for name in ( + raw_names if isinstance(raw_names, list) else [] + ) + if isinstance(name, str) + ] + restorable_skills = { + agent_name: names + for agent_name, names in registered_skills.items() + if agent_name != resolved_active + } + if stale_names: + self._delete_agent_preset_skills( + resolved_active, stale_names, pack_id + ) + override_sources = { + skill_name: f"override:{command_name}" + for command_name in removed_cmd_names + for skill_name in self._skill_names_for_command(command_name) + } + affected_skill_dirs = self._unregister_skills( + restorable_skills, + pack_dir, + additional_owned_sources=override_sources, + restore_from_bundled_core=True, + ) + try: + from ..agents import CommandRegistrar + except ImportError: + CommandRegistrar = None + if CommandRegistrar is not None: + skill_coverage = ( + registered_skills + if isinstance(registered_skills, dict) + else {} + ) + commands_to_unregister: Dict[str, List[str]] = {} + for agent_name, cmd_names in registered_commands.items(): + is_native_skill_agent = ( + CommandRegistrar.AGENT_CONFIGS.get( + agent_name, {} + ).get("extension") + == "/SKILL.md" + ) + if not is_native_skill_agent: + commands_to_unregister[agent_name] = cmd_names + continue + + raw_skill_names = skill_coverage.get(agent_name, []) + covered_skill_names = { + name + for name in ( + raw_skill_names + if isinstance(raw_skill_names, list) + else [] + ) + if isinstance(name, str) + } + uncovered_commands = [ + cmd_name + for cmd_name in cmd_names + if not isinstance(cmd_name, str) + or covered_skill_names.isdisjoint( + self._skill_names_for_command(cmd_name) + ) + ] + if uncovered_commands: + commands_to_unregister[agent_name] = ( + uncovered_commands + ) + registered_commands = commands_to_unregister + + # Unregister non-skill command files from AI agents. + if registered_commands: + self._unregister_commands(registered_commands) + + if pack_dir.exists(): + shutil.rmtree(pack_dir) + + self.registry.remove(pack_id) + + # Reconcile: if other presets still provide these commands, + # re-resolve from the remaining stack so the next layer takes effect. + if removed_cmd_names: + try: + self._reconcile_composed_commands( + list(removed_cmd_names), extra_agents=affected_command_agents + ) + self._reconcile_skills( + list(removed_cmd_names), extra_skills_dirs=affected_skill_dirs + ) + except Exception as exc: + import warnings + warnings.warn( + f"Post-removal reconciliation failed for {pack_id}: {exc}. " + f"Agent command files may be stale; reinstall affected presets " + f"or run 'specify preset add' to refresh.", + stacklevel=2, + ) + + if removed_constitution: + try: + self._reconcile_constitution() + except (OSError, UnicodeDecodeError, PresetValidationError, ValueError) as exc: + import warnings + + warnings.warn( + f"Post-removal constitution reconciliation failed for {pack_id}: " + f"{exc}. The live constitution may be stale.", + stacklevel=2, + ) + + return True + + def list_installed(self) -> List[Dict[str, Any]]: + """List all installed presets with metadata. + + Returns: + List of preset metadata dictionaries + """ + result = [] + + for pack_id, metadata in self.registry.list().items(): + # Ensure metadata is a dictionary to avoid AttributeError when using .get() + if not isinstance(metadata, dict): + metadata = {} + pack_dir = self.presets_dir / pack_id + manifest_path = pack_dir / "preset.yml" + + try: + manifest = PresetManifest(manifest_path) + provided_counts = {"commands": 0, "templates": 0, "scripts": 0, "hooks": 0} + for template in manifest.templates: + provided_counts[f"{template['type']}s"] += 1 + author = manifest.author + result.append({ + "id": pack_id, + "name": manifest.name, + "version": metadata.get("version", manifest.version), + "description": manifest.description, + "enabled": metadata.get("enabled", True), + "installed_at": metadata.get("installed_at"), + "template_count": len(manifest.templates), + "tags": manifest.tags, + "priority": normalize_priority(metadata.get("priority")), + "_json_author": author if isinstance(author, str) and author else None, + "_json_source": metadata.get("source"), + "_json_provides": provided_counts, + }) + except PresetValidationError: + result.append({ + "id": pack_id, + "name": pack_id, + "version": metadata.get("version", "unknown"), + "description": "⚠️ Corrupted preset", + "enabled": False, + "installed_at": metadata.get("installed_at"), + "template_count": 0, + "tags": [], + "priority": normalize_priority(metadata.get("priority")), + "_json_author": None, + "_json_source": metadata.get("source"), + "_json_provides": {"commands": 0, "templates": 0, "scripts": 0, "hooks": 0}, + }) + + return result + + def get_pack(self, pack_id: str) -> Optional[PresetManifest]: + """Get manifest for an installed preset. + + Args: + pack_id: Preset ID + + Returns: + Preset manifest or None if not installed + """ + if not self.registry.is_installed(pack_id): + return None + + pack_dir = self.presets_dir / pack_id + manifest_path = pack_dir / "preset.yml" + + try: + return PresetManifest(manifest_path) + except PresetValidationError: + return None diff --git a/src/specify_cli/presets/_manager_commands.py b/src/specify_cli/presets/_manager_commands.py new file mode 100644 index 0000000000..4d665b45dc --- /dev/null +++ b/src/specify_cli/presets/_manager_commands.py @@ -0,0 +1,1284 @@ +"""Agent command registration and reconciliation for installed presets.""" + +import copy +from pathlib import Path +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Set + +if TYPE_CHECKING: + from ..agents import CommandRegistrar + + +from .._init_options import ( + MISSING_INIT_OPTIONS_FILE, + is_ai_skills_enabled, + load_init_options, + resolve_active_agent_for_registration, +) +from ..extensions import ExtensionRegistry +from ._manifest import PresetManifest +from ._resolver import PresetResolver + + +def _substitute_core_template( + body: str, + cmd_name: str, + project_root: "Path", + registrar: "CommandRegistrar", +) -> "tuple[str, dict]": + """Substitute {CORE_TEMPLATE} with the body of the installed core command template. + + Args: + body: Preset command body (may contain {CORE_TEMPLATE} placeholder). + cmd_name: Full command name (e.g. "speckit.git.feature" or "speckit.specify"). + project_root: Project root path. + registrar: CommandRegistrar instance for parse_frontmatter. + + Returns: + A tuple of (body, core_frontmatter) where body has {CORE_TEMPLATE} replaced + by the core template body and core_frontmatter holds the core template's parsed + frontmatter (so callers can inherit scripts/agent_scripts from it). Both are + unchanged / empty when the placeholder is absent or the core template file does + not exist or cannot be read. + """ + if "{CORE_TEMPLATE}" not in body: + return body, {} + + # Derive the short name (strip "speckit." prefix) used by core command templates. + short_name = cmd_name + if short_name.startswith("speckit."): + short_name = short_name[len("speckit."):] + + resolver = PresetResolver(project_root) + # Resolution order for the core template: + # 1. resolve_core(cmd_name) — covers tier-1 project overrides and tier-3/4 + # name-based lookup (file named .md). Checked first so that a + # local override always wins, even for extension commands. + # 2. resolve_extension_command_via_manifest(cmd_name) — manifest-based tier-3 + # fallback for extension commands whose file is named differently from the + # command name (e.g. speckit.selftest.extension → commands/selftest.md). + # 3. resolve_core(short_name) — core template fallback using the unprefixed + # name (e.g. specify → templates/commands/specify.md). + # resolve_core() skips installed presets (tier 2) to prevent accidental nesting + # where another preset's wrap output is mistaken for the real core. + core_file = ( + resolver.resolve_core(cmd_name, "command") + or resolver.resolve_extension_command_via_manifest(cmd_name) + or resolver.resolve_core(short_name, "command") + ) + if core_file is None: + return body, {} + + # Treat an unreadable/undecodable core template like a missing one so a + # single corrupted project override cannot crash command registration — + # the wrap-strategy callers already skip an unreadable preset source with + # a warning (CommandRegistrar.register_pack). + try: + core_content = core_file.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError) as exc: + import warnings + + warnings.warn( + f"Ignoring core template for command '{cmd_name}': could not read " + f"'{core_file.name}' ({exc.__class__.__name__}: {exc}).", + stacklevel=2, + ) + return body, {} + + core_frontmatter, core_body = registrar.parse_frontmatter(core_content) + return body.replace("{CORE_TEMPLATE}", core_body), core_frontmatter + + +class _PresetCommandMethods: + """Command artifact methods shared through PresetManager's lifecycle state.""" + + def _register_commands( + self, + manifest: PresetManifest, + preset_dir: Path + ) -> Dict[str, List[str]]: + """Register preset command overrides with all detected AI agents. + + Scans the preset's templates for type "command", reads each command + file, and writes it to every detected agent directory using the + CommandRegistrar from the agents module. + + When a command uses a composition strategy (prepend, append, wrap), + the content is composed with the lower-priority command before + registration. + + Args: + manifest: Preset manifest + preset_dir: Installed preset directory + + Returns: + Dictionary mapping agent names to lists of registered command names + """ + command_templates = [ + t for t in manifest.templates if t.get("type") == "command" + ] + if not command_templates: + return {} + + # A preset command template always ships its own body, so it is + # self-contained and scaffolds regardless of whether any similarly + # named extension is installed. Namespaced names (speckit..) + # are treated exactly like short names (speckit.) — they are NOT + # filtered out just because ``.specify/extensions//`` is absent. + # The only command that cannot be materialized is a composition + # (prepend/append/wrap) with no base layer to compose onto; that case + # is handled per-command below (warn + skip), not by dropping names up + # front. + # Handle composition strategies: resolve composed content for non-replace commands + resolver = PresetResolver(self.project_root) + composed_dir = None + commands_to_register = [] + for cmd in command_templates: + strategy = cmd.get("strategy", "replace") + if strategy != "replace": + # Only pre-compose if this preset is the top composing layer. + # If a higher-priority replace already wins, skip composition + # here — reconciliation will write the correct content. + layers = resolver.collect_all_layers(cmd["name"], "command") + top_layer_is_ours = ( + layers and layers[0]["path"].is_relative_to(preset_dir) + ) + if top_layer_is_ours: + composed = resolver.resolve_content(cmd["name"], "command") + if composed is not None: + if composed_dir is None: + composed_dir = preset_dir / ".composed" + composed_dir.mkdir(parents=True, exist_ok=True) + composed_file = composed_dir / f"{cmd['name']}.md" + composed_file.write_text(composed, encoding="utf-8") + commands_to_register.append({ + **cmd, + "file": f".composed/{cmd['name']}.md", + }) + else: + # No base layer to compose onto (e.g. the command it + # would wrap comes from an extension that isn't + # installed). Warn and skip this single command rather + # than aborting the whole install — mirrors the + # "composed is None" branch in + # _reconcile_composed_commands so command-mode and + # reconciliation behave identically. + import warnings + warnings.warn( + f"Command '{cmd['name']}' uses '{strategy}' " + f"strategy but no base command layer exists to " + f"compose onto; skipping. Provide a lower-priority " + f"preset, extension, or core command for it before " + f"using composition strategies.", + stacklevel=2, + ) + continue + else: + # Not the top layer — register raw file; reconciliation + # will overwrite with the correct composed/winning content. + # Note: CommandRegistrar may process frontmatter strategy: wrap + # from the raw file (legacy compat), but reconciliation runs + # immediately after install and corrects the final output. + commands_to_register.append(cmd) + else: + commands_to_register.append(cmd) + + try: + from ..agents import CommandRegistrar + except ImportError: + return {} + + registrar = CommandRegistrar() + + # Single-active rule (#2948): preset command overrides register for + # the active integration only. A project without a recorded active + # integration (init-options.json does not exist at all — a legacy + # pre-init-options layout or direct library use) falls back to + # detection-based registration for all agents. A recorded key with + # no registrar config (e.g. "generic") naturally yields no matches + # via only_agent instead of falling back. + # + # An init-options.json that exists but is corrupted, unreadable, or + # has a malformed/empty "ai" value must not be treated the same as + # "no file" — that would silently reintroduce all-agent + # registration. Fail closed (register nothing) instead. + resolved_agent = resolve_active_agent_for_registration(self.project_root) + if resolved_agent is MISSING_INIT_OPTIONS_FILE: + active_agent = None + elif resolved_agent is None: + return {} + else: + active_agent = resolved_agent + # Mirror the extension path's ai_skills guard: when the active + # agent is a command-backed integration (extension != "/SKILL.md") + # running in skills mode, its preset command overrides render as + # skills via _register_skills, not as command files. Command-mode + # and skills-mode artifacts are mutually exclusive — writing both + # (e.g. `integration use copilot` with `--skills`) leaves a stale + # command file alongside the SKILL.md that is actually active. + init_options = load_init_options(self.project_root) + agent_config = registrar.AGENT_CONFIGS.get(active_agent) + if ( + agent_config + and is_ai_skills_enabled(init_options) + and agent_config.get("extension") != "/SKILL.md" + ): + return {} + + return registrar.register_commands_for_all_agents( + commands_to_register, + manifest.id, + preset_dir, + self.project_root, + create_missing_active_skills_dir=True, + only_agent=active_agent, + ) + + def register_enabled_presets_for_agent(self, agent_name: str) -> None: + """Re-register enabled presets' command overrides and skills for ``agent_name``. + + Mirrors ``ExtensionManager.register_enabled_extensions_for_agent`` for + presets (#2948): ``integration use`` / ``switch`` call this for the + newly active agent so a preset installed while a different + integration was active gets rescaffolded on activation, instead of + writing artifacts for inactive integrations at install time. + ``_register_commands`` / ``_register_skills`` already resolve the + active integration from init-options themselves, so this re-runs them + for every enabled preset and merges the fresh result for + ``agent_name`` into its stored registry metadata. + + Presets are processed in *reverse* priority order (lowest-precedence + first). Each pass overwrites the same target command/skill files, so + writing the highest-precedence preset last is what makes it win when + two enabled presets override the same command — matching the + priority stack documented for ``list_by_priority()``. + """ + if not agent_name: + return + + # Resolve once: whether agent_name is a command-backed integration + # (extension != "/SKILL.md") currently running in skills mode, or + # vice versa. Native skill-only agents (extension == "/SKILL.md", + # e.g. claude/codex) have no command/skill toggle at all — both + # registered_commands and registered_skills legitimately co-exist + # for them by design, so this restriction only applies to + # command-backed integrations. + try: + from ..agents import CommandRegistrar + + agent_config = CommandRegistrar().AGENT_CONFIGS.get(agent_name) + except ImportError: + agent_config = None + is_command_backed = bool(agent_config) and agent_config.get("extension") != "/SKILL.md" + ai_skills_now = is_command_backed and is_ai_skills_enabled( + load_init_options(self.project_root) + ) + + resolver = PresetResolver(self.project_root) + affected_cmd_names: set = set() + presets_by_priority = list(self.registry.list_by_priority()) + winning_pack_by_command: Dict[str, str] = {} + winning_source_by_command: Dict[str, Path] = {} + project_override_commands: set[str] = set() + for candidate_pack_id, _candidate_metadata in presets_by_priority: + candidate_manifest = resolver._get_manifest( + self.presets_dir / candidate_pack_id + ) + if candidate_manifest is None: + continue + for template in candidate_manifest.templates: + command_name = template.get("name") + if ( + template.get("type") == "command" + and isinstance(command_name, str) + ): + if ( + resolver.overrides_dir / f"{command_name}.md" + ).is_file(): + project_override_commands.add(command_name) + winning_pack_by_command.setdefault( + command_name, candidate_pack_id + ) + source_file = template.get("file") + if isinstance(source_file, str): + winning_source_by_command.setdefault( + command_name, + self.presets_dir + / candidate_pack_id + / source_file, + ) + + pending_command_cleanups: List[ + tuple[ + str, + Dict[str, List[str]], + List[str], + Dict[str, str], + ] + ] = [] + successful_skill_replacements: set[tuple[str, str]] = set() + pending_skill_cleanups: List[ + tuple[ + str, + Path, + Dict[str, List[str]], + List[str], + Dict[str, str], + ] + ] = [] + successful_command_replacements: set[tuple[str, str]] = set() + for pack_id, metadata in reversed(presets_by_priority): + pack_dir = self.presets_dir / pack_id + manifest = resolver._get_manifest(pack_dir) + if manifest is None: + continue + + # Registration can write one command and then fail on a later + # template. Record names first so final reconciliation can repair + # any partial writes even when _register_commands never returns. + for tmpl in manifest.templates: + name = tmpl.get("name") + if tmpl.get("type") == "command" and isinstance(name, str): + affected_cmd_names.add(name) + + # Isolate per-preset failures: one preset that fails to register + # must not abort registration of the remaining enabled presets. + try: + registered_commands = self._register_commands(manifest, pack_dir) + registered_command_names = set( + registered_commands.get(agent_name) or [] + ) + for tmpl in manifest.templates: + if tmpl.get("type") != "command": + continue + primary_name = tmpl.get("name") + if ( + isinstance(primary_name, str) + and primary_name in registered_command_names + ): + successful_command_replacements.add( + (pack_id, primary_name) + ) + existing_commands = metadata.get("registered_commands", {}) + if not isinstance(existing_commands, dict): + existing_commands = {} + merged_commands = copy.deepcopy(existing_commands) + # Toggled command -> skills for this same agent: + # _register_commands's ai_skills guard just made this a + # no-op, but the command file this preset wrote while + # command mode was active is still on disk and still + # tracked. Do NOT unregister it yet — _register_skills() + # below is an independently fallible replacement step, and + # deleting the old artifact before it succeeds would leave + # neither the old command file nor a new skill file if + # skills registration raises. The old artifact is only + # removed after the skills phase below completes without + # raising, preserving command/skill mutual exclusion while + # never leaving a transient failure with nothing in place + # (#2948). + stale_command_names: Optional[List[str]] = None + if registered_commands.get(agent_name): + existing_names = merged_commands.get(agent_name, []) + merged_commands[agent_name] = existing_names + [ + name + for name in registered_commands[agent_name] + if name not in existing_names + ] + elif ai_skills_now and merged_commands.get(agent_name): + stale_command_names = merged_commands[agent_name] + # Persist the commands phase immediately, mirroring + # install_from_directory(): _register_skills is an + # independently fallible phase, and if it raises, the files + # the commands phase already wrote to disk must still be + # tracked so preset removal can clean them up (#2948). + if merged_commands != existing_commands: + self.registry.update(pack_id, {"registered_commands": merged_commands}) + + registered_skills = self._register_skills(manifest, pack_dir) + replaced_skill_names = set(registered_skills.get(agent_name) or []) + for tmpl in manifest.templates: + if tmpl.get("type") != "command": + continue + primary_name = tmpl.get("name") + if not isinstance(primary_name, str): + continue + modern_name, legacy_name = self._skill_names_for_command( + primary_name + ) + if ( + modern_name in replaced_skill_names + or legacy_name in replaced_skill_names + ): + successful_skill_replacements.add( + (pack_id, primary_name) + ) + raw_existing_skills = metadata.get("registered_skills") + if isinstance(raw_existing_skills, list) and raw_existing_skills: + # Legacy flat-list value: don't assume agent_name wrote + # every name (the first post-upgrade operation may be a + # direct switch to a different skill-mode agent) — + # infer real ownership from on-disk provenance instead + # (#2948). + existing_skills = self._infer_legacy_skill_provenance( + [n for n in raw_existing_skills if isinstance(n, str)], + pack_id, + fallback_agent=agent_name, + ) + else: + existing_skills = self._normalize_registered_skills( + raw_existing_skills, fallback_agent=agent_name + ) + merged_skills = copy.deepcopy(existing_skills) + if registered_skills.get(agent_name): + existing_names = merged_skills.get(agent_name, []) + merged_skills[agent_name] = existing_names + [ + name + for name in registered_skills[agent_name] + if name not in existing_names + ] + elif is_command_backed and not ai_skills_now and merged_skills.get(agent_name): + # Mirror image: toggled skills -> command for this same + # agent. _get_skills_dir() no longer resolves a skills + # directory once ai_skills is off, so _register_skills + # is a no-op — but the SKILL.md this preset wrote while + # skills mode was active is still tracked and still on + # disk. Restore/remove it narrowly for this agent. This + # direction is already register-new-then-remove-old: + # _register_commands (the replacement) ran unconditionally + # above and only reaches here once it has already + # succeeded — but that call can still have returned + # empty or partial results (missing source template, + # safety-validation skip, corrupted manifest), so only + # retire the subset of stale skills whose corresponding + # command name was actually returned for this agent; + # anything unreplaced stays tracked and on disk (#2948). + stale_skill_names = merged_skills[agent_name] + skill_to_primary: Dict[str, str] = {} + for tmpl in manifest.templates: + if tmpl.get("type") != "command": + continue + primary_name = tmpl.get("name") + if not isinstance(primary_name, str): + continue + modern_name, legacy_name = self._skill_names_for_command( + primary_name + ) + skill_to_primary[modern_name] = primary_name + skill_to_primary[legacy_name] = primary_name + pending_skill_cleanups.append( + ( + pack_id, + pack_dir, + merged_skills, + stale_skill_names, + skill_to_primary, + ) + ) + # A legacy flat-list registered_skills value (predating + # per-agent provenance) must migrate to the dict format on + # disk even when the rescaffolded names are unchanged from + # what the list already held — comparing only the + # *normalized* forms would otherwise treat that as a no-op + # and leave the raw un-migrated list in the registry, which + # later removal/switch handling treats as legacy + # best-effort (restoring only the currently active agent's + # directory) instead of per-agent provenance (#2948). + needs_migration = ( + isinstance(raw_existing_skills, list) and raw_existing_skills + ) + if merged_skills != existing_skills or needs_migration: + self.registry.update(pack_id, {"registered_skills": merged_skills}) + + # The skills phase above completed without raising, but a + # non-raising result can still be empty or partial (missing + # source template, safety-validation skip, corrupted + # manifest) — retiring every stale command purely on "did + # not raise" would delete a command whose replacement skill + # never actually landed, leaving neither artifact. Only + # retire the subset of stale commands whose corresponding + # skill name was actually returned for this agent; anything + # unreplaced stays tracked and on disk (#2948). + if stale_command_names: + # Commands may carry aliases (CommandRegistrar.register_ + # commands() tracks and returns primary + alias names + # flattened together into one list), but _register_ + # skills() only ever renders/returns the *primary* + # command name's skill — running an alias's own name + # through _skill_names_for_command() never matches + # anything real, so an alias would stay tracked/on-disk + # forever even after its primary's skill replacement + # landed. Map each stale name back to its template's + # primary via the manifest so the whole primary+alias + # group is retired or kept together, based solely on + # whether the *primary*'s skill replacement actually + # landed (#2948). + alias_to_primary: Dict[str, str] = {} + for tmpl in manifest.templates: + if tmpl.get("type") != "command": + continue + primary_name = tmpl.get("name") + if not isinstance(primary_name, str): + continue + for alias in tmpl.get("aliases", []): + if isinstance(alias, str): + alias_to_primary[alias] = primary_name + + pending_command_cleanups.append( + ( + pack_id, + merged_commands, + stale_command_names, + alias_to_primary, + ) + ) + except Exception as pack_err: + from .. import _print_cli_warning + + _print_cli_warning( + "register preset artifacts for", + "preset", + pack_id, + pack_err, + continuing="Continuing with the remaining presets.", + ) + continue + + # Registration writes each preset's raw layer. Reconcile before + # retiring opposite-mode artifacts so project overrides and composed + # winners are materialized first, and so cleanup runs last instead of + # being undone by skill reconciliation. + reconciled_commands: set[str] = set() + reconciled_skills: set[str] = set() + if affected_cmd_names: + try: + reconciled_commands = self._reconcile_composed_commands( + list(affected_cmd_names), target_agent=agent_name + ) + reconciled_skills = self._reconcile_skills( + list(affected_cmd_names), target_agent=agent_name + ) + except Exception as exc: + import warnings + + warnings.warn( + f"Post-rescaffold reconciliation failed for '{agent_name}': " + f"{exc}. Agent command files may be stale; re-run " + f"'specify integration use {agent_name}' or reinstall " + f"affected presets to refresh.", + stacklevel=2, + ) + + successfully_replaced_winners = { + command_name + for command_name, winning_pack_id in winning_pack_by_command.items() + if command_name not in project_override_commands + and ( + (winning_pack_id, command_name) + in successful_skill_replacements + or ( + command_name in reconciled_skills + and command_name in winning_source_by_command + and winning_source_by_command[command_name].is_file() + ) + ) + } + successfully_replaced_winners.update( + project_override_commands & reconciled_skills + ) + + for ( + pack_id, + merged_commands, + stale_command_names, + alias_to_primary, + ) in pending_command_cleanups: + fully_replaced = [ + command_name + for command_name in stale_command_names + if alias_to_primary.get(command_name, command_name) + in successfully_replaced_winners + ] + if not fully_replaced: + continue + remaining_stale = [ + command_name + for command_name in stale_command_names + if command_name not in fully_replaced + ] + self._unregister_commands({agent_name: fully_replaced}) + if remaining_stale: + merged_commands[agent_name] = remaining_stale + else: + merged_commands.pop(agent_name, None) + self.registry.update( + pack_id, {"registered_commands": merged_commands} + ) + + successfully_replaced_command_winners = { + command_name + for command_name, winning_pack_id in winning_pack_by_command.items() + if command_name not in project_override_commands + and ( + (winning_pack_id, command_name) + in successful_command_replacements + or ( + command_name in reconciled_commands + and command_name in winning_source_by_command + and winning_source_by_command[command_name].is_file() + ) + ) + } + successfully_replaced_command_winners.update( + project_override_commands & reconciled_commands + ) + + # Skill restoration walks the priority stack, so retire stale layers + # from highest to lowest. The last cleanup then restores the true + # non-preset fallback (or removes the skill) rather than cycling back + # to a lower-priority preset. + for ( + pack_id, + pack_dir, + merged_skills, + stale_skill_names, + skill_to_primary, + ) in reversed(pending_skill_cleanups): + fully_replaced = [ + skill_name + for skill_name in stale_skill_names + if skill_to_primary.get(skill_name) + in successfully_replaced_command_winners + ] + if not fully_replaced: + continue + remaining_stale = [ + skill_name + for skill_name in stale_skill_names + if skill_name not in fully_replaced + ] + override_sources = { + skill_name: f"override:{skill_to_primary[skill_name]}" + for skill_name in fully_replaced + if skill_name in skill_to_primary + } + self._unregister_skills( + {agent_name: fully_replaced}, + pack_dir, + additional_owned_sources=override_sources, + ) + if remaining_stale: + merged_skills[agent_name] = remaining_stale + else: + merged_skills.pop(agent_name, None) + self.registry.update( + pack_id, {"registered_skills": merged_skills} + ) + + def unregister_agent_artifacts(self, agent_name: str) -> None: + """Remove ``agent_name``'s tracked preset command/skill artifacts. + + Mirrors ``ExtensionManager.unregister_agent_artifacts()`` (#2948): + used by ``integration switch`` when deactivating the previous + integration, so a preset's command overrides and skill mirrors + written for that agent don't linger as orphans in its directory + once a different (possibly not-yet-installed) integration becomes + active — including custom preset commands and files the registrar + would otherwise skip as user-modified. + + Scoped strictly to ``agent_name``: only that agent's own tracked + artifacts and registry entries are touched. Other agents' files, + tracking, and preset packs themselves are left untouched, and no + priority-stack reconciliation runs — this is agent-scoped cleanup + only, not preset removal. + """ + if not agent_name: + return + + try: + from ..agents import CommandRegistrar + + registrar = CommandRegistrar() + agent_config = registrar.AGENT_CONFIGS.get(agent_name) + except ImportError: + registrar = None + agent_config = None + if agent_config is None or registrar is None: + return + + for pack_id, metadata in list(self.registry.list().items()): + updates: Dict[str, Any] = {} + + raw_skills = metadata.get("registered_skills", []) + if isinstance(raw_skills, list) and raw_skills: + # Legacy flat-list value predating per-agent provenance: + # infer real ownership from on-disk markers before removing + # anything, so only agent_name's actual share is unregistered + # and the rest migrates to per-agent form instead of either + # guessing every name belongs to agent_name or blindly + # leaving other agents' shares unrecoverable (#2948). + registered_skills_all = self._infer_legacy_skill_provenance( + [n for n in raw_skills if isinstance(n, str)], + pack_id, + fallback_agent=agent_name, + ) + skills_migrated = True + elif isinstance(raw_skills, dict): + registered_skills_all = copy.deepcopy(raw_skills) + skills_migrated = False + else: + registered_skills_all = {} + skills_migrated = False + + registered_commands = metadata.get("registered_commands", {}) + if not isinstance(registered_commands, dict): + registered_commands = {} + + agent_command_names = [ + n for n in registered_commands.get(agent_name, []) if isinstance(n, str) + ] + + # Native SKILL.md agents (claude/codex/agy/…) materialize their + # preset override in _register_commands(), tracked under + # registered_commands, not registered_skills — see + # _register_skills()'s own docstring ("Native skill agents … + # materialize brand-new preset skills in _register_commands()"). + # A legacy flat-list registered_skills value predating that + # split can still attribute the very same on-disk file to this + # agent via provenance inference; unregistering through both + # paths would double-process the identical directory (delete + # via the commands path, then no-op "restore" via the skills + # path since the directory is already gone). Mirror remove()'s + # own coordination: whenever this agent's artifact is already + # handled via registered_commands, never additionally treat it + # as a registered_skills entry for the same agent. + native_skills_entry_removed = False + if agent_command_names and agent_config.get("extension") == "/SKILL.md": + native_skills_entry_removed = agent_name in registered_skills_all + registered_skills_all.pop(agent_name, None) + + if agent_command_names: + command_names_to_unregister = agent_command_names + if agent_config.get("extension") == "/SKILL.md": + agent_output = registrar._resolve_agent_dir( + agent_name, agent_config, self.project_root + ) + shared_names: set[str] = set() + for other_agent, other_names in registered_commands.items(): + if ( + other_agent == agent_name + or not isinstance(other_names, list) + ): + continue + other_config = registrar.AGENT_CONFIGS.get(other_agent) + if ( + not other_config + or other_config.get("extension") != "/SKILL.md" + ): + continue + other_output = registrar._resolve_agent_dir( + other_agent, other_config, self.project_root + ) + if other_output == agent_output: + shared_names.update( + name + for name in other_names + if isinstance(name, str) + ) + command_names_to_unregister = [ + name + for name in agent_command_names + if name not in shared_names + ] + if command_names_to_unregister: + self._unregister_commands( + {agent_name: command_names_to_unregister} + ) + new_registered_commands = copy.deepcopy(registered_commands) + new_registered_commands.pop(agent_name, None) + updates["registered_commands"] = new_registered_commands + + agent_skill_names = registered_skills_all.get(agent_name) or [] + if ( + agent_skill_names + or skills_migrated + or native_skills_entry_removed + ): + if agent_skill_names: + self._delete_agent_preset_skills( + agent_name, agent_skill_names, pack_id + ) + remaining = { + other_agent: names + for other_agent, names in registered_skills_all.items() + if other_agent != agent_name + } + updates["registered_skills"] = remaining + + if updates: + self.registry.update(pack_id, updates) + + def _unregister_commands(self, registered_commands: Dict[str, List[str]]) -> None: + """Remove previously registered command files from agent directories. + + Args: + registered_commands: Dict mapping agent names to command name lists + """ + try: + from ..agents import CommandRegistrar + except ImportError: + return + + registrar = CommandRegistrar() + registrar.unregister_commands(registered_commands, self.project_root) + + def _merge_pack_registered_commands( + self, pack_id: str, written: Optional[Dict[str, List[str]]] + ) -> None: + """Merge actually-written agent command registrations into a preset's metadata. + + Reconciliation (``_reconcile_composed_commands``) can write a + preset's content into an agent directory the preset never wrote to + before — most notably a historical (currently inactive) agent + supplied via ``extra_agents`` when a higher-priority preset is + removed. If that write isn't reflected back into the winning + preset's own ``registered_commands``, the registry silently lies + about which directories the preset owns: a later removal of this + same preset only cleans up the agents it already knew about, + orphaning the directory reconciliation just wrote to on its behalf + (#2948). + + Args: + pack_id: The preset whose metadata should be updated. + written: ``{agent_name: [cmd_name, ...]}`` actually written by + the reconciliation call just made, exactly mirroring + ``CommandRegistrar.register_commands_for_non_skill_agents``'s + return value. A falsy value is a no-op. + """ + if not written: + return + metadata = self.registry.get(pack_id) + if metadata is None: + return # pack_id no longer installed (e.g. removed mid-loop) + existing_commands = metadata.get("registered_commands", {}) + if not isinstance(existing_commands, dict): + existing_commands = {} + merged_commands = copy.deepcopy(existing_commands) + changed = False + for agent_name, cmd_names in written.items(): + if not cmd_names: + continue + existing_names = merged_commands.get(agent_name, []) + new_names = [n for n in cmd_names if n not in existing_names] + if new_names: + merged_commands[agent_name] = existing_names + new_names + changed = True + if changed: + self.registry.update(pack_id, {"registered_commands": merged_commands}) + + def _merge_extension_registered_commands( + self, extension_id: str, written: Optional[Dict[str, List[str]]] + ) -> None: + """Merge reconciliation writes into an extension's registry entry.""" + if not written: + return + registry = ExtensionRegistry(self.project_root / ".specify" / "extensions") + metadata = registry.get(extension_id) + if metadata is None: + return + existing_commands = metadata.get("registered_commands", {}) + if not isinstance(existing_commands, dict): + existing_commands = {} + merged_commands = copy.deepcopy(existing_commands) + changed = False + for agent_name, cmd_names in written.items(): + existing_names = merged_commands.get(agent_name, []) + new_names = [name for name in cmd_names if name not in existing_names] + if new_names: + merged_commands[agent_name] = existing_names + new_names + changed = True + if changed: + registry.update(extension_id, {"registered_commands": merged_commands}) + + def _reconcile_composed_commands( + self, + command_names: List[str], + extra_agents: Optional[Set[str]] = None, + target_agent: Optional[str] = None, + ) -> Set[str]: + """Re-resolve and re-register composed commands from the full stack. + + After install or remove, recompute the effective content for each + command name that participates in composition, and write the winning + content to the agent directories. This ensures command files always + reflect the current priority stack rather than depending on + install/remove order. + + Single-active rule (#2948): non-skill command-file registration + performed by this pass is restricted to the active integration, the + same as ``_register_commands``. Without this, reconciliation after + install/remove would write command files for every detected + non-skill agent even though registration itself is active-only, + leaving inactive integrations with artifacts that are never + recorded in ``registered_commands`` (and therefore never cleaned up + on removal). + + Args: + command_names: List of command names to reconcile + extra_agents: Additional agent names to also reconcile besides + the currently active one. Populated by ``remove()`` with the + historical agents a just-removed preset's + ``registered_commands`` actually targeted, so a surviving + lower-priority preset's content is restored there too — not + only for the currently active agent (#2948). Install/use + callers omit this, preserving pure active-only behavior. + target_agent: If set, report only command names written for this + agent. Other callers receive the union of all written names. + + Returns: + Command names successfully written by this reconciliation pass. + """ + if not command_names: + return set() + + # Every preset-owned command name flows through unchanged. Names are + # NOT filtered by the ``speckit..`` shape: a self-contained + # preset command scaffolds whether or not a like-named extension is + # installed (parity with _register_commands), and a name whose base + # layer has disappeared must still reach the loop below so its now + # uncomposable stale file gets unregistered. The loop already skips + # names that resolve to no layers at all (``if not layers: continue``). + try: + from ..agents import CommandRegistrar + except ImportError: + return set() + + resolver = PresetResolver(self.project_root) + registrar = CommandRegistrar() + reconciled_commands: set[str] = set() + + def record_written(written: Dict[str, List[str]]) -> None: + if target_agent is not None: + reconciled_commands.update(written.get(target_agent, [])) + else: + for names in written.values(): + reconciled_commands.update(names) + + # Resolve the active-only restriction once. MISSING_INIT_OPTIONS_FILE + # (legacy pre-init-options project) keeps the pre-#2948 fallback of + # registering every detected non-skill agent; a corrupted/malformed + # init-options.json fails closed via a sentinel that matches no real + # agent name instead of silently falling back to "no restriction". + resolved_agent = resolve_active_agent_for_registration(self.project_root) + if resolved_agent is MISSING_INIT_OPTIONS_FILE: + only_agent: Optional[str] = None + elif resolved_agent is None: + only_agent = "" + else: + only_agent = resolved_agent + # Mirror _register_commands's ai_skills guard: a command-backed + # active agent running in skills mode renders preset/extension + # overrides as skills, not command files, so this non-skill + # command reconciliation pass must not target it either. + agent_config = registrar.AGENT_CONFIGS.get(only_agent) + if ( + agent_config + and is_ai_skills_enabled(load_init_options(self.project_root)) + and agent_config.get("extension") != "/SKILL.md" + ): + only_agent = "" + + # The active agent's participation is decided exclusively by the + # only_agent guard above (which encodes the ai_skills mode). A + # partially failed command→skills toggle can leave the active agent + # behind in extra_agents via its stale registered_commands entry, + # and register_commands_for_non_skill_agents admits every + # extra_agents member even when only_agent excludes the agent — + # recreating a command file for an agent now running in skills + # mode. Never re-admit the active agent through the + # historical-agents side channel (#2948). + if extra_agents and isinstance(resolved_agent, str): + extra_agents = set(extra_agents) - {resolved_agent} + + # Cache registry and manifests outside the loop to avoid + # repeated filesystem reads for each command name. + presets_by_priority = list(self.registry.list_by_priority()) + + for cmd_name in command_names: + layers = resolver.collect_all_layers(cmd_name, "command") + if not layers: + continue + + # If the top layer is replace, it wins entirely — lower layers + # are irrelevant regardless of their strategies. + top_is_replace = layers[0]["strategy"] == "replace" + has_composition = not top_is_replace and any( + layer["strategy"] != "replace" for layer in layers + ) + if not has_composition: + # Pure replace — the top layer wins. + top_layer = layers[0] + top_path = top_layer["path"] + # Try to find which preset owns this layer + registered = False + for pack_id, _meta in presets_by_priority: + pack_dir = self.presets_dir / pack_id + if top_path.is_relative_to(pack_dir): + manifest = resolver._get_manifest(pack_dir) + if manifest: + for tmpl in manifest.templates: + if tmpl.get("name") == cmd_name and tmpl.get("type") == "command": + written = self._register_for_non_skill_agents( + registrar, [tmpl], manifest.id, pack_dir, + only_agent=only_agent, extra_agents=extra_agents, + ) + record_written(written) + self._merge_pack_registered_commands(manifest.id, written) + registered = True + break + break + if not registered: + # Top layer is a non-preset source (extension, core, or + # project override). Register directly from the layer path. + source = layers[0]["source"] + extension_id = None + written: Dict[str, List[str]] = {} + if source.startswith("extension:"): + # Use extension's own registration to preserve context formatting + extension_id = source.split(":", 1)[1].split(" ", 1)[0] + ext_dir = ( + self.project_root / ".specify" / "extensions" / extension_id + ) + ext_manifest_path = ext_dir / "extension.yml" + if ext_manifest_path.exists(): + try: + from ..extensions import ExtensionManifest + ext_manifest = ExtensionManifest(ext_manifest_path) + # Filter to only the command being reconciled + matching_cmds = [ + c for c in ext_manifest.commands + if c.get("name") == cmd_name + ] + if matching_cmds: + written = registrar.register_commands_for_non_skill_agents( + matching_cmds, extension_id, ext_dir, + self.project_root, + context_note=f"\n\n\n", + extension_id=extension_id, + only_agent=only_agent, + extra_agents=extra_agents, + ) + record_written(written) + registered = True + except (ImportError, FileNotFoundError, OSError): + # Extension registration failed; fall back to + # generic path-based registration below. + pass + if not registered: + source_id = extension_id or source + written = self._register_command_from_path( + registrar, cmd_name, top_path, + source_id=source_id, + only_agent=only_agent, extra_agents=extra_agents, + ) + record_written(written) + if extension_id: + self._merge_extension_registered_commands( + extension_id, written + ) + else: + # Composed command — resolve from full stack + composed = resolver.resolve_content(cmd_name, "command") + if composed is None: + # Composition no longer possible (e.g. base layer removed). + # Unregister any stale command file from non-skill agents. + import warnings + warnings.warn( + f"Cannot compose command '{cmd_name}': no base layer. " + f"Stale command files may remain.", + stacklevel=2, + ) + registrar._ensure_configs() + # Include aliases from the top layer's manifest + cmd_names_to_unregister = [cmd_name] + for _pid, _meta in presets_by_priority: + _pd = self.presets_dir / _pid + _m = resolver._get_manifest(_pd) + if _m: + for _t in _m.templates: + if _t.get("name") == cmd_name and _t.get("type") == "command": + for alias in _t.get("aliases", []): + if isinstance(alias, str): + cmd_names_to_unregister.append(alias) + break + # Mirror the active-only restriction used elsewhere in + # this pass: without it, unregistering a stale composed + # command would touch every non-skill agent's directory, + # deleting historical artifacts from integrations that + # were never active when this preset registered (#2948). + registrar.unregister_commands( + { + agent: cmd_names_to_unregister + for agent in registrar.AGENT_CONFIGS + if registrar.AGENT_CONFIGS[agent].get("extension") != "/SKILL.md" + and ( + only_agent is None + or agent == only_agent + or agent in (extra_agents or ()) + ) + }, + self.project_root, + ) + continue + + # Write to the highest-priority preset's .composed dir + registered = False + for pack_id, _meta in presets_by_priority: + pack_dir = self.presets_dir / pack_id + manifest = resolver._get_manifest(pack_dir) + if not manifest: + continue + for tmpl in manifest.templates: + if tmpl.get("name") == cmd_name and tmpl.get("type") == "command": + composed_dir = pack_dir / ".composed" + composed_dir.mkdir(parents=True, exist_ok=True) + composed_file = composed_dir / f"{cmd_name}.md" + composed_file.write_text(composed, encoding="utf-8") + written = self._register_for_non_skill_agents( + registrar, + [{**tmpl, "file": f".composed/{cmd_name}.md"}], + manifest.id, pack_dir, + only_agent=only_agent, extra_agents=extra_agents, + ) + record_written(written) + self._merge_pack_registered_commands(manifest.id, written) + registered = True + break + else: + continue + break + if not registered: + # No preset owns this composed command — write to a + # shared .composed dir and register from the top layer. + shared_composed = self.presets_dir / ".composed" + shared_composed.mkdir(parents=True, exist_ok=True) + composed_file = shared_composed / f"{cmd_name}.md" + composed_file.write_text(composed, encoding="utf-8") + source = layers[0]["source"] + if source.startswith("extension:"): + source_id = source.split(":", 1)[1].split(" ", 1)[0] + else: + source_id = source + written = self._register_command_from_path( + registrar, cmd_name, composed_file, + source_id=source_id, + only_agent=only_agent, extra_agents=extra_agents, + ) + record_written(written) + if source.startswith("extension:"): + self._merge_extension_registered_commands( + source_id, written + ) + + return reconciled_commands + + def _register_command_from_path( + self, + registrar: Any, + cmd_name: str, + cmd_path: Path, + source_id: str = "reconciled", + only_agent: Optional[str] = None, + extra_agents: Optional[Set[str]] = None, + ) -> Dict[str, List[str]]: + """Register a single command from a file path (non-preset source). + + Used by reconciliation when the winning layer is an extension, + core template, or project override rather than a preset. + + Args: + registrar: CommandRegistrar instance + cmd_name: Command name + cmd_path: Path to the command file + source_id: Source attribution for rendered output + only_agent: If set, restrict registration to this single agent (#2948). + extra_agents: Additional agent names to register for besides + ``only_agent`` (post-removal reconciliation only, #2948). + + Returns: + ``{agent_name: [cmd_name, ...]}`` for every agent this call + actually registered the command for (empty if the source path + doesn't exist or nothing was written). + """ + if not cmd_path.exists(): + return {} + cmd_tmpl: Dict[str, Any] = { + "name": cmd_name, + "type": "command", + "file": cmd_path.name, + } + # Load aliases from extension manifest when the winning layer is an extension + if source_id and not source_id.startswith("preset:"): + try: + from ..extensions import ExtensionManifest + for ext_dir in (self.project_root / ".specify" / "extensions").iterdir(): + if not ext_dir.is_dir(): + continue + if cmd_path.is_relative_to(ext_dir): + manifest_path = ext_dir / "extension.yml" + if manifest_path.exists(): + ext_manifest = ExtensionManifest(manifest_path) + for cmd in ext_manifest.commands: + if cmd.get("name") == cmd_name: + aliases = cmd.get("aliases", []) + if isinstance(aliases, list) and aliases: + cmd_tmpl["aliases"] = aliases + break + break + except Exception: + pass # best-effort alias loading + return self._register_for_non_skill_agents( + registrar, [cmd_tmpl], source_id, cmd_path.parent, + only_agent=only_agent, extra_agents=extra_agents, + ) + + def _register_for_non_skill_agents( + self, + registrar: Any, + commands: List[Dict[str, Any]], + source_id: str, + source_dir: Path, + only_agent: Optional[str] = None, + extra_agents: Optional[Set[str]] = None, + ) -> Dict[str, List[str]]: + """Register commands for non-skill agents during reconciliation. + + Skill-based agents (``/SKILL.md`` layout) are handled separately: + - On removal: ``_unregister_skills()`` restores from core/extension, + then ``_reconcile_skills()`` re-runs ``_register_skills()`` for the + next winning preset so SKILL.md files get proper frontmatter and + descriptions. + - On install: ``_register_skills()`` writes formatted SKILL.md, then + ``_reconcile_skills()`` ensures the actual priority winner is used. + + Writing raw command content to skill agents would produce invalid + SKILL.md files (missing skill frontmatter, descriptions, etc.). + + Args: + only_agent: If set, restrict registration to this single agent, + matching the active-only rule applied by ``_register_commands`` + (#2948). + extra_agents: Additional agent names to register for besides + ``only_agent``. Used by post-removal reconciliation to also + restore surviving content into historical agent directories + a just-removed preset actually wrote to (#2948). + + Returns: + ``{agent_name: [cmd_name, ...]}`` for every agent this call + actually registered a command for, mirroring + ``CommandRegistrar.register_commands_for_non_skill_agents``'s + return value so callers can merge it into a preset's own + ``registered_commands`` tracking (#2948). + """ + return registrar.register_commands_for_non_skill_agents( + commands, source_id, source_dir, self.project_root, + only_agent=only_agent, extra_agents=extra_agents, + ) diff --git a/src/specify_cli/presets/_manager_skills.py b/src/specify_cli/presets/_manager_skills.py new file mode 100644 index 0000000000..fa7e715a31 --- /dev/null +++ b/src/specify_cli/presets/_manager_skills.py @@ -0,0 +1,1544 @@ +"""Agent skill registration and safe reconciliation for installed presets.""" + +import copy +import os +import shutil +from pathlib import Path +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Set, Union + +if TYPE_CHECKING: + from ..agents import CommandRegistrar + + +from .._init_options import is_ai_skills_enabled +from .._invocation_style import get_invocation_prefix +from .._utils import dump_frontmatter +from ..integrations.base import IntegrationBase +from ._manager_commands import _substitute_core_template +from ._manifest import PresetManifest, PresetValidationError +from ._resolver import PresetResolver + + +class _PresetSkillMethods: + """Skill artifact methods shared through PresetManager's lifecycle state.""" + + class _FilteredManifest: + """Wrapper that exposes only selected command templates from a manifest. + + Used by _reconcile_skills to avoid overwriting skills for commands + that aren't being reconciled. + """ + + def __init__(self, manifest: "PresetManifest", cmd_names: set): + self._manifest = manifest + self._cmd_names = cmd_names + + def __getattr__(self, name: str): + return getattr(self._manifest, name) + + @property + def templates(self) -> List[Dict[str, Any]]: + return [ + t for t in self._manifest.templates + if t.get("name") in self._cmd_names + ] + + def _merge_pack_registered_skills( + self, pack_id: str, written: Optional[Dict[str, List[str]]] + ) -> None: + """Merge actually-written agent skill registrations into a preset's metadata. + + Mirrors :meth:`_merge_pack_registered_commands` for the skills + side: ``_reconcile_skills`` can render a preset's SKILL.md content + into an agent directory the preset never wrote to before — most + notably a historical (currently inactive) agent restored via + ``extra_skills_dirs`` when a higher-priority preset is removed. If + that write isn't reflected back into the winning preset's own + ``registered_skills``, a later removal of this same preset only + cleans up the agents it already knew about, orphaning the skill + directory reconciliation just wrote to on its behalf (#2948). + + Args: + pack_id: The preset whose metadata should be updated. + written: ``{agent_name: [skill_name, ...]}`` actually written + by the ``_register_skills`` call just made. A falsy value + is a no-op. + """ + if not written: + return + metadata = self.registry.get(pack_id) + if metadata is None: + return # pack_id no longer installed (e.g. removed mid-loop) + raw_existing_skills = metadata.get("registered_skills") + if isinstance(raw_existing_skills, list) and raw_existing_skills: + # Legacy flat-list value: infer real per-agent ownership from + # on-disk provenance rather than guessing (#2948). + fallback_agent = next(iter(written)) if written else None + existing_skills = self._infer_legacy_skill_provenance( + [n for n in raw_existing_skills if isinstance(n, str)], + pack_id, + fallback_agent=fallback_agent, + ) + else: + existing_skills = self._normalize_registered_skills(raw_existing_skills) + merged_skills = copy.deepcopy(existing_skills) + changed = ( + isinstance(raw_existing_skills, list) and bool(raw_existing_skills) + ) + for agent_name, skill_names in written.items(): + if not skill_names: + continue + existing_names = merged_skills.get(agent_name, []) + new_names = [n for n in skill_names if n not in existing_names] + if new_names: + merged_skills[agent_name] = existing_names + new_names + changed = True + if changed: + self.registry.update(pack_id, {"registered_skills": merged_skills}) + + def _reconcile_skills( + self, + command_names: List[str], + extra_skills_dirs: Optional[ + Dict[Path, tuple[Optional[str], List[str]]] + ] = None, + target_agent: Optional[str] = None, + ) -> Set[str]: + """Re-register skills for commands whose winning layer changed. + + After a preset is removed, finds the next preset in the priority + stack that provides each command and re-runs skill registration + for that preset so SKILL.md files reflect the current winner. + + Args: + command_names: List of command names to reconcile skills for + extra_skills_dirs: Additional + ``{skills_dir: (renderer_agent, managed_skill_names)}`` + entries restored by ``_unregister_skills``. Reconciliation + is limited to the names actually managed in each directory. + target_agent: If set, report only command names written for this + agent. Other callers receive the union of all written names. + + Returns: + Command names whose skill output was successfully written. + """ + if not command_names: + return set() + + # Preset-owned command names are not filtered by the + # ``speckit..`` shape here either: a self-contained preset + # command renders its skill whether or not a like-named extension is + # installed. The per-name loop below skips anything that doesn't + # resolve to a managed skill directory. + resolver = PresetResolver(self.project_root) + active_skills_dir = self._get_skills_dir() + + from .. import load_init_options + + init_opts = load_init_options(self.project_root) + active_ai = init_opts.get("ai") if isinstance(init_opts, dict) else None + if not isinstance(active_ai, str) or not active_ai: + active_ai = None + + # Cache registry once to avoid repeated filesystem reads + presets_by_priority = list(self.registry.list_by_priority()) + + # Group command names by winning preset to batch _register_skills calls + # while only registering skills for the specific commands being + # reconciled. This resolution (which preset/content wins) is + # directory-independent, so it's computed once and then applied to + # every affected directory below. + preset_cmds: Dict[str, List[str]] = {} + non_preset_skills: List[tuple] = [] + managed_skill_names: set = set() + reconciled_skill_commands: set[str] = set() + + for cmd_name in command_names: + layers = resolver.collect_all_layers(cmd_name, "command") + if not layers: + continue + + skill_name, legacy_skill_name = self._skill_names_for_command( + cmd_name + ) + candidate_skill_names = {skill_name, legacy_skill_name} + # Track whether any preset previously registered this skill + # (i.e., it was actively managed), so a not-yet-existing skill + # dir can be re-created per affected directory below. + for _pid, meta in presets_by_priority: + if not isinstance(meta, dict): + continue + recorded = meta.get("registered_skills", []) + if isinstance(recorded, dict): + recorded_names = { + name + for names in recorded.values() + if isinstance(names, list) + for name in names + } + elif isinstance(recorded, list): + recorded_names = set(recorded) + else: + recorded_names = set() + recorded_candidates = ( + candidate_skill_names & recorded_names + ) + if recorded_candidates: + managed_skill_names.update(recorded_candidates) + + top_path = layers[0]["path"] + # Find the preset that owns the winning layer + found_preset = False + for pack_id, _meta in presets_by_priority: + pack_dir = self.presets_dir / pack_id + if top_path.is_relative_to(pack_dir): + preset_cmds.setdefault(pack_id, []).append(cmd_name) + found_preset = True + break + if not found_preset: + # Winner is a non-preset source (core/extension/override). + # Track the winning layer path for skill restoration. + non_preset_skills.append((skill_name, cmd_name, layers[0])) + + core_ext_skills = [s for s in non_preset_skills if s[2]["source"] != "project override"] + override_skills = [s for s in non_preset_skills if s[2]["source"] == "project override"] + + def apply_to_dir( + skills_dir: Path, + dir_agent: Optional[str], + *, + is_active: bool, + managed_names: Optional[Set[str]] = None, + ) -> None: + dir_managed_names = ( + managed_skill_names if managed_names is None else managed_names + ) + # Restore skills for commands whose winner is non-preset. + # _unregister_skills_in_dir can rmtree the skill dir, so + # overrides must be handled directly (create dir + write) + # without that call. + dir_core_ext_names = [ + candidate + for _skill_name, cmd_name, _top_layer in core_ext_skills + for candidate in self._skill_names_for_command(cmd_name) + if candidate in dir_managed_names + ] + if dir_core_ext_names: + self._unregister_skills_in_dir( + dir_core_ext_names, + skills_dir, + dir_agent, + restore_from_bundled_core=True, + ) + + for _skill_name, cmd_name, top_layer in override_skills: + target_skill_names = [ + name + for name in self._skill_names_for_command(cmd_name) + if name in dir_managed_names + ] + if not target_skill_names: + continue + try: + from .. import SKILL_DESCRIPTIONS + from ..agents import CommandRegistrar + from ..shared_infra import _write_shared_text + registrar = CommandRegistrar() + content = top_layer["path"].read_text(encoding="utf-8") + fm, body = registrar.parse_frontmatter(content) + short_name = cmd_name + if short_name.startswith("speckit."): + short_name = short_name[len("speckit."):] + desc = fm.get("description", "") or SKILL_DESCRIPTIONS.get( + short_name.replace(".", "-"), + f"Command: {short_name}", + ) + selected_ai = dir_agent if isinstance(dir_agent, str) else "" + if selected_ai: + body = registrar.resolve_skill_placeholders( + selected_ai, fm, body, self.project_root + ) + body = self._resolve_skill_command_refs( + body, registrar, selected_ai, self.project_root + ) + from ..integrations import get_integration + integration = get_integration(selected_ai) if selected_ai else None + skill_title = self._skill_title_from_command(cmd_name) + wrote_override = False + for target_skill_name in target_skill_names: + skill_subdir = skills_dir / target_skill_name + # Same symlink guard as _register_skills's + # registration path (#2948). + if not self._validate_skill_subdir( + skill_subdir, + create=True, + skills_root=skills_dir, + ): + continue + fm_data = registrar.build_skill_frontmatter( + selected_ai, + target_skill_name, + desc, + f"override:{cmd_name}", + ) + registrar.apply_argument_hint( + fm, fm_data, integration + ) + fm_text = dump_frontmatter(fm_data) + skill_content = ( + f"---\n{fm_text}\n---\n\n" + f"# Speckit {skill_title} Skill\n\n{body}\n" + ) + if integration is not None and hasattr( + integration, "post_process_skill_content" + ): + skill_content = ( + integration.post_process_skill_content( + skill_content + ) + ) + _write_shared_text( + skills_dir, + skill_subdir / "SKILL.md", + skill_content, + ) + wrote_override = True + if ( + wrote_override + and ( + target_agent is None + or dir_agent == target_agent + ) + ): + reconciled_skill_commands.add(cmd_name) + except Exception: + pass # best-effort override skill restoration + + # Register skills only for the specific commands being + # reconciled, not all commands in each winning preset's + # manifest. + for pack_id, cmds in preset_cmds.items(): + dir_cmds = [ + cmd + for cmd in cmds + if any( + name in dir_managed_names + for name in self._skill_names_for_command(cmd) + ) + ] + if not dir_cmds: + continue + pack_dir = self.presets_dir / pack_id + manifest_path = pack_dir / "preset.yml" + if not manifest_path.exists(): + continue + try: + manifest = PresetManifest(manifest_path) + except PresetValidationError: + continue + cmds_set = set(dir_cmds) + filtered_manifest = self._FilteredManifest(manifest, cmds_set) + # Not dead code: _register_skills only *overwrites* skill + # subdirectories that already exist (plus brand-new ones for + # the active ai_skills agent). For a restore into a + # historical directory, _unregister_skills has just deleted + # the retiring preset's subdirectory, so pre-create the + # tracked (dir_managed_names) subdirectories here — under + # the same symlink guard — or the surviving preset's + # override would be silently skipped (#2948). + for cmd_name in dir_cmds: + for skill_name in self._skill_names_for_command(cmd_name): + if skill_name not in dir_managed_names: + continue + skill_subdir = skills_dir / skill_name + if not self._validate_skill_subdir( + skill_subdir, + create=True, + skills_root=skills_dir, + ): + continue + if is_active: + # Preserve exact prior behaviour for the currently + # active directory (including the ability to create + # brand-new skill subdirectories when ai_skills is on). + written = self._register_skills(filtered_manifest, pack_dir) + else: + written = self._register_skills( + filtered_manifest, pack_dir, + target_dir=skills_dir, target_agent=dir_agent or "", + ) + if target_agent is None: + written_names = { + name + for names in written.values() + for name in names + } + else: + written_names = set(written.get(target_agent, [])) + for cmd_name in dir_cmds: + if written_names.intersection( + self._skill_names_for_command(cmd_name) + ): + reconciled_skill_commands.add(cmd_name) + # The winning preset may not have previously written to + # this directory's agent (most notably a historical agent + # reconciliation just restored content into via + # extra_skills_dirs). If that write isn't merged back into + # the preset's own registered_skills, its registry entry + # silently lies about which directories it owns and a + # later removal of this same preset orphans the directory + # reconciliation just wrote to on its behalf (#2948). + self._merge_pack_registered_skills(pack_id, written) + + extra_dirs = extra_skills_dirs or {} + if active_skills_dir: + active_provenance = extra_dirs.get(active_skills_dir) + if extra_skills_dirs is None or active_provenance: + apply_to_dir( + active_skills_dir, + active_ai, + is_active=True, + managed_names=( + set(active_provenance[1]) + if active_provenance + else None + ), + ) + + for extra_dir, (extra_agent, extra_names) in extra_dirs.items(): + if extra_dir == active_skills_dir: + continue # already reconciled above as the active directory + apply_to_dir( + extra_dir, + extra_agent, + is_active=False, + managed_names=set(extra_names), + ) + + return reconciled_skill_commands + + def _resolve_agent_skills_dir(self, agent_name: str) -> Path: + """Resolve the real skill output directory for an integration.""" + from .. import _get_skills_dir as _project_skills_dir + from ..agents import CommandRegistrar + + registrar = CommandRegistrar() + agent_config = registrar.AGENT_CONFIGS.get(agent_name) + if agent_config and agent_config.get("extension") == "/SKILL.md": + return registrar._resolve_agent_dir( + agent_name, agent_config, self.project_root + ) + return _project_skills_dir(self.project_root, agent_name) + + def _skills_validation_root(self, skills_dir: Path) -> Optional[Path]: + """Return the trusted root containing a project or user skill dir.""" + for root in (self.project_root, Path.home()): + if skills_dir.is_relative_to(root): + return root + return None + + def _get_skills_dir(self) -> Optional[Path]: + """Return the active skills directory for preset skill overrides. + + Uses :func:`resolve_active_skills_dir` for activation/detection, + then resolves native skill agents through the registrar's output + directory so integrations such as Hermes write to their global + skills path rather than their project-local detection marker. + + Returns ``None`` (instead of raising) when the directory cannot + be created due to symlink, containment, or permission issues so + that callers can fall back gracefully. + """ + from .. import ( + _print_cli_warning, + load_init_options, + resolve_active_skills_dir, + ) + from ..shared_infra import _ensure_safe_shared_directory + try: + skills_dir = resolve_active_skills_dir(self.project_root) + except (ValueError, OSError) as exc: + _print_cli_warning( + "resolve", "skills directory", None, exc, + continuing="Continuing without skill registration.", + ) + return None + if skills_dir is None: + return None + + opts = load_init_options(self.project_root) + selected_ai = opts.get("ai") if isinstance(opts, dict) else None + if not isinstance(selected_ai, str) or not selected_ai: + return skills_dir + + agent_skills_dir = self._resolve_agent_skills_dir(selected_ai) + if agent_skills_dir == skills_dir: + return skills_dir + + validation_root = self._skills_validation_root(agent_skills_dir) + if validation_root is None: + _print_cli_warning( + "resolve", + "skills directory", + str(agent_skills_dir), + ValueError("skills directory is outside trusted roots"), + continuing="Continuing without skill registration.", + ) + return None + try: + _ensure_safe_shared_directory( + validation_root, + agent_skills_dir, + context="preset skills directory", + ) + except (ValueError, OSError) as exc: + _print_cli_warning( + "resolve", "skills directory", str(agent_skills_dir), exc, + continuing="Continuing without skill registration.", + ) + return None + return agent_skills_dir + + @staticmethod + def _skill_names_for_command(cmd_name: str) -> tuple[str, str]: + """Return the modern and legacy skill directory names for a command.""" + raw_short_name = cmd_name + if raw_short_name.startswith("speckit."): + raw_short_name = raw_short_name[len("speckit."):] + + modern_skill_name = f"speckit-{raw_short_name.replace('.', '-')}" + legacy_skill_name = f"speckit.{raw_short_name}" + return modern_skill_name, legacy_skill_name + + @staticmethod + def _skill_title_from_command(cmd_name: str) -> str: + """Return a human-friendly title for a skill command name.""" + title_name = cmd_name + if title_name.startswith("speckit."): + title_name = title_name[len("speckit."):] + return title_name.replace(".", " ").replace("-", " ").title() + + @staticmethod + def _resolve_skill_command_refs( + body: str, + registrar: "CommandRegistrar", + selected_ai: str, + project_root: "Path | None" = None, + ) -> str: + """Render ``__SPECKIT_COMMAND_*__`` tokens in a skill body as invocations. + + Looks up the agent's invoke separator and rewrites each + ``__SPECKIT_COMMAND___`` placeholder into the matching + agent-native invocation -- ``/speckit-`` or ``$speckit-`` for + a ``-`` separator, ``/speckit.`` for ``.``, or + ``/skill:speckit-`` for skill-colon agents (e.g. Kimi) -- the + same rendering the command layer applies via + ``CommandRegistrar.register_commands()``. + + For dual-layout agents (e.g. Bob) the separator depends on the + project's persisted skills state, so -- when *project_root* is provided + -- the separator is resolved from the integration via + ``invoke_separator_for_mode`` rather than the single static + ``AGENT_CONFIGS`` value. + """ + separator = None + if project_root is not None and isinstance(selected_ai, str): + try: + from .. import load_init_options + from ..integrations import get_integration + + integration = get_integration(selected_ai) + if integration is not None: + separator = integration.invoke_separator_for_mode( + is_ai_skills_enabled(load_init_options(project_root)) + ) + except Exception: + separator = None + if separator is None: + separator = registrar.AGENT_CONFIGS.get(selected_ai, {}).get( + "invoke_separator", "." + ) + prefix = get_invocation_prefix(selected_ai, separator == "-") + return IntegrationBase.resolve_command_refs(body, separator, prefix) + + def _build_extension_skill_restore_index(self) -> Dict[str, Dict[str, Any]]: + """Index extension-backed skill restore data by skill directory name.""" + from ..extensions import ExtensionManifest, ValidationError + + resolver = PresetResolver(self.project_root) + extensions_dir = self.project_root / ".specify" / "extensions" + restore_index: Dict[str, Dict[str, Any]] = {} + + for _priority, ext_id, _metadata in resolver._get_all_extensions_by_priority(): + ext_dir = extensions_dir / ext_id + manifest_path = ext_dir / "extension.yml" + if not manifest_path.is_file(): + continue + + try: + manifest = ExtensionManifest(manifest_path) + except (ValidationError, TypeError, AttributeError): + continue + + ext_root = ext_dir.resolve() + for cmd_info in manifest.commands: + cmd_name = cmd_info.get("name") + cmd_file_rel = cmd_info.get("file") + if not isinstance(cmd_name, str) or not isinstance(cmd_file_rel, str): + continue + + cmd_path = Path(cmd_file_rel) + if cmd_path.is_absolute(): + continue + + try: + source_file = (ext_root / cmd_path).resolve() + source_file.relative_to(ext_root) + except (OSError, ValueError): + continue + + if not source_file.is_file(): + continue + + restore_info = { + "command_name": cmd_name, + "source_file": source_file, + "source": f"extension:{manifest.id}", + "author": manifest.data["extension"].get("author"), + "extension_id": manifest.id, + "extension_dir": ext_root, + } + modern_skill_name, legacy_skill_name = self._skill_names_for_command(cmd_name) + restore_index.setdefault(modern_skill_name, restore_info) + if legacy_skill_name != modern_skill_name: + restore_index.setdefault(legacy_skill_name, restore_info) + + return restore_index + + def _register_skills( + self, + manifest: "PresetManifest", + preset_dir: Path, + *, + target_dir: Optional[Path] = None, + target_agent: Optional[str] = None, + ) -> Dict[str, List[str]]: + """Generate SKILL.md files for preset command overrides. + + For every command template in the preset, checks whether a + corresponding skill already exists in any detected skills + directory. If so, the skill is overwritten with content derived + from the preset's command file. This ensures that presets that + override commands also propagate to the agentskills.io skill + layer when skills mode was used during project initialisation. + + Args: + manifest: Preset manifest. + preset_dir: Installed preset directory. + target_dir: Explicit skills directory to render into, instead + of resolving the currently active one. Used by + ``_reconcile_skills`` to restore a surviving preset's + override into a historical (currently inactive) agent's + directory that removal of a higher-priority preset just + reverted (#2948). + target_agent: Explicit agent name to render for, paired with + ``target_dir``. When set, skills are only ever restored + into already-tracked directories/names — brand-new skill + subdirectories are never created for a non-active, + explicitly targeted directory (that creation path is only + meaningful for the currently active agent). + + Returns: + ``{agent_name: [skill_name, ...]}`` for the single active + agent skills were written for (empty if none were written), + matching the shape ``registered_commands`` already uses so the + two can be tracked/restored consistently (#2948). + """ + command_templates = [ + t for t in manifest.templates if t.get("type") == "command" + ] + if not command_templates: + return {} + + # Preset command templates are self-contained and render as skills + # regardless of whether a like-named extension is installed — the same + # rule _register_commands() uses. No ``speckit..`` name-shape + # filtering; the per-command loop below skips anything without a target + # skill directory. + skills_dir = target_dir if target_dir is not None else self._get_skills_dir() + if not skills_dir: + return {} + + resolver = PresetResolver(self.project_root) + + from .. import SKILL_DESCRIPTIONS, load_init_options + from ..agents import CommandRegistrar + from ..integrations import get_integration + from ..shared_infra import _write_shared_text + + init_opts = load_init_options(self.project_root) + if not isinstance(init_opts, dict): + init_opts = {} + selected_ai = target_agent if target_agent is not None else init_opts.get("ai") + if not isinstance(selected_ai, str) or not selected_ai: + return {} + # A target_dir/target_agent call reconciles an explicitly-known, + # already-tracked directory (see _reconcile_skills) rather than the + # currently active agent, so ai_skills_enabled must not be derived + # from the *current* project-wide toggle for that other agent — it + # only controls whether brand-new skill subdirectories may be + # created below, which is only meaningful for the active agent. + ai_skills_enabled = target_agent is None and is_ai_skills_enabled(init_opts) + registrar = CommandRegistrar() + integration = get_integration(selected_ai) + agent_config = registrar.AGENT_CONFIGS.get(selected_ai, {}) + # Native skill agents (e.g. codex/kimi/agy/trae) materialize brand-new + # preset skills in _register_commands() because their detected agent + # directory is already the skills directory. This flag is only for + # command-backed agents that also mirror commands into skills. + create_missing_skills = ai_skills_enabled and agent_config.get("extension") != "/SKILL.md" + + written: List[str] = [] + + for cmd_tmpl in command_templates: + cmd_name = cmd_tmpl["name"] + cmd_file_rel = cmd_tmpl["file"] + source_file = preset_dir / cmd_file_rel + if not source_file.exists(): + continue + + # Use composed content if available (written by _register_commands + # for commands with non-replace strategies), otherwise the original. + composed_file = preset_dir / ".composed" / f"{cmd_name}.md" + if composed_file.exists(): + source_file = composed_file + + # Derive the short command name (e.g. "specify" from "speckit.specify") + raw_short_name = cmd_name + if raw_short_name.startswith("speckit."): + raw_short_name = raw_short_name[len("speckit."):] + short_name = raw_short_name.replace(".", "-") + skill_name, legacy_skill_name = self._skill_names_for_command(cmd_name) + skill_title = self._skill_title_from_command(cmd_name) + + # Only overwrite skills that already exist under skills_dir, + # including Kimi native skills when ai_skills is false. + # If both modern and legacy directories exist, update both. + target_skill_names: List[str] = [] + if (skills_dir / skill_name).is_dir(): + target_skill_names.append(skill_name) + if legacy_skill_name != skill_name and (skills_dir / legacy_skill_name).is_dir(): + target_skill_names.append(legacy_skill_name) + if not target_skill_names and create_missing_skills: + missing_skill_dir = skills_dir / skill_name + if not missing_skill_dir.exists(): + target_skill_names.append(skill_name) + if not target_skill_names: + continue + + # Parse the command file + content = source_file.read_text(encoding="utf-8") + frontmatter, body = registrar.parse_frontmatter(content) + + # A composition-strategy command (wrap/prepend/append) needs a + # base layer to compose onto. When _register_commands produced no + # composed file for it and the stack still has no base + # (resolve_content is None) — e.g. the command it wraps comes from + # an extension that isn't installed — rendering the raw preset + # fragment as a skill would emit broken output: a literal + # {CORE_TEMPLATE} for wrap, or only the preset's own fragment for + # prepend/append. Skip it here too so command mode and skills mode + # agree (mirrors _register_commands, which skips the same command). + # _register_commands already warned for this command in the same + # pass, so the skip is silent here to avoid a duplicate warning. + effective_strategy = ( + cmd_tmpl.get("strategy") + or frontmatter.get("strategy") + or "replace" + ) + if ( + effective_strategy != "replace" + and not composed_file.exists() + and resolver.resolve_content(cmd_name, "command") is None + ): + continue + + if frontmatter.get("strategy") == "wrap": + body, core_frontmatter = _substitute_core_template(body, cmd_name, self.project_root, registrar) + frontmatter = dict(frontmatter) + for key in ("scripts", "agent_scripts", "argument-hint"): + if key not in frontmatter and key in core_frontmatter: + frontmatter[key] = core_frontmatter[key] + + original_desc = frontmatter.get("description", "") + enhanced_desc = original_desc or SKILL_DESCRIPTIONS.get( + short_name, + f"Spec-kit workflow command: {short_name}", + ) + frontmatter = dict(frontmatter) + frontmatter["description"] = enhanced_desc + body = registrar.resolve_skill_placeholders( + selected_ai, frontmatter, body, self.project_root + ) + body = self._resolve_skill_command_refs(body, registrar, selected_ai, self.project_root) + + for target_skill_name in target_skill_names: + skill_subdir = skills_dir / target_skill_name + if skill_subdir.exists() and not skill_subdir.is_dir(): + continue + # Validate (and create, if missing) the skill's own + # subdirectory under the same symlink guard as its parent — + # is_dir() above follows symlinks, so a symlinked subdir + # with a real parent would otherwise slip through and have + # SKILL.md written through it to an arbitrary location (#2948). + if not self._validate_skill_subdir( + skill_subdir, create=True, skills_root=skills_dir + ): + continue + frontmatter_data = registrar.build_skill_frontmatter( + selected_ai, + target_skill_name, + enhanced_desc, + f"preset:{manifest.id}", + ) + registrar.apply_argument_hint(frontmatter, frontmatter_data, integration) + frontmatter_text = dump_frontmatter(frontmatter_data) + skill_content = ( + f"---\n" + f"{frontmatter_text}\n" + f"---\n\n" + f"# Speckit {skill_title} Skill\n\n" + f"{body}\n" + ) + if integration is not None and hasattr(integration, "post_process_skill_content"): + skill_content = integration.post_process_skill_content( + skill_content + ) + + skill_file = skill_subdir / "SKILL.md" + _write_shared_text( + skills_dir, skill_file, skill_content + ) + written.append(target_skill_name) + self._merge_pack_registered_skills( + manifest.id, {selected_ai: [target_skill_name]} + ) + + return {selected_ai: written} if written else {} + + def _infer_legacy_skill_provenance( + self, skill_names: List[str], pack_id: str, fallback_agent: str + ) -> Dict[str, List[str]]: + """Infer per-agent ownership of a legacy flat-list ``registered_skills`` value. + + Pre-#2948 registries recorded ``registered_skills`` as a flat list + with no record of which agent directory each name was actually + written under. Blindly attributing every name to ``fallback_agent`` + (the agent currently being processed) loses the real writer whenever + the *first* operation after upgrading is a direct switch to a + *different* agent — e.g. a legacy Copilot override (written while + Copilot was active with ``ai_skills`` enabled) followed directly by + ``integration use claude``, with no intervening rescaffold for + Copilot — permanently orphaning Copilot's override on later + removal. + + Every project-local configured integration's skills directory is probed (via + the same safe, symlink-validated helpers used for + restore/removal), not only agents whose registrar config is + statically ``/SKILL.md``-only: a command-backed agent (e.g. + Copilot, whose command extension is ``.agent.md``) renders its + preset overrides as ``SKILL.md`` files exactly like a native + skill-only agent whenever it was the active agent with + ``ai_skills`` enabled, so excluding it would miss real, + preset-owned provenance and misattribute it to whichever agent + happens to be processed first. Each directory is probed for a + ``SKILL.md`` whose frontmatter records this exact preset as the + owner (``metadata.source == "preset:"``, the same marker + :meth:`_register_skills` writes) — this marker check is what keeps + the broadened probe from falsely attributing ownership to an + agent's directory that never actually held this preset's override + (e.g. a command-mode agent that never rendered skills, or an + unrelated skill of the same name). A name can legitimately be + found under more than one agent's directory — the preset may have + been active while the user switched between several agents before + provenance tracking existed — so every matching agent is recorded, + not just the first. Names that can't be matched to any directory + (e.g. the file was deleted out of band) fall back to + ``fallback_agent``, preserving the previous best-effort behaviour + for the unrecoverable case. + """ + from ..agents import CommandRegistrar + + registrar = CommandRegistrar() + candidate_agents = sorted(registrar.AGENT_CONFIGS) + + # Multiple agent names can resolve to the same physical directory + # (e.g. agy/amp/codex/zed all use .agents/skills); group by + # directory so each is probed once and attributed to a single + # deterministic canonical agent name, matching the tie-break + # already used by _unregister_skills's directory grouping. Deliberately + # keep the unresolved path (matching what _safe_skills_dir_for_agent + # already validated) rather than calling .resolve() here: on macOS + # /var is itself a symlink to /private/var, so resolving would make + # this path diverge from self.project_root's own resolution state + # and make every subsequent containment check in + # _validate_skill_subdir() spuriously fail. + dir_to_agents: Dict[Path, List[str]] = {} + for agent_name in candidate_agents: + skills_dir = self._safe_skills_dir_for_agent(agent_name) + if skills_dir is None: + continue + # Only project-local skills directories are eligible: the + # legacy provenance markers don't record which project owns a + # skill under a home directory, so deletion stays restricted + # to the project root. Revisit if provenance ever records the + # owning project. + if not Path(os.path.abspath(skills_dir)).is_relative_to( + Path(os.path.abspath(self.project_root)) + ): + continue + dir_to_agents.setdefault(skills_dir, []).append(agent_name) + + marker = f"preset:{pack_id}" + # Filter unsafe names once, up front, rather than only inside the + # matching loop: any name skipped there would otherwise still + # land in "unmatched" below and get blindly attributed to + # fallback_agent anyway, defeating the guard entirely (#2948). + safe_skill_names = [ + name for name in skill_names if self._is_safe_registry_skill_name(name) + ] + inferred: Dict[str, List[str]] = {} + matched_names: set = set() + for resolved_dir, agents in dir_to_agents.items(): + canonical_agent = fallback_agent if fallback_agent in agents else sorted(agents)[0] + for name in safe_skill_names: + skill_subdir = resolved_dir / name + if not self._validate_skill_subdir( + skill_subdir, create=False, skills_root=resolved_dir + ): + continue + skill_file = skill_subdir / "SKILL.md" + if not skill_file.is_file(): + continue + try: + content = skill_file.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + continue + frontmatter, _ = registrar.parse_frontmatter(content) + skill_metadata = frontmatter.get("metadata") + source = ( + skill_metadata.get("source") + if isinstance(skill_metadata, dict) + else None + ) + if source == marker: + inferred.setdefault(canonical_agent, []).append(name) + matched_names.add(name) + + unmatched = [name for name in safe_skill_names if name not in matched_names] + if unmatched and fallback_agent: + fallback_names = inferred.setdefault(fallback_agent, []) + for name in unmatched: + if name not in fallback_names: + fallback_names.append(name) + + return inferred + + @staticmethod + def _normalize_registered_skills( + value: Any, fallback_agent: Optional[str] = None + ) -> Dict[str, List[str]]: + """Normalize a ``registered_skills`` registry value to per-agent form. + + The registry stores ``registered_skills`` as ``Dict[str, List[str]]`` + (agent name -> skill names actually written for that agent), + mirroring ``registered_commands``. Older registries predate that + provenance and stored a flat ``List[str]`` with no record of which + agent directory the names were written under; since that can't be + recovered, ``fallback_agent`` (when given) attributes the legacy + list to the agent currently being processed so the format + self-migrates on the next write. Without a fallback agent, legacy + lists are dropped rather than guessed at. + + Callers that can identify the owning preset (i.e. have a + ``pack_id``) should prefer :meth:`_infer_legacy_skill_provenance` + for a legacy flat-list value instead, which probes on-disk + provenance rather than assuming ``fallback_agent`` wrote every name. + """ + if isinstance(value, dict): + return { + agent: list(names) + for agent, names in value.items() + if isinstance(agent, str) and isinstance(names, list) + } + if isinstance(value, list) and value and fallback_agent: + return {fallback_agent: [n for n in value if isinstance(n, str)]} + return {} + + def _safe_skills_dir_for_agent(self, agent_name: str) -> Optional[Path]: + """Resolve ``agent_name``'s skills directory, validated for safety. + + Unlike :meth:`_get_skills_dir` (which resolves only the *currently + active* integration via init-options), this resolves an arbitrary + agent's directory from persisted provenance so a preset's skill + registrations can be restored/cleaned up under an agent that isn't + currently active. The candidate directory is validated through the + project's shared symlink/containment guard before any file in it is + touched; directories that don't exist or fail validation are + skipped rather than raising. + """ + from ..agents import CommandRegistrar + from ..shared_infra import _ensure_safe_shared_directory + + if agent_name not in CommandRegistrar.AGENT_CONFIGS: + return None + skills_dir = self._resolve_agent_skills_dir(agent_name) + validation_root = self._skills_validation_root(skills_dir) + if validation_root is None: + return None + try: + _ensure_safe_shared_directory( + validation_root, skills_dir, + create=False, context="preset skills directory", + ) + except (ValueError, OSError): + return None + return skills_dir + + @staticmethod + def _is_safe_registry_skill_name(name: Any) -> bool: + """Validate a registry-provided skill name is a single safe path component. + + ``registered_skills`` entries are persisted registry data, not + derived from the current preset manifest, so a corrupted or + maliciously edited registry could contain an absolute path, a + multi-segment path (containing ``/`` or ``\\``), or a traversal + component (``"."``/``".."``) instead of a plain skill directory + name. Any of these — if joined directly onto a skills directory — + can escape the intended skill subtree while still resolving to a + location inside the project root, which is enough to pass the + parent-directory containment/symlink check alone (#2948). This + centralizes the single boundary check every preset cleanup and + provenance loop that consumes registry-provided skill names must + apply before ever constructing a path from one. + """ + if not isinstance(name, str) or not name: + return False + if name in (".", ".."): + return False + candidate = Path(name) + if candidate.is_absolute(): + return False + if len(candidate.parts) != 1: + return False + if candidate.name != name: + return False + return True + + def _validate_skill_subdir( + self, + skill_subdir: Path, + *, + create: bool, + skills_root: Optional[Path] = None, + ) -> bool: + """Validate a single skill's subdirectory is symlink-free. + + Unlike :meth:`_safe_skills_dir_for_agent` (which only validates the + *parent* skills directory), this validates the skill's own + subdirectory — e.g. ``.claude/skills/speckit-specify`` — so a + symlink planted at that level (with a safe parent) can't be used to + write or delete through to a location outside the project. Shared by + both the registration path (``create=True``, so a missing directory + is created component-by-component under the same guard) and the + restore/removal path (``create=False``, so a missing directory is + left for the caller's own existence check to skip). Returns + ``False`` rather than raising when the path escapes the project + root or crosses a symlink. ``skills_root`` supplies the trusted + agent output boundary for native global skill integrations such as + Hermes; project-local callers default to ``self.project_root``. + """ + from ..shared_infra import ( + _ensure_safe_shared_directory, + _validate_safe_shared_directory, + ) + + validation_root = skills_root or self.project_root + if validation_root.is_symlink(): + return False + try: + if create: + _ensure_safe_shared_directory( + validation_root, skill_subdir, + create=True, context="preset skill directory", + ) + else: + _validate_safe_shared_directory( + validation_root, skill_subdir + ) + except (ValueError, OSError): + return False + return True + + def _unregister_skills( + self, + registered_skills: Union[Dict[str, List[str]], List[str]], + preset_dir: Union[Path, str], + *, + additional_owned_sources: Optional[Dict[str, str]] = None, + restore_from_bundled_core: bool = False, + ) -> Dict[Path, tuple[Optional[str], List[str]]]: + """Restore original SKILL.md files after a preset is removed. + + For each skill that was overridden by the preset, attempts to + regenerate the skill from the core command template. If no core + template exists, the skill directory is removed. + + Args: + restore_from_bundled_core: When True, a missing project-local + core template (the common case — ``specify init`` never + populates ``.specify/templates/commands``) falls back to + the bundled core_pack/repo-root templates so the skill is + restored instead of deleted (#3928). Callers that are + retiring a skill because its command now renders elsewhere + (a command file superseding it) must leave this False so + the skill is removed rather than resurrected with core + content that would duplicate the winning command. + + ``registered_skills`` records exactly which agent directories this + preset actually wrote to (see :meth:`_register_skills`), so removal + restores precisely those directories rather than guessing at every + skill-mode agent that happens to exist on disk. Each directory is + re-resolved and safety-validated at removal time (see + :meth:`_safe_skills_dir_for_agent`) since it may belong to an agent + that isn't currently active. + + Args: + registered_skills: Per-agent skill names written by the preset + (``{agent_name: [skill_name, ...]}``), or a legacy flat + ``List[str]`` from a registry written before this + provenance tracking existed. + preset_dir: The preset's installed directory (may already be deleted). + additional_owned_sources: Generated non-preset source markers + that this cleanup may also replace for specific skill names. + + Returns: + ``{skills_dir: (renderer_agent, managed_skill_names)}`` for + every directory and skill name actually restored or removed. + """ + if not registered_skills: + return {} + + pack_id = preset_dir if isinstance(preset_dir, str) else preset_dir.name + + if isinstance(registered_skills, dict): + from .. import load_init_options + + init_opts = load_init_options(self.project_root) + active_agent = init_opts.get("ai") if isinstance(init_opts, dict) else None + if not isinstance(active_agent, str) or not active_agent: + active_agent = None + + # Multiple integration keys can share the same physical + # directory (e.g. agy/codex/zed all resolve to + # ``.agents/skills``). Restoring that directory once per + # recorded agent would have each pass's agent-specific + # rendering (frontmatter, post-processing) overwrite the + # previous one, with whichever agent is iterated *last* silently + # winning regardless of which agent is actually active. Group + # provenance by resolved directory so each physical directory is + # restored exactly once, using the active agent's renderer when + # it shares that directory (otherwise any recorded owner, + # chosen deterministically). + groups: Dict[Path, Dict[str, Any]] = {} + for agent_name, skill_names in registered_skills.items(): + if not skill_names: + continue + skills_dir = self._safe_skills_dir_for_agent(agent_name) + if skills_dir is None: + continue + group = groups.setdefault(skills_dir, {"agents": [], "names": []}) + group["agents"].append(agent_name) + for name in skill_names: + if ( + self._is_safe_registry_skill_name(name) + and name not in group["names"] + ): + group["names"].append(name) + + restored: Dict[Path, tuple[Optional[str], List[str]]] = {} + for skills_dir, group in groups.items(): + agents = group["agents"] + renderer_agent = ( + active_agent if active_agent in agents else sorted(agents)[0] + ) + mutated_names = self._unregister_skills_in_dir( + group["names"], + skills_dir, + renderer_agent, + pack_id=pack_id, + additional_owned_sources=additional_owned_sources, + restore_from_bundled_core=restore_from_bundled_core, + ) + if mutated_names: + restored[skills_dir] = ( + renderer_agent, + mutated_names, + ) + return restored + + # Legacy flat-list format: no record of which agent directory these + # names were written under, so best-effort restore is limited to the + # currently active agent's directory (the pre-provenance behaviour). + skills_dir = self._get_skills_dir() + if not skills_dir: + return {} + from .. import load_init_options + + init_opts = load_init_options(self.project_root) + if not isinstance(init_opts, dict): + init_opts = {} + selected_ai = init_opts.get("ai") + selected_ai = selected_ai if isinstance(selected_ai, str) else None + safe_names = [ + name + for name in registered_skills + if self._is_safe_registry_skill_name(name) + ] + mutated_names = self._unregister_skills_in_dir( + safe_names, + skills_dir, + selected_ai, + pack_id=pack_id, + additional_owned_sources=additional_owned_sources, + restore_from_bundled_core=restore_from_bundled_core, + ) + return ( + {skills_dir: (selected_ai, mutated_names)} + if mutated_names + else {} + ) + + def _delete_agent_preset_skills( + self, agent_name: str, skill_names: List[str], pack_id: str + ) -> None: + """Delete still-preset-owned skills when an agent is deactivated.""" + skills_dir = self._safe_skills_dir_for_agent(agent_name) + if skills_dir is None: + return + + from ..agents import CommandRegistrar + + registrar = CommandRegistrar() + marker = f"preset:{pack_id}" + override_sources: Dict[str, str] = {} + manifest = PresetResolver(self.project_root)._get_manifest( + self.presets_dir / pack_id + ) + if manifest is not None: + for template in manifest.templates: + command_name = template.get("name") + if ( + template.get("type") == "command" + and isinstance(command_name, str) + ): + for skill_name in self._skill_names_for_command( + command_name + ): + override_sources[skill_name] = ( + f"override:{command_name}" + ) + for skill_name in skill_names: + if not self._is_safe_registry_skill_name(skill_name): + continue + skill_subdir = skills_dir / skill_name + if not self._validate_skill_subdir( + skill_subdir, create=False, skills_root=skills_dir + ): + continue + skill_file = skill_subdir / "SKILL.md" + if not skill_file.is_file(): + continue + try: + content = skill_file.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + continue + frontmatter, _ = registrar.parse_frontmatter(content) + metadata = frontmatter.get("metadata") + source = ( + metadata.get("source") + if isinstance(metadata, dict) + else None + ) + owned_sources = {marker} + override_source = override_sources.get(skill_name) + if override_source: + owned_sources.add(override_source) + if source in owned_sources: + shutil.rmtree(skill_subdir) + + @staticmethod + def _warn_unrestored_skill( + skill_name: str, source_file: Path, exc: BaseException + ) -> None: + """Warn that a skill kept preset content because its restore source is unreadable. + + Skipping the restore is the safe recovery — the alternative branch + deletes the skill outright — but it is still a partial removal: the + preset directory and registry entry go away while this ``SKILL.md`` + keeps the removed preset's content, and reconciliation never revisits + it because the name is left out of ``mutated_names``. Name the skill + and the source so the condition is actionable instead of silent. + """ + import warnings + + warnings.warn( + f"Skill '{skill_name}' still contains the removed preset's content: " + f"its restore source '{source_file}' could not be read " + f"({exc.__class__.__name__}: {exc}). The skill was left in place " + f"rather than deleted. Fix or remove that file and re-run " + f"'specify preset add'/'specify preset remove' to refresh it.", + stacklevel=2, + ) + + def _unregister_skills_in_dir( + self, + skill_names: List[str], + skills_dir: Path, + selected_ai: Optional[str], + *, + pack_id: Optional[str] = None, + additional_owned_sources: Optional[Dict[str, str]] = None, + restore_from_bundled_core: bool = False, + ) -> List[str]: + """Restore original SKILL.md files within a single skills directory. + + Args: + skill_names: List of skill names written by the preset. + skills_dir: The skills directory to restore within. + selected_ai: The agent name that owns ``skills_dir``, used for + placeholder resolution and argument-hint formatting. + additional_owned_sources: Generated non-preset source markers + accepted as owned for specific skill names. + restore_from_bundled_core: See ``_unregister_skills``. + + Returns: + Skill names whose files were restored or removed. + """ + from .. import SKILL_DESCRIPTIONS + from ..agents import CommandRegistrar + from ..integrations import get_integration + from ..shared_infra import _write_shared_text + + # Locate core command templates from the project's installed templates + core_templates_dir = self.project_root / ".specify" / "templates" / "commands" + registrar = CommandRegistrar() + integration = get_integration(selected_ai) if isinstance(selected_ai, str) else None + extension_restore_index = self._build_extension_skill_restore_index() + mutated_names: List[str] = [] + + for skill_name in skill_names: + # Guard against a corrupted/malicious registry entry: a + # registered_skills name is persisted data, not derived from + # the current manifest, so it must be validated as a single, + # relative, non-"."/".." path component before ever being + # joined onto skills_dir. Without this, an absolute name + # discards skills_dir entirely (Path's "/" operator drops the + # left side for an absolute right side) or a multi-component + # name containing ".." can resolve to a different, unrelated + # directory that still happens to be inside the project root + # — passing the containment-only symlink guard below and + # letting removal overwrite/delete it (#2948). + if not self._is_safe_registry_skill_name(skill_name): + continue + + # Derive command name from skill name (speckit-specify -> specify) + short_name = skill_name + if short_name.startswith("speckit-"): + short_name = short_name[len("speckit-"):] + elif short_name.startswith("speckit."): + short_name = short_name[len("speckit."):] + + skill_subdir = skills_dir / skill_name + skill_file = skill_subdir / "SKILL.md" + if not skill_subdir.is_dir(): + continue + # is_dir() follows symlinks, so a symlinked skill subdirectory + # (with a safe, non-symlinked parent) would otherwise slip past + # _safe_skills_dir_for_agent's parent-only check and have + # write_text/rmtree operate through it (#2948). + if not self._validate_skill_subdir( + skill_subdir, create=False, skills_root=skills_dir + ): + continue + if not skill_file.is_file(): + # Only manage directories that contain the expected skill entrypoint. + continue + if pack_id is not None: + try: + current_content = skill_file.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + continue + current_frontmatter, _ = registrar.parse_frontmatter(current_content) + current_metadata = current_frontmatter.get("metadata") + current_source = ( + current_metadata.get("source") + if isinstance(current_metadata, dict) + else None + ) + owned_sources = {f"preset:{pack_id}"} + if additional_owned_sources: + additional_source = additional_owned_sources.get( + skill_name + ) + if additional_source: + owned_sources.add(additional_source) + if current_source not in owned_sources: + continue + + extension_restore = extension_restore_index.get(skill_name) + + # Try to find the core command template. Project-local overrides + # in core_templates_dir take precedence, but that directory is + # rarely populated — the real core commands ship in the bundled + # core_pack (wheel install) or the repo-root templates/ tree + # (source checkout). Callers that want a genuine restore (a + # preset was removed outright, not superseded by another + # renderer) opt into that fallback via restore_from_bundled_core + # so the skill is restored instead of deleted (#3928). An + # installed extension providing a core-named command resolves + # ahead of bundled core elsewhere, so skip the bundled fallback + # when an extension restore exists — otherwise it would win + # over the higher-priority extension layer below. + core_file = core_templates_dir / f"{short_name}.md" + if ( + not core_file.exists() + and restore_from_bundled_core + and extension_restore is None + ): + from .. import _locate_core_pack, _repo_root + + _core_pack = _locate_core_pack() + if _core_pack is not None: + core_file = _core_pack / "commands" / f"{short_name}.md" + else: + core_file = _repo_root() / "templates" / "commands" / f"{short_name}.md" + if not core_file.exists(): + core_file = None + + if core_file: + # Restore from core template. An unreadable/undecodable + # source cannot produce restored content, so leave the + # existing skill untouched rather than leaking a raw + # OSError/UnicodeDecodeError out of `preset remove` — and + # rather than falling through to the rmtree below, which + # would delete a skill precisely when its replacement + # cannot be generated. Matches the `continue` guards above + # (unsafe name, missing subdir, foreign owner), which also + # skip without recording the name as mutated. + try: + content = core_file.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError) as exc: + self._warn_unrestored_skill(skill_name, core_file, exc) + continue + frontmatter, body = registrar.parse_frontmatter(content) + if isinstance(selected_ai, str): + body = registrar.resolve_skill_placeholders( + selected_ai, frontmatter, body, self.project_root + ) + body = self._resolve_skill_command_refs( + body, registrar, selected_ai, self.project_root + ) + + original_desc = frontmatter.get("description", "") + enhanced_desc = original_desc or SKILL_DESCRIPTIONS.get( + short_name, + f"Spec-kit workflow command: {short_name}", + ) + + frontmatter_data = registrar.build_skill_frontmatter( + selected_ai if isinstance(selected_ai, str) else "", + skill_name, + enhanced_desc, + f"templates/commands/{short_name}.md", + ) + registrar.apply_argument_hint(frontmatter, frontmatter_data, integration) + frontmatter_text = dump_frontmatter(frontmatter_data) + skill_title = self._skill_title_from_command(short_name) + skill_content = ( + f"---\n" + f"{frontmatter_text}\n" + f"---\n\n" + f"# Speckit {skill_title} Skill\n\n" + f"{body}\n" + ) + if integration is not None and hasattr(integration, "post_process_skill_content"): + skill_content = integration.post_process_skill_content( + skill_content + ) + _write_shared_text(skills_dir, skill_file, skill_content) + mutated_names.append(skill_name) + continue + + if extension_restore: + # Same boundary as the core-template branch above: an + # unreadable extension source leaves the skill in place + # instead of crashing or being deleted. + try: + content = extension_restore["source_file"].read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError) as exc: + self._warn_unrestored_skill( + skill_name, extension_restore["source_file"], exc + ) + continue + frontmatter, body = registrar.parse_frontmatter(content) + # Mirror the register-time rewrite (#2101): resolve + # extension-relative subdir references (agents/, + # knowledge-base/, etc.) to their installed location before + # the generic placeholder resolution below, otherwise + # restoring after a preset override removal would leave + # bare, unresolvable paths in the skill body. + body = registrar.rewrite_extension_paths( + body, + extension_restore["extension_id"], + extension_restore["extension_dir"], + ) + if isinstance(selected_ai, str): + body = registrar.resolve_skill_placeholders( + selected_ai, frontmatter, body, self.project_root + ) + body = self._resolve_skill_command_refs( + body, registrar, selected_ai, self.project_root + ) + + command_name = extension_restore["command_name"] + title_name = self._skill_title_from_command(command_name) + + frontmatter_data = registrar.build_skill_frontmatter( + selected_ai if isinstance(selected_ai, str) else "", + skill_name, + frontmatter.get("description", f"Extension command: {command_name}"), + extension_restore["source"], + author=extension_restore.get("author", "github-spec-kit"), + ) + registrar.apply_argument_hint(frontmatter, frontmatter_data, integration) + frontmatter_text = dump_frontmatter(frontmatter_data) + skill_content = ( + f"---\n" + f"{frontmatter_text}\n" + f"---\n\n" + f"# {title_name} Skill\n\n" + f"{body}\n" + ) + if integration is not None and hasattr(integration, "post_process_skill_content"): + skill_content = integration.post_process_skill_content( + skill_content + ) + _write_shared_text(skills_dir, skill_file, skill_content) + mutated_names.append(skill_name) + else: + # No core or extension template — remove the skill entirely + shutil.rmtree(skill_subdir) + mutated_names.append(skill_name) + + return mutated_names diff --git a/src/specify_cli/presets/_manifest.py b/src/specify_cli/presets/_manifest.py new file mode 100644 index 0000000000..b5a7ff03c3 --- /dev/null +++ b/src/specify_cli/presets/_manifest.py @@ -0,0 +1,438 @@ +"""Preset manifest validation and domain errors (private implementation).""" + +import hashlib +import os +import re +from pathlib import Path +from typing import Any, Dict, List + +import yaml +from packaging import version as pkg_version +from packaging.specifiers import InvalidSpecifier, SpecifierSet + + +class PresetError(Exception): + """Base exception for preset-related errors.""" + pass + + +class PresetValidationError(PresetError): + """Raised when preset manifest validation fails.""" + pass + + +class PresetCompatibilityError(PresetError): + """Raised when preset is incompatible with current environment.""" + pass + + +VALID_PRESET_TEMPLATE_TYPES = {"template", "command", "script"} +VALID_PRESET_STRATEGIES = {"replace", "prepend", "append", "wrap"} +# Scripts only support replace and wrap (prepend/append don't make semantic sense for executable code) +VALID_SCRIPT_STRATEGIES = {"replace", "wrap"} + + +class PresetManifest: + """Represents and validates a preset manifest (preset.yml).""" + + SCHEMA_VERSION = "1.0" + REQUIRED_FIELDS = ["schema_version", "preset", "requires", "provides"] + + def __init__(self, manifest_path: Path): + """Load and validate preset manifest. + + Args: + manifest_path: Path to preset.yml file + + Raises: + PresetValidationError: If manifest is invalid + """ + self.path = manifest_path + self.data = self._load_yaml(manifest_path) + self._validate() + + def _load_yaml(self, path: Path) -> dict: + """Load YAML file safely.""" + try: + with open(path, 'r', encoding='utf-8') as f: + data = yaml.safe_load(f) + except yaml.YAMLError as e: + raise PresetValidationError(f"Invalid YAML in {path}: {e}") + except FileNotFoundError: + raise PresetValidationError(f"Manifest not found: {path}") + except UnicodeDecodeError as e: + raise PresetValidationError( + f"Manifest is not valid UTF-8: {path} ({e.reason} at byte {e.start})" + ) + except OSError as e: + raise PresetValidationError(f"Could not read manifest {path}: {e}") + if data is None: + return {} + if not isinstance(data, dict): + raise PresetValidationError( + f"Manifest must be a YAML mapping, got {type(data).__name__}: {path}" + ) + return data + + def _validate(self): + """Validate manifest structure and required fields.""" + # Check required top-level fields + for field in self.REQUIRED_FIELDS: + if field not in self.data: + raise PresetValidationError(f"Missing required field: {field}") + + # Validate schema version + if self.data["schema_version"] != self.SCHEMA_VERSION: + raise PresetValidationError( + f"Unsupported schema version: {self.data['schema_version']} " + f"(expected {self.SCHEMA_VERSION})" + ) + + for section in ("preset", "requires", "provides"): + if not isinstance(self.data[section], dict): + raise PresetValidationError( + f"Invalid {section}: expected a mapping" + ) + + # Validate preset metadata + pack = self.data["preset"] + # Check presence AND type: the format/version checks below feed these + # values straight to ``re.match`` and ``packaging.Version``, both of + # which raise a bare TypeError on a non-string. YAML makes that an easy + # authoring slip -- unquoted ``version: 1.0`` parses as a float and + # ``id: 2`` as an int -- and TypeError is not a PresetValidationError, + # so it escapes every caller that already handles a malformed manifest + # (see list_installed()'s "Corrupted preset" fallback, which catches + # PresetValidationError only, making one bad preset exit ``specify + # preset list`` with a raw traceback and hide the healthy ones). + # Mirrors the sibling IntegrationDescriptor, which already type-checks + # the same four fields. + for field in ["id", "name", "version", "description"]: + if field not in pack: + raise PresetValidationError(f"Missing preset.{field}") + if not isinstance(pack[field], str): + raise PresetValidationError( + f"Invalid preset.{field}: expected a string, " + f"got {type(pack[field]).__name__}" + ) + + # Validate pack ID format + if not re.match(r'^[a-z0-9-]+$', pack["id"]): + raise PresetValidationError( + f"Invalid preset ID '{pack['id']}': " + "must be lowercase alphanumeric with hyphens only" + ) + + # Validate semantic version + try: + pkg_version.Version(pack["version"]) + except pkg_version.InvalidVersion: + raise PresetValidationError(f"Invalid version: {pack['version']}") + + # Validate requires section + requires = self.data["requires"] + if "speckit_version" not in requires: + raise PresetValidationError("Missing requires.speckit_version") + # Presence alone is not enough: check_compatibility() feeds this value to + # ``SpecifierSet(required)``, guarded only by ``except InvalidSpecifier``, + # which a non-string escapes two different ways. A float/int/bool/None + # raises TypeError from the constructor, while a list or dict is an + # *iterable*, so SpecifierSet accepts it and the failure surfaces much + # later as ``AttributeError: 'str' object has no attribute 'filter'`` from + # inside .contains(). Neither is a PresetCompatibilityError, so both + # bypass the CLI's "Compatibility Error" handler and exit 1 with a raw + # traceback naming no field. An unquoted ``speckit_version: 1.0`` is an + # easy YAML slip. Mirrors the sibling IntegrationDescriptor, which already + # requires a non-empty string here. + if ( + not isinstance(requires["speckit_version"], str) + or not requires["speckit_version"].strip() + ): + raise PresetValidationError( + "Invalid requires.speckit_version: expected a non-empty string, " + f"got {type(requires['speckit_version']).__name__}" + ) + + # Validate the optional extension dependency list. A preset that + # overrides commands calling into an extension is inert without it, and + # until now the only place that could be said was the README -- see + # issue #4231. Absent means "no dependencies", so every existing preset + # stays valid. + if "extensions" in requires: + self._validate_requires_extensions(requires["extensions"]) + + # Validate provides section + provides = self.data["provides"] + if "templates" not in provides: + raise PresetValidationError( + "Preset must provide at least one template" + ) + + # Validate templates. Guard the container and each entry's shape so a + # malformed third-party preset.yml (e.g. ``templates: 5`` or + # ``templates: [null]``) raises a clean PresetValidationError the + # install handler already catches, instead of a raw TypeError + # ('int'/'NoneType' object is not iterable) that escapes to an + # unhandled traceback. Mirrors the sibling ExtensionManifest guards. + # + # Order matters: the container's TYPE is checked before its emptiness, + # so a FALSY non-list (``templates: 0``/``false``/``null``/``''``/``{}``) + # reports the accurate type error rather than the misleading "must + # provide at least one template". An empty list still reports the + # latter, since that genuinely is a list with no templates. + templates = provides["templates"] + if not isinstance(templates, list): + raise PresetValidationError( + "Invalid provides.templates: expected a list" + ) + if not templates: + raise PresetValidationError( + "Preset must provide at least one template" + ) + seen_name_types: set[tuple[str, str]] = set() + for tmpl in templates: + if not isinstance(tmpl, dict): + raise PresetValidationError( + "Each template entry in 'provides.templates' must be a mapping" + ) + if "type" not in tmpl or "name" not in tmpl or "file" not in tmpl: + raise PresetValidationError( + "Template missing 'type', 'name', or 'file'" + ) + + # 'name' feeds re.match and 'file' feeds os.path.normpath below; + # both raise a bare TypeError on a non-string, which is not a + # PresetValidationError and so escapes the callers that handle a + # malformed manifest. The sibling extension manifest already + # rejects a non-string command 'file' via + # relative_extension_path_violation(). + for field in ("type", "name", "file"): + if not isinstance(tmpl[field], str): + raise PresetValidationError( + f"Invalid template {field}: expected a string, " + f"got {type(tmpl[field]).__name__}" + ) + + if tmpl["type"] not in VALID_PRESET_TEMPLATE_TYPES: + raise PresetValidationError( + f"Invalid template type '{tmpl['type']}': " + f"must be one of {sorted(VALID_PRESET_TEMPLATE_TYPES)}" + ) + + # PresetResolver._manifest_declared_template returns the first + # 'provides.templates' entry matching a given (name, type) pair, so + # a later duplicate would be silently unreachable while still being + # counted by PresetManifest.templates. Reject at validation time + # instead, mirroring the sibling fix for ExtensionManifest's + # provides.templates/scripts (#4016). + name_type = (tmpl["name"], tmpl["type"]) + if name_type in seen_name_types: + raise PresetValidationError( + f"Duplicate template name '{tmpl['name']}' of type " + f"'{tmpl['type']}' in 'provides.templates'" + ) + seen_name_types.add(name_type) + + # Validate file path safety: must be relative, no parent traversal + file_path = tmpl["file"] + normalized = os.path.normpath(file_path) + if os.path.isabs(normalized) or normalized.startswith(".."): + raise PresetValidationError( + f"Invalid template file path '{file_path}': " + "must be a relative path within the preset directory" + ) + + # Validate strategy field (optional, defaults to "replace") + strategy = tmpl.get("strategy", "replace") + if not isinstance(strategy, str): + raise PresetValidationError( + f"Invalid strategy value: must be a string, " + f"got {type(strategy).__name__}" + ) + strategy = strategy.lower() + # Persist normalized value so downstream code sees lowercase + if "strategy" in tmpl: + tmpl["strategy"] = strategy + if strategy not in VALID_PRESET_STRATEGIES: + raise PresetValidationError( + f"Invalid strategy '{strategy}': " + f"must be one of {sorted(VALID_PRESET_STRATEGIES)}" + ) + if tmpl["type"] == "script" and strategy not in VALID_SCRIPT_STRATEGIES: + raise PresetValidationError( + f"Invalid strategy '{strategy}' for script: " + f"scripts only support {sorted(VALID_SCRIPT_STRATEGIES)}" + ) + + # Validate template name format + if tmpl["type"] == "command": + # Commands use dot notation (e.g. speckit.specify) + if not re.match(r'^[a-z0-9.-]+$', tmpl["name"]): + raise PresetValidationError( + f"Invalid command name '{tmpl['name']}': " + "must be lowercase alphanumeric with hyphens and dots only" + ) + else: + if not re.match(r'^[a-z0-9-]+$', tmpl["name"]): + raise PresetValidationError( + f"Invalid template name '{tmpl['name']}': " + "must be lowercase alphanumeric with hyphens only" + ) + + @property + def id(self) -> str: + """Get preset ID.""" + return self.data["preset"]["id"] + + @property + def name(self) -> str: + """Get preset name.""" + return self.data["preset"]["name"] + + @property + def version(self) -> str: + """Get preset version.""" + return self.data["preset"]["version"] + + @property + def description(self) -> str: + """Get preset description.""" + return self.data["preset"]["description"] + + @property + def author(self) -> str: + """Get preset author.""" + return self.data["preset"].get("author", "") + + @staticmethod + def _validate_requires_extensions(declared: Any) -> None: + """Validate the optional ``requires.extensions`` list. + + Accepts either a bare extension id or a mapping carrying an optional + version specifier and an optional ``required`` flag: + + .. code-block:: yaml + + requires: + extensions: + - speckit-inventory + - id: other-ext + version: ">=1.2.0" + required: false + + Raises: + PresetValidationError: If the list or any entry is malformed. + """ + if not isinstance(declared, list): + raise PresetValidationError( + "Invalid requires.extensions: expected a list, " + f"got {type(declared).__name__}" + ) + + for index, entry in enumerate(declared): + label = f"requires.extensions[{index}]" + + if isinstance(entry, str): + entry = {"id": entry} + elif not isinstance(entry, dict): + raise PresetValidationError( + f"Invalid {label}: expected a string or a mapping, " + f"got {type(entry).__name__}" + ) + + if "id" not in entry: + raise PresetValidationError(f"Missing {label}.id") + extension_id = entry["id"] + if not isinstance(extension_id, str): + raise PresetValidationError( + f"Invalid {label}.id: expected a string, " + f"got {type(extension_id).__name__}" + ) + # Same id shape the extension loader enforces, so a dependency can + # never name something that could not be installed in the first + # place. fullmatch rather than match with an anchored pattern: `$` + # also matches before a trailing newline, so "demo-ext\n" would + # otherwise validate here while PresetResolver._is_safe_registry_id + # (which uses fullmatch) rejects it, and the newline would land in + # a suggested command. + if not re.fullmatch(r'[a-z0-9-]+', extension_id): + raise PresetValidationError( + f"Invalid {label}.id {extension_id!r}: " + "must be lowercase alphanumeric with hyphens only" + ) + + if "version" in entry: + constraint = entry["version"] + # Mirrors the requires.speckit_version reasoning: a non-string + # escapes InvalidSpecifier two ways -- scalars raise TypeError + # from the constructor, and a list/dict is iterable so it + # constructs and only fails later inside .contains(). + if not isinstance(constraint, str) or not constraint.strip(): + raise PresetValidationError( + f"Invalid {label}.version: expected a non-empty string, " + f"got {type(constraint).__name__}" + ) + try: + SpecifierSet(constraint) + except InvalidSpecifier: + raise PresetValidationError( + f"Invalid {label}.version '{constraint}': " + "not a valid version specifier" + ) + + if "required" in entry and not isinstance(entry["required"], bool): + raise PresetValidationError( + f"Invalid {label}.required: expected a boolean, " + f"got {type(entry['required']).__name__}" + ) + + @property + def requires_speckit_version(self) -> str: + """Get required spec-kit version range.""" + return self.data["requires"]["speckit_version"] + + @property + def requires_extensions(self) -> List[Dict[str, Any]]: + """Get declared extension dependencies, normalized to mappings. + + Returns: + One entry per dependency with ``id``, ``version`` (``None`` when + unconstrained), and ``required`` (defaulting to ``True``). Empty + when the manifest declares no dependencies. + """ + declared = self.data["requires"].get("extensions") + if not isinstance(declared, list): + return [] + + normalized: List[Dict[str, Any]] = [] + for entry in declared: + if isinstance(entry, str): + entry = {"id": entry} + if not isinstance(entry, dict) or not isinstance(entry.get("id"), str): + continue + normalized.append( + { + "id": entry["id"], + "version": entry.get("version"), + "required": entry.get("required", True), + } + ) + return normalized + + @property + def templates(self) -> List[Dict[str, Any]]: + """Get list of provided templates.""" + return self.data["provides"]["templates"] + + @property + def tags(self) -> List[str]: + """Get preset tags.""" + return self.data.get("tags", []) + + def get_hash(self) -> str: + """Calculate SHA256 hash of manifest file.""" + h = hashlib.sha256() + with open(self.path, 'rb') as f: + for chunk in iter(lambda: f.read(8192), b""): + h.update(chunk) + return f"sha256:{h.hexdigest()}" diff --git a/src/specify_cli/presets/_registry.py b/src/specify_cli/presets/_registry.py new file mode 100644 index 0000000000..766e7a694b --- /dev/null +++ b/src/specify_cli/presets/_registry.py @@ -0,0 +1,245 @@ +"""Installed preset registry (private implementation).""" + +import copy +import json +from datetime import datetime, timezone +from pathlib import Path +from typing import Dict, List, Optional + +from ..extensions import normalize_priority + + +class PresetRegistry: + """Manages the registry of installed presets.""" + + REGISTRY_FILE = ".registry" + SCHEMA_VERSION = "1.0" + + def __init__(self, packs_dir: Path): + """Initialize registry. + + Args: + packs_dir: Path to .specify/presets/ directory + """ + self.packs_dir = packs_dir + self.registry_path = packs_dir / self.REGISTRY_FILE + self.data = self._load() + + def _load(self) -> dict: + """Load registry from disk.""" + if not self.registry_path.exists(): + return { + "schema_version": self.SCHEMA_VERSION, + "presets": {} + } + + try: + with open(self.registry_path, 'r', encoding='utf-8') as f: + data = json.load(f) + # Validate loaded data is a dict (handles corrupted registry files) + if not isinstance(data, dict): + return { + "schema_version": self.SCHEMA_VERSION, + "presets": {} + } + # Normalize presets field (handles corrupted presets value) + if not isinstance(data.get("presets"), dict): + data["presets"] = {} + return data + except (json.JSONDecodeError, UnicodeDecodeError, FileNotFoundError): + # Corrupted or missing registry, start fresh. A registry whose + # bytes cannot be decoded as UTF-8 is the same corruption class + # as malformed JSON — only the exception type differs. OSError is + # deliberately not caught: the data may be intact on disk, and + # starting fresh would let a later _save() wipe it. + return { + "schema_version": self.SCHEMA_VERSION, + "presets": {} + } + + def _save(self): + """Save registry to disk.""" + self.packs_dir.mkdir(parents=True, exist_ok=True) + with open(self.registry_path, 'w', encoding='utf-8') as f: + json.dump(self.data, f, indent=2) + + def add(self, pack_id: str, metadata: dict): + """Add preset to registry. + + Args: + pack_id: Preset ID + metadata: Pack metadata (version, source, etc.) + """ + self.data["presets"][pack_id] = { + **copy.deepcopy(metadata), + "installed_at": datetime.now(timezone.utc).isoformat() + } + self._save() + + def remove(self, pack_id: str): + """Remove preset from registry. + + Args: + pack_id: Preset ID + """ + packs = self.data.get("presets") + if not isinstance(packs, dict): + return + if pack_id in packs: + del packs[pack_id] + self._save() + + def update(self, pack_id: str, updates: dict): + """Update preset metadata in registry. + + Merges the provided updates with the existing entry, preserving any + fields not specified. The installed_at timestamp is always preserved + from the original entry. + + Args: + pack_id: Preset ID + updates: Partial metadata to merge into existing metadata + + Raises: + KeyError: If preset is not installed + """ + packs = self.data.get("presets") + if not isinstance(packs, dict) or pack_id not in packs: + raise KeyError(f"Preset '{pack_id}' not found in registry") + existing = packs[pack_id] + # Handle corrupted registry entries (e.g., string/list instead of dict) + if not isinstance(existing, dict): + existing = {} + # Merge: existing fields preserved, new fields override (deep copy to prevent caller mutation) + merged = {**existing, **copy.deepcopy(updates)} + # Always preserve original installed_at based on key existence, not truthiness, + # to handle cases where the field exists but may be falsy (legacy/corruption) + if "installed_at" in existing: + merged["installed_at"] = existing["installed_at"] + else: + # If not present in existing, explicitly remove from merged if caller provided it + merged.pop("installed_at", None) + packs[pack_id] = merged + self._save() + + def restore(self, pack_id: str, metadata: dict): + """Restore preset metadata to registry without modifying timestamps. + + Use this method for rollback scenarios where you have a complete backup + of the registry entry (including installed_at) and want to restore it + exactly as it was. + + Args: + pack_id: Preset ID + metadata: Complete preset metadata including installed_at + + Raises: + ValueError: If metadata is None or not a dict + """ + if metadata is None or not isinstance(metadata, dict): + raise ValueError(f"Cannot restore '{pack_id}': metadata must be a dict") + # Ensure presets dict exists (handle corrupted registry) + if not isinstance(self.data.get("presets"), dict): + self.data["presets"] = {} + self.data["presets"][pack_id] = copy.deepcopy(metadata) + self._save() + + def get(self, pack_id: str) -> Optional[dict]: + """Get preset metadata from registry. + + Returns a deep copy to prevent callers from accidentally mutating + nested internal registry state without going through the write path. + + Args: + pack_id: Preset ID + + Returns: + Deep copy of preset metadata, or None if not found or corrupted + """ + packs = self.data.get("presets") + if not isinstance(packs, dict): + return None + entry = packs.get(pack_id) + # Return None for missing or corrupted (non-dict) entries + if entry is None or not isinstance(entry, dict): + return None + return copy.deepcopy(entry) + + def list(self) -> Dict[str, dict]: + """Get all installed presets with valid metadata. + + Returns a deep copy of presets with dict metadata only. + Corrupted entries (non-dict values) are filtered out. + + Returns: + Dictionary of pack_id -> metadata (deep copies), empty dict if corrupted + """ + packs = self.data.get("presets", {}) or {} + if not isinstance(packs, dict): + return {} + # Filter to only valid dict entries to match type contract + return { + pack_id: copy.deepcopy(meta) + for pack_id, meta in packs.items() + if isinstance(meta, dict) + } + + def keys(self) -> set: + """Get all preset IDs including corrupted entries. + + Lightweight method that returns IDs without deep-copying metadata. + Use this when you only need to check which presets are tracked. + + Returns: + Set of preset IDs (includes corrupted entries) + """ + packs = self.data.get("presets", {}) or {} + if not isinstance(packs, dict): + return set() + return set(packs.keys()) + + def list_by_priority(self, include_disabled: bool = False) -> List[tuple]: + """Get all installed presets sorted by priority. + + Lower priority number = higher precedence (checked first). + Presets with equal priority are sorted alphabetically by ID + for deterministic ordering. + + Args: + include_disabled: If True, include disabled presets. Default False. + + Returns: + List of (pack_id, metadata_copy) tuples sorted by priority. + Metadata is deep-copied to prevent accidental mutation. + """ + packs = self.data.get("presets", {}) or {} + if not isinstance(packs, dict): + packs = {} + sortable_packs = [] + for pack_id, meta in packs.items(): + if not isinstance(meta, dict): + continue + # Skip disabled presets unless explicitly requested + if not include_disabled and not meta.get("enabled", True): + continue + metadata_copy = copy.deepcopy(meta) + metadata_copy["priority"] = normalize_priority(metadata_copy.get("priority", 10)) + sortable_packs.append((pack_id, metadata_copy)) + return sorted( + sortable_packs, + key=lambda item: (item[1]["priority"], item[0]), + ) + + def is_installed(self, pack_id: str) -> bool: + """Check if preset is installed. + + Args: + pack_id: Preset ID + + Returns: + True if pack is installed, False if not or registry corrupted + """ + packs = self.data.get("presets") + if not isinstance(packs, dict): + return False + return pack_id in packs diff --git a/src/specify_cli/presets/_resolver.py b/src/specify_cli/presets/_resolver.py new file mode 100644 index 0000000000..ea1c4e03f9 --- /dev/null +++ b/src/specify_cli/presets/_resolver.py @@ -0,0 +1,949 @@ +"""Layered preset and extension resolution (private implementation).""" + +import re +from pathlib import Path +from typing import Any, Dict, List, Optional + +import yaml + +from .._utils import dump_frontmatter +from ..extensions import ExtensionRegistry, normalize_priority +from ._manifest import VALID_PRESET_STRATEGIES, PresetManifest, PresetValidationError +from ._registry import PresetRegistry + + +class PresetResolver: + """Resolves template names to file paths using a priority stack. + + Resolution order: + 1. .specify/templates/overrides/ - Project-local overrides + 2. .specify/presets// - Installed presets + 3. .specify/extensions//templates/ - Extension-provided templates + 4. .specify/templates/ - Core templates (shipped with Spec Kit) + """ + + def __init__(self, project_root: Path): + """Initialize preset resolver. + + Args: + project_root: Path to project root directory + """ + self.project_root = project_root + self.templates_dir = project_root / ".specify" / "templates" + self.presets_dir = project_root / ".specify" / "presets" + self.overrides_dir = self.templates_dir / "overrides" + self.extensions_dir = project_root / ".specify" / "extensions" + self._manifest_cache: Dict[str, Optional["PresetManifest"]] = {} + + def _get_manifest(self, pack_dir: Path) -> Optional["PresetManifest"]: + """Get a cached preset manifest, parsing it on first access.""" + key = str(pack_dir) + if key not in self._manifest_cache: + manifest_path = pack_dir / "preset.yml" + if manifest_path.exists(): + try: + self._manifest_cache[key] = PresetManifest(manifest_path) + except PresetValidationError: + self._manifest_cache[key] = None + else: + self._manifest_cache[key] = None + return self._manifest_cache[key] + + @staticmethod + def _is_safe_registry_id(value: object) -> bool: + return isinstance(value, str) and re.fullmatch(r"[a-z0-9-]+", value) is not None + + def _get_all_presets_by_priority(self) -> List[tuple[str, dict]]: + registry = PresetRegistry(self.presets_dir) + return [ + (pack_id, metadata) + for pack_id, metadata in registry.list_by_priority() + if self._is_safe_registry_id(pack_id) + ] + + def _manifest_declared_template( + self, pack_dir: Path, template_name: str, template_type: str + ) -> tuple[dict | None, Path | None]: + """Resolve a preset's manifest-declared template entry and usable file. + + Returns ``(entry, candidate)``: + - ``entry`` is the matching ``provides.templates`` mapping, or ``None`` if + the manifest is absent or does not list this ``(name, type)``. + - ``candidate`` is the declared ``file:`` resolved under ``pack_dir`` IFF + it is a regular file (``is_file()``); ``None`` otherwise — a missing, + empty, or non-file (e.g. directory) declaration yields ``(entry, None)``. + + The manifest is authoritative: when it declares a template (``entry`` is + not ``None``) but the file is unusable (``candidate`` is ``None``), + callers must NOT fall back to the convention lookup — that would mask a + typo or pick up an undeclared file. Shared by ``resolve()`` and + ``collect_all_layers()`` so their manifest-first resolution cannot + silently diverge again (the divergence this fix addressed). + """ + manifest = self._get_manifest(pack_dir) + if not manifest: + return None, None + for tmpl in manifest.templates: + if tmpl.get("name") == template_name and tmpl.get("type") == template_type: + file_path = tmpl.get("file") + if file_path: + manifest_candidate = pack_dir / file_path + return tmpl, ( + manifest_candidate if manifest_candidate.is_file() else None + ) + return tmpl, None + return None, None + + def _extension_manifest_declared_template( + self, ext_dir: Path, template_name: str, template_type: str + ) -> tuple[dict | None, Path | None]: + """Resolve an extension's manifest-declared command/template/script entry and usable file. + + Mirrors ``_manifest_declared_template`` (for presets): returns ``(entry, candidate)`` + where ``entry`` is the matching ``provides.`` mapping, or ``None`` if the + extension has no (valid) manifest or doesn't declare this ``(name, type)``. + ``candidate`` is the declared ``file:`` resolved under ``ext_dir`` IFF it is a + regular file that stays within ``ext_dir`` (guards against path traversal via a + malformed manifest, mirroring ``resolve_extension_command_via_manifest``); + ``None`` otherwise. + + The manifest is authoritative: when ``entry`` is not ``None`` but ``candidate`` is + ``None``, callers must NOT fall back to convention-based lookup — that would mask + a typo or pick up an undeclared file. Shared by ``resolve()`` and + ``collect_all_layers()`` so their manifest-first resolution cannot silently + diverge (the divergence flagged in review on #4012). + """ + if template_type not in ("command", "template", "script"): + return None, None + ext_manifest_path = ext_dir / "extension.yml" + if not ext_manifest_path.exists(): + return None, None + from ..extensions import ExtensionManifest + from ..extensions import ValidationError as ExtValidationError + + try: + ext_manifest = ExtensionManifest(ext_manifest_path) + except (ExtValidationError, yaml.YAMLError, OSError, TypeError, AttributeError): + return None, None + if template_type == "command": + entries = ext_manifest.commands + elif template_type == "template": + entries = ext_manifest.templates + else: + entries = ext_manifest.scripts + for entry in entries: + if entry.get("name") != template_name: + continue + file_rel = entry.get("file") + if not file_rel: + return entry, None + rel_path = Path(file_rel) + if rel_path.is_absolute(): + return entry, None + candidate = ext_dir / rel_path + try: + # Resolve only for the containment check, not for the + # returned path -- resolving the returned path would follow + # symlinks in ext_dir's ancestors (e.g. a symlinked tmp dir + # on macOS) and diverge from the unresolved paths convention + # lookup returns for the same directory. + candidate.resolve().relative_to(ext_dir.resolve()) # raises ValueError if outside + except (OSError, ValueError): + return entry, None + return entry, (candidate if candidate.is_file() else None) + return None, None + + 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) + # Fail closed on a corrupt registry. ExtensionRegistry._load() recovers + # by normalizing an unreadable registry to an empty mapping, which would + # otherwise cause the directory scan below to admit every on-disk + # directory as an unregistered, enabled extension — a fail-open path + # that could supply constitution content from an invalid registry state. + if registry.is_corrupt(): + raise PresetValidationError( + f"Invalid extension registry {registry.registry_path}: " + "refusing to enumerate extensions" + ) + # 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: + if not self._is_safe_registry_id(ext_id): + continue + # 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 not self._is_safe_registry_id(ext_dir.name): + 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 + + @staticmethod + def _core_stem(template_name: str) -> Optional[str]: + """Extract the stem for core command lookup. + + Commands use dot notation (e.g. ``speckit.specify``), but core + command files are named by stem (e.g. ``specify.md``). Returns + the stem if *template_name* follows the ``speckit.`` pattern, + or ``None`` otherwise. + """ + if template_name.startswith("speckit."): + return template_name[len("speckit."):] + return None + + def resolve( + self, + template_name: str, + template_type: str = "template", + skip_presets: bool = False, + ) -> Optional[Path]: + """Resolve a template name to its file path. + + Walks the priority stack and returns the first match. + + Args: + template_name: Template name (e.g., "spec-template") + template_type: Template type ("template", "command", or "script") + skip_presets: When True, skip tier 2 (installed presets). Use + resolve_core() as the preferred caller-facing API for this. + + Returns: + Path to the resolved template file, or None if not found + """ + # Determine subdirectory based on template type + if template_type == "template": + subdirs = ["templates", ""] + elif template_type == "command": + subdirs = ["commands"] + elif template_type == "script": + subdirs = ["scripts"] + 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 + 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 not skip_presets and self.presets_dir.exists(): + for pack_id, _metadata in self._get_all_presets_by_priority(): + pack_dir = self.presets_dir / pack_id + # The preset manifest is authoritative: if it declares this + # template with an explicit ``file:``, resolve to that path — + # and do NOT fall back to convention when it's missing, to + # avoid masking typos or picking up an undeclared file. Only + # when the manifest is absent or doesn't list this template do + # we use the convention-based subdir lookup. Mirrors + # collect_all_layers()/resolve_content() so resolve() and + # resolve_with_source() agree with them instead of returning + # the core template (or a stray convention file). + entry, manifest_candidate = self._manifest_declared_template( + pack_dir, template_name, template_type + ) + if manifest_candidate is not None: + return manifest_candidate + if entry is not None: + # Manifest declares this template but the file is missing, + # non-file (e.g. a directory), or an empty/falsey ``file`` + # value. The manifest is authoritative, so skip this pack's + # convention fallback rather than mask a typo — mirrors + # collect_all_layers(). + continue + 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 + # The extension manifest is authoritative, same as preset manifests + # above: check it before convention-based lookup so a declared entry + # at a non-conventional path wins over a stale conventional file. + entry, manifest_candidate = self._extension_manifest_declared_template( + ext_dir, template_name, template_type + ) + if manifest_candidate is not None: + return manifest_candidate + if entry is not None: + 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 + + # Priority 4: Core templates + if template_type == "template": + core = self.templates_dir / f"{template_name}.md" + if core.exists(): + return core + elif template_type == "command": + core = self.templates_dir / "commands" / f"{template_name}.md" + if core.exists(): + return core + # Fallback: speckit. → .md + stem = self._core_stem(template_name) + if stem: + core = self.templates_dir / "commands" / f"{stem}.md" + if core.exists(): + return core + elif template_type == "script": + core = self.templates_dir / "scripts" / f"{template_name}{ext}" + if core.exists(): + return core + + # Priority 5: Bundled core_pack (wheel install) or repo-root templates + # (source-checkout / editable install). This is the canonical home for + # speckit's built-in command/template files and must always be checked + # so that strategy:wrap presets can locate {CORE_TEMPLATE}. + from specify_cli import ( # local import to avoid cycles + _locate_core_pack, + _repo_root, + ) + _core_pack = _locate_core_pack() + if _core_pack is not None: + # Wheel install path + if template_type == "template": + candidate = _core_pack / "templates" / f"{template_name}.md" + elif template_type == "command": + candidate = _core_pack / "commands" / f"{template_name}.md" + if not candidate.exists(): + stem = self._core_stem(template_name) + if stem: + candidate = _core_pack / "commands" / f"{stem}.md" + elif template_type == "script": + candidate = _core_pack / "scripts" / f"{template_name}{ext}" + else: + candidate = _core_pack / f"{template_name}.md" + if candidate.exists(): + return candidate + else: + # Source-checkout / editable install: templates live at repo root + repo_root = _repo_root() + if template_type == "template": + candidate = repo_root / "templates" / f"{template_name}.md" + elif template_type == "command": + candidate = repo_root / "templates" / "commands" / f"{template_name}.md" + if not candidate.exists(): + stem = self._core_stem(template_name) + if stem: + candidate = repo_root / "templates" / "commands" / f"{stem}.md" + elif template_type == "script": + candidate = repo_root / "scripts" / f"{template_name}{ext}" + else: + candidate = repo_root / f"{template_name}.md" + if candidate.exists(): + return candidate + + return None + + def resolve_core( + self, + template_name: str, + template_type: str = "template", + ) -> Optional[Path]: + """Resolve while skipping installed presets (tier 2). + + Searches tiers 1, 3, 4, and 5 (bundled core_pack / repo-root fallback). + Use when resolving {CORE_TEMPLATE} to guarantee the result is actual + base content, never another preset's wrap output. + """ + return self.resolve(template_name, template_type, skip_presets=True) + + def resolve_extension_command_via_manifest(self, cmd_name: str) -> Optional[Path]: + """Resolve an extension command by consulting installed extension manifests. + + Walks installed extension directories in priority order, loads each + extension.yml via ExtensionManifest, and looks up the command by its + declared name to find the actual file path. This is necessary because + the manifest's ``provides.commands[].file`` field is authoritative and + may differ from the command name + (e.g. ``speckit.selftest.extension`` → ``commands/selftest.md``). + + Returns None if no manifest maps the given command name, so the caller + can fall back to the name-based lookup. + """ + if not self.extensions_dir.exists(): + return None + + from ..extensions import ExtensionManifest, ValidationError + + for _priority, ext_id, _metadata in self._get_all_extensions_by_priority(): + ext_dir = self.extensions_dir / ext_id + manifest_path = ext_dir / "extension.yml" + if not manifest_path.is_file(): + continue + try: + manifest = ExtensionManifest(manifest_path) + except (ValidationError, OSError, TypeError, AttributeError): + continue + for cmd_info in manifest.commands: + if cmd_info.get("name") != cmd_name: + continue + file_rel = cmd_info.get("file") + if not file_rel: + continue + # Mirror the containment check in ExtensionManager to guard against + # path traversal via a malformed manifest (e.g. file: ../../AGENTS.md). + cmd_path = Path(file_rel) + if cmd_path.is_absolute(): + continue + try: + ext_root = ext_dir.resolve() + candidate = (ext_root / cmd_path).resolve() + candidate.relative_to(ext_root) # raises ValueError if outside + except (OSError, ValueError): + continue + if candidate.is_file(): + 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 + """ + # Delegate to resolve() for the actual lookup, then determine source + resolved = self.resolve(template_name, template_type) + if resolved is None: + return None + + 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(): + for pack_id, metadata in self._get_all_presets_by_priority(): + pack_dir = self.presets_dir / pack_id + try: + resolved.relative_to(pack_dir) + version = metadata.get("version", "?") + return { + "path": resolved_str, + "source": f"{pack_id} v{version}", + } + except ValueError: + continue + + 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 + try: + resolved.relative_to(ext_dir) + if ext_meta: + version = ext_meta.get("version", "?") + return { + "path": resolved_str, + "source": f"extension:{ext_id} v{version}", + } + else: + return { + "path": resolved_str, + "source": f"extension:{ext_id} (unregistered)", + } + except ValueError: + continue + + return {"path": resolved_str, "source": "core"} + + def collect_all_layers( + self, + template_name: str, + template_type: str = "template", + ) -> List[Dict[str, Any]]: + """Collect all layers in the priority stack for a template. + + Returns layers from highest priority (checked first) to lowest priority. + Each layer is a dict with 'path', 'source', and 'strategy' keys. + + Args: + template_name: Template name (e.g., "spec-template") + template_type: Template type ("template", "command", or "script") + + Returns: + List of layer dicts ordered highest-to-lowest priority. + """ + if template_type == "template": + subdirs = ["templates", ""] + elif template_type == "command": + subdirs = ["commands"] + elif template_type == "script": + subdirs = ["scripts"] + else: + subdirs = [""] + + ext = ".md" + if template_type == "script": + ext = ".sh" + + layers: List[Dict[str, Any]] = [] + + def _find_in_subdirs(base_dir: Path) -> Optional[Path]: + for subdir in subdirs: + if subdir: + candidate = base_dir / subdir / f"{template_name}{ext}" + else: + candidate = base_dir / f"{template_name}{ext}" + if candidate.exists(): + return candidate + return None + + # Priority 1: Project-local overrides (always "replace" strategy) + 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(): + layers.append({ + "path": override, + "source": "project override", + "strategy": "replace", + }) + + # Priority 2: Installed presets (sorted by priority — lower number = higher precedence) + if self.presets_dir.exists(): + for pack_id, metadata in self._get_all_presets_by_priority(): + pack_dir = self.presets_dir / pack_id + # Read strategy and manifest file path from preset manifest + strategy = "replace" + manifest_has_strategy = False + entry, manifest_candidate = self._manifest_declared_template( + pack_dir, template_name, template_type + ) + if entry is not None: + strategy = entry.get("strategy", "replace") + manifest_has_strategy = "strategy" in entry + # Use the manifest's declared file when it's a usable regular file; + # only fall back to convention-based lookup when the manifest + # doesn't list this template at all, so preset.yml stays + # authoritative (a declared-but-unusable file skips convention — + # parity with resolve()). + candidate = None + if manifest_candidate is not None: + candidate = manifest_candidate + elif entry is None: + candidate = _find_in_subdirs(pack_dir) + if candidate: + # Legacy fallback: if manifest doesn't explicitly declare a + # strategy, check the command file's frontmatter for any valid + # strategy. Skip when the manifest entry includes strategy key + # (even if it's "replace") to avoid overriding explicit declarations. + if not manifest_has_strategy and strategy == "replace" and template_type == "command": + try: + cmd_content = candidate.read_text(encoding="utf-8") + lines = cmd_content.splitlines(keepends=True) + if lines and lines[0].rstrip("\r\n") == "---": + fence_end = -1 + for fi, fline in enumerate(lines[1:], start=1): + if fline.rstrip("\r\n") == "---": + fence_end = fi + break + if fence_end > 0: + fm_text = "".join(lines[1:fence_end]) + fm_data = yaml.safe_load(fm_text) + if isinstance(fm_data, dict): + fm_strategy = fm_data.get("strategy") + if isinstance(fm_strategy, str) and fm_strategy.lower() in VALID_PRESET_STRATEGIES: + strategy = fm_strategy.lower() + except (UnicodeDecodeError, yaml.YAMLError, OSError): + # Best-effort legacy frontmatter parsing: keep default + # strategy ("replace") when content is unreadable/invalid. + pass + version = metadata.get("version", "?") if metadata else "?" + layers.append({ + "path": candidate, + "source": f"{pack_id} v{version}", + "strategy": strategy, + }) + + # Priority 3: Extension-provided templates (always "replace") + 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 + # The extension manifest is authoritative, same as preset manifests + # above: check it before convention-based lookup so a declared entry + # at a non-conventional path wins over a stale conventional file, and + # a declared-but-missing file isn't silently masked by convention. + entry, candidate = self._extension_manifest_declared_template( + ext_dir, template_name, template_type + ) + if entry is None: + candidate = _find_in_subdirs(ext_dir) + if candidate: + if ext_meta: + version = ext_meta.get("version", "?") + source = f"extension:{ext_id} v{version}" + else: + source = f"extension:{ext_id} (unregistered)" + layers.append({ + "path": candidate, + "source": source, + "strategy": "replace", + "extension_id": ext_id, + "extension_dir": ext_dir, + }) + + # Priority 4: Core templates (always "replace") + core = None + if template_type == "template": + c = self.templates_dir / f"{template_name}.md" + if c.exists(): + core = c + elif template_type == "command": + c = self.templates_dir / "commands" / f"{template_name}.md" + if c.exists(): + core = c + else: + # Fallback: speckit. → .md + stem = self._core_stem(template_name) + if stem: + c = self.templates_dir / "commands" / f"{stem}.md" + if c.exists(): + core = c + elif template_type == "script": + c = self.templates_dir / "scripts" / f"{template_name}{ext}" + if c.exists(): + core = c + if core: + layers.append({ + "path": core, + "source": "core", + "strategy": "replace", + }) + else: + # Priority 5: Bundled core_pack (wheel install) or repo-root + # templates (source-checkout), matching resolve()'s tier-5 fallback. + bundled = self._find_bundled_core(template_name, template_type, ext) + if bundled: + layers.append({ + "path": bundled, + "source": "core (bundled)", + "strategy": "replace", + }) + + return layers + + def _find_bundled_core( + self, + template_name: str, + template_type: str, + ext: str, + ) -> Optional[Path]: + """Find a core template from the bundled pack or source checkout. + + Mirrors the tier-5 fallback logic in ``resolve()`` so that + ``collect_all_layers()`` can locate base layers even when + ``.specify/templates/`` doesn't contain the core file. + """ + try: + from specify_cli import _locate_core_pack, _repo_root + except ImportError: + return None + + stem = self._core_stem(template_name) + names = [template_name] + if stem and stem != template_name: + names.append(stem) + + core_pack = _locate_core_pack() + if core_pack is not None: + for name in names: + if template_type == "template": + c = core_pack / "templates" / f"{name}.md" + elif template_type == "command": + c = core_pack / "commands" / f"{name}.md" + elif template_type == "script": + c = core_pack / "scripts" / f"{name}{ext}" + else: + c = core_pack / f"{name}.md" + if c.exists(): + return c + else: + repo_root = _repo_root() + for name in names: + if template_type == "template": + c = repo_root / "templates" / f"{name}.md" + elif template_type == "command": + c = repo_root / "templates" / "commands" / f"{name}.md" + elif template_type == "script": + c = repo_root / "scripts" / f"{name}{ext}" + else: + c = repo_root / f"{name}.md" + if c.exists(): + return c + return None + + def resolve_content( + self, + template_name: str, + template_type: str = "template", + ) -> Optional[str]: + """Resolve a template name and return composed content. + + Walks the priority stack and composes content using strategies: + - replace (default): highest-priority content wins entirely + - prepend: content is placed before lower-priority content + - append: content is placed after lower-priority content + - wrap: content contains {CORE_TEMPLATE} placeholder replaced + with lower-priority content (or $CORE_SCRIPT for scripts) + + Composition is recursive — multiple composing presets chain. + + Args: + template_name: Template name (e.g., "spec-template") + template_type: Template type ("template", "command", or "script") + + Returns: + Composed content string, or None if not found + """ + layers = self.collect_all_layers(template_name, template_type) + if not layers: + return None + + def _read_layer_content(layer: Dict[str, Any]) -> Optional[str]: + """Read a layer's raw text, rewriting extension-relative subdir + references (agents/, knowledge-base/, etc.) to their installed + location when the layer is extension-provided (#2101). + + Extension layers are always inserted with strategy "replace" + (see collect_all_layers), so a layer only ever needs this + rewrite when it wins outright above or serves as the + composition base below — never as a mid-stack composing + (append/prepend/wrap) layer. + + Returns None when the layer cannot be read or decoded: + collect_all_layers deliberately keeps a non-UTF-8 legacy layer + (with its "replace" default) so unrelated commands still + resolve, so the same tolerance must apply here — the documented + contract is "Composed content string, or None if not found", + not a raw UnicodeDecodeError at composition time. + """ + try: + text = layer["path"].read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + return None + extension_id = layer.get("extension_id") + extension_dir = layer.get("extension_dir") + if extension_id and extension_dir: + from ..agents import CommandRegistrar + + text = CommandRegistrar.rewrite_extension_paths( + text, extension_id, extension_dir + ) + return text + + # If the top (highest-priority) layer is replace, it wins entirely — + # lower layers are irrelevant regardless of their strategies. + if layers[0]["strategy"] == "replace": + return _read_layer_content(layers[0]) + + # Composition: build content bottom-up from the effective base. + # The base is the nearest replace layer scanning from highest priority + # downward. Only layers above the base contribute to composition. + # + # layers is ordered highest-priority first. We process in reverse. + reversed_layers = list(reversed(layers)) + + # Find the effective base: scan from highest priority (layers[0]) downward + # to find the nearest replace layer. Only compose layers above that base. + # layers is highest-priority first; reversed_layers is lowest first. + base_layer_idx = None # index in layers[] (highest-priority first) + for idx, layer in enumerate(layers): + if layer["strategy"] == "replace": + base_layer_idx = idx + break + + if base_layer_idx is None: + return None # no replace base found + + # Convert to reversed_layers index + base_reversed_idx = len(layers) - 1 - base_layer_idx + content = _read_layer_content(layers[base_layer_idx]) + if content is None: + return None + # Compose only the layers above the base (higher priority = lower index in layers, + # higher index in reversed_layers). Process bottom-up from base+1. + start_idx = base_reversed_idx + 1 + + # For command composition, strip frontmatter from each layer to avoid + # leaking YAML metadata into the composed body. The highest-priority + # layer's frontmatter will be reattached at the end. + is_command = template_type == "command" + top_frontmatter_text = None + base_frontmatter_text = None + + def _split_frontmatter(text: str) -> tuple: + """Return (frontmatter_block_with_fences, body) or (None, text). + + Uses line-based fence detection (fence must be ``---`` on its + own line) to avoid false matches on ``---`` inside YAML values. + """ + lines = text.splitlines(keepends=True) + if not lines or lines[0].rstrip("\r\n") != "---": + return None, text + + fence_end = -1 + for i, line in enumerate(lines[1:], start=1): + if line.rstrip("\r\n") == "---": + fence_end = i + break + + if fence_end == -1: + return None, text + + fm_block = "".join(lines[:fence_end + 1]).rstrip("\r\n") + body = "".join(lines[fence_end + 1:]) + return fm_block, body + + if is_command: + fm, body = _split_frontmatter(content) + if fm: + top_frontmatter_text = fm + base_frontmatter_text = fm + content = body + + # Apply composition layers from bottom to top + for layer in reversed_layers[start_idx:]: + try: + layer_content = layer["path"].read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + # Same tolerance as _read_layer_content: an unreadable layer + # means the composed result cannot be produced. + return None + strategy = layer["strategy"] + + if is_command: + fm, layer_body = _split_frontmatter(layer_content) + layer_content = layer_body + # Track the highest-priority frontmatter seen; + # replace layers reset both top and base frontmatter since + # they replace the entire command including metadata. + if strategy == "replace": + top_frontmatter_text = fm + base_frontmatter_text = fm + elif fm: + top_frontmatter_text = fm + + if strategy == "replace": + content = layer_content + elif strategy == "prepend": + content = layer_content + "\n\n" + content + elif strategy == "append": + content = content + "\n\n" + layer_content + elif strategy == "wrap": + if template_type == "script": + placeholder = "$CORE_SCRIPT" + else: + placeholder = "{CORE_TEMPLATE}" + if placeholder not in layer_content: + raise PresetValidationError( + f"Wrap strategy in '{layer['source']}' is missing " + f"the {placeholder} placeholder. The wrapper must " + f"contain {placeholder} to indicate where the " + f"lower-priority content should be inserted." + ) + content = layer_content.replace(placeholder, content) + + # Reattach the highest-priority frontmatter for commands, + # inheriting scripts/agent_scripts from the base if missing + # and stripping the strategy key (internal-only, not for agent output). + if is_command and top_frontmatter_text: + def _parse_fm_yaml(fm_block: str) -> dict: + """Parse YAML from a frontmatter block (with --- fences).""" + lines = fm_block.splitlines() + # Parse only interior lines (between --- fences) + if len(lines) >= 2: + yaml_lines = lines[1:-1] + else: + yaml_lines = [] + try: + return yaml.safe_load("\n".join(yaml_lines)) or {} + except yaml.YAMLError: + return {} + + top_fm = _parse_fm_yaml(top_frontmatter_text) + + # Inherit scripts/agent_scripts from base frontmatter if missing + if base_frontmatter_text and base_frontmatter_text != top_frontmatter_text: + base_fm = _parse_fm_yaml(base_frontmatter_text) + for key in ("scripts", "agent_scripts", "argument-hint"): + if key not in top_fm and key in base_fm: + top_fm[key] = base_fm[key] + + # Strip strategy key — it's an internal composition directive, + # not meant for rendered agent command files + top_fm.pop("strategy", None) + + if top_fm: + top_frontmatter_text = ( + "---\n" + + dump_frontmatter(top_fm) + + "\n---" + ) + else: + # Empty frontmatter — omit rather than emitting {} + top_frontmatter_text = None + + if top_frontmatter_text: + content = top_frontmatter_text + "\n\n" + content + + return content diff --git a/tests/integration/test_preset_update_workflow.py b/tests/integration/test_preset_update_workflow.py index 559882282d..a0d2e5b8a9 100644 --- a/tests/integration/test_preset_update_workflow.py +++ b/tests/integration/test_preset_update_workflow.py @@ -1,6 +1,6 @@ """Workflow-level integration tests for ``specify preset update``. -``tests/test_presets.py`` covers the orchestration contract with mocked +``tests/specify_cli/presets/test_command_update.py`` covers the orchestration contract with mocked ``preset_remove``/``preset_add`` calls, which proves *what* the wrapper calls but not that the calls are wired to the real install/remove machinery. These tests drive the CLI through ``CliRunner`` against a real project and a real diff --git a/tests/specify_cli/presets/_helpers.py b/tests/specify_cli/presets/_helpers.py index dc3661c055..32cb132adb 100644 --- a/tests/specify_cli/presets/_helpers.py +++ b/tests/specify_cli/presets/_helpers.py @@ -126,3 +126,129 @@ def make_convention_constitution_preset(temp_dir: Path) -> Path: ) ) return preset_dir + + +class PresetArtifactTestHelpers: + """Shared setup for preset command, skill, and lifecycle tests.""" + + def _write_init_options(self, project_dir, ai="claude", ai_skills=True, script="sh"): + from specify_cli import save_init_options + + save_init_options(project_dir, {"ai": ai, "ai_skills": ai_skills, "script": script}) + + def _create_skill(self, skills_dir, skill_name, body="original body"): + skill_dir = skills_dir / skill_name + skill_dir.mkdir(parents=True, exist_ok=True) + (skill_dir / "SKILL.md").write_text( + f"---\nname: {skill_name}\n---\n\n{body}\n" + ) + return skill_dir + + def _create_command_preset(self, temp_dir, preset_id, command_name, description, body): + preset_dir = temp_dir / preset_id + preset_dir.mkdir() + (preset_dir / "commands").mkdir() + command_file = f"{command_name}.md" + (preset_dir / "commands" / command_file).write_text( + f"---\ndescription: {description}\n---\n\n{body}\n" + ) + manifest_data = { + "schema_version": "1.0", + "preset": { + "id": preset_id, + "name": preset_id, + "version": "1.0.0", + "description": "Test", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "templates": [ + { + "type": "command", + "name": command_name, + "file": f"commands/{command_file}", + } + ] + }, + } + with open(preset_dir / "preset.yml", "w") as f: + yaml.dump(manifest_data, f) + return preset_dir + + def _create_multi_command_preset(self, temp_dir, preset_id, command_names): + """Install-directory helper for a preset with more than one command. + + Used to prove partial-result handling: a command's own template + entry can genuinely be skipped by registration (missing source + file, safety-validation rejection) while sibling commands in the + same preset still succeed. + """ + preset_dir = temp_dir / preset_id + preset_dir.mkdir() + (preset_dir / "commands").mkdir() + templates = [] + for command_name in command_names: + command_file = f"{command_name}.md" + (preset_dir / "commands" / command_file).write_text( + f"---\ndescription: {command_name} test command\n---\n\n" + f"{command_name} body\n" + ) + templates.append({ + "type": "command", + "name": command_name, + "file": f"commands/{command_file}", + }) + manifest_data = { + "schema_version": "1.0", + "preset": { + "id": preset_id, + "name": preset_id, + "version": "1.0.0", + "description": "Test", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": {"templates": templates}, + } + with open(preset_dir / "preset.yml", "w") as f: + yaml.dump(manifest_data, f) + return preset_dir + + def _create_multi_command_preset_with_aliases(self, temp_dir, preset_id, command_specs): + """Install-directory helper for a preset whose commands carry aliases. + + ``command_specs`` is a list of ``(primary_name, [alias, ...])`` + tuples. Each command gets its own source file (aliases share the + same source/content as their primary — CommandRegistrar renders + them from the same command file, just under a different output + name (#2948)). + """ + preset_dir = temp_dir / preset_id + preset_dir.mkdir() + (preset_dir / "commands").mkdir() + templates = [] + for primary_name, aliases in command_specs: + command_file = f"{primary_name}.md" + (preset_dir / "commands" / command_file).write_text( + f"---\ndescription: {primary_name} test command\n---\n\n" + f"{primary_name} body\n" + ) + templates.append({ + "type": "command", + "name": primary_name, + "file": f"commands/{command_file}", + "aliases": list(aliases), + }) + manifest_data = { + "schema_version": "1.0", + "preset": { + "id": preset_id, + "name": preset_id, + "version": "1.0.0", + "description": "Test", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": {"templates": templates}, + } + with open(preset_dir / "preset.yml", "w") as f: + yaml.dump(manifest_data, f) + return preset_dir diff --git a/tests/specify_cli/presets/conftest.py b/tests/specify_cli/presets/conftest.py index e8cdb4f34b..9fa00b4c17 100644 --- a/tests/specify_cli/presets/conftest.py +++ b/tests/specify_cli/presets/conftest.py @@ -1,4 +1,4 @@ -"""Load shared fixtures for mirrored preset command tests.""" +"""Load shared fixtures for mirrored preset domain and command tests.""" from __future__ import annotations diff --git a/tests/specify_cli/presets/test_catalog.py b/tests/specify_cli/presets/test_catalog.py new file mode 100644 index 0000000000..2ffbddfed9 --- /dev/null +++ b/tests/specify_cli/presets/test_catalog.py @@ -0,0 +1,1668 @@ +"""Tests for preset discovery and downloads in specify_cli.presets._catalog.""" + +import io +import json +import tarfile +import zipfile +from contextlib import contextmanager +from datetime import datetime, timezone +from pathlib import Path +from unittest.mock import MagicMock + +import pytest +import yaml + +from specify_cli.presets import ( + PresetCatalog, + PresetCatalogEntry, + PresetError, + PresetValidationError, +) + + +class TestPresetCatalog: + """Test template catalog functionality.""" + + def _inject_github_config(self, monkeypatch, token_env="GH_TOKEN"): + from tests.specify_cli.authentication.helpers import inject_github_config + inject_github_config(monkeypatch, token_env) + + def test_default_catalog_url(self, project_dir): + """Test default catalog URL.""" + catalog = PresetCatalog(project_dir) + assert catalog.DEFAULT_CATALOG_URL.startswith("https://") + assert catalog.DEFAULT_CATALOG_URL.endswith("/presets/catalog.json") + + def test_community_catalog_url(self, project_dir): + """Test community catalog URL.""" + catalog = PresetCatalog(project_dir) + assert "presets/catalog.community.json" in catalog.COMMUNITY_CATALOG_URL + + def test_cache_validation_no_cache(self, project_dir): + """Test cache validation when no cache exists.""" + catalog = PresetCatalog(project_dir) + assert catalog.is_cache_valid() is False + + def test_cache_validation_valid(self, project_dir): + """Test cache validation with valid cache.""" + catalog = PresetCatalog(project_dir) + catalog.cache_dir.mkdir(parents=True, exist_ok=True) + + catalog.cache_file.write_text(json.dumps({ + "schema_version": "1.0", + "presets": {}, + })) + catalog.cache_metadata_file.write_text(json.dumps({ + "cached_at": datetime.now(timezone.utc).isoformat(), + })) + + assert catalog.is_cache_valid() is True + + def test_cache_validation_expired(self, project_dir): + """Test cache validation with expired cache.""" + catalog = PresetCatalog(project_dir) + catalog.cache_dir.mkdir(parents=True, exist_ok=True) + + catalog.cache_file.write_text(json.dumps({ + "schema_version": "1.0", + "presets": {}, + })) + catalog.cache_metadata_file.write_text(json.dumps({ + "cached_at": "2020-01-01T00:00:00+00:00", + })) + + assert catalog.is_cache_valid() is False + + def test_cache_validation_corrupted(self, project_dir): + """Test cache validation with corrupted metadata.""" + catalog = PresetCatalog(project_dir) + catalog.cache_dir.mkdir(parents=True, exist_ok=True) + + catalog.cache_file.write_text("not json") + catalog.cache_metadata_file.write_text("not json") + + assert catalog.is_cache_valid() is False + + def test_clear_cache(self, project_dir): + """Test clearing the cache.""" + catalog = PresetCatalog(project_dir) + catalog.cache_dir.mkdir(parents=True, exist_ok=True) + catalog.cache_file.write_text("{}") + catalog.cache_metadata_file.write_text("{}") + + catalog.clear_cache() + + assert not catalog.cache_file.exists() + assert not catalog.cache_metadata_file.exists() + + def test_search_with_cached_data(self, project_dir, monkeypatch): + """Test search with cached catalog data.""" + from unittest.mock import patch + + monkeypatch.delenv("SPECKIT_PRESET_CATALOG_URL", raising=False) + catalog = PresetCatalog(project_dir) + catalog.cache_dir.mkdir(parents=True, exist_ok=True) + + catalog_data = { + "schema_version": "1.0", + "presets": { + "safe-agile": { + "name": "SAFe Agile Templates", + "description": "SAFe-aligned templates", + "author": "agile-community", + "version": "1.0.0", + "tags": ["safe", "agile"], + }, + "healthcare": { + "name": "Healthcare Compliance", + "description": "HIPAA-compliant templates", + "author": "healthcare-org", + "version": "1.0.0", + "tags": ["healthcare", "hipaa"], + }, + } + } + + catalog.cache_file.write_text(json.dumps(catalog_data)) + catalog.cache_metadata_file.write_text(json.dumps({ + "cached_at": datetime.now(timezone.utc).isoformat(), + })) + + # Isolate from community catalog so results are deterministic + default_only = [PresetCatalogEntry(url=catalog.DEFAULT_CATALOG_URL, name="default", priority=1, install_allowed=True)] + with patch.object(catalog, "get_active_catalogs", return_value=default_only): + # Search by query + results = catalog.search(query="agile") + assert len(results) == 1 + assert results[0]["id"] == "safe-agile" + + # Search by tag + results = catalog.search(tag="hipaa") + assert len(results) == 1 + assert results[0]["id"] == "healthcare" + + # Search by author + results = catalog.search(author="agile-community") + assert len(results) == 1 + + # Search all + results = catalog.search() + assert len(results) == 2 + + def test_get_pack_info(self, project_dir): + """Test getting info for a specific pack.""" + catalog = PresetCatalog(project_dir) + catalog.cache_dir.mkdir(parents=True, exist_ok=True) + + catalog_data = { + "schema_version": "1.0", + "presets": { + "test-pack": { + "name": "Test Pack", + "version": "1.0.0", + }, + } + } + + catalog.cache_file.write_text(json.dumps(catalog_data)) + catalog.cache_metadata_file.write_text(json.dumps({ + "cached_at": datetime.now(timezone.utc).isoformat(), + })) + + info = catalog.get_pack_info("test-pack") + assert info is not None + assert info["name"] == "Test Pack" + assert info["id"] == "test-pack" + + assert catalog.get_pack_info("nonexistent") is None + + def test_validate_catalog_url_https(self, project_dir): + """Test that HTTPS URLs are accepted.""" + catalog = PresetCatalog(project_dir) + catalog._validate_catalog_url("https://example.com/catalog.json") + + def test_validate_catalog_url_http_rejected(self, project_dir): + """Test that HTTP URLs are rejected.""" + catalog = PresetCatalog(project_dir) + with pytest.raises(PresetValidationError, match="must use HTTPS"): + catalog._validate_catalog_url("http://example.com/catalog.json") + + def test_validate_catalog_url_localhost_http_allowed(self, project_dir): + """Test that HTTP is allowed for localhost.""" + catalog = PresetCatalog(project_dir) + catalog._validate_catalog_url("http://localhost:8080/catalog.json") + catalog._validate_catalog_url("http://127.0.0.1:8080/catalog.json") + + @pytest.mark.parametrize( + "url", + [ + "https://:8080", # port only, no host + "https://:8080/catalog.json", # port only, with path + "https://:0", # port only, no host + "https://user@", # userinfo only, no host + "https://user:pass@", # userinfo only, no host + ], + ) + def test_validate_catalog_url_hostless_rejected(self, project_dir, url): + """Reject host-less URLs whose netloc is truthy but hostname is None (#3209). + + ``urlparse('https://:8080').netloc`` is ``':8080'`` (truthy) but its + ``hostname`` is ``None``, so a netloc-based check would accept a URL + with no actual host, contradicting the "valid URL with a host" error. + """ + catalog = PresetCatalog(project_dir) + with pytest.raises(PresetValidationError, match="valid URL with a host"): + catalog._validate_catalog_url(url) + + def test_validate_catalog_url_malformed_rejected(self, project_dir): + """A malformed URL raises PresetValidationError, not a raw ValueError. + + ``urlparse('https://[::1').hostname`` raises ``ValueError: Invalid IPv6 + URL`` (unterminated bracket). Without wrapping, that leaks past callers' + ``except PresetValidationError`` guards and crashes the CLI. Mirrors the + shared ``CatalogStackBase`` (#3435) and ``IntegrationCatalog`` behaviour. + """ + catalog = PresetCatalog(project_dir) + with pytest.raises(PresetValidationError, match="malformed"): + catalog._validate_catalog_url("https://[::1") + + def test_validate_catalog_url_out_of_range_port_rejected(self, project_dir): + """An out-of-range port raises ValueError lazily on ``.port`` access. + + ``urlparse(...).hostname`` alone does not validate the port, so + without a ``_ = parsed.port`` probe inside the try/except, a URL like + ``https://example.com:99999/catalog.json`` sails through this + validator and only fails later, at fetch time, with a raw + untranslated error instead of a clean ``PresetValidationError``. The + sibling ``preset add --from `` download-URL guard already + catches this shape (see + ``test_preset_add_from_url_out_of_range_port_exits_cleanly``); this + catalog-source-URL validator had drifted from it and from the + original guard in ``specify_cli.catalogs``/ + ``bundler/services/adapters.py``. + """ + catalog = PresetCatalog(project_dir) + with pytest.raises(PresetValidationError, match="malformed"): + catalog._validate_catalog_url("https://example.com:99999/catalog.json") + + def test_env_var_catalog_url(self, project_dir, monkeypatch): + """Test catalog URL from environment variable.""" + monkeypatch.setenv("SPECKIT_PRESET_CATALOG_URL", "https://custom.example.com/catalog.json") + catalog = PresetCatalog(project_dir) + assert catalog.get_catalog_url() == "https://custom.example.com/catalog.json" + + # --- _make_request / GitHub auth --- + + def test_make_request_no_token_no_auth_header(self, project_dir, monkeypatch): + """Without a token, requests carry no Authorization header.""" + monkeypatch.delenv("GITHUB_TOKEN", raising=False) + monkeypatch.delenv("GH_TOKEN", raising=False) + catalog = PresetCatalog(project_dir) + req = catalog._make_request("https://raw.githubusercontent.com/org/repo/main/catalog.json") + assert "Authorization" not in req.headers + + def test_make_request_whitespace_only_github_token_ignored(self, project_dir, monkeypatch): + """A whitespace-only GITHUB_TOKEN is treated as unset.""" + monkeypatch.setenv("GITHUB_TOKEN", " ") + monkeypatch.delenv("GH_TOKEN", raising=False) + catalog = PresetCatalog(project_dir) + req = catalog._make_request("https://raw.githubusercontent.com/org/repo/main/catalog.json") + assert "Authorization" not in req.headers + + def test_make_request_whitespace_github_token_falls_back_to_gh_token(self, project_dir, monkeypatch): + """When GITHUB_TOKEN is whitespace-only, GH_TOKEN is used as fallback.""" + monkeypatch.setenv("GITHUB_TOKEN", " ") + monkeypatch.setenv("GH_TOKEN", "ghp_fallback") + self._inject_github_config(monkeypatch, token_env="GH_TOKEN") + catalog = PresetCatalog(project_dir) + req = catalog._make_request("https://raw.githubusercontent.com/org/repo/main/catalog.json") + assert req.get_header("Authorization") == "Bearer ghp_fallback" + + def test_make_request_github_token_added_for_github_url(self, project_dir, monkeypatch): + """GITHUB_TOKEN is attached for raw.githubusercontent.com URLs.""" + monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken") + monkeypatch.delenv("GH_TOKEN", raising=False) + self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN") + catalog = PresetCatalog(project_dir) + req = catalog._make_request("https://raw.githubusercontent.com/org/repo/main/catalog.json") + assert req.get_header("Authorization") == "Bearer ghp_testtoken" + + def test_make_request_gh_token_fallback(self, project_dir, monkeypatch): + """GH_TOKEN is used when GITHUB_TOKEN is absent.""" + monkeypatch.delenv("GITHUB_TOKEN", raising=False) + monkeypatch.setenv("GH_TOKEN", "ghp_ghtoken") + self._inject_github_config(monkeypatch, token_env="GH_TOKEN") + catalog = PresetCatalog(project_dir) + req = catalog._make_request("https://github.com/org/repo/releases/download/v1/pack.zip") + assert req.get_header("Authorization") == "Bearer ghp_ghtoken" + + def test_make_request_gh_token_takes_precedence(self, project_dir, monkeypatch): + """When auth.json uses GH_TOKEN, that token is used regardless of GITHUB_TOKEN.""" + monkeypatch.setenv("GITHUB_TOKEN", "ghp_secondary") + monkeypatch.setenv("GH_TOKEN", "ghp_primary") + self._inject_github_config(monkeypatch, token_env="GH_TOKEN") + catalog = PresetCatalog(project_dir) + req = catalog._make_request("https://api.github.com/repos/org/repo") + assert req.get_header("Authorization") == "Bearer ghp_primary" + + def test_make_request_token_added_for_codeload_github_com(self, project_dir, monkeypatch): + """GITHUB_TOKEN is attached for codeload.github.com URLs.""" + monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken") + self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN") + catalog = PresetCatalog(project_dir) + req = catalog._make_request("https://codeload.github.com/org/repo/zip/refs/tags/v1.0.0") + assert req.get_header("Authorization") == "Bearer ghp_testtoken" + + def test_make_request_no_auth_for_non_matching_host(self, project_dir, monkeypatch): + """Auth is NOT attached to hosts not listed in auth.json.""" + monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken") + self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN") + catalog = PresetCatalog(project_dir) + req = catalog._make_request("https://internal.example.com/catalog.json") + assert "Authorization" not in req.headers + + def test_make_request_no_auth_when_no_config(self, project_dir, monkeypatch): + """No auth header when no auth.json config exists.""" + monkeypatch.delenv("GITHUB_TOKEN", raising=False) + monkeypatch.delenv("GH_TOKEN", raising=False) + catalog = PresetCatalog(project_dir) + req = catalog._make_request("https://github.com/org/repo/releases/download/v1/pack.zip") + assert "Authorization" not in req.headers + + def test_fetch_single_catalog_sends_auth_header(self, project_dir, monkeypatch): + """_fetch_single_catalog passes Authorization header when configured.""" + from unittest.mock import patch, MagicMock + + monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken") + self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN") + catalog = PresetCatalog(project_dir) + + catalog_data = {"schema_version": "1.0", "presets": {}} + mock_response = MagicMock() + mock_response.read.side_effect = io.BytesIO(json.dumps(catalog_data).encode()).read + mock_response.__enter__ = lambda s: s + mock_response.__exit__ = MagicMock(return_value=False) + mock_response.geturl.return_value = "https://raw.githubusercontent.com/org/repo/main/presets/catalog.json" + + captured = {} + mock_opener = MagicMock() + + def fake_open(req, timeout=None): + captured["req"] = req + return mock_response + + mock_opener.open.side_effect = fake_open + + entry = PresetCatalogEntry( + url="https://raw.githubusercontent.com/org/repo/main/presets/catalog.json", + name="private", + priority=1, + install_allowed=True, + ) + + with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener): + catalog._fetch_single_catalog(entry, force_refresh=True) + + assert captured["req"].get_header("Authorization") == "Bearer ghp_testtoken" + + def test_fetch_single_catalog_revalidates_redirected_url(self, project_dir): + """An HTTPS catalog URL that redirects to http:// must be rejected AFTER + the redirect. _open_url follows redirects (auth stripped on downgrade), + so without re-validating response.geturl() the http payload would still + be fetched and trusted — and it supplies each preset's download_url + + sha256, defeating verify_archive_sha256. Parity with the + integrations/workflows catalog fetchers.""" + catalog = PresetCatalog(project_dir) + + class _Resp: + def __enter__(self): + return self + + def __exit__(self, *a): + return False + + def read(self): + return json.dumps({"schema_version": "1.0", "presets": {}}).encode() + + def geturl(self): + return "http://evil.test/catalog.json" # downgraded via redirect + + catalog._open_url = lambda url, timeout=None, redirect_validator=None: _Resp() + + entry = PresetCatalogEntry( + url="https://good.example/catalog.json", + name="c", + priority=1, + install_allowed=True, + ) + with pytest.raises(PresetValidationError, match="HTTPS"): + catalog._fetch_single_catalog(entry, force_refresh=True) + + def test_fetch_single_catalog_validates_every_redirect_hop(self, project_dir): + """A redirect_validator is passed to _open_url and rejects a non-HTTPS + INTERMEDIATE hop — closing the https -> http -> attacker-https chain that + a terminal-URL-only check would miss.""" + catalog = PresetCatalog(project_dir) + captured = {} + + def fake_open(url, timeout=None, redirect_validator=None): + captured["rv"] = redirect_validator + # Simulate the hop urllib validates before following the redirect. + redirect_validator("https://good.example/catalog.json", "http://evil.test/hop") + raise AssertionError("redirect_validator should have raised") + + catalog._open_url = fake_open + entry = PresetCatalogEntry( + url="https://good.example/catalog.json", + name="c", + priority=1, + install_allowed=True, + ) + with pytest.raises(PresetValidationError, match="HTTPS"): + catalog._fetch_single_catalog(entry, force_refresh=True) + assert captured["rv"] is not None + + def test_fetch_catalog_legacy_revalidates_redirected_url(self, project_dir): + """The legacy single-catalog fetch_catalog() path also rejects an + HTTPS -> http redirected payload (final geturl() check), matching + _fetch_single_catalog — it previously parsed the body with no check.""" + catalog = PresetCatalog(project_dir) + + class _Resp: + def __enter__(self): + return self + + def __exit__(self, *a): + return False + + def read(self): + return json.dumps({"schema_version": "1.0", "presets": {}}).encode() + + def geturl(self): + return "http://evil.test/catalog.json" + + catalog._open_url = lambda url, timeout=None, redirect_validator=None: _Resp() + with pytest.raises(PresetError, match="HTTPS"): + catalog.fetch_catalog(force_refresh=True) + + def test_fetch_catalog_legacy_validates_every_redirect_hop(self, project_dir): + """The legacy fetch_catalog() path also validates every INTERMEDIATE hop + (not just the terminal URL): it must supply a redirect_validator that + rejects an insecure hop, so an https -> http -> https chain is caught.""" + catalog = PresetCatalog(project_dir) + captured = {} + + def fake_open(url, timeout=None, redirect_validator=None): + captured["rv"] = redirect_validator + redirect_validator(url, "http://evil.test/hop") + raise AssertionError("redirect_validator should have raised") + + catalog._open_url = fake_open + with pytest.raises(PresetError, match="HTTPS"): + catalog.fetch_catalog(force_refresh=True) + assert captured["rv"] is not None + + @pytest.mark.parametrize( + "payload", + [ + # Root is not a JSON object. + [], + "oops", + 42, + None, + # Root is fine but ``presets`` is the wrong type. + {"schema_version": "1.0", "presets": []}, + {"schema_version": "1.0", "presets": "oops"}, + {"schema_version": "1.0", "presets": None}, + {"schema_version": "1.0", "presets": 42}, + ], + ) + def test_fetch_single_catalog_rejects_malformed_payload(self, project_dir, payload): + """Malformed catalog payloads raise PresetError, not AttributeError. + + Without this guard, a payload like ``{"presets": []}`` would pass the + key-presence check and then crash with ``AttributeError: 'list' object + has no attribute 'items'`` deep inside ``_get_merged_packs``. The + sibling integration catalog reader already validates both the root + object and the nested mapping (see ``integrations/catalog.py``); the + preset catalog must stay consistent. + """ + from unittest.mock import patch, MagicMock + + catalog = PresetCatalog(project_dir) + + mock_response = MagicMock() + mock_response.read.side_effect = io.BytesIO(json.dumps(payload).encode()).read + mock_response.__enter__ = lambda s: s + mock_response.__exit__ = MagicMock(return_value=False) + # A real urllib response reports the final URL (== request URL with no + # redirect); the fetcher re-validates it after redirects. + mock_response.geturl.return_value = "https://example.com/catalog.json" + + entry = PresetCatalogEntry( + url="https://example.com/catalog.json", + name="default", + priority=1, + install_allowed=True, + ) + + with patch.object(catalog, "_open_url", return_value=mock_response): + with pytest.raises(PresetError, match="Invalid preset catalog format"): + catalog._fetch_single_catalog(entry, force_refresh=True) + + @pytest.mark.parametrize( + "cached_payload", + [ + [], + "oops", + 42, + None, + {"schema_version": "1.0", "presets": []}, + {"schema_version": "1.0", "presets": "oops"}, + {"schema_version": "1.0", "presets": None}, + ], + ) + def test_fetch_single_catalog_rejects_malformed_cached_payload( + self, project_dir, cached_payload + ): + """A poisoned cache silently falls back to the network instead of + crashing — cached payloads pass through the same shape validation + as freshly-fetched ones. + + Without this, a cache poisoned by an older spec-kit version (or a + manual edit, or an upstream that briefly served a bad payload + before the network guards landed) would re-crash every invocation + of ``_get_merged_packs`` despite the cache being "valid" by age. + The recovery contract is: if the cached payload fails validation, + drop it and refetch — never propagate ``AttributeError`` to the + caller. + """ + from unittest.mock import patch, MagicMock + + catalog = PresetCatalog(project_dir) + + # Poison the default-URL cache. ``DEFAULT_CATALOG_URL`` and + # non-default URLs both flow through the same cache-load branch. + cache_file, metadata_file = catalog._get_cache_paths( + catalog.DEFAULT_CATALOG_URL + ) + cache_file.parent.mkdir(parents=True, exist_ok=True) + cache_file.write_text(json.dumps(cached_payload)) + metadata_file.write_text( + json.dumps( + { + "cached_at": datetime.now(timezone.utc).isoformat(), + "catalog_url": catalog.DEFAULT_CATALOG_URL, + } + ) + ) + + # Network refetch returns a valid payload so the recovery path + # can complete. + valid = { + "schema_version": "1.0", + "presets": {"foo": {"name": "Foo", "version": "1.0.0"}}, + } + mock_response = MagicMock() + mock_response.read.side_effect = io.BytesIO(json.dumps(valid).encode()).read + mock_response.__enter__ = lambda s: s + mock_response.__exit__ = MagicMock(return_value=False) + mock_response.geturl.return_value = catalog.DEFAULT_CATALOG_URL + + entry = PresetCatalogEntry( + url=catalog.DEFAULT_CATALOG_URL, + name="default", + priority=1, + install_allowed=True, + ) + + with patch.object(catalog, "_open_url", return_value=mock_response): + result = catalog._fetch_single_catalog(entry, force_refresh=False) + + # The poisoned cache was discarded and the network payload returned. + assert result == valid + + @pytest.mark.parametrize( + "payload", + [ + # Root is not a JSON object. + [], + "oops", + 42, + None, + # Root is fine but ``presets`` is the wrong type. + {"schema_version": "1.0", "presets": []}, + {"schema_version": "1.0", "presets": "oops"}, + {"schema_version": "1.0", "presets": None}, + ], + ) + def test_fetch_catalog_rejects_malformed_payload(self, project_dir, payload): + """Legacy ``fetch_catalog`` reuses the same shape-validation helper. + + Before this change ``fetch_catalog`` only checked key presence — + so a payload like ``42`` would crash with + ``TypeError: argument of type 'int' is not iterable`` during the + ``"schema_version" in catalog_data`` check, and an entry mapping + of the wrong type would crash downstream. Reusing + ``_validate_catalog_payload`` keeps the network-side behaviour of + the legacy single-catalog method consistent with the multi-catalog + ``_fetch_single_catalog`` path. + """ + from unittest.mock import patch, MagicMock + + catalog = PresetCatalog(project_dir) + mock_response = MagicMock() + mock_response.read.side_effect = io.BytesIO(json.dumps(payload).encode()).read + mock_response.__enter__ = lambda s: s + mock_response.__exit__ = MagicMock(return_value=False) + mock_response.geturl.return_value = "https://example.com/catalog.json" + + with patch.object(catalog, "_open_url", return_value=mock_response): + with pytest.raises(PresetError, match="Invalid preset catalog format"): + catalog.fetch_catalog(force_refresh=True) + + def test_fetch_catalog_recovers_from_unreadable_cache(self, project_dir): + """An unreadable / wrong-encoded cache file silently refetches. + + The cache contract is best-effort: a JSON-decode failure, an OS + read failure (permissions / disk / handle limit), or an invalid + text encoding on a cache file written by an older client must + all fall through to the network fetch rather than crash the + caller. Covers Copilot's review point that the previous + ``except (json.JSONDecodeError, OSError)`` was missing + ``UnicodeError``. + """ + from unittest.mock import patch, MagicMock + + catalog = PresetCatalog(project_dir) + catalog.cache_dir.mkdir(parents=True, exist_ok=True) + # Invalid UTF-8 bytes so ``read_text`` raises ``UnicodeDecodeError`` + # (a subclass of ``UnicodeError``). + catalog.cache_file.write_bytes(b"\xff\xfe\x00not-utf-8") + catalog.cache_metadata_file.write_text( + json.dumps( + { + "cached_at": datetime.now(timezone.utc).isoformat(), + "catalog_url": catalog.get_catalog_url(), + } + ), + encoding="utf-8", + ) + + valid = { + "schema_version": "1.0", + "presets": {"foo": {"name": "Foo", "version": "1.0.0"}}, + } + mock_response = MagicMock() + mock_response.read.side_effect = io.BytesIO(json.dumps(valid).encode()).read + mock_response.__enter__ = lambda s: s + mock_response.__exit__ = MagicMock(return_value=False) + mock_response.geturl.return_value = "https://example.com/catalog.json" + + with patch.object(catalog, "_open_url", return_value=mock_response): + result = catalog.fetch_catalog(force_refresh=False) + + # Recovered via network rather than crashing on the unreadable cache. + assert result == valid + + def test_fetch_catalog_recovers_from_unreadable_metadata(self, project_dir): + """A wrongly-encoded metadata file degrades to a cache miss. + + ``is_cache_valid`` is consulted *before* the cache payload is + read; if the metadata file itself can't be decoded (e.g. it was + written on a host whose default codec isn't UTF-8) the validity + check must return ``False`` rather than propagate + ``UnicodeDecodeError``. Without that guard, a corrupted metadata + file would crash every invocation instead of falling through to + a network refetch. + """ + from unittest.mock import patch, MagicMock + + catalog = PresetCatalog(project_dir) + catalog.cache_dir.mkdir(parents=True, exist_ok=True) + catalog.cache_file.write_text("{}", encoding="utf-8") + # Bytes that are not valid UTF-8 — ``read_text(encoding="utf-8")`` + # will raise ``UnicodeDecodeError`` (subclass of ``UnicodeError``). + catalog.cache_metadata_file.write_bytes(b"\xff\xfe\x00bad") + + # is_cache_valid must absorb the decode failure, not crash. + assert catalog.is_cache_valid() is False + + valid = { + "schema_version": "1.0", + "presets": {"foo": {"name": "Foo", "version": "1.0.0"}}, + } + mock_response = MagicMock() + mock_response.read.side_effect = io.BytesIO(json.dumps(valid).encode()).read + mock_response.__enter__ = lambda s: s + mock_response.__exit__ = MagicMock(return_value=False) + mock_response.geturl.return_value = "https://example.com/catalog.json" + + with patch.object(catalog, "_open_url", return_value=mock_response): + result = catalog.fetch_catalog(force_refresh=False) + + assert result == valid + + @pytest.mark.parametrize( + "non_mapping_metadata", + [ + "[]", # JSON array + '"oops"', # JSON string + "42", # JSON number + "true", # JSON bool + "null", # JSON null + ], + ) + def test_is_cache_valid_handles_non_mapping_metadata( + self, project_dir, non_mapping_metadata + ): + """Metadata that parses to a non-mapping degrades to cache-invalid. + + The cache-validity check calls ``metadata.get("cached_at", "")`` + immediately after ``json.loads``. If the metadata file is valid + JSON but parses to a non-mapping (``[]``, ``"oops"``, ``42``, + ``true``, ``null``), ``.get`` raises ``AttributeError`` — which + previously slipped past the except tuple and crashed the + caller. The contract documented on ``is_cache_valid`` says any + decode/shape failure should return ``False`` so ``fetch_catalog`` + falls through to a network refetch. This test pins that + contract across every JSON non-mapping root type. + """ + catalog = PresetCatalog(project_dir) + catalog.cache_dir.mkdir(parents=True, exist_ok=True) + catalog.cache_file.write_text("{}", encoding="utf-8") + catalog.cache_metadata_file.write_text( + non_mapping_metadata, encoding="utf-8" + ) + + # Must not raise — the contract is "any decode/shape failure → False". + assert catalog.is_cache_valid() is False + + def test_fetch_catalog_writes_cache_as_utf8(self, project_dir, monkeypatch): + """Cache + metadata writes pass ``encoding="utf-8"``, observably. + + The earlier version of this test claimed to assert UTF-8 at the + byte level but actually only round-tripped a non-ASCII string + through ``json.dumps`` and ``read_text(encoding="utf-8")``. + Because ``json.dumps`` defaults to ``ensure_ascii=True``, "café" + was serialized as the all-ASCII escape ``caf\\u00e9`` before it + ever reached ``write_text`` — the bytes on disk were identical + regardless of the encoding kwarg. The drift Copilot's review + flagged wasn't actually being caught. + + Fix: directly observe the ``encoding`` argument passed to every + ``write_text`` call made against the cache directory. This is + the production code's encoding choice, which is exactly what + the regression guard cares about. + """ + from unittest.mock import patch, MagicMock + from pathlib import Path as _PathCls + + catalog = PresetCatalog(project_dir) + payload = { + "schema_version": "1.0", + "presets": {"foo": {"name": "Foo", "version": "1.0.0"}}, + } + mock_response = MagicMock() + mock_response.read.side_effect = io.BytesIO(json.dumps(payload).encode("utf-8")).read + mock_response.__enter__ = lambda s: s + mock_response.__exit__ = MagicMock(return_value=False) + mock_response.geturl.return_value = "https://example.com/catalog.json" + + # Record every ``write_text`` call's encoding kwarg so the + # assertion observes the production writer's argument directly. + recorded: list[dict] = [] + real_write_text = _PathCls.write_text + + def recording_write_text(self, data, *args, **kwargs): + recorded.append( + {"path": str(self), "encoding": kwargs.get("encoding")} + ) + return real_write_text(self, data, *args, **kwargs) + + monkeypatch.setattr(_PathCls, "write_text", recording_write_text) + + with patch.object(catalog, "_open_url", return_value=mock_response): + catalog.fetch_catalog(force_refresh=True) + + cache_writes = [ + r for r in recorded if str(catalog.cache_dir) in r["path"] + ] + assert cache_writes, "fetch_catalog made no writes to the cache dir" + for record in cache_writes: + assert record["encoding"] == "utf-8", ( + f"write_text on {record['path']} used encoding " + f"{record['encoding']!r}; expected 'utf-8'" + ) + + def test_fetch_catalog_survives_unwritable_cache(self, project_dir, monkeypatch): + """An unwritable cache dir doesn't fail a successful fetch. + + Cache writes are best-effort, mirroring the read side and the + ``integrations/catalog.py`` precedent: if ``mkdir``/``write_text`` + raises ``OSError`` (read-only checkout, permissions), the + already-fetched-and-validated payload must still be returned — + not swallowed into the broad except and re-raised as a + ``PresetError``. + """ + from unittest.mock import patch, MagicMock + from pathlib import Path as _PathCls + + catalog = PresetCatalog(project_dir) + valid = { + "schema_version": "1.0", + "presets": {"foo": {"name": "Foo", "version": "1.0.0"}}, + } + def make_response(): + mock_response = MagicMock() + mock_response.read.side_effect = io.BytesIO(json.dumps(valid).encode()).read + mock_response.__enter__ = lambda s: s + mock_response.__exit__ = MagicMock(return_value=False) + mock_response.geturl.return_value = catalog.DEFAULT_CATALOG_URL + return mock_response + + # Simulate an unwritable cache dir: every write_text under the + # cache directory raises PermissionError (an OSError subclass). + real_write_text = _PathCls.write_text + + def failing_write_text(self, data, *args, **kwargs): + if str(catalog.cache_dir) in str(self): + raise PermissionError("cache dir is read-only") + return real_write_text(self, data, *args, **kwargs) + + monkeypatch.setattr(_PathCls, "write_text", failing_write_text) + + with patch.object(catalog, "_open_url", side_effect=lambda *a, **kw: make_response()): + # Legacy single-catalog path. + assert catalog.fetch_catalog(force_refresh=True) == valid + + # Multi-catalog path. + entry = PresetCatalogEntry( + url=catalog.DEFAULT_CATALOG_URL, + name="default", + priority=1, + install_allowed=True, + ) + assert ( + catalog._fetch_single_catalog(entry, force_refresh=True) == valid + ) + + def test_get_merged_packs_skips_non_mapping_entries(self, project_dir): + """Per-entry guard: one malformed entry shouldn't poison the merge. + + ``_fetch_single_catalog`` validates that ``presets`` is a mapping, + but it doesn't (and shouldn't) validate every entry inside it — a + single bad entry in an otherwise-valid catalog should be skipped, + not crash the whole resolve path. Mirrors the per-entry skip in + ``integrations/catalog.py``: a malformed entry returns no error, + valid entries continue to merge normally. + """ + from unittest.mock import patch, MagicMock + + catalog = PresetCatalog(project_dir) + payload = { + "schema_version": "1.0", + "presets": { + "good": {"name": "Good", "version": "1.0.0"}, + "bad-list": [], + "bad-str": "oops", + }, + } + mock_response = MagicMock() + mock_response.read.side_effect = io.BytesIO(json.dumps(payload).encode()).read + mock_response.__enter__ = lambda s: s + mock_response.__exit__ = MagicMock(return_value=False) + mock_response.geturl.return_value = "https://example.com/catalog.json" + + entry = PresetCatalogEntry( + url="https://example.com/catalog.json", + name="default", + priority=1, + install_allowed=True, + ) + + with patch.object(catalog, "_open_url", return_value=mock_response), \ + patch.object(catalog, "get_active_catalogs", return_value=[entry]): + merged = catalog._get_merged_packs(force_refresh=True) + + # Only the well-formed entry survives; the two malformed entries are + # silently dropped rather than raising or crashing. + assert list(merged.keys()) == ["good"] + + def test_download_pack_sends_auth_header(self, project_dir, monkeypatch): + """download_pack passes Authorization header when configured.""" + from unittest.mock import patch, MagicMock + + monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken") + self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN") + catalog = PresetCatalog(project_dir) + + import io + zip_buf = io.BytesIO() + with zipfile.ZipFile(zip_buf, "w") as zf: + zf.writestr("preset.yml", "id: test-pack\nname: Test\nversion: 1.0.0\n") + zip_bytes = zip_buf.getvalue() + + release_response = MagicMock() + release_response.read.side_effect = io.BytesIO(json.dumps( + { + "assets": [ + { + "name": "test-pack.zip", + "url": "https://api.github.com/repos/org/repo/releases/assets/1", + } + ] + } + ).encode()).read + release_response.__enter__ = lambda s: s + release_response.__exit__ = MagicMock(return_value=False) + + asset_response = MagicMock() + asset_response.read.side_effect = io.BytesIO(zip_bytes).read + asset_response.__enter__ = lambda s: s + asset_response.__exit__ = MagicMock(return_value=False) + + captured = [] + mock_opener = MagicMock() + + def fake_open(req, timeout=None): + captured.append(req) + if req.full_url.endswith("/releases/tags/v1"): + return release_response + return asset_response + + mock_opener.open.side_effect = fake_open + + pack_info = { + "id": "test-pack", + "name": "Test Pack", + "version": "1.0.0", + "download_url": "https://github.com/org/repo/releases/download/v1/test-pack.zip", + "_install_allowed": True, + } + + with patch.object(catalog, "get_pack_info", return_value=pack_info), \ + patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener): + catalog.download_pack("test-pack", target_dir=project_dir) + + assert captured[0].full_url == "https://api.github.com/repos/org/repo/releases/tags/v1" + assert captured[0].get_header("Authorization") == "Bearer ghp_testtoken" + assert captured[1].full_url == "https://api.github.com/repos/org/repo/releases/assets/1" + assert captured[1].get_header("Authorization") == "Bearer ghp_testtoken" + assert captured[1].get_header("Accept") == "application/octet-stream" + + def _pack_zip_and_response(self): + """Build a minimal preset ZIP and a context-manager mock response.""" + from unittest.mock import MagicMock + import io + + zip_buf = io.BytesIO() + with zipfile.ZipFile(zip_buf, "w") as zf: + zf.writestr("preset.yml", "id: test-pack\nname: Test\nversion: 1.0.0\n") + zip_bytes = zip_buf.getvalue() + + resp = MagicMock() + resp.read.side_effect = io.BytesIO(zip_bytes).read + # Configure the context-manager protocol explicitly so `with resp` + # yields `resp` itself, independent of how the protocol is invoked. + resp.__enter__.return_value = resp + resp.__exit__.return_value = False + return zip_bytes, resp + + def test_fetch_single_catalog_rejects_oversized_body_without_cache( + self, project_dir, monkeypatch + ): + """Catalog bounds are enforced at the preset call site.""" + import specify_cli.presets as preset_module + from unittest.mock import patch + + catalog = PresetCatalog(project_dir) + entry = PresetCatalogEntry( + url="https://example.com/catalog.json", + name="default", + priority=1, + install_allowed=True, + ) + body = b'{"schema_version":"1.0","presets":{}}' + response = MagicMock() + response.read.side_effect = io.BytesIO(body).read + response.__enter__.return_value = response + response.__exit__.return_value = False + response.geturl.return_value = entry.url + monkeypatch.setattr( + preset_module, + "MAX_JSON_CATALOG_BYTES", + len(body) - 1, + ) + + with patch.object(catalog, "_open_url", return_value=response): + with pytest.raises(PresetError, match="exceeds maximum size"): + catalog._fetch_single_catalog(entry, force_refresh=True) + + assert not catalog.cache_dir.exists() or not any(catalog.cache_dir.iterdir()) + + def test_download_pack_rejects_oversized_body_without_output( + self, project_dir, monkeypatch + ): + """Package bounds fail before checksum verification or disk writes.""" + import specify_cli.presets as preset_module + from unittest.mock import patch + from specify_cli._download_security import ( + read_response_limited as real_read_response_limited, + ) + + catalog = PresetCatalog(project_dir) + pack_info = { + "id": "test-pack", + "name": "Test Pack", + "version": "1.0.0", + "download_url": "https://example.com/test-pack.zip", + "_install_allowed": True, + } + response = MagicMock() + response.read.side_effect = io.BytesIO(b"12345").read + response.__enter__.return_value = response + response.__exit__.return_value = False + + def read_with_tiny_limit(stream, **kwargs): + kwargs.pop("max_bytes", None) + return real_read_response_limited(stream, max_bytes=4, **kwargs) + + monkeypatch.setattr( + preset_module, + "read_response_limited", + read_with_tiny_limit, + ) + with patch.object(preset_module, "verify_archive_sha256") as verify, \ + patch.object(catalog, "get_pack_info", return_value=pack_info), \ + patch.object(catalog, "_open_url", return_value=response): + with pytest.raises(PresetError, match="exceeds maximum size"): + catalog.download_pack("test-pack", target_dir=project_dir) + + verify.assert_not_called() + assert not (project_dir / "test-pack-1.0.0.zip").exists() + + def test_download_pack_rejects_unsafe_output_filename(self, project_dir): + """Catalog-controlled IDs cannot escape the requested target directory.""" + from unittest.mock import patch + + catalog = PresetCatalog(project_dir) + outside_stem = project_dir.parent / "outside-preset" + pack_id = str(outside_stem) + pack_info = { + "id": pack_id, + "name": "Test Pack", + "version": "1.0.0", + "download_url": "https://example.com/test-pack.zip", + "_install_allowed": True, + } + + with patch.object(catalog, "get_pack_info", return_value=pack_info), \ + patch.object(catalog, "_open_url") as open_url: + with pytest.raises(PresetError, match="filename"): + catalog.download_pack(pack_id, target_dir=project_dir) + + open_url.assert_not_called() + assert not Path(f"{outside_stem}-1.0.0.zip").exists() + + def test_download_pack_accepts_matching_sha256(self, project_dir): + """A catalog ``sha256`` that matches the preset archive is accepted.""" + import hashlib + from unittest.mock import patch + + catalog = PresetCatalog(project_dir) + zip_bytes, resp = self._pack_zip_and_response() + pack_info = { + "id": "test-pack", + "name": "Test Pack", + "version": "1.0.0", + "download_url": "https://example.com/test-pack.zip", + "sha256": hashlib.sha256(zip_bytes).hexdigest(), + "_install_allowed": True, + } + + with patch.object(catalog, "get_pack_info", return_value=pack_info), \ + patch.object(catalog, "_open_url", return_value=resp): + zip_path = catalog.download_pack("test-pack", target_dir=project_dir) + + assert zip_path.read_bytes() == zip_bytes + + def test_download_pack_rejects_sha256_mismatch(self, project_dir): + """A catalog ``sha256`` that does not match the archive aborts install.""" + from unittest.mock import patch + + catalog = PresetCatalog(project_dir) + _zip_bytes, resp = self._pack_zip_and_response() + pack_info = { + "id": "test-pack", + "name": "Test Pack", + "version": "1.0.0", + "download_url": "https://example.com/test-pack.zip", + "sha256": "0" * 64, # deliberately wrong + "_install_allowed": True, + } + + with patch.object(catalog, "get_pack_info", return_value=pack_info), \ + patch.object(catalog, "_open_url", return_value=resp): + with pytest.raises(PresetError, match="[Ii]ntegrity"): + catalog.download_pack("test-pack", target_dir=project_dir) + + def test_download_pack_malformed_url_raises_preset_error(self, project_dir): + """A catalog ``download_url`` with a malformed authority (e.g. an + unterminated IPv6 bracket) surfaces a clean ``PresetError`` rather than + leaking a raw ``ValueError`` from ``urlparse``/``.hostname`` past the + command handler (which only catches ``PresetError``). Mirrors the + extensions coverage. + """ + from unittest.mock import patch + + catalog = PresetCatalog(project_dir) + for bad_url in ( + "https://[::1", + "https://[not-an-ip]/x", + "https://example.com:65536/x", + "https:///x", + 123, + ): + pack_info = { + "id": "test-pack", + "name": "Test Pack", + "version": "1.0.0", + "download_url": bad_url, + "_install_allowed": True, + } + with patch.object(catalog, "get_pack_info", return_value=pack_info), \ + patch.object(catalog, "_open_url") as open_url: + with pytest.raises(PresetError, match="malformed"): + catalog.download_pack("test-pack", target_dir=project_dir) + open_url.assert_not_called() + + def test_download_pack_without_sha256_skips_verification(self, project_dir): + """A catalog entry with no ``sha256`` keeps working: verification is + opt-in, so the backwards-compatible path (``pack_info.get("sha256")`` + is ``None``) must download without aborting — mirrors the extensions + coverage so the helper never silently becomes mandatory for presets. + """ + from unittest.mock import patch + + catalog = PresetCatalog(project_dir) + zip_bytes, resp = self._pack_zip_and_response() + pack_info = { + "id": "test-pack", + "name": "Test Pack", + "version": "1.0.0", + "download_url": "https://example.com/test-pack.zip", + "_install_allowed": True, + } + + with patch.object(catalog, "get_pack_info", return_value=pack_info), \ + patch.object(catalog, "_open_url", return_value=resp): + zip_path = catalog.download_pack("test-pack", target_dir=project_dir) + + assert zip_path.read_bytes() == zip_bytes + + def test_download_pack_accepts_direct_github_rest_asset_url(self, project_dir, monkeypatch): + """download_pack can use a GitHub REST release asset URL directly.""" + from unittest.mock import patch, MagicMock + + monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken") + self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN") + catalog = PresetCatalog(project_dir) + + import io + zip_buf = io.BytesIO() + with zipfile.ZipFile(zip_buf, "w") as zf: + zf.writestr("preset.yml", "id: test-pack\nname: Test\nversion: 1.0.0\n") + zip_bytes = zip_buf.getvalue() + + asset_response = MagicMock() + asset_response.read.side_effect = io.BytesIO(zip_bytes).read + asset_response.__enter__ = lambda s: s + asset_response.__exit__ = MagicMock(return_value=False) + + captured = [] + mock_opener = MagicMock() + + def fake_open(req, timeout=None): + captured.append(req) + return asset_response + + mock_opener.open.side_effect = fake_open + + pack_info = { + "id": "test-pack", + "name": "Test Pack", + "version": "1.0.0", + "download_url": "https://api.github.com/repos/org/repo/releases/assets/1", + "_install_allowed": True, + } + + with patch.object(catalog, "get_pack_info", return_value=pack_info), \ + patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener): + catalog.download_pack("test-pack", target_dir=project_dir) + + assert len(captured) == 1 + assert captured[0].full_url == "https://api.github.com/repos/org/repo/releases/assets/1" + assert captured[0].get_header("Authorization") == "Bearer ghp_testtoken" + assert captured[0].get_header("Accept") == "application/octet-stream" + + @pytest.mark.parametrize("suffix", [".tar.gz", ".tgz"]) + def test_download_pack_preserves_tar_archive_format( + self, project_dir, suffix + ): + from unittest.mock import patch, MagicMock + + archive_buffer = io.BytesIO() + with tarfile.open(fileobj=archive_buffer, mode="w:gz") as archive: + content = b"preset:\n id: test-pack\n" + member = tarfile.TarInfo("preset.yml") + member.size = len(content) + archive.addfile(member, io.BytesIO(content)) + archive_bytes = archive_buffer.getvalue() + response = MagicMock() + response.read.side_effect = io.BytesIO(archive_bytes).read + response.__enter__.return_value = response + response.__exit__.return_value = False + catalog = PresetCatalog(project_dir) + pack_info = { + "id": "test-pack", + "name": "Test Pack", + "version": "1.0.0", + "download_url": f"https://example.com/test-pack{suffix}", + "_install_allowed": True, + } + + with patch.object(catalog, "get_pack_info", return_value=pack_info), \ + patch.object(catalog, "_open_url", return_value=response): + archive_path = catalog.download_pack("test-pack", target_dir=project_dir) + + assert archive_path.name == "test-pack-1.0.0.tar.gz" + assert archive_path.read_bytes() == archive_bytes + + +class TestPresetCatalogEntry: + """Test PresetCatalogEntry dataclass.""" + + def test_create_entry(self): + """Test creating a catalog entry.""" + entry = PresetCatalogEntry( + url="https://example.com/catalog.json", + name="test", + priority=1, + install_allowed=True, + description="Test catalog", + ) + assert entry.url == "https://example.com/catalog.json" + assert entry.name == "test" + assert entry.priority == 1 + assert entry.install_allowed is True + assert entry.description == "Test catalog" + + def test_default_description(self): + """Test default empty description.""" + entry = PresetCatalogEntry( + url="https://example.com/catalog.json", + name="test", + priority=1, + install_allowed=False, + ) + assert entry.description == "" + + +class TestPresetCatalogMultiCatalog: + """Test multi-catalog support in PresetCatalog.""" + + def test_default_active_catalogs(self, project_dir): + """Test that default catalogs are returned when no config exists.""" + catalog = PresetCatalog(project_dir) + active = catalog.get_active_catalogs() + assert len(active) == 2 + assert active[0].name == "default" + assert active[0].priority == 1 + assert active[0].install_allowed is True + assert active[1].name == "community" + assert active[1].priority == 2 + assert active[1].install_allowed is False + + + + + + + def test_env_var_overrides_catalogs(self, project_dir, monkeypatch): + """Test that SPECKIT_PRESET_CATALOG_URL env var overrides defaults.""" + monkeypatch.setenv( + "SPECKIT_PRESET_CATALOG_URL", + "https://custom.example.com/catalog.json", + ) + catalog = PresetCatalog(project_dir) + active = catalog.get_active_catalogs() + assert len(active) == 1 + assert active[0].name == "custom" + assert active[0].url == "https://custom.example.com/catalog.json" + assert active[0].install_allowed is True + + def test_project_config_overrides_defaults(self, project_dir): + """Test that project-level config overrides built-in defaults.""" + config_path = project_dir / ".specify" / "preset-catalogs.yml" + config_path.write_text(yaml.dump({ + "catalogs": [ + { + "name": "my-catalog", + "url": "https://my.example.com/catalog.json", + "priority": 1, + "install_allowed": True, + } + ] + })) + + catalog = PresetCatalog(project_dir) + active = catalog.get_active_catalogs() + assert len(active) == 1 + assert active[0].name == "my-catalog" + assert active[0].url == "https://my.example.com/catalog.json" + + def test_load_catalog_config_nonexistent(self, project_dir): + """Test loading config from nonexistent file returns None.""" + catalog = PresetCatalog(project_dir) + result = catalog._load_catalog_config( + project_dir / ".specify" / "nonexistent.yml" + ) + assert result is None + + def test_load_catalog_config_empty(self, project_dir): + """Test loading empty config returns None.""" + config_path = project_dir / ".specify" / "preset-catalogs.yml" + config_path.write_text("") + + catalog = PresetCatalog(project_dir) + result = catalog._load_catalog_config(config_path) + assert result is None + + def test_load_catalog_config_defaults_blank_names(self, project_dir): + """Blank and null names normalize by valid catalog order.""" + config_path = project_dir / ".specify" / "preset-catalogs.yml" + config_path.write_text( + yaml.dump( + { + "catalogs": [ + {"name": "skipped", "url": " "}, + { + "name": None, + "url": "https://one.example.com/catalog.json", + }, + { + "name": " ", + "url": "https://two.example.com/catalog.json", + }, + { + "name": " padded-name ", + "url": "https://three.example.com/catalog.json", + }, + ] + } + ), + encoding="utf-8", + ) + + entries = PresetCatalog(project_dir)._load_catalog_config(config_path) + + assert [entry.name for entry in entries] == [ + "catalog-1", + "catalog-2", + "padded-name", + ] + + @pytest.mark.parametrize("bad", [[], False, 0, ""]) + def test_load_catalog_config_rejects_falsy_non_mapping_root( + self, project_dir, bad + ): + config_path = project_dir / ".specify" / "preset-catalogs.yml" + config_path.write_text(yaml.safe_dump(bad), encoding="utf-8") + + catalog = PresetCatalog(project_dir) + with pytest.raises(PresetValidationError, match="expected a mapping"): + catalog._load_catalog_config(config_path) + + def test_load_catalog_config_invalid_yaml(self, project_dir): + """Test loading invalid YAML raises error.""" + config_path = project_dir / ".specify" / "preset-catalogs.yml" + config_path.write_text(": invalid: {{{") + + catalog = PresetCatalog(project_dir) + with pytest.raises(PresetValidationError, match="Failed to read"): + catalog._load_catalog_config(config_path) + + def test_load_catalog_config_not_a_list(self, project_dir): + """Test that non-list catalogs key raises error.""" + config_path = project_dir / ".specify" / "preset-catalogs.yml" + config_path.write_text(yaml.dump({"catalogs": "not-a-list"})) + + catalog = PresetCatalog(project_dir) + with pytest.raises(PresetValidationError, match="must be a list"): + catalog._load_catalog_config(config_path) + + @pytest.mark.parametrize("body", ["catalogs: {}\n", "catalogs: ''\n", "catalogs: 0\n", "catalogs: false\n"]) + def test_load_catalog_config_rejects_falsy_non_list_catalogs(self, project_dir, body): + """A FALSY non-list ``catalogs:`` value must raise, like a truthy one + (``catalogs: "not-a-list"``) already does. The shape check sat behind + the emptiness check, so these were silently swallowed as "no catalogs".""" + config_path = project_dir / ".specify" / "preset-catalogs.yml" + config_path.write_text(body, encoding="utf-8") + + catalog = PresetCatalog(project_dir) + with pytest.raises(PresetValidationError, match="must be a list"): + catalog._load_catalog_config(config_path) + + def test_load_catalog_config_invalid_entry(self, project_dir): + """Test that non-dict entry raises error.""" + config_path = project_dir / ".specify" / "preset-catalogs.yml" + config_path.write_text(yaml.dump({"catalogs": ["not-a-dict"]})) + + catalog = PresetCatalog(project_dir) + with pytest.raises(PresetValidationError, match="expected a mapping"): + catalog._load_catalog_config(config_path) + + def test_load_catalog_config_http_url_rejected(self, project_dir): + """Test that HTTP URLs are rejected.""" + config_path = project_dir / ".specify" / "preset-catalogs.yml" + config_path.write_text(yaml.dump({ + "catalogs": [ + { + "name": "bad", + "url": "http://insecure.example.com/catalog.json", + "priority": 1, + } + ] + })) + + catalog = PresetCatalog(project_dir) + with pytest.raises(PresetValidationError, match="must use HTTPS"): + catalog._load_catalog_config(config_path) + + def test_load_catalog_config_priority_sorting(self, project_dir): + """Test that catalogs are sorted by priority.""" + config_path = project_dir / ".specify" / "preset-catalogs.yml" + config_path.write_text(yaml.dump({ + "catalogs": [ + { + "name": "low-priority", + "url": "https://low.example.com/catalog.json", + "priority": 10, + "install_allowed": False, + }, + { + "name": "high-priority", + "url": "https://high.example.com/catalog.json", + "priority": 1, + "install_allowed": True, + }, + ] + })) + + catalog = PresetCatalog(project_dir) + entries = catalog._load_catalog_config(config_path) + assert entries is not None + assert len(entries) == 2 + assert entries[0].name == "high-priority" + assert entries[1].name == "low-priority" + + def test_load_catalog_config_invalid_priority(self, project_dir): + """Test that invalid priority raises error.""" + config_path = project_dir / ".specify" / "preset-catalogs.yml" + config_path.write_text(yaml.dump({ + "catalogs": [ + { + "name": "bad", + "url": "https://example.com/catalog.json", + "priority": "not-a-number", + } + ] + })) + + catalog = PresetCatalog(project_dir) + with pytest.raises(PresetValidationError, match="Invalid priority"): + catalog._load_catalog_config(config_path) + + def test_load_catalog_config_rejects_boolean_priority(self, project_dir): + """A YAML ``priority: true`` is a typo, not a request for priority 1. + + ``bool`` is a subclass of ``int`` in Python, so ``int(True)`` silently + returns ``1``. Without an explicit guard a malformed config like + ``priority: yes`` would be accepted as a valid priority of 1 and + silently change catalog ordering. The sibling integration-catalog + reader rejects this case (see ``catalogs.py``); the preset catalog + reader must stay consistent. + """ + config_path = project_dir / ".specify" / "preset-catalogs.yml" + config_path.write_text(yaml.dump({ + "catalogs": [ + { + "name": "bool-priority", + "url": "https://example.com/catalog.json", + "priority": True, + } + ] + })) + + catalog = PresetCatalog(project_dir) + with pytest.raises(PresetValidationError, match="Invalid priority|expected integer"): + catalog._load_catalog_config(config_path) + + def test_load_catalog_config_rejects_infinite_priority(self, project_dir): + """A ``priority: .inf`` yields a clean validation error, not an uncaught + OverflowError from int(float('inf')).""" + config_path = project_dir / ".specify" / "preset-catalogs.yml" + config_path.write_text(yaml.dump({ + "catalogs": [ + { + "name": "inf-priority", + "url": "https://example.com/catalog.json", + "priority": float("inf"), + } + ] + })) + + catalog = PresetCatalog(project_dir) + with pytest.raises(PresetValidationError, match="Invalid priority|expected integer"): + catalog._load_catalog_config(config_path) + + def test_load_catalog_config_install_allowed_string(self, project_dir): + """Test that install_allowed accepts string values.""" + config_path = project_dir / ".specify" / "preset-catalogs.yml" + config_path.write_text(yaml.dump({ + "catalogs": [ + { + "name": "test", + "url": "https://example.com/catalog.json", + "priority": 1, + "install_allowed": "true", + } + ] + })) + + catalog = PresetCatalog(project_dir) + entries = catalog._load_catalog_config(config_path) + assert entries is not None + assert entries[0].install_allowed is True + + def test_get_catalog_url_uses_highest_priority(self, project_dir): + """Test that get_catalog_url returns URL of highest priority catalog.""" + config_path = project_dir / ".specify" / "preset-catalogs.yml" + config_path.write_text(yaml.dump({ + "catalogs": [ + { + "name": "secondary", + "url": "https://secondary.example.com/catalog.json", + "priority": 5, + }, + { + "name": "primary", + "url": "https://primary.example.com/catalog.json", + "priority": 1, + }, + ] + })) + + catalog = PresetCatalog(project_dir) + assert catalog.get_catalog_url() == "https://primary.example.com/catalog.json" + + def test_cache_paths_default_url(self, project_dir): + """Test cache paths for default catalog URL use legacy locations.""" + catalog = PresetCatalog(project_dir) + cache_file, metadata_file = catalog._get_cache_paths( + PresetCatalog.DEFAULT_CATALOG_URL + ) + assert cache_file == catalog.cache_file + assert metadata_file == catalog.cache_metadata_file + + def test_cache_paths_custom_url(self, project_dir): + """Test cache paths for custom URLs use hash-based files.""" + catalog = PresetCatalog(project_dir) + cache_file, metadata_file = catalog._get_cache_paths( + "https://custom.example.com/catalog.json" + ) + assert cache_file != catalog.cache_file + assert "catalog-" in cache_file.name + assert cache_file.name.endswith(".json") + + def test_url_cache_valid(self, project_dir): + """Test URL-specific cache validation.""" + catalog = PresetCatalog(project_dir) + url = "https://custom.example.com/catalog.json" + cache_file, metadata_file = catalog._get_cache_paths(url) + + catalog.cache_dir.mkdir(parents=True, exist_ok=True) + cache_file.write_text(json.dumps({"schema_version": "1.0", "presets": {}})) + metadata_file.write_text(json.dumps({ + "cached_at": datetime.now(timezone.utc).isoformat(), + })) + + assert catalog._is_url_cache_valid(url) is True + + def test_url_cache_expired(self, project_dir): + """Test URL-specific cache expiration.""" + catalog = PresetCatalog(project_dir) + url = "https://custom.example.com/catalog.json" + cache_file, metadata_file = catalog._get_cache_paths(url) + + catalog.cache_dir.mkdir(parents=True, exist_ok=True) + cache_file.write_text(json.dumps({"schema_version": "1.0", "presets": {}})) + metadata_file.write_text(json.dumps({ + "cached_at": "2020-01-01T00:00:00+00:00", + })) + + assert catalog._is_url_cache_valid(url) is False + + +class TestBundledPresetLocator: + """Catalog downloads reject bundled presets.""" + + def test_bundled_preset_download_raises_error(self, project_dir): + """download_pack raises PresetError for bundled presets without download_url.""" + catalog = PresetCatalog(project_dir) + + catalog_data = { + "test-bundled": { + "name": "Test Bundled", + "version": "1.0.0", + "bundled": True, + } + } + from unittest.mock import patch + with patch.object(catalog, "_get_merged_packs", return_value=catalog_data): + with pytest.raises(PresetError, match="bundled with spec-kit"): + catalog.download_pack("test-bundled") + + +def test_preset_wrapper_resolves_ghes_asset_when_host_configured(tmp_path, monkeypatch): + """End-to-end wiring for presets: auth.json github host → GHES asset resolution.""" + from specify_cli.authentication import http as _auth_http + from specify_cli.authentication.config import AuthConfigEntry + from specify_cli.presets import PresetCatalog + + monkeypatch.setattr(_auth_http, "_config_override", [ + AuthConfigEntry(hosts=("ghes.example",), provider="github", + auth="bearer", token="t"), + ]) + catalog = PresetCatalog(tmp_path) + + captured = [] + + @contextmanager + def fake_open(url, timeout=None, extra_headers=None): + captured.append(url) + resp = MagicMock() + resp.read.side_effect = io.BytesIO(json.dumps({ + "assets": [{"name": "pack.zip", + "url": "https://ghes.example/api/v3/repos/o/r/releases/assets/9"}] + }).encode()).read + yield resp + + monkeypatch.setattr(catalog, "_open_url", fake_open) + + resolved = catalog._resolve_github_release_asset_api_url( + "https://ghes.example/o/r/releases/download/v2/pack.zip" + ) + assert resolved == "https://ghes.example/api/v3/repos/o/r/releases/assets/9" + assert captured == ["https://ghes.example/api/v3/repos/o/r/releases/tags/v2"] diff --git a/tests/specify_cli/presets/test_domain_exports.py b/tests/specify_cli/presets/test_domain_exports.py new file mode 100644 index 0000000000..b812b52f1e --- /dev/null +++ b/tests/specify_cli/presets/test_domain_exports.py @@ -0,0 +1,30 @@ +"""Existing preset import paths remain usable after the domain split.""" + +from importlib import import_module + +import pytest + +import specify_cli.presets as presets + + +@pytest.mark.parametrize( + ("name", "module"), + [ + ("PresetError", "_manifest"), + ("PresetValidationError", "_manifest"), + ("PresetCompatibilityError", "_manifest"), + ("PresetManifest", "_manifest"), + ("PresetRegistry", "_registry"), + ("PresetCatalogEntry", "_catalog"), + ("PresetCatalog", "_catalog"), + ("PresetResolver", "_resolver"), + ("PresetManager", "_manager"), + ("_materialize_constitution_template", "_manager"), + ("_constitution_provenance_matches_preset", "_manager"), + ("_substitute_core_template", "_manager_commands"), + ], +) +def test_package_exports_preserve_private_implementation_identity(name, module): + implementation = import_module(f"specify_cli.presets.{module}") + + assert getattr(presets, name) is getattr(implementation, name) diff --git a/tests/specify_cli/presets/test_manager.py b/tests/specify_cli/presets/test_manager.py new file mode 100644 index 0000000000..91e0805593 --- /dev/null +++ b/tests/specify_cli/presets/test_manager.py @@ -0,0 +1,1609 @@ +"""Tests for preset installation and removal in specify_cli.presets._manager.""" + +import json +import tarfile +import zipfile +from pathlib import Path + +import pytest +import yaml + +from specify_cli.presets import ( + PresetCompatibilityError, + PresetError, + PresetManager, + PresetManifest, + PresetResolver, + PresetValidationError, +) +from tests.specify_cli.presets._helpers import ( + CORE_TEMPLATE_NAMES, + PresetArtifactTestHelpers, + install_constitution_sync_preset, + install_self_test_preset, +) +from tests.specify_cli.presets._helpers import ( + make_convention_constitution_preset as _make_convention_constitution_preset, +) + + +class TestPresetManifest: + """Manager handling of invalid installed manifests.""" + + def test_one_bad_manifest_does_not_hide_healthy_presets(self, temp_dir): + """End-to-end guard for the symptom: an unquoted ``version: 1.0`` in one + installed preset must degrade to "Corrupted preset" and still let + list_installed() report the healthy ones, instead of raising TypeError + out of the whole call. + """ + preset_root = temp_dir / ".specify" / "presets" + for pack_id, version in (("good-pack", '"1.0.0"'), ("bad-pack", "1.0")): + pack_path = preset_root / pack_id + pack_path.mkdir(parents=True, exist_ok=True) + (pack_path / "preset.yml").write_text( + f"""schema_version: "1.0" +preset: + id: {pack_id} + name: {pack_id} + version: {version} + description: desc +requires: + speckit_version: ">=0.1.0" +provides: + templates: + - type: template + name: spec + file: templates/spec.md +""", + encoding="utf-8", + ) + (preset_root / ".registry").write_text( + json.dumps( + { + "schema_version": "1.0", + "presets": { + "good-pack": {"version": "1.0.0", "enabled": True}, + "bad-pack": {"version": "1.0", "enabled": True}, + }, + } + ), + encoding="utf-8", + ) + + listed = {row["id"]: row for row in PresetManager(temp_dir).list_installed()} + + assert set(listed) == {"good-pack", "bad-pack"} + assert "Corrupted" not in listed["good-pack"]["description"] + assert "Corrupted" in listed["bad-pack"]["description"] + + +def test_unreadable_constitution_provenance_fails_closed( + project_dir, monkeypatch +): + from specify_cli.presets import _constitution_provenance_matches_preset + + memory = project_dir / ".specify" / "memory" / "constitution.md" + memory.parent.mkdir(parents=True, exist_ok=True) + memory.write_text("# Constitution\n", encoding="utf-8") + provenance = memory.parent / ".constitution-template.json" + provenance.write_text("{}", encoding="utf-8") + real_read_text = Path.read_text + + def unreadable(path, *args, **kwargs): + if path == provenance: + raise OSError("simulated read failure") + return real_read_text(path, *args, **kwargs) + + monkeypatch.setattr(Path, "read_text", unreadable) + + assert not _constitution_provenance_matches_preset( + project_dir, memory, "example", "1.0.0" + ) + + +class TestPresetManager: + """Test PresetManager installation and removal.""" + + def test_install_from_directory(self, project_dir, pack_dir): + """Test installing a preset from a directory.""" + manager = PresetManager(project_dir) + manifest = manager.install_from_directory(pack_dir, "0.1.5") + + assert manifest.id == "test-pack" + assert manager.registry.is_installed("test-pack") + + # Verify files are copied + installed_dir = project_dir / ".specify" / "presets" / "test-pack" + assert installed_dir.exists() + assert (installed_dir / "preset.yml").exists() + assert (installed_dir / "templates" / "spec-template.md").exists() + + def test_install_already_installed(self, project_dir, pack_dir): + """Test installing an already-installed pack raises error.""" + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.5") + + with pytest.raises(PresetError, match="already installed"): + manager.install_from_directory(pack_dir, "0.1.5") + + def test_install_incompatible(self, project_dir, temp_dir, valid_pack_data): + """Test installing an incompatible pack raises error.""" + valid_pack_data["requires"]["speckit_version"] = ">=99.0.0" + incompat_dir = temp_dir / "incompat-pack" + incompat_dir.mkdir() + manifest_path = incompat_dir / "preset.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_pack_data, f) + (incompat_dir / "templates").mkdir() + (incompat_dir / "templates" / "spec-template.md").write_text("test") + + manager = PresetManager(project_dir) + with pytest.raises(PresetCompatibilityError): + manager.install_from_directory(incompat_dir, "0.1.5") + + def test_install_from_zip(self, project_dir, pack_dir, temp_dir): + """Test installing from a ZIP file.""" + zip_path = temp_dir / "test-pack.zip" + with zipfile.ZipFile(zip_path, 'w') as zf: + for file_path in pack_dir.rglob('*'): + if file_path.is_file(): + arcname = file_path.relative_to(pack_dir) + zf.write(file_path, arcname) + + manager = PresetManager(project_dir) + manifest = manager.install_from_zip( + zip_path, "0.1.5", catalog_name="preset-catalog" + ) + assert manifest.id == "test-pack" + assert manager.registry.is_installed("test-pack") + assert manager.registry.get("test-pack")["source"] == { + "kind": "catalog", + "catalog": "preset-catalog", + } + + def test_install_from_zip_forwards_force( + self, project_dir, pack_dir, temp_dir + ): + """The compatibility wrapper must retain forced reinstall behavior.""" + zip_path = temp_dir / "test-pack.zip" + with zipfile.ZipFile(zip_path, "w") as zf: + for file_path in pack_dir.rglob("*"): + if file_path.is_file(): + zf.write(file_path, file_path.relative_to(pack_dir)) + + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.5") + manifest = manager.install_from_zip( + zip_path, + "0.1.5", + force=True, + ) + + assert manifest.id == "test-pack" + assert manager.registry.is_installed("test-pack") + + def test_install_from_zip_nested(self, project_dir, pack_dir, temp_dir): + """Test installing from ZIP with nested directory.""" + zip_path = temp_dir / "test-pack.zip" + with zipfile.ZipFile(zip_path, 'w') as zf: + for file_path in pack_dir.rglob('*'): + if file_path.is_file(): + arcname = Path("test-pack-v1.0.0") / file_path.relative_to(pack_dir) + zf.write(file_path, arcname) + + manager = PresetManager(project_dir) + manifest = manager.install_from_zip(zip_path, "0.1.5") + assert manifest.id == "test-pack" + + def test_install_from_zip_no_manifest(self, project_dir, temp_dir): + """Test installing from ZIP without manifest raises error.""" + zip_path = temp_dir / "bad.zip" + with zipfile.ZipFile(zip_path, 'w') as zf: + zf.writestr("readme.txt", "no manifest here") + + manager = PresetManager(project_dir) + with pytest.raises(PresetValidationError, match="No preset.yml found"): + manager.install_from_zip(zip_path, "0.1.5") + + def test_install_from_zip_rejects_symlink_entry( + self, project_dir, pack_dir, temp_dir + ): + """Preset ZIPs delegate to the shared symlink-safe extractor.""" + import stat + + zip_path = temp_dir / "symlink-preset.zip" + link = zipfile.ZipInfo("templates/escape") + link.create_system = 3 + link.external_attr = (stat.S_IFLNK | 0o777) << 16 + with zipfile.ZipFile(zip_path, "w") as zf: + for file_path in pack_dir.rglob("*"): + if file_path.is_file(): + zf.write(file_path, file_path.relative_to(pack_dir)) + zf.writestr(link, "../../outside") + + manager = PresetManager(project_dir) + with pytest.raises(PresetValidationError, match="Unsafe symlink"): + manager.install_from_zip(zip_path, "0.1.5") + + assert not manager.registry.is_installed("test-pack") + + @pytest.mark.parametrize("suffix", [".tar.gz", ".tgz"]) + @pytest.mark.parametrize("nested", [False, True]) + def test_install_from_tar_archive( + self, project_dir, pack_dir, temp_dir, suffix, nested + ): + """Tar archives install with the same flat/nested behavior as ZIP.""" + archive_path = temp_dir / f"test-pack{suffix}" + with tarfile.open(archive_path, "w:gz") as archive: + for file_path in pack_dir.rglob("*"): + if file_path.is_file(): + relative = file_path.relative_to(pack_dir) + arcname = Path("test-pack-v1") / relative if nested else relative + archive.add(file_path, arcname=arcname) + + manager = PresetManager(project_dir) + manifest = manager.install_from_archive( + archive_path, "0.1.5", catalog_name="preset-catalog" + ) + + assert manifest.id == "test-pack" + assert manager.registry.is_installed("test-pack") + assert manager.registry.get("test-pack")["source"] == { + "kind": "catalog", + "catalog": "preset-catalog", + } + + def test_install_from_tar_rejects_symlink_entry( + self, project_dir, pack_dir, temp_dir + ): + archive_path = temp_dir / "symlink-preset.tar.gz" + with tarfile.open(archive_path, "w:gz") as archive: + for file_path in pack_dir.rglob("*"): + if file_path.is_file(): + archive.add(file_path, arcname=file_path.relative_to(pack_dir)) + link = tarfile.TarInfo("templates/escape") + link.type = tarfile.SYMTYPE + link.linkname = "../../outside" + archive.addfile(link) + + manager = PresetManager(project_dir) + with pytest.raises(PresetValidationError, match="Unsafe symlink"): + manager.install_from_archive(archive_path, "0.1.5") + + assert not manager.registry.is_installed("test-pack") + + def test_remove(self, project_dir, pack_dir): + """Test removing a preset.""" + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.5") + assert manager.registry.is_installed("test-pack") + + result = manager.remove("test-pack") + assert result is True + assert not manager.registry.is_installed("test-pack") + + installed_dir = project_dir / ".specify" / "presets" / "test-pack" + assert not installed_dir.exists() + + def test_remove_nonexistent(self, project_dir): + """Test removing a pack that doesn't exist.""" + manager = PresetManager(project_dir) + result = manager.remove("nonexistent") + assert result is False + + def test_list_installed(self, project_dir, pack_dir): + """Test listing installed packs.""" + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.5") + + installed = manager.list_installed() + assert len(installed) == 1 + assert installed[0]["id"] == "test-pack" + assert installed[0]["name"] == "Test Preset" + assert installed[0]["version"] == "1.0.0" + assert installed[0]["template_count"] == 1 + + def test_list_installed_empty(self, project_dir): + """Test listing when no packs installed.""" + manager = PresetManager(project_dir) + assert manager.list_installed() == [] + + def test_get_pack(self, project_dir, pack_dir): + """Test getting a specific installed pack.""" + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.5") + + pack = manager.get_pack("test-pack") + assert pack is not None + assert pack.id == "test-pack" + + def test_get_pack_not_installed(self, project_dir): + """Test getting a non-installed pack returns None.""" + manager = PresetManager(project_dir) + assert manager.get_pack("nonexistent") is None + + def test_check_compatibility_valid(self, pack_dir, temp_dir): + """Test compatibility check with valid version.""" + manager = PresetManager(temp_dir) + manifest = PresetManifest(pack_dir / "preset.yml") + assert manager.check_compatibility(manifest, "0.1.5") is True + + def test_check_compatibility_prerelease(self, pack_dir, temp_dir): + """Test compatibility check allows prereleases and fails on boundary.""" + manager = PresetManager(temp_dir) + manifest = PresetManifest(pack_dir / "preset.yml") + # manifest requires >=0.1.0 + assert manager.check_compatibility(manifest, "0.8.8.dev0") is True + with pytest.raises(PresetCompatibilityError, match="Preset requires spec-kit"): + manager.check_compatibility(manifest, "0.1.0.dev0") + + def test_check_compatibility_invalid(self, pack_dir, temp_dir): + """Test compatibility check with invalid specifier.""" + manager = PresetManager(temp_dir) + manifest = PresetManifest(pack_dir / "preset.yml") + manifest.data["requires"]["speckit_version"] = "not-a-specifier" + with pytest.raises(PresetCompatibilityError, match="Invalid version specifier"): + manager.check_compatibility(manifest, "0.1.5") + + @pytest.mark.parametrize( + "bad", + [1.0, 5, True, None, [">=0.1.0"], {"min": "0.1"}], + ) + def test_check_compatibility_non_string_specifier(self, pack_dir, temp_dir, bad): + """check_compatibility() must report a non-string as a compatibility error. + + Defense in depth for the validator check: this method is public and the + specifier is read back out of mutable manifest data, and ``except + InvalidSpecifier`` does not cover a non-string. Without the guard, scalars + raise a bare TypeError and iterables construct fine only to break inside + .contains() -- neither is a PresetCompatibilityError, so both bypass the + CLI's "Compatibility Error" handler and exit 1 with a raw traceback. + """ + manager = PresetManager(temp_dir) + manifest = PresetManifest(pack_dir / "preset.yml") + manifest.data["requires"]["speckit_version"] = bad + with pytest.raises(PresetCompatibilityError, match="Invalid version specifier"): + manager.check_compatibility(manifest, "0.1.5") + + def test_install_with_priority(self, project_dir, pack_dir): + """Test installing a pack with custom priority.""" + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.5", priority=5) + + metadata = manager.registry.get("test-pack") + assert metadata is not None + assert metadata["priority"] == 5 + + def test_install_default_priority(self, project_dir, pack_dir): + """Test that default priority is 10.""" + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.5") + + metadata = manager.registry.get("test-pack") + assert metadata is not None + assert metadata["priority"] == 10 + + def test_list_installed_includes_priority(self, project_dir, pack_dir): + """Test that list_installed includes priority.""" + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.5", priority=3) + + installed = manager.list_installed() + assert len(installed) == 1 + assert installed[0]["priority"] == 3 + + +class TestPresetExtensionDependencies: + """Test find_unmet_extension_dependencies (issue #4231).""" + + @staticmethod + def _install_extension( + project_dir, extension_id, version, enabled=True, with_files=True + ): + """Register an installed extension the way the extension installer does. + + ``with_files=False`` leaves the registry entry without its directory, + reproducing the stale state left behind when the files are deleted out + from under the registry. + """ + extensions_dir = project_dir / ".specify" / "extensions" + extensions_dir.mkdir(parents=True, exist_ok=True) + if with_files: + (extensions_dir / extension_id).mkdir(parents=True, exist_ok=True) + registry_path = extensions_dir / ".registry" + data = {"schema_version": "1.0", "extensions": {}} + if registry_path.exists(): + data = json.loads(registry_path.read_text(encoding="utf-8")) + data["extensions"][extension_id] = {"version": version, "enabled": enabled} + registry_path.write_text(json.dumps(data), encoding="utf-8") + + @staticmethod + def _manifest(temp_dir, valid_pack_data, declared): + valid_pack_data["requires"]["extensions"] = declared + manifest_path = temp_dir / "dep-preset.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_pack_data, f) + return PresetManifest(manifest_path) + + def test_no_declared_dependencies_is_satisfied( + self, project_dir, temp_dir, valid_pack_data + ): + """A preset declaring nothing never reports an unmet dependency.""" + manifest_path = temp_dir / "plain-preset.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_pack_data, f) + + manager = PresetManager(project_dir) + assert manager.find_unmet_extension_dependencies( + PresetManifest(manifest_path) + ) == [] + + def test_missing_dependency_is_reported( + self, project_dir, temp_dir, valid_pack_data + ): + """An uninstalled required extension is reported as missing.""" + manifest = self._manifest(temp_dir, valid_pack_data, ["speckit-inventory"]) + + unmet = PresetManager(project_dir).find_unmet_extension_dependencies(manifest) + + assert len(unmet) == 1 + assert unmet[0]["id"] == "speckit-inventory" + assert unmet[0]["reason"] == "missing" + assert unmet[0]["installed"] is None + + def test_installed_dependency_is_satisfied( + self, project_dir, temp_dir, valid_pack_data + ): + """An installed extension with no version constraint is satisfied.""" + self._install_extension(project_dir, "speckit-inventory", "0.1.0") + manifest = self._manifest(temp_dir, valid_pack_data, ["speckit-inventory"]) + + assert PresetManager(project_dir).find_unmet_extension_dependencies( + manifest + ) == [] + + def test_satisfied_version_constraint( + self, project_dir, temp_dir, valid_pack_data + ): + """A satisfied version constraint reports nothing.""" + self._install_extension(project_dir, "speckit-inventory", "1.5.0") + manifest = self._manifest( + temp_dir, valid_pack_data, + [{"id": "speckit-inventory", "version": ">=1.2.0"}], + ) + + assert PresetManager(project_dir).find_unmet_extension_dependencies( + manifest + ) == [] + + def test_unsatisfied_version_constraint_reports_both_versions( + self, project_dir, temp_dir, valid_pack_data + ): + """A version mismatch reports the installed version alongside the constraint.""" + self._install_extension(project_dir, "speckit-inventory", "0.1.0") + manifest = self._manifest( + temp_dir, valid_pack_data, + [{"id": "speckit-inventory", "version": ">=9.0.0"}], + ) + + unmet = PresetManager(project_dir).find_unmet_extension_dependencies(manifest) + + assert len(unmet) == 1 + assert unmet[0]["reason"] == "version" + assert unmet[0]["installed"] == "0.1.0" + assert unmet[0]["version"] == ">=9.0.0" + + + def test_optional_dependency_is_never_reported( + self, project_dir, temp_dir, valid_pack_data + ): + """`required: false` opts out of the warning even when absent.""" + manifest = self._manifest( + temp_dir, valid_pack_data, + [{"id": "speckit-inventory", "required": False}], + ) + + assert PresetManager(project_dir).find_unmet_extension_dependencies( + manifest + ) == [] + + @pytest.mark.parametrize("bad_version", [None, 5, "unknown", "", "latest"]) + def test_uncomparable_registry_version_is_not_a_mismatch( + self, project_dir, temp_dir, valid_pack_data, bad_version + ): + """A version that cannot be evaluated must not be reported as a mismatch. + + ``version_satisfies()`` returns False for an unparseable version, which + is indistinguishable from a genuine mismatch -- so a string like + "unknown" would otherwise be reported as failing a constraint nobody + can actually evaluate it against. + """ + self._install_extension(project_dir, "speckit-inventory", "0.1.0") + registry_path = project_dir / ".specify" / "extensions" / ".registry" + data = json.loads(registry_path.read_text(encoding="utf-8")) + data["extensions"]["speckit-inventory"]["version"] = bad_version + registry_path.write_text(json.dumps(data), encoding="utf-8") + + manifest = self._manifest( + temp_dir, valid_pack_data, + [{"id": "speckit-inventory", "version": ">=9.0.0"}], + ) + + assert PresetManager(project_dir).find_unmet_extension_dependencies( + manifest + ) == [] + + def test_unregistered_extension_on_disk_is_satisfied( + self, project_dir, temp_dir, valid_pack_data + ): + """A directory with no registry entry still resolves, so it is not missing. + + ``_get_all_extensions_by_priority`` admits safe unregistered + directories at implicit priority 10, so the preset works -- warning + that the dependency is absent would be a false alarm. + """ + (project_dir / ".specify" / "extensions" / "speckit-inventory").mkdir( + parents=True + ) + manifest = self._manifest(temp_dir, valid_pack_data, ["speckit-inventory"]) + + assert PresetManager(project_dir).find_unmet_extension_dependencies( + manifest + ) == [] + + def test_unregistered_extension_cannot_be_version_checked( + self, project_dir, temp_dir, valid_pack_data + ): + """No registry entry means no recorded version, so nothing to compare.""" + (project_dir / ".specify" / "extensions" / "speckit-inventory").mkdir( + parents=True + ) + manifest = self._manifest( + temp_dir, valid_pack_data, + [{"id": "speckit-inventory", "version": ">=9.0.0"}], + ) + + assert PresetManager(project_dir).find_unmet_extension_dependencies( + manifest + ) == [] + + def test_corrupted_registry_entry_with_directory_is_not_satisfied( + self, project_dir, temp_dir, valid_pack_data + ): + """A corrupted entry keeps its id registered, so its directory is excluded. + + ``get()`` returns None for a non-dict entry just as it does for an + absent one, but ``keys()`` retains the id specifically so resolution + does not re-admit the directory as an unregistered extension. The + fallback must not revive what resolution excludes. + """ + extensions_dir = project_dir / ".specify" / "extensions" + (extensions_dir / "speckit-inventory").mkdir(parents=True) + (extensions_dir / ".registry").write_text( + json.dumps( + {"schema_version": "1.0", "extensions": {"speckit-inventory": "corrupt"}} + ), + encoding="utf-8", + ) + manifest = self._manifest(temp_dir, valid_pack_data, ["speckit-inventory"]) + + unmet = PresetManager(project_dir).find_unmet_extension_dependencies(manifest) + + # Reported as corrupt rather than missing: the id is still registered, + # so a plain `extension add` would be refused as already installed. + assert [dep["reason"] for dep in unmet] == ["corrupt"] + + + + + def test_unregistered_extension_with_corrupt_registry_is_missing( + self, project_dir, temp_dir, valid_pack_data + ): + """A corrupt registry makes resolution fail closed, so it is not usable.""" + extensions_dir = project_dir / ".specify" / "extensions" + (extensions_dir / "speckit-inventory").mkdir(parents=True) + (extensions_dir / ".registry").write_text("{not valid json", encoding="utf-8") + manifest = self._manifest(temp_dir, valid_pack_data, ["speckit-inventory"]) + + unmet = PresetManager(project_dir).find_unmet_extension_dependencies(manifest) + + assert [dep["reason"] for dep in unmet] == ["missing"] + + def test_corrupted_entry_gets_a_forced_reinstall_remedy( + self, project_dir, temp_dir, valid_pack_data + ): + """A corrupted entry is not simply absent: `add ` would be refused. + + ``get()`` returns None for it, but ``is_installed()`` still counts the + key, so a plain add reports "already installed". It needs --force. + """ + extensions_dir = project_dir / ".specify" / "extensions" + extensions_dir.mkdir(parents=True) + (extensions_dir / ".registry").write_text( + json.dumps( + {"schema_version": "1.0", "extensions": {"speckit-inventory": "bad"}} + ), + encoding="utf-8", + ) + manifest = self._manifest(temp_dir, valid_pack_data, ["speckit-inventory"]) + + unmet = PresetManager(project_dir).find_unmet_extension_dependencies(manifest) + + assert [dep["reason"] for dep in unmet] == ["corrupt"] + + + def test_unreadable_registry_does_not_raise( + self, project_dir, temp_dir, valid_pack_data, monkeypatch + ): + """An OSError from the registry must not crash an already-completed install. + + ``_load()`` lets OSError through, and ``preset_add`` only handles + preset-domain errors, so raising here would turn a finished install + into a traceback over what is only a warning. + """ + manifest = self._manifest(temp_dir, valid_pack_data, ["speckit-inventory"]) + + import specify_cli.presets as presets_mod + + def _boom(*args, **kwargs): + raise PermissionError("registry unreadable") + + monkeypatch.setattr(presets_mod, "ExtensionRegistry", _boom) + + assert PresetManager(project_dir).find_unmet_extension_dependencies( + manifest + ) == [] + + + + def test_exact_duplicate_declarations_warn_once( + self, project_dir, temp_dir, valid_pack_data + ): + """Naming the same dependency twice must not print the warning twice.""" + manifest = self._manifest( + temp_dir, valid_pack_data, ["speckit-inventory", "speckit-inventory"] + ) + + unmet = PresetManager(project_dir).find_unmet_extension_dependencies(manifest) + + assert [dep["id"] for dep in unmet] == ["speckit-inventory"] + + def test_same_id_with_different_constraints_is_checked_twice( + self, project_dir, temp_dir, valid_pack_data + ): + """Distinct constraints on one id both have to hold, so both are checked.""" + self._install_extension(project_dir, "speckit-inventory", "1.0.0") + manifest = self._manifest( + temp_dir, valid_pack_data, + [ + {"id": "speckit-inventory", "version": ">=9.0.0"}, + {"id": "speckit-inventory", "version": "<0.5"}, + ], + ) + + unmet = PresetManager(project_dir).find_unmet_extension_dependencies(manifest) + + assert [dep["version"] for dep in unmet] == [">=9.0.0", "<0.5"] + + def test_stale_registry_entry_is_reported( + self, project_dir, temp_dir, valid_pack_data + ): + """A registry entry whose extension directory is gone counts as unmet. + + PresetResolver guards on ``ext_dir.is_dir()`` in both template lookup + and layer collection, so a stale entry contributes nothing -- but the + surviving registry entry would otherwise read as satisfied. + """ + self._install_extension( + project_dir, "speckit-inventory", "0.1.0", with_files=False + ) + manifest = self._manifest(temp_dir, valid_pack_data, ["speckit-inventory"]) + + unmet = PresetManager(project_dir).find_unmet_extension_dependencies(manifest) + + assert len(unmet) == 1 + assert unmet[0]["reason"] == "stale" + assert unmet[0]["installed"] == "0.1.0" + + def test_stale_is_reported_ahead_of_disabled_and_version( + self, project_dir, temp_dir, valid_pack_data + ): + """Restoring the files is the prerequisite, so it is reported first.""" + self._install_extension( + project_dir, "speckit-inventory", "0.1.0", + enabled=False, with_files=False, + ) + manifest = self._manifest( + temp_dir, valid_pack_data, + [{"id": "speckit-inventory", "version": ">=9.0.0"}], + ) + + unmet = PresetManager(project_dir).find_unmet_extension_dependencies(manifest) + + assert [dep["reason"] for dep in unmet] == ["stale"] + + + def test_disabled_dependency_is_reported( + self, project_dir, temp_dir, valid_pack_data + ): + """A disabled extension contributes nothing, so it counts as unmet. + + Resolution skips disabled extensions, leaving the preset just as inert + as if the extension were absent -- but the registry entry exists, so a + presence-only check would call it satisfied and stay silent. + """ + self._install_extension(project_dir, "speckit-inventory", "0.1.0", enabled=False) + manifest = self._manifest(temp_dir, valid_pack_data, ["speckit-inventory"]) + + unmet = PresetManager(project_dir).find_unmet_extension_dependencies(manifest) + + assert len(unmet) == 1 + assert unmet[0]["reason"] == "disabled" + assert unmet[0]["installed"] == "0.1.0" + + def test_disabled_is_reported_ahead_of_version_mismatch( + self, project_dir, temp_dir, valid_pack_data + ): + """Enabling is the prerequisite, so it is reported before the version.""" + self._install_extension(project_dir, "speckit-inventory", "0.1.0", enabled=False) + manifest = self._manifest( + temp_dir, valid_pack_data, + [{"id": "speckit-inventory", "version": ">=9.0.0"}], + ) + + unmet = PresetManager(project_dir).find_unmet_extension_dependencies(manifest) + + assert [dep["reason"] for dep in unmet] == ["disabled"] + + def test_multiple_dependencies_report_independently( + self, project_dir, temp_dir, valid_pack_data + ): + """Each declared dependency is evaluated on its own.""" + self._install_extension(project_dir, "present-ext", "1.0.0") + self._install_extension(project_dir, "off-ext", "1.0.0", enabled=False) + manifest = self._manifest( + temp_dir, valid_pack_data, + [ + "present-ext", + "absent-ext", + "off-ext", + {"id": "opt-ext", "required": False}, + ], + ) + + unmet = PresetManager(project_dir).find_unmet_extension_dependencies(manifest) + + assert [(dep["id"], dep["reason"]) for dep in unmet] == [ + ("absent-ext", "missing"), + ("off-ext", "disabled"), + ] + + +class TestSelfTestPreset: + """Installation, removal, and constitution materialization using self-test.""" + + def test_install_self_test_preset(self, project_dir): + """Test installing the self-test preset from its directory.""" + manager = PresetManager(project_dir) + manifest = install_self_test_preset(manager) + assert manifest.id == "self-test" + assert manager.registry.is_installed("self-test") + + def test_self_test_removal_restores_core(self, project_dir): + """Test that removing self-test falls back to core templates.""" + templates_dir = project_dir / ".specify" / "templates" + for name in CORE_TEMPLATE_NAMES: + (templates_dir / f"{name}.md").write_text(f"# Core {name}\n") + + manager = PresetManager(project_dir) + install_constitution_sync_preset(manager) + install_self_test_preset(manager) + manager.remove("self-test") + + resolver = PresetResolver(project_dir) + for name in CORE_TEMPLATE_NAMES: + result = resolver.resolve_with_source(name) + assert result is not None + assert result["source"] == "core" + + memory = project_dir / ".specify" / "memory" / "constitution.md" + assert memory.read_text() == "# Core constitution-template\n" + + def test_self_test_removal_preserves_edited_constitution(self, project_dir): + """Removing a preset does not overwrite an edited generated constitution.""" + templates_dir = project_dir / ".specify" / "templates" + (templates_dir / "constitution-template.md").write_text("# Core Constitution\n") + + manager = PresetManager(project_dir) + install_constitution_sync_preset(manager) + install_self_test_preset(manager) + memory = project_dir / ".specify" / "memory" / "constitution.md" + edited = memory.read_text() + "\n## Authored amendment\n" + memory.write_text(edited) + + manager.remove("self-test") + + assert memory.read_text() == edited + + def test_self_test_does_not_seed_constitution_without_sync(self, project_dir): + """Installing a preset does not materialize its constitution by default.""" + manager = PresetManager(project_dir) + install_self_test_preset(manager) + + memory = project_dir / ".specify" / "memory" / "constitution.md" + assert not memory.exists() + + def test_self_test_preserves_generated_constitution_without_sync(self, project_dir): + """Preset install and removal preserve generated content without the opt-in.""" + resolver = PresetResolver(project_dir) + bundled_core = resolver._find_bundled_core( + "constitution-template", "template", ".md" + ) + assert bundled_core is not None + core = bundled_core.read_bytes() + memory = project_dir / ".specify" / "memory" / "constitution.md" + memory.parent.mkdir(parents=True, exist_ok=True) + memory.write_bytes(core) + + manager = PresetManager(project_dir) + install_self_test_preset(manager) + manager.remove("self-test") + + assert memory.read_bytes() == core + + def test_self_test_seeds_constitution_with_sync(self, project_dir): + """constitution-sync preserves the previous install-time seeding behavior.""" + manager = PresetManager(project_dir) + install_constitution_sync_preset(manager) + install_self_test_preset(manager) + + memory = project_dir / ".specify" / "memory" / "constitution.md" + assert "preset:self-test" in memory.read_text() + assert "[PROJECT_NAME]" not in memory.read_text() + + @pytest.mark.parametrize( + "provenance_content", + [ + '{"sha256": "does-not-match", "source": "old-preset"}\n', + "{not valid json", + ], + ids=["hash-mismatch", "malformed"], + ) + def test_self_test_preserves_core_content_with_existing_invalid_provenance( + self, project_dir, provenance_content + ): + """A present invalid sidecar disables legacy core-template migration.""" + resolver = PresetResolver(project_dir) + bundled_core = resolver._find_bundled_core( + "constitution-template", "template", ".md" + ) + assert bundled_core is not None + memory = project_dir / ".specify" / "memory" / "constitution.md" + memory.parent.mkdir(parents=True, exist_ok=True) + memory.write_bytes(bundled_core.read_bytes()) + (memory.parent / ".constitution-template.json").write_text( + provenance_content + ) + original = memory.read_bytes() + + manager = PresetManager(project_dir) + install_constitution_sync_preset(manager) + install_self_test_preset(manager) + + assert memory.read_bytes() == original + + def test_self_test_preserves_mutable_project_core_copy(self, project_dir): + """A project template copy does not establish generated provenance.""" + authored = "# Acme Organization Constitution\n\nOrganization policy.\n" + project_template = ( + project_dir / ".specify" / "templates" / "constitution-template.md" + ) + project_template.write_text(authored) + memory = project_dir / ".specify" / "memory" / "constitution.md" + memory.parent.mkdir(parents=True, exist_ok=True) + memory.write_text(authored) + + manager = PresetManager(project_dir) + install_constitution_sync_preset(manager) + install_self_test_preset(manager) + + assert memory.read_text() == authored + assert not (memory.parent / ".constitution-template.json").exists() + + def test_core_prefixed_preset_does_not_establish_generated_provenance( + self, project_dir, temp_dir + ): + """A preset ID beginning with core is not an immutable core source.""" + authored = "# Acme Organization Constitution\n\nOrganization policy.\n" + memory = project_dir / ".specify" / "memory" / "constitution.md" + memory.parent.mkdir(parents=True, exist_ok=True) + memory.write_text(authored) + + preset_dir = temp_dir / "core-company" + (preset_dir / "templates").mkdir(parents=True) + (preset_dir / "templates" / "constitution-template.md").write_text(authored) + (preset_dir / "preset.yml").write_text( + yaml.safe_dump( + { + "schema_version": "1.0", + "preset": { + "id": "core-company", + "name": "Core Company", + "version": "1.0.0", + "description": "Company constitution preset", + "author": "Test Author", + "repository": "https://github.com/test/core-company", + "license": "MIT", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "templates": [ + { + "type": "template", + "name": "constitution-template", + "file": "templates/constitution-template.md", + "description": "Company constitution", + "replaces": "constitution-template", + } + ] + }, + } + ) + ) + + manager = PresetManager(project_dir) + install_constitution_sync_preset(manager) + manager.install_from_directory(preset_dir, "0.1.5") + + assert memory.read_text() == authored + assert not (memory.parent / ".constitution-template.json").exists() + + def test_self_test_preserves_authored_constitution_with_placeholder( + self, project_dir + ): + """A placeholder mention does not establish generated provenance.""" + memory = project_dir / ".specify" / "memory" / "constitution.md" + memory.parent.mkdir(parents=True, exist_ok=True) + authored = "# Acme Constitution\n\nGuidance for [PROJECT_NAME].\n" + memory.write_text(authored) + + manager = PresetManager(project_dir) + install_constitution_sync_preset(manager) + install_self_test_preset(manager) + + assert memory.read_text() == authored + + def test_self_test_preserves_authored_constitution(self, project_dir): + """An authored (placeholder-free) constitution is never overwritten.""" + memory = project_dir / ".specify" / "memory" / "constitution.md" + memory.parent.mkdir(parents=True, exist_ok=True) + authored = "# Acme Constitution\n\n### I. Ship It\nAuthored by a human.\n" + memory.write_text(authored) + + manager = PresetManager(project_dir) + install_constitution_sync_preset(manager) + install_self_test_preset(manager) + + assert memory.read_text() == authored, "authored constitution was overwritten" + + def test_constitution_seed_composes_wrap_strategy(self, project_dir, temp_dir): + """Seeding memory composes wrap constitution-template layers.""" + templates_dir = project_dir / ".specify" / "templates" + templates_dir.mkdir(parents=True, exist_ok=True) + (templates_dir / "constitution-template.md").write_text( + "# Core Constitution\n\n## Core Principle\n" + ) + + preset_dir = temp_dir / "constitution-wrap" + (preset_dir / "templates").mkdir(parents=True) + (preset_dir / "templates" / "constitution-template.md").write_text( + "# Wrapper Constitution\n\n{CORE_TEMPLATE}\n\n## Wrapper Footer\n" + ) + (preset_dir / "preset.yml").write_text( + yaml.dump( + { + "schema_version": "1.0", + "preset": { + "id": "constitution-wrap", + "name": "Constitution Wrap", + "version": "1.0.0", + "description": "Wrap constitution template for testing", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "templates": [ + { + "type": "template", + "name": "constitution-template", + "file": "templates/constitution-template.md", + "strategy": "wrap", + "description": "Wrapped constitution template", + } + ] + }, + } + ) + ) + + manager = PresetManager(project_dir) + install_constitution_sync_preset(manager) + manager.install_from_directory(preset_dir, "0.1.5") + + memory = project_dir / ".specify" / "memory" / "constitution.md" + content = memory.read_text() + assert "{CORE_TEMPLATE}" not in content + assert "# Wrapper Constitution" in content + assert "## Core Principle" in content + + def test_constitution_follows_priority_when_winning_preset_removed( + self, project_dir, temp_dir + ): + """An unchanged generated constitution follows priority and fallback layers.""" + manager = PresetManager(project_dir) + install_constitution_sync_preset(manager) + install_self_test_preset(manager) + + preset_dir = temp_dir / "higher-priority" + (preset_dir / "templates").mkdir(parents=True) + (preset_dir / "templates" / "constitution-template.md").write_text( + "# Higher Priority Constitution\n" + ) + (preset_dir / "preset.yml").write_text( + yaml.dump( + { + "schema_version": "1.0", + "preset": { + "id": "higher-priority", + "name": "Higher Priority", + "version": "1.0.0", + "description": "Higher-priority constitution", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "templates": [ + { + "type": "template", + "name": "constitution-template", + "file": "templates/constitution-template.md", + "strategy": "replace", + "description": "Higher-priority constitution", + } + ] + }, + } + ) + ) + + manager.install_from_directory(preset_dir, "0.1.5", priority=1) + + memory = project_dir / ".specify" / "memory" / "constitution.md" + assert memory.read_text() == "# Higher Priority Constitution\n" + + manager.remove("higher-priority") + + assert "preset:self-test" in memory.read_text() + + def test_convention_constitution_removal_restores_remaining_layer( + self, project_dir, temp_dir + ): + """Removing a convention layer rematerializes the remaining resolver layer.""" + manager = PresetManager(project_dir) + install_constitution_sync_preset(manager) + install_self_test_preset(manager) + manager.install_from_directory( + _make_convention_constitution_preset(temp_dir), "0.1.5", priority=1 + ) + + memory = project_dir / ".specify" / "memory" / "constitution.md" + assert memory.read_text() == "# Convention Constitution\n" + + manager.remove("convention-constitution") + + assert "preset:self-test" in memory.read_text() + + def test_convention_constitution_removal_preserves_edited_content( + self, project_dir, temp_dir + ): + """Removing a convention layer does not overwrite edited generated content.""" + from specify_cli.command_init import ensure_constitution_from_template + + templates_dir = project_dir / ".specify" / "templates" + (templates_dir / "constitution-template.md").write_text("# Core Constitution\n") + manager = PresetManager(project_dir) + install_constitution_sync_preset(manager) + manager.install_from_directory( + _make_convention_constitution_preset(temp_dir), "0.1.5" + ) + ensure_constitution_from_template(project_dir) + memory = project_dir / ".specify" / "memory" / "constitution.md" + edited = memory.read_text() + "\n## Authored amendment\n" + memory.write_text(edited) + + manager.remove("convention-constitution") + + assert memory.read_text() == edited + + def test_custom_constitution_removal_recovers_with_invalid_manifest( + self, project_dir, temp_dir + ): + """Provenance triggers fallback when a custom-path manifest is invalid.""" + manager = PresetManager(project_dir) + install_constitution_sync_preset(manager) + install_self_test_preset(manager) + + preset_dir = temp_dir / "custom-constitution" + (preset_dir / "policy").mkdir(parents=True) + (preset_dir / "policy" / "charter.md").write_text("# Custom Constitution\n") + (preset_dir / "preset.yml").write_text( + yaml.dump( + { + "schema_version": "1.0", + "preset": { + "id": "custom-constitution", + "name": "Custom Constitution", + "version": "1.0.0", + "description": "Custom-path constitution for testing", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "templates": [ + { + "type": "template", + "name": "constitution-template", + "file": "policy/charter.md", + } + ] + }, + } + ) + ) + manager.install_from_directory(preset_dir, "0.1.5", priority=1) + memory = project_dir / ".specify" / "memory" / "constitution.md" + assert memory.read_text() == "# Custom Constitution\n" + + installed_manifest = ( + project_dir + / ".specify" + / "presets" + / "custom-constitution" + / "preset.yml" + ) + installed_manifest.write_text("invalid: [") + + manager.remove("custom-constitution") + + assert "preset:self-test" in memory.read_text() + + def test_constitution_seed_rejects_symlinked_memory_directory( + self, project_dir, temp_dir + ): + """Preset installation cannot seed through a symlinked memory directory.""" + outside = temp_dir / "outside" + outside.mkdir() + try: + (project_dir / ".specify" / "memory").symlink_to( + outside, target_is_directory=True + ) + except OSError: + pytest.skip("symlinks are unavailable") + + manager = PresetManager(project_dir) + with pytest.warns(UserWarning, match="symlinked"): + install_constitution_sync_preset(manager) + + assert manager.registry.is_installed("constitution-sync") + assert not (outside / "constitution.md").exists() + + def test_constitution_seed_rejects_dangling_destination_symlink( + self, project_dir, temp_dir + ): + """Preset installation cannot seed through a dangling destination symlink.""" + memory = project_dir / ".specify" / "memory" + memory.mkdir(parents=True) + outside = temp_dir / "outside-constitution.md" + try: + (memory / "constitution.md").symlink_to(outside) + except OSError: + pytest.skip("symlinks are unavailable") + + manager = PresetManager(project_dir) + with pytest.warns(UserWarning, match="symlinked"): + install_constitution_sync_preset(manager) + + assert manager.registry.is_installed("constitution-sync") + assert not outside.exists() + + def test_constitution_materialization_error_is_nonfatal( + self, project_dir, temp_dir + ): + """An invalid wrap warns without reporting an uninstalled preset.""" + preset_dir = temp_dir / "invalid-wrap" + (preset_dir / "templates").mkdir(parents=True) + (preset_dir / "templates" / "constitution-template.md").write_text( + "# Missing core placeholder\n" + ) + (preset_dir / "preset.yml").write_text( + yaml.dump( + { + "schema_version": "1.0", + "preset": { + "id": "invalid-wrap", + "name": "Invalid Wrap", + "version": "1.0.0", + "description": "Invalid wrapping constitution", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "templates": [ + { + "type": "template", + "name": "constitution-template", + "file": "templates/constitution-template.md", + "strategy": "wrap", + "description": "Invalid wrap", + } + ] + }, + } + ) + ) + + manager = PresetManager(project_dir) + install_constitution_sync_preset(manager) + with pytest.warns(UserWarning, match="Failed to seed constitution"): + manifest = manager.install_from_directory(preset_dir, "0.1.5") + + assert manifest.id == "invalid-wrap" + assert manager.registry.is_installed("invalid-wrap") + + +class TestPresetSkills(PresetArtifactTestHelpers): + """Manager lifecycle behavior across skill and command mode changes.""" + + def test_remove_after_partial_command_to_skills_toggle_keeps_skills_mode_agent_command_free( + self, project_dir, temp_dir + ): + """Removal must not recreate a command file for a skills-mode agent. + + A partially failed command→skills toggle leaves the active agent's + stale ``registered_commands`` entry behind. Removing that preset + records the agent in ``extra_agents`` for post-removal + reconciliation, and ``register_commands_for_non_skill_agents`` + admits every ``extra_agents`` member even when the active-only + ``only_agent`` guard excludes the agent — so the surviving + lower-priority preset's command file was recreated for an agent + now running in skills mode (#2948). + """ + self._write_init_options(project_dir, ai="copilot", ai_skills=False) + copilot_commands_dir = project_dir / ".github" / "agents" + copilot_commands_dir.mkdir(parents=True) + + lower_dir = self._create_command_preset( + temp_dir, "stale-toggle-lower-preset", "speckit.plan", + "Lower preset", "Lower body", + ) + higher_dir = self._create_command_preset( + temp_dir, "stale-toggle-higher-preset", "speckit.plan", + "Higher preset", "Higher body", + ) + manager = PresetManager(project_dir) + manager.install_from_directory(lower_dir, "0.1.5", priority=20) + manager.install_from_directory(higher_dir, "0.1.5", priority=10) + + command_file = copilot_commands_dir / "speckit.plan.agent.md" + assert "Higher body" in command_file.read_text(encoding="utf-8"), ( + "sanity: command mode should have written the winning preset" + ) + + # Break the higher preset's installed source so its skill + # replacement is silently skipped during the toggle — a genuine + # partial command→skills toggle that leaves the stale + # registered_commands entry for copilot behind. + (manager.presets_dir / "stale-toggle-higher-preset" / "commands" / "speckit.plan.md").unlink() + self._write_init_options(project_dir, ai="copilot", ai_skills=True) + manager.register_enabled_presets_for_agent("copilot") + + metadata = manager.registry.get("stale-toggle-higher-preset") + assert "speckit.plan" in metadata["registered_commands"].get("copilot", []), ( + "sanity: the partial toggle must leave the stale command " + "tracking behind" + ) + + manager.remove("stale-toggle-higher-preset") + + assert not command_file.exists(), ( + "removing the preset while copilot runs in skills mode must " + "not recreate its command file from the surviving lower " + "preset via the stale extra_agents entry (#2948)" + ) + + def test_remove_after_partial_skills_to_command_toggle_deletes_stale_skill( + self, project_dir, temp_dir + ): + """Removal must delete, not restore, a command-mode agent's stale skill. + + The inverse partial toggle: a skills→command conversion that could + not replace one command leaves that skill tracked in + ``registered_skills``. Removing the preset while the agent is now + in command mode sent it through ``_unregister_skills()``, which + restored a core/extension ``SKILL.md``, and ``extra_skills_dirs`` + then let ``_reconcile_skills`` reapply the surviving lower preset — + leaving the active command-mode agent with a skill artifact it + must not have (#2948). + """ + self._write_init_options(project_dir, ai="copilot", ai_skills=True) + copilot_commands_dir = project_dir / ".github" / "agents" + copilot_commands_dir.mkdir(parents=True) + + lower_dir = self._create_command_preset( + temp_dir, "inverse-toggle-lower-preset", "speckit.plan", + "Lower preset", "Lower body", + ) + higher_dir = self._create_command_preset( + temp_dir, "inverse-toggle-higher-preset", "speckit.plan", + "Higher preset", "Higher body", + ) + manager = PresetManager(project_dir) + manager.install_from_directory(lower_dir, "0.1.5", priority=20) + manager.install_from_directory(higher_dir, "0.1.5", priority=10) + + skill_dir = project_dir / ".github" / "skills" / "speckit-plan" + assert (skill_dir / "SKILL.md").exists(), ( + "sanity: skills mode should have written the skill" + ) + + # Break the higher preset's installed source so its command + # replacement never lands during the skills→command toggle, + # leaving the skill tracked for copilot. + (manager.presets_dir / "inverse-toggle-higher-preset" / "commands" / "speckit.plan.md").unlink() + self._write_init_options(project_dir, ai="copilot", ai_skills=False) + manager.register_enabled_presets_for_agent("copilot") + + metadata = manager.registry.get("inverse-toggle-higher-preset") + registered_skills = metadata.get("registered_skills") or {} + assert registered_skills.get("copilot"), ( + "sanity: the partial toggle must leave the stale skill " + "tracking behind" + ) + assert (skill_dir / "SKILL.md").exists(), ( + "sanity: the stale skill artifact must survive the partial toggle" + ) + + manager.remove("inverse-toggle-higher-preset") + + assert not skill_dir.exists(), ( + "removing the preset while copilot runs in command mode must " + "delete the stale preset-owned skill instead of restoring core " + "content or reapplying the surviving lower preset (#2948)" + ) + + def test_partial_skill_install_failure_rolls_back_persisted_writes( + self, project_dir, temp_dir, monkeypatch + ): + """Install rollback must reload partial skill ownership before removal.""" + self._write_init_options(project_dir, ai="copilot", ai_skills=True) + (project_dir / ".github" / "agents").mkdir(parents=True) + preset_dir = self._create_multi_command_preset( + temp_dir, + "partial-install-failure-preset", + ["speckit.specify", "speckit.plan"], + ) + manager = PresetManager(project_dir) + original_read_text = Path.read_text + + def fail_plan_source(path, *args, **kwargs): + if ( + path.name == "speckit.plan.md" + and path.parent.name == "commands" + and "partial-install-failure-preset" in path.parts + ): + raise UnicodeDecodeError("utf-8", b"\xff", 0, 1, "invalid") + return original_read_text(path, *args, **kwargs) + + monkeypatch.setattr(Path, "read_text", fail_plan_source) + with pytest.raises(UnicodeDecodeError): + manager.install_from_directory(preset_dir, "0.1.5") + + assert not manager.registry.is_installed( + "partial-install-failure-preset" + ) + skill_file = ( + project_dir + / ".github" + / "skills" + / "speckit-specify" + / "SKILL.md" + ) + assert ( + not skill_file.exists() + or "preset:partial-install-failure-preset" + not in original_read_text(skill_file, encoding="utf-8") + ), "rollback must not orphan a skill written before the later failure" + + +class TestPresetPriorityBackwardsCompatibility: + """Test backwards compatibility for presets installed before priority feature.""" + + def test_legacy_preset_in_list_installed(self, project_dir, pack_dir): + """list_installed returns priority=10 for legacy presets without priority field.""" + manager = PresetManager(project_dir) + + # Install preset normally + manager.install_from_directory(pack_dir, "0.1.5") + + # Manually remove priority to simulate legacy preset + pack_data = manager.registry.data["presets"]["test-pack"] + del pack_data["priority"] + manager.registry._save() + + # list_installed should still return priority=10 + installed = manager.list_installed() + assert len(installed) == 1 + assert installed[0]["priority"] == 10 + + +class TestRemoveReconciliation: + """Test that removing a preset re-registers the next layer's command.""" + + def test_remove_restores_extension_command_subdir_paths_for_non_skill_agent( + self, project_dir, temp_dir + ): + """When a preset override of an extension command is removed, the + reconciled non-skill-agent command file should have the extension's + own subdir references rewritten to their installed location (#2101), + not left as bare, unresolvable paths.""" + gemini_dir = project_dir / ".gemini" / "commands" + gemini_dir.mkdir(parents=True) + + extension_dir = project_dir / ".specify" / "extensions" / "fakeext" + (extension_dir / "commands").mkdir(parents=True, exist_ok=True) + (extension_dir / "agents" / "control").mkdir(parents=True, exist_ok=True) + (extension_dir / "agents" / "control" / "commander.md").write_text("# Commander\n") + (extension_dir / "commands" / "cmd.md").write_text( + "---\ndescription: Extension fakeext cmd\n---\n\n" + "Read agents/control/commander.md for context.\n" + ) + extension_manifest = { + "schema_version": "1.0", + "extension": { + "id": "fakeext", + "name": "Fake Extension", + "version": "1.0.0", + "description": "Test", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "commands": [ + { + "name": "speckit.fakeext.cmd", + "file": "commands/cmd.md", + "description": "Fake extension command", + } + ] + }, + } + with open(extension_dir / "extension.yml", "w") as f: + yaml.dump(extension_manifest, f) + + manager = PresetManager(project_dir) + + preset_dir = temp_dir / "ext-cmd-override" + preset_dir.mkdir() + (preset_dir / "commands").mkdir() + (preset_dir / "commands" / "speckit.fakeext.cmd.md").write_text( + "---\ndescription: Override fakeext cmd\n---\n\npreset override content\n" + ) + preset_manifest = { + "schema_version": "1.0", + "preset": { + "id": "ext-cmd-override", + "name": "Ext Cmd Override", + "version": "1.0.0", + "description": "Test", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "templates": [ + { + "type": "command", + "name": "speckit.fakeext.cmd", + "file": "commands/speckit.fakeext.cmd.md", + } + ] + }, + } + with open(preset_dir / "preset.yml", "w") as f: + yaml.dump(preset_manifest, f) + + manager.install_from_directory(preset_dir, "0.1.5") + + cmd_files = list(gemini_dir.glob("*fakeext*")) + assert cmd_files, "Command file should exist in gemini dir" + assert "preset override content" in cmd_files[0].read_text() + + manager.remove("ext-cmd-override") + + cmd_files = list(gemini_dir.glob("*fakeext*")) + assert cmd_files, "Command file should still exist after removal" + content = cmd_files[0].read_text() + assert "preset override content" not in content + assert ".specify/extensions/fakeext/agents/control/commander.md" in content + assert "Read agents/control" not in content + + def test_remove_restores_lower_priority_command( + self, project_dir, temp_dir, valid_pack_data + ): + """After removing the top-priority preset, the next preset's command + should be re-registered in agent directories.""" + manager = PresetManager(project_dir) + + # Create a gemini commands dir so reconciliation writes there + gemini_dir = project_dir / ".gemini" / "commands" + gemini_dir.mkdir(parents=True) + + # Install a low-priority preset with a command + lo_data = {**valid_pack_data} + lo_data["preset"] = { + **valid_pack_data["preset"], + "id": "lo-preset", + "name": "Lo", + } + lo_data["provides"] = { + "templates": [{ + "type": "command", + "name": "speckit.specify", + "file": "commands/speckit.specify.md", + }] + } + lo_dir = temp_dir / "lo-preset" + lo_dir.mkdir() + with open(lo_dir / "preset.yml", "w") as f: + yaml.dump(lo_data, f) + (lo_dir / "commands").mkdir() + (lo_dir / "commands" / "speckit.specify.md").write_text( + "---\ndescription: lo\n---\nLo content\n" + ) + manager.install_from_directory(lo_dir, "0.1.5", priority=10) + + # Install a high-priority preset overriding the same command + hi_data = {**valid_pack_data} + hi_data["preset"] = { + **valid_pack_data["preset"], + "id": "hi-preset", + "name": "Hi", + } + hi_data["provides"] = { + "templates": [{ + "type": "command", + "name": "speckit.specify", + "file": "commands/speckit.specify.md", + }] + } + hi_dir = temp_dir / "hi-preset" + hi_dir.mkdir() + with open(hi_dir / "preset.yml", "w") as f: + yaml.dump(hi_data, f) + (hi_dir / "commands").mkdir() + (hi_dir / "commands" / "speckit.specify.md").write_text( + "---\ndescription: hi\n---\nHi content\n" + ) + manager.install_from_directory(hi_dir, "0.1.5", priority=1) + + # Verify the hi-preset's content is active in agent dir + cmd_files = list(gemini_dir.glob("*specify*")) + assert cmd_files, "Command file should exist in gemini dir" + assert "Hi content" in cmd_files[0].read_text() + + # Remove the high-priority preset + manager.remove("hi-preset") + + # The low-priority preset's command should now be in the resolution stack + resolver = PresetResolver(project_dir) + layers = resolver.collect_all_layers("speckit.specify", "command") + assert len(layers) >= 1 + assert "lo-preset" in layers[0]["source"] + + # Verify on-disk agent command file switched to lo-preset content + cmd_files = list(gemini_dir.glob("*specify*")) + assert cmd_files, "Command file should still exist after removal" + assert "Lo content" in cmd_files[0].read_text() diff --git a/tests/specify_cli/presets/test_manager_commands.py b/tests/specify_cli/presets/test_manager_commands.py new file mode 100644 index 0000000000..d83ef12d17 --- /dev/null +++ b/tests/specify_cli/presets/test_manager_commands.py @@ -0,0 +1,3160 @@ +"""Tests for preset command artifacts in specify_cli.presets._manager_commands.""" + +from pathlib import Path + +import pytest +import yaml + +from specify_cli.presets import PresetManager +from tests.specify_cli.presets._helpers import ( + PresetArtifactTestHelpers, + install_self_test_preset, +) + + +class TestSelfTestPreset: + """Command registration and removal using self-test.""" + + def test_self_test_registers_commands_for_claude(self, project_dir): + """Test that installing self-test registers skills in .claude/skills/.""" + # Create Claude skills directory to simulate Claude being set up + claude_dir = project_dir / ".claude" / "skills" + claude_dir.mkdir(parents=True) + + manager = PresetManager(project_dir) + install_self_test_preset(manager) + + # Check the skill was registered + cmd_file = claude_dir / "speckit-specify" / "SKILL.md" + assert cmd_file.exists(), "Skill not registered in .claude/skills/" + content = cmd_file.read_text() + assert "self-test" in content + assert "source:" in content # skill frontmatter includes metadata.source + + def test_self_test_registers_commands_for_gemini(self, project_dir): + """Test that installing self-test registers commands in .gemini/commands/ as TOML.""" + # Create Gemini agent directory + gemini_dir = project_dir / ".gemini" / "commands" + gemini_dir.mkdir(parents=True) + + manager = PresetManager(project_dir) + install_self_test_preset(manager) + + # Check the command was registered in TOML format + cmd_file = gemini_dir / "speckit.specify.toml" + assert cmd_file.exists(), "Command not registered in .gemini/commands/" + content = cmd_file.read_text() + assert "prompt" in content # TOML format has a prompt field + assert "{{args}}" in content # Gemini uses {{args}} placeholder + + def test_self_test_unregisters_commands_on_remove(self, project_dir): + """Test that removing self-test cleans up registered commands.""" + claude_dir = project_dir / ".claude" / "skills" + claude_dir.mkdir(parents=True) + + manager = PresetManager(project_dir) + install_self_test_preset(manager) + + cmd_file = claude_dir / "speckit-specify" / "SKILL.md" + assert cmd_file.exists() + + manager.remove("self-test") + assert not cmd_file.exists(), "Command not cleaned up after preset removal" + + def test_self_test_no_commands_without_agent_dirs(self, project_dir): + """Test that no commands are registered when no agent dirs exist.""" + manager = PresetManager(project_dir) + install_self_test_preset(manager) + + metadata = manager.registry.get("self-test") + assert metadata["registered_commands"] == {} + + def test_selfcontained_namespaced_command_scaffolds_without_extension(self, project_dir, temp_dir): + """A preset shipping a self-contained ``speckit..`` command + scaffolds even when no matching extension is installed. + + The command template ships its own body, so it is self-contained and + must render just like a short ``speckit.`` command. It is not + dropped merely because ``.specify/extensions/fakeext/`` is absent. + """ + claude_dir = project_dir / ".claude" / "skills" + claude_dir.mkdir(parents=True) + + preset_dir = temp_dir / "ext-override-preset" + preset_dir.mkdir() + (preset_dir / "commands").mkdir() + (preset_dir / "commands" / "speckit.fakeext.cmd.md").write_text( + "---\ndescription: Override fakeext cmd\n---\nOverridden content" + ) + manifest_data = { + "schema_version": "1.0", + "preset": { + "id": "ext-override", + "name": "Ext Override", + "version": "1.0.0", + "description": "Test", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "templates": [ + { + "type": "command", + "name": "speckit.fakeext.cmd", + "file": "commands/speckit.fakeext.cmd.md", + "description": "Override fakeext cmd", + } + ] + }, + } + with open(preset_dir / "preset.yml", "w") as f: + yaml.dump(manifest_data, f) + + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "0.1.5") + + # Extension not installed, but the preset ships its own command body — + # it must scaffold (as a native-skill SKILL.md for claude) and be + # tracked in the preset's registered_commands. + skill_file = claude_dir / "speckit-fakeext-cmd" / "SKILL.md" + assert skill_file.exists(), "Self-contained namespaced command was dropped" + metadata = manager.registry.get("ext-override") + assert metadata["registered_commands"] != {} + + def test_extension_command_registered_when_extension_present(self, project_dir, temp_dir): + """Test that extension command overrides ARE registered when the extension is installed.""" + claude_dir = project_dir / ".claude" / "skills" + claude_dir.mkdir(parents=True) + (project_dir / ".specify" / "extensions" / "fakeext").mkdir(parents=True) + + preset_dir = temp_dir / "ext-override-preset2" + preset_dir.mkdir() + (preset_dir / "commands").mkdir() + (preset_dir / "commands" / "speckit.fakeext.cmd.md").write_text( + "---\ndescription: Override fakeext cmd\n---\nOverridden content" + ) + manifest_data = { + "schema_version": "1.0", + "preset": { + "id": "ext-override2", + "name": "Ext Override", + "version": "1.0.0", + "description": "Test", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "templates": [ + { + "type": "command", + "name": "speckit.fakeext.cmd", + "file": "commands/speckit.fakeext.cmd.md", + "description": "Override fakeext cmd", + } + ] + }, + } + with open(preset_dir / "preset.yml", "w") as f: + yaml.dump(manifest_data, f) + + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "0.1.5") + + cmd_file = claude_dir / "speckit-fakeext-cmd" / "SKILL.md" + assert cmd_file.exists(), "Skill not registered despite extension being present" + + +class TestPresetSkills(PresetArtifactTestHelpers): + """Preset command activation, reconciliation, and mode switching.""" + + def test_preset_add_corrupted_init_options_fails_closed(self, project_dir, temp_dir): + """Corrupted (but present) init-options.json must not back-fill every + detected agent for preset command registration. + + Before the shared ``resolve_active_agent_for_registration`` fix, + ``load_init_options`` returning ``{}`` for a corrupted file was + indistinguishable from "no file at all", so ``_register_commands`` + treated it like a legacy pre-init-options project and registered + the preset's command override for every detected agent (#2948). + """ + init_options = project_dir / ".specify" / "init-options.json" + init_options.parent.mkdir(parents=True, exist_ok=True) + init_options.write_text("{not valid json", encoding="utf-8") + + gemini_dir = project_dir / ".gemini" / "commands" + gemini_dir.mkdir(parents=True) + + preset_dir = self._create_command_preset( + temp_dir, "corrupt-init-preset", "speckit.specify", + "Corrupt init test", "preset body", + ) + + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "0.1.5") + + metadata = manager.registry.get("corrupt-init-preset") + assert metadata.get("registered_commands") == {}, ( + "a corrupted init-options.json must fail closed, not " + "back-fill every detected agent (#2948)" + ) + assert not list(gemini_dir.glob("*specify*")), ( + "no command file should be written for any agent when " + "init-options.json is corrupted" + ) + + def test_reconciliation_restricted_to_active_agent(self, project_dir, temp_dir): + """Reconciliation after install/remove must also respect the + single-active rule, not just the initial registration. + + ``_reconcile_composed_commands`` (invoked after + ``install_from_directory``/``remove``) resolves composition winners + via ``register_commands_for_non_skill_agents``, a separate code + path from ``_register_commands``'s initial registration. Before the + fix it ignored the active-agent restriction entirely and wrote the + winning content for every detected non-skill agent, leaving + untracked orphaned artifacts in inactive integrations (#2948). + """ + self._write_init_options(project_dir, ai="claude", ai_skills=True) + gemini_dir = project_dir / ".gemini" / "commands" + gemini_dir.mkdir(parents=True) + + # A non-replace (append) strategy command forces reconciliation to + # run register_commands_for_non_skill_agents for every non-skill + # agent directory it detects. + preset_dir = temp_dir / "reconcile-active-only" + preset_dir.mkdir() + (preset_dir / "commands").mkdir() + (preset_dir / "commands" / "speckit.specify.md").write_text( + "---\ndescription: Appended\nstrategy: append\n---\n\nAppended body\n", + encoding="utf-8", + ) + manifest_data = { + "schema_version": "1.0", + "preset": { + "id": "reconcile-active-only", + "name": "Reconcile Active Only", + "version": "1.0.0", + "description": "Test", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "templates": [{ + "type": "command", + "name": "speckit.specify", + "file": "commands/speckit.specify.md", + "strategy": "append", + }] + }, + } + with open(preset_dir / "preset.yml", "w") as f: + yaml.dump(manifest_data, f) + + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "0.1.5") + + assert not list(gemini_dir.glob("*specify*")), ( + "reconciliation must not write command files for a detected " + "but inactive non-skill agent (#2948)" + ) + + def test_use_rescaffold_reconciles_project_override(self, project_dir, temp_dir): + """``integration use``/``switch`` rescaffolding must reconcile the + full priority stack, not just write each preset's own content. + + Project overrides are the highest-priority layer, above every + preset. ``register_enabled_presets_for_agent`` (invoked by + ``integration use``/``switch``) calls ``_register_commands`` for + each enabled preset directly, the same as ``install_from_directory`` + — but unlike install/remove, it never followed up with + ``_reconcile_composed_commands``. Before the fix, rescaffolding a + newly activated agent could leave the preset's raw content in + place instead of resolving the real winner (the project override) + from the full stack (#2948). + """ + self._write_init_options(project_dir, ai="claude", ai_skills=True) + + overrides_dir = project_dir / ".specify" / "templates" / "overrides" + overrides_dir.mkdir(parents=True, exist_ok=True) + (overrides_dir / "speckit.specify.md").write_text( + "---\ndescription: Override specify\n---\n\nOverride body\n", + encoding="utf-8", + ) + + gemini_dir = project_dir / ".gemini" / "commands" + gemini_dir.mkdir(parents=True) + + preset_dir = self._create_command_preset( + temp_dir, "use-reconcile-preset", "speckit.specify", + "Preset specify", "Preset body", + ) + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "0.1.5") + + # Simulate `integration use gemini`: switch the active agent and + # rescaffold enabled presets for it, mirroring what the CLI does. + self._write_init_options(project_dir, ai="gemini", ai_skills=False) + manager.register_enabled_presets_for_agent("gemini") + + cmd_file = gemini_dir / "speckit.specify.toml" + assert cmd_file.exists(), "sanity: gemini should get a command file at all" + content = cmd_file.read_text() + assert "Override body" in content, ( + "the project override must still win after rescaffold " + "reconciliation, not the preset's raw content (#2948)" + ) + assert "Preset body" not in content + + def test_hermes_rescaffold_reconciles_global_skill_output( + self, project_dir, temp_dir, monkeypatch + ): + home = temp_dir / "home" + home.mkdir() + monkeypatch.setattr(Path, "home", lambda: home) + (home / ".hermes" / "skills").mkdir(parents=True) + self._write_init_options(project_dir, ai="hermes", ai_skills=True) + (project_dir / ".hermes" / "skills").mkdir(parents=True) + + overrides_dir = project_dir / ".specify" / "templates" / "overrides" + overrides_dir.mkdir(parents=True, exist_ok=True) + (overrides_dir / "speckit.specify.md").write_text( + "---\ndescription: Override specify\n---\n\nOverride body\n", + encoding="utf-8", + ) + + preset_dir = self._create_command_preset( + temp_dir, "hermes-reconcile-preset", "speckit.specify", + "Preset specify", "Preset body", + ) + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "0.1.5") + manager.register_enabled_presets_for_agent("hermes") + + skill_file = ( + home / ".hermes" / "skills" / "speckit-specify" / "SKILL.md" + ) + assert skill_file.exists() + content = skill_file.read_text(encoding="utf-8") + assert "Override body" in content + assert "Preset body" not in content + assert not list( + (project_dir / ".hermes" / "skills").glob("speckit-*/SKILL.md") + ) + + (overrides_dir / "speckit.specify.md").unlink() + assert manager.remove("hermes-reconcile-preset") is True + if skill_file.exists(): + restored = skill_file.read_text(encoding="utf-8") + assert "Override body" not in restored + assert "Preset body" not in restored + + def test_rescaffold_persists_commands_before_fallible_skills_phase( + self, project_dir, temp_dir + ): + """A failure in the skills phase must not lose track of command + files the commands phase already wrote to disk. + + ``register_enabled_presets_for_agent`` computes both + ``registered_commands`` and ``registered_skills`` and persists them + together in a single ``registry.update()`` call after both phases + run. If ``_register_skills`` raises, the whole per-preset ``try`` + block is caught and ``registry.update()`` is never reached — even + though ``_register_commands`` already wrote a real command file to + disk. That file becomes untracked and preset removal can no longer + clean it up. ``install_from_directory`` avoids this by persisting + ``registered_commands`` immediately after the commands phase, + before starting the independently fallible skills phase; rescaffold + must do the same (#2948). + """ + self._write_init_options(project_dir, ai="claude", ai_skills=True) + + preset_dir = self._create_command_preset( + temp_dir, "rescaffold-persist-preset", "speckit.specify", + "Rescaffold persist test", "preset body", + ) + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "0.1.5") + + # Switch to gemini (a plain command-file agent, so _register_commands + # writes a real file) and make the *skills* phase blow up. + self._write_init_options(project_dir, ai="gemini", ai_skills=False) + gemini_commands_dir = project_dir / ".gemini" / "commands" + gemini_commands_dir.mkdir(parents=True) + + from unittest.mock import patch + + with patch.object( + PresetManager, "_register_skills", + side_effect=RuntimeError("simulated skills failure"), + ): + manager.register_enabled_presets_for_agent("gemini") + + cmd_file = gemini_commands_dir / "speckit.specify.toml" + assert cmd_file.exists(), ( + "sanity: the commands phase must have written the file before " + "the skills phase raised" + ) + + metadata = manager.registry.get("rescaffold-persist-preset") + assert metadata["registered_commands"].get("gemini"), ( + "registered_commands must be persisted immediately after the " + "commands phase, not only after the (fallible) skills phase " + "also succeeds — otherwise the file written above is untracked " + "and preset removal can't clean it up (#2948)" + ) + + def test_rescaffold_reconciles_override_even_when_skills_phase_fails( + self, project_dir, temp_dir + ): + """A project override must still win after rescaffold even if the + independently-fallible skills phase raises for that preset. + + ``register_enabled_presets_for_agent`` only records a preset's + command names into ``affected_cmd_names`` — the set later passed to + ``_reconcile_composed_commands``/``_reconcile_skills`` — in the + ``for tmpl in manifest.templates`` loop that runs *after* + ``_register_skills`` inside the per-preset ``try`` block. If + ``_register_skills`` raises, the per-preset ``except`` catches it + and ``continue``s before that loop ever runs, so this preset's + command names never make it into ``affected_cmd_names`` even though + ``_register_commands`` already wrote its raw content to disk. The + final reconciliation call is skipped for this preset entirely, + leaving the raw preset content in place instead of the project + override that should win (#2948). + """ + self._write_init_options(project_dir, ai="claude", ai_skills=True) + + overrides_dir = project_dir / ".specify" / "templates" / "overrides" + overrides_dir.mkdir(parents=True, exist_ok=True) + (overrides_dir / "speckit.specify.md").write_text( + "---\ndescription: Override specify\n---\n\nOverride body\n", + encoding="utf-8", + ) + + gemini_dir = project_dir / ".gemini" / "commands" + gemini_dir.mkdir(parents=True) + + preset_dir = self._create_command_preset( + temp_dir, "reconcile-despite-skills-failure", "speckit.specify", + "Preset specify", "Preset body", + ) + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "0.1.5") + + # Simulate `integration use gemini` with the skills phase failing + # for this preset (e.g. a symlink/permission error unrelated to the + # commands phase, which already succeeded). + self._write_init_options(project_dir, ai="gemini", ai_skills=False) + + from unittest.mock import patch + + with patch.object( + PresetManager, "_register_skills", + side_effect=RuntimeError("simulated skills failure"), + ): + manager.register_enabled_presets_for_agent("gemini") + + cmd_file = gemini_dir / "speckit.specify.toml" + assert cmd_file.exists(), "sanity: gemini should get a command file at all" + content = cmd_file.read_text() + assert "Override body" in content, ( + "the project override must still win after rescaffold, even " + "though this preset's skills phase raised — a fallible skills " + "phase must not skip reconciliation for command writes that " + "already succeeded (#2948)" + ) + assert "Preset body" not in content + + def test_rescaffold_reconciles_partial_command_write_after_failure( + self, project_dir, temp_dir, monkeypatch + ): + """A command written before _register_commands raises must still be + included in final priority-stack reconciliation.""" + self._write_init_options(project_dir, ai="claude", ai_skills=True) + + overrides_dir = project_dir / ".specify" / "templates" / "overrides" + overrides_dir.mkdir(parents=True, exist_ok=True) + (overrides_dir / "speckit.specify.md").write_text( + "---\ndescription: Override specify\n---\n\nOverride body\n", + encoding="utf-8", + ) + + preset_dir = self._create_command_preset( + temp_dir, + "partial-command-failure-preset", + "speckit.specify", + "Preset specify", + "Preset body", + ) + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "0.1.5") + + self._write_init_options(project_dir, ai="gemini", ai_skills=False) + gemini_dir = project_dir / ".gemini" / "commands" + gemini_dir.mkdir(parents=True) + cmd_file = gemini_dir / "speckit.specify.toml" + + def partial_register(manifest, pack_dir): + cmd_file.write_text("Partially written preset body\n", encoding="utf-8") + raise RuntimeError("simulated partial command failure") + + monkeypatch.setattr(manager, "_register_commands", partial_register) + manager.register_enabled_presets_for_agent("gemini") + + content = cmd_file.read_text(encoding="utf-8") + assert "Override body" in content + assert "Partially written preset body" not in content + + def test_copilot_skills_mode_skips_command_registration(self, project_dir, temp_dir): + """``integration use copilot`` with skills mode enabled must only + write the SKILL.md mirror, not also copilot's static command file. + + Copilot is command-backed (``extension: ".agent.md"``), but when + ``ai_skills`` is enabled its preset overrides are meant to render + exclusively as skills via ``_register_skills``. Before the fix, + ``_register_commands`` had no ``ai_skills`` guard (unlike the + extensions path), so both a stale ``.agent.md`` command file and + the ``SKILL.md`` mirror were written for the same override (#2948). + """ + self._write_init_options(project_dir, ai="copilot", ai_skills=True) + copilot_commands_dir = project_dir / ".github" / "agents" + copilot_commands_dir.mkdir(parents=True) + skills_dir = project_dir / ".github" / "skills" + self._create_skill(skills_dir, "speckit-specify") + + preset_dir = self._create_command_preset( + temp_dir, "copilot-skills-preset", "speckit.specify", + "Copilot skills test", "preset body", + ) + + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "0.1.5") + + assert not list(copilot_commands_dir.glob("*specify*")), ( + "command-mode and skills-mode artifacts are mutually exclusive: " + "no .agent.md command file should be written when copilot is " + "running in skills mode (#2948)" + ) + skill_file = skills_dir / "speckit-specify" / "SKILL.md" + assert "preset:copilot-skills-preset" in skill_file.read_text() + + def test_rescaffold_toggle_command_to_skills_removes_stale_command_file( + self, project_dir, temp_dir + ): + """Toggling the *same* agent from command mode to skills mode must + remove the stale command-mode artifact, not just add the new one. + + Copilot stays the active agent throughout (``integration upgrade + copilot`` after flipping ``ai_skills``, not a switch to a different + agent). Before the fix, ``_register_commands``'s ``ai_skills`` guard + made rescaffold a no-op for the commands phase once skills mode was + on, leaving the previously written ``.agent.md`` file and its + ``registered_commands`` entry behind even though ``_register_skills`` + went on to also write the ``SKILL.md`` mirror — violating the + command/skill mutual-exclusion invariant this PR otherwise enforces + (#2948). + """ + self._write_init_options(project_dir, ai="copilot", ai_skills=False) + copilot_commands_dir = project_dir / ".github" / "agents" + copilot_commands_dir.mkdir(parents=True) + + preset_dir = self._create_command_preset( + temp_dir, "toggle-cmd-to-skill-preset", "speckit.specify", + "Toggle test", "preset body", + ) + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "0.1.5") + + cmd_file = copilot_commands_dir / "speckit.specify.agent.md" + assert cmd_file.exists(), ( + "sanity: command mode should have written copilot's command file" + ) + metadata = manager.registry.get("toggle-cmd-to-skill-preset") + assert metadata["registered_commands"].get("copilot"), ( + "sanity: the command-mode write should be tracked for copilot" + ) + + # Flip ai_skills on for the *same* active agent and rescaffold, as + # `integration upgrade copilot` would after the mode toggle. + self._write_init_options(project_dir, ai="copilot", ai_skills=True) + manager.register_enabled_presets_for_agent("copilot") + + assert not cmd_file.exists(), ( + "the stale command-mode file must be removed once copilot has " + "toggled to skills mode for the same agent (#2948)" + ) + metadata = manager.registry.get("toggle-cmd-to-skill-preset") + assert not metadata["registered_commands"].get("copilot"), ( + "registered_commands must stop tracking copilot once its " + "artifact has been unregistered, or removal will try to clean " + "up a file that no longer exists (#2948)" + ) + skill_file = project_dir / ".github" / "skills" / "speckit-specify" / "SKILL.md" + assert "preset:toggle-cmd-to-skill-preset" in skill_file.read_text(), ( + "sanity: the new skills-mode artifact should still be written" + ) + + def test_rescaffold_scaffolds_selfcontained_namespaced_commands( + self, project_dir, temp_dir + ): + """A self-contained ``speckit..`` preset command scaffolds and + survives rescaffold, even when no matching extension is installed. + + The preset ships the command body itself, so it is materialized just + like a short ``speckit.`` command — both at install and through a + later reconciliation/rescaffold pass. It is not dropped by the + ``speckit..`` name shape (#4076). + """ + self._write_init_options(project_dir, ai="copilot", ai_skills=False) + commands_dir = project_dir / ".github" / "agents" + commands_dir.mkdir(parents=True) + + preset_dir = self._create_command_preset( + temp_dir, "ext-scoped-preset", "speckit.git.feature", + "Ext override", "ext body", + ) + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "0.1.5") + + ext_cmd = commands_dir / "speckit.git.feature.agent.md" + assert ext_cmd.exists(), ( + "sanity: install must scaffold a self-contained namespaced command " + "even when its like-named extension isn't installed" + ) + + manager.register_enabled_presets_for_agent("copilot") + + assert ext_cmd.exists(), ( + "rescaffold must keep the self-contained namespaced command" + ) + metadata = manager.registry.get("ext-scoped-preset") + assert (metadata.get("registered_commands") or {}).get("copilot") + + def test_rescaffold_scaffolds_selfcontained_namespaced_skills( + self, project_dir, temp_dir + ): + """A self-contained ``speckit..`` preset command renders its + skill even when no matching extension is installed.""" + self._write_init_options(project_dir, ai="copilot", ai_skills=True) + skills_dir = project_dir / ".github" / "skills" + skills_dir.mkdir(parents=True) + + preset_dir = self._create_command_preset( + temp_dir, + "ext-scoped-skill-preset", + "speckit.git.feature", + "Ext override", + "preset body", + ) + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "0.1.5") + + skill_name = "speckit-git-feature" + skill_file = skills_dir / skill_name / "SKILL.md" + assert skill_file.exists(), ( + "install must render a self-contained namespaced command's skill " + "even when its like-named extension isn't installed" + ) + + manager.register_enabled_presets_for_agent("copilot") + + assert skill_file.exists(), ( + "rescaffold must keep the self-contained namespaced command's skill" + ) + + def test_uncomposable_wrap_command_skips_skill_in_skills_mode( + self, project_dir, temp_dir + ): + """A wrap command with no base layer must not materialize a broken + skill in skills mode. + + When ``_register_commands`` skips an uncomposable wrap command (no + base to compose onto — e.g. the command it wraps comes from an + uninstalled extension), ``_register_skills`` must skip it too. Before + this fix, skills mode fell back to the raw preset body and wrote a + SKILL.md containing a literal ``{CORE_TEMPLATE}`` placeholder. + """ + self._write_init_options(project_dir, ai="copilot", ai_skills=True) + skills_dir = project_dir / ".github" / "skills" + skills_dir.mkdir(parents=True) + + preset_dir = temp_dir / "uncomposable-wrap" + preset_dir.mkdir() + (preset_dir / "commands").mkdir() + # speckit.git.feature has no core command template and no installed + # extension, so there is no base layer to wrap. + (preset_dir / "commands" / "speckit.git.feature.md").write_text( + "---\ndescription: Wrap\nstrategy: wrap\n---\n\n" + "wrap start\n{CORE_TEMPLATE}\nwrap end\n" + ) + manifest_data = { + "schema_version": "1.0", + "preset": { + "id": "uncomposable-wrap", + "name": "uncomposable-wrap", + "version": "1.0.0", + "description": "Test", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "templates": [ + { + "type": "command", + "name": "speckit.git.feature", + "file": "commands/speckit.git.feature.md", + "strategy": "wrap", + } + ] + }, + } + with open(preset_dir / "preset.yml", "w") as f: + yaml.dump(manifest_data, f) + + manager = PresetManager(project_dir) + with pytest.warns(UserWarning, match="no base command layer"): + manager.install_from_directory(preset_dir, "0.1.5") + + skill_file = skills_dir / "speckit-git-feature" / "SKILL.md" + assert not skill_file.exists(), ( + "an uncomposable wrap command must not be rendered as a skill" + ) + # Belt-and-suspenders: no artifact anywhere may leak the raw placeholder. + leaked = [ + p for p in skills_dir.rglob("*") + if p.is_file() and "{CORE_TEMPLATE}" in p.read_text(encoding="utf-8") + ] + assert not leaked, f"literal {{CORE_TEMPLATE}} leaked into {leaked}" + + def test_same_mode_partial_command_rescaffold_keeps_skipped_tracking( + self, project_dir, temp_dir + ): + """A partial command refresh must keep still-live skipped artifacts tracked.""" + self._write_init_options(project_dir, ai="copilot", ai_skills=False) + commands_dir = project_dir / ".github" / "agents" + commands_dir.mkdir(parents=True) + preset_dir = self._create_multi_command_preset( + temp_dir, + "same-mode-partial-command-preset", + ["speckit.specify", "speckit.plan"], + ) + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "0.1.5") + + installed_dir = manager.presets_dir / "same-mode-partial-command-preset" + (installed_dir / "commands" / "speckit.plan.md").unlink() + manager.register_enabled_presets_for_agent("copilot") + + metadata = manager.registry.get("same-mode-partial-command-preset") + assert set(metadata["registered_commands"]["copilot"]) == { + "speckit.specify", + "speckit.plan", + } + assert (commands_dir / "speckit.plan.agent.md").exists() + + def test_same_mode_partial_skill_rescaffold_keeps_skipped_tracking( + self, project_dir, temp_dir + ): + """A partial skill refresh must keep still-live skipped artifacts tracked.""" + self._write_init_options(project_dir, ai="copilot", ai_skills=True) + skills_dir = project_dir / ".github" / "skills" + self._create_skill(skills_dir, "speckit-specify") + self._create_skill(skills_dir, "speckit-plan") + preset_dir = self._create_multi_command_preset( + temp_dir, + "same-mode-partial-skill-preset", + ["speckit.specify", "speckit.plan"], + ) + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "0.1.5") + + installed_dir = manager.presets_dir / "same-mode-partial-skill-preset" + (installed_dir / "commands" / "speckit.plan.md").unlink() + manager.register_enabled_presets_for_agent("copilot") + + metadata = manager.registry.get("same-mode-partial-skill-preset") + assert set(metadata["registered_skills"]["copilot"]) == { + "speckit-specify", + "speckit-plan", + } + + def test_toggle_command_to_skills_preserves_old_command_on_skills_failure( + self, project_dir, temp_dir, monkeypatch + ): + """A command->skills toggle must not destroy the old command + artifact before the new skill registration has actually succeeded. + + Before the fix, the stale command-mode file/tracking was + unregistered unconditionally as soon as ``_register_commands``'s + ``ai_skills`` guard made the commands phase a no-op — regardless + of whether the subsequent, independently-fallible + ``_register_skills()`` call actually succeeded. If skills raises + (e.g. a transient I/O error), the per-preset exception handler + just logs and continues, leaving neither the old command file + nor a new skill file — the preset's command override vanishes + entirely from copilot until the next successful rescaffold + (#2948). + """ + self._write_init_options(project_dir, ai="copilot", ai_skills=False) + copilot_commands_dir = project_dir / ".github" / "agents" + copilot_commands_dir.mkdir(parents=True) + + preset_dir = self._create_command_preset( + temp_dir, "toggle-failure-preset", "speckit.specify", + "Toggle failure test", "preset body", + ) + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "0.1.5") + + cmd_file = copilot_commands_dir / "speckit.specify.agent.md" + assert cmd_file.exists(), ( + "sanity: command mode should have written copilot's command file" + ) + metadata = manager.registry.get("toggle-failure-preset") + assert metadata["registered_commands"].get("copilot"), ( + "sanity: the command-mode write should be tracked for copilot" + ) + + # Flip ai_skills on for the *same* active agent and rescaffold, as + # `integration upgrade copilot` would, but with skills registration + # injected to fail. + self._write_init_options(project_dir, ai="copilot", ai_skills=True) + + def _raise_register_skills(*args, **kwargs): + raise OSError("simulated skills-phase failure") + + monkeypatch.setattr(manager, "_register_skills", _raise_register_skills) + manager.register_enabled_presets_for_agent("copilot") + + assert cmd_file.exists(), ( + "the old command-mode artifact must survive when the " + "replacement skills registration fails — deleting it before " + "the new artifact is confirmed leaves neither in place (#2948)" + ) + metadata = manager.registry.get("toggle-failure-preset") + assert metadata["registered_commands"].get("copilot"), ( + "registered_commands must keep tracking copilot's still-live " + "command file when the skills replacement failed, or a later " + "removal/rescaffold will believe there is nothing to clean up " + "even though the file is still on disk (#2948)" + ) + + def test_toggle_command_to_skills_empty_result_preserves_old_command( + self, project_dir, temp_dir + ): + """A non-raising but empty skills result must not delete the old command. + + Before the fix, the stale command-mode artifact was retired as + soon as ``_register_skills()`` completed without raising — + regardless of whether it actually wrote anything for this agent. + Deleting the preset's own command source file (simulating a + missing/corrupted override) makes ``_register_skills`` return + ``{}`` for copilot without raising at all, which must leave the + old command file and its tracking untouched (#2948). + """ + self._write_init_options(project_dir, ai="copilot", ai_skills=False) + copilot_commands_dir = project_dir / ".github" / "agents" + copilot_commands_dir.mkdir(parents=True) + + preset_dir = self._create_command_preset( + temp_dir, "toggle-empty-result-preset", "speckit.specify", + "Toggle empty-result test", "preset body", + ) + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "0.1.5") + + cmd_file = copilot_commands_dir / "speckit.specify.agent.md" + assert cmd_file.exists(), ( + "sanity: command mode should have written copilot's command file" + ) + + # Remove the *installed* copy of the preset's source file (not the + # original temp source) so _register_skills can find nothing to + # render — a real "missing source" case, not an exception — leaving + # registered_skills empty for copilot. + (manager.presets_dir / "toggle-empty-result-preset" / "commands" / "speckit.specify.md").unlink() + + self._write_init_options(project_dir, ai="copilot", ai_skills=True) + manager.register_enabled_presets_for_agent("copilot") + + assert cmd_file.exists(), ( + "an empty (non-raising) skills registration result must not " + "cause the old command-mode artifact to be deleted (#2948)" + ) + metadata = manager.registry.get("toggle-empty-result-preset") + assert metadata["registered_commands"].get("copilot"), ( + "registered_commands must keep tracking copilot's still-live " + "command file when nothing was actually replaced (#2948)" + ) + skill_file = project_dir / ".github" / "skills" / "speckit-specify" / "SKILL.md" + assert not skill_file.exists(), ( + "sanity: no skill should have been written when the source " + "was missing" + ) + + def test_toggle_command_to_skills_partial_result_only_removes_replaced_command( + self, project_dir, temp_dir + ): + """Only the command whose skill replacement actually landed is retired. + + A two-command preset where one command's source file goes missing + right before the toggle: ``_register_skills`` genuinely returns a + partial result (one name present, one silently skipped) without + raising. The command whose skill was written must be retired; the + other must keep both its old command file and its registry + tracking, since no replacement for it actually landed (#2948). + """ + self._write_init_options(project_dir, ai="copilot", ai_skills=False) + copilot_commands_dir = project_dir / ".github" / "agents" + copilot_commands_dir.mkdir(parents=True) + + preset_dir = self._create_multi_command_preset( + temp_dir, "toggle-partial-result-preset", + ["speckit.specify", "speckit.plan"], + ) + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "0.1.5") + + specify_cmd_file = copilot_commands_dir / "speckit.specify.agent.md" + plan_cmd_file = copilot_commands_dir / "speckit.plan.agent.md" + assert specify_cmd_file.exists() and plan_cmd_file.exists(), ( + "sanity: command mode should have written both command files" + ) + metadata = manager.registry.get("toggle-partial-result-preset") + assert set(metadata["registered_commands"].get("copilot", [])) == { + "speckit.specify", "speckit.plan", + }, "sanity: both commands should be tracked for copilot" + + # Remove only the plan command's *installed* source so its skill + # replacement is silently skipped (missing source), while + # specify's succeeds — a genuine partial result, not an injected + # exception. + (manager.presets_dir / "toggle-partial-result-preset" / "commands" / "speckit.plan.md").unlink() + + self._write_init_options(project_dir, ai="copilot", ai_skills=True) + manager.register_enabled_presets_for_agent("copilot") + + assert not specify_cmd_file.exists(), ( + "the specify command's old artifact must be retired since its " + "skill replacement actually landed (#2948)" + ) + assert plan_cmd_file.exists(), ( + "the plan command's old artifact must survive since its skill " + "replacement never landed (missing source) (#2948)" + ) + metadata = manager.registry.get("toggle-partial-result-preset") + tracked_commands = metadata["registered_commands"].get("copilot", []) + assert "speckit.specify" not in tracked_commands, ( + "specify must stop being tracked as a command once its " + "artifact has been unregistered (#2948)" + ) + assert "speckit.plan" in tracked_commands, ( + "plan must keep being tracked as a command since its old " + "artifact is still on disk (#2948)" + ) + specify_skill_file = ( + project_dir / ".github" / "skills" / "speckit-specify" / "SKILL.md" + ) + assert "preset:toggle-partial-result-preset" in specify_skill_file.read_text(), ( + "sanity: specify's new skill artifact should exist" + ) + plan_skill_file = project_dir / ".github" / "skills" / "speckit-plan" + assert not plan_skill_file.exists(), ( + "sanity: no skill should have been written for plan since its " + "source was missing" + ) + + def test_lower_priority_skill_does_not_remove_failed_winner_command( + self, project_dir, temp_dir + ): + """Only a successfully rendered winning layer may retire a command.""" + self._write_init_options(project_dir, ai="copilot", ai_skills=False) + commands_dir = project_dir / ".github" / "agents" + commands_dir.mkdir(parents=True) + + lower_dir = self._create_command_preset( + temp_dir, + "lower-toggle-preset", + "speckit.specify", + "Lower preset", + "Lower body", + ) + higher_dir = self._create_command_preset( + temp_dir, + "higher-toggle-preset", + "speckit.specify", + "Higher preset", + "Higher body", + ) + manager = PresetManager(project_dir) + manager.install_from_directory(lower_dir, "0.1.5", priority=20) + manager.install_from_directory(higher_dir, "0.1.5", priority=10) + + command_file = commands_dir / "speckit.specify.agent.md" + assert "Higher body" in command_file.read_text(encoding="utf-8") + + higher_source = ( + manager.presets_dir + / "higher-toggle-preset" + / "commands" + / "speckit.specify.md" + ) + higher_source.unlink() + self._write_init_options(project_dir, ai="copilot", ai_skills=True) + + manager.register_enabled_presets_for_agent("copilot") + + assert command_file.exists(), ( + "a lower-priority skill replacement must not remove the existing " + "higher-priority command when the winning layer did not render" + ) + assert "Higher body" in command_file.read_text(encoding="utf-8") + + higher_source.write_text( + "---\ndescription: Higher preset\n---\n\nHigher body\n", + encoding="utf-8", + ) + manager.register_enabled_presets_for_agent("copilot") + + assert not command_file.exists(), ( + "the old command should be retired after the winning skill " + "layer renders successfully" + ) + for preset_id in ("lower-toggle-preset", "higher-toggle-preset"): + metadata = manager.registry.get(preset_id) + assert not metadata["registered_commands"].get("copilot"), ( + "all layers sharing the retired command output must drop " + "their stale command tracking" + ) + + def test_successful_winner_without_stale_tracking_retires_lower_command( + self, project_dir, temp_dir + ): + """Winner success is independent of whether that layer tracked a command.""" + self._write_init_options(project_dir, ai="copilot", ai_skills=False) + commands_dir = project_dir / ".github" / "agents" + commands_dir.mkdir(parents=True) + + lower_dir = self._create_command_preset( + temp_dir, + "lower-command-preset", + "speckit.specify", + "Lower preset", + "Lower body", + ) + higher_dir = self._create_command_preset( + temp_dir, + "higher-skill-preset", + "speckit.specify", + "Higher preset", + "Higher body", + ) + manager = PresetManager(project_dir) + manager.install_from_directory(lower_dir, "0.1.5", priority=20) + + command_file = commands_dir / "speckit.specify.agent.md" + assert command_file.exists() + + self._write_init_options(project_dir, ai="copilot", ai_skills=True) + manager.install_from_directory(higher_dir, "0.1.5", priority=10) + higher_metadata = manager.registry.get("higher-skill-preset") + assert not higher_metadata["registered_commands"].get("copilot") + + manager.register_enabled_presets_for_agent("copilot") + + assert not command_file.exists(), ( + "a successfully rendered winning skill must retire a lower " + "layer's stale command even when the winner never tracked one" + ) + lower_metadata = manager.registry.get("lower-command-preset") + assert not lower_metadata["registered_commands"].get("copilot") + + def test_lower_priority_command_does_not_remove_failed_winner_skill( + self, project_dir, temp_dir + ): + """Only a successfully rendered winning command may retire a skill.""" + self._write_init_options(project_dir, ai="copilot", ai_skills=True) + commands_dir = project_dir / ".github" / "agents" + commands_dir.mkdir(parents=True) + + lower_dir = self._create_command_preset( + temp_dir, + "lower-skill-preset", + "speckit.specify", + "Lower preset", + "Lower body", + ) + higher_dir = self._create_command_preset( + temp_dir, + "higher-command-preset", + "speckit.specify", + "Higher preset", + "Higher body", + ) + manager = PresetManager(project_dir) + manager.install_from_directory(lower_dir, "0.1.5", priority=20) + manager.install_from_directory(higher_dir, "0.1.5", priority=10) + + skill_file = ( + project_dir + / ".github" + / "skills" + / "speckit-specify" + / "SKILL.md" + ) + assert "Higher body" in skill_file.read_text(encoding="utf-8") + + higher_source = ( + manager.presets_dir + / "higher-command-preset" + / "commands" + / "speckit.specify.md" + ) + higher_source.unlink() + self._write_init_options(project_dir, ai="copilot", ai_skills=False) + + manager.register_enabled_presets_for_agent("copilot") + + assert skill_file.exists(), ( + "a lower-priority command replacement must not remove the " + "existing higher-priority skill when the winner did not render" + ) + assert "Higher body" in skill_file.read_text(encoding="utf-8") + + higher_source.write_text( + "---\ndescription: Higher preset\n---\n\nHigher body\n", + encoding="utf-8", + ) + manager.register_enabled_presets_for_agent("copilot") + + assert not skill_file.exists(), ( + "the old skill should be retired after the winning command " + "layer renders successfully" + ) + for preset_id in ("lower-skill-preset", "higher-command-preset"): + metadata = manager.registry.get(preset_id) + assert not metadata["registered_skills"].get("copilot"), ( + "all layers sharing the retired skill output must drop " + "their stale skill tracking" + ) + + def test_project_override_command_retires_stale_preset_skill( + self, project_dir, temp_dir + ): + """A reconciled project override can replace a stale preset skill.""" + self._write_init_options(project_dir, ai="copilot", ai_skills=True) + commands_dir = project_dir / ".github" / "agents" + commands_dir.mkdir(parents=True) + + preset_dir = self._create_command_preset( + temp_dir, + "override-toggle-preset", + "speckit.specify", + "Preset", + "Preset body", + ) + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "0.1.5") + + skill_file = ( + project_dir + / ".github" + / "skills" + / "speckit-specify" + / "SKILL.md" + ) + assert skill_file.exists() + + overrides_dir = ( + project_dir / ".specify" / "templates" / "overrides" + ) + overrides_dir.mkdir(parents=True) + (overrides_dir / "speckit.specify.md").write_text( + "---\ndescription: Project override\n---\n\nOverride body\n", + encoding="utf-8", + ) + ( + manager.presets_dir + / "override-toggle-preset" + / "commands" + / "speckit.specify.md" + ).unlink() + self._write_init_options(project_dir, ai="copilot", ai_skills=False) + + manager.register_enabled_presets_for_agent("copilot") + + command_file = commands_dir / "speckit.specify.agent.md" + assert "Override body" in command_file.read_text(encoding="utf-8") + assert not skill_file.exists(), ( + "the stale preset skill must be retired once the project " + "override command is successfully reconciled" + ) + metadata = manager.registry.get("override-toggle-preset") + assert not metadata["registered_skills"].get("copilot") + + def test_project_override_command_retires_reconciled_override_skill( + self, project_dir, temp_dir + ): + self._write_init_options(project_dir, ai="copilot", ai_skills=True) + commands_dir = project_dir / ".github" / "agents" + commands_dir.mkdir(parents=True) + skills_dir = project_dir / ".github" / "skills" + self._create_skill(skills_dir, "speckit-specify") + + preset_dir = self._create_command_preset( + temp_dir, + "reconciled-override-toggle-preset", + "speckit.specify", + "Preset", + "Preset body", + ) + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "0.1.5") + + overrides_dir = ( + project_dir / ".specify" / "templates" / "overrides" + ) + overrides_dir.mkdir(parents=True) + (overrides_dir / "speckit.specify.md").write_text( + "---\ndescription: Project override\n---\n\nOverride body\n", + encoding="utf-8", + ) + ( + manager.presets_dir + / "reconciled-override-toggle-preset" + / "commands" + / "speckit.specify.md" + ).unlink() + + manager.register_enabled_presets_for_agent("copilot") + + skill_file = skills_dir / "speckit-specify" / "SKILL.md" + assert "override:speckit.specify" in skill_file.read_text( + encoding="utf-8" + ) + + self._write_init_options(project_dir, ai="copilot", ai_skills=False) + manager.register_enabled_presets_for_agent("copilot") + + assert "Override body" in ( + commands_dir / "speckit.specify.agent.md" + ).read_text(encoding="utf-8") + assert not skill_file.exists() + metadata = manager.registry.get( + "reconciled-override-toggle-preset" + ) + assert not metadata["registered_skills"].get("copilot") + + def test_project_override_skill_retires_stale_preset_command( + self, project_dir, temp_dir + ): + """A reconciled override skill may retire a stale preset command.""" + self._write_init_options(project_dir, ai="copilot", ai_skills=False) + commands_dir = project_dir / ".github" / "agents" + commands_dir.mkdir(parents=True) + + preset_dir = self._create_command_preset( + temp_dir, + "override-skill-toggle-preset", + "speckit.specify", + "Preset", + "Preset body", + ) + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "0.1.5") + + command_file = commands_dir / "speckit.specify.agent.md" + assert command_file.exists() + skills_dir = project_dir / ".github" / "skills" + self._create_skill(skills_dir, "speckit-specify") + manager.registry.update( + "override-skill-toggle-preset", + {"registered_skills": {"copilot": ["speckit-specify"]}}, + ) + + overrides_dir = ( + project_dir / ".specify" / "templates" / "overrides" + ) + overrides_dir.mkdir(parents=True) + (overrides_dir / "speckit.specify.md").write_text( + "---\ndescription: Project override\n---\n\nOverride body\n", + encoding="utf-8", + ) + ( + manager.presets_dir + / "override-skill-toggle-preset" + / "commands" + / "speckit.specify.md" + ).unlink() + self._write_init_options(project_dir, ai="copilot", ai_skills=True) + + manager.register_enabled_presets_for_agent("copilot") + + skill_file = skills_dir / "speckit-specify" / "SKILL.md" + assert "Override body" in skill_file.read_text(encoding="utf-8") + assert not command_file.exists(), ( + "the stale preset command must be retired after the project " + "override skill is successfully reconciled" + ) + metadata = manager.registry.get("override-skill-toggle-preset") + assert not metadata["registered_commands"].get("copilot") + + def test_toggle_skills_to_command_empty_result_preserves_old_skill( + self, project_dir, temp_dir + ): + """A non-raising but empty command result must not delete the old skill. + + Mirror image of the empty-result command->skills case: deleting the + preset's own source file makes ``_register_commands`` return ``{}`` + for copilot without raising, which must leave the old SKILL.md and + its tracking untouched (#2948). + """ + self._write_init_options(project_dir, ai="copilot", ai_skills=True) + copilot_commands_dir = project_dir / ".github" / "agents" + copilot_commands_dir.mkdir(parents=True) + skills_dir = project_dir / ".github" / "skills" + self._create_skill(skills_dir, "speckit-specify") + + preset_dir = self._create_command_preset( + temp_dir, "toggle-skill-empty-result-preset", "speckit.specify", + "Toggle empty-result test", "preset body", + ) + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "0.1.5") + + skill_file = skills_dir / "speckit-specify" / "SKILL.md" + assert "preset:toggle-skill-empty-result-preset" in skill_file.read_text(), ( + "sanity: skills mode should have written the SKILL.md mirror" + ) + + # Remove the preset's own *installed* source file so + # _register_commands can find nothing to render — a real "missing + # source" case, not an exception — leaving registered_commands + # empty for copilot. + (manager.presets_dir / "toggle-skill-empty-result-preset" / "commands" / "speckit.specify.md").unlink() + + self._write_init_options(project_dir, ai="copilot", ai_skills=False) + manager.register_enabled_presets_for_agent("copilot") + + assert "preset:toggle-skill-empty-result-preset" in skill_file.read_text(), ( + "an empty (non-raising) command registration result must not " + "cause the old skills-mode artifact to be deleted/reverted " + "(#2948)" + ) + metadata = manager.registry.get("toggle-skill-empty-result-preset") + assert "speckit-specify" in metadata["registered_skills"].get("copilot", []), ( + "registered_skills must keep tracking copilot's still-live " + "skill file when nothing was actually replaced (#2948)" + ) + + def test_toggle_skills_to_command_partial_result_only_removes_replaced_skill( + self, project_dir, temp_dir + ): + """Only the skill whose command replacement actually landed is retired. + + Mirror image of the partial-result command->skills case: a + two-command preset where one command's source file goes missing + right before the toggle, so ``_register_commands`` genuinely + returns a partial result. The skill whose command was written + must be retired; the other must keep both its old SKILL.md and + its registry tracking, since no replacement for it landed (#2948). + """ + self._write_init_options(project_dir, ai="copilot", ai_skills=True) + copilot_commands_dir = project_dir / ".github" / "agents" + copilot_commands_dir.mkdir(parents=True) + skills_dir = project_dir / ".github" / "skills" + self._create_skill(skills_dir, "speckit-specify") + self._create_skill(skills_dir, "speckit-plan") + + # A core template lets the retired skill restore to core content + # instead of being removed entirely (it has nothing else to fall + # back to), matching _unregister_skills's behaviour elsewhere. + core_cmds = project_dir / ".specify" / "templates" / "commands" + core_cmds.mkdir(parents=True, exist_ok=True) + (core_cmds / "specify.md").write_text( + "---\ndescription: Core specify command\n---\n\nCore specify body\n", + encoding="utf-8", + ) + + preset_dir = self._create_multi_command_preset( + temp_dir, "toggle-skill-partial-result-preset", + ["speckit.specify", "speckit.plan"], + ) + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "0.1.5") + + specify_skill_file = skills_dir / "speckit-specify" / "SKILL.md" + plan_skill_file = skills_dir / "speckit-plan" / "SKILL.md" + assert "preset:toggle-skill-partial-result-preset" in specify_skill_file.read_text() + assert "preset:toggle-skill-partial-result-preset" in plan_skill_file.read_text() + metadata = manager.registry.get("toggle-skill-partial-result-preset") + assert set(metadata["registered_skills"].get("copilot", [])) == { + "speckit-specify", "speckit-plan", + }, "sanity: both skills should be tracked for copilot" + + # Remove only the plan command's *installed* source so its command + # replacement is silently skipped (missing source), while + # specify's succeeds. + (manager.presets_dir / "toggle-skill-partial-result-preset" / "commands" / "speckit.plan.md").unlink() + + self._write_init_options(project_dir, ai="copilot", ai_skills=False) + manager.register_enabled_presets_for_agent("copilot") + + assert "preset:toggle-skill-partial-result-preset" not in specify_skill_file.read_text(), ( + "the specify skill's old artifact must be retired/reverted " + "since its command replacement actually landed (#2948)" + ) + assert "preset:toggle-skill-partial-result-preset" in plan_skill_file.read_text(), ( + "the plan skill's old artifact must survive since its command " + "replacement never landed (missing source) (#2948)" + ) + metadata = manager.registry.get("toggle-skill-partial-result-preset") + tracked_skills = metadata["registered_skills"].get("copilot", []) + assert "speckit-specify" not in tracked_skills, ( + "specify must stop being tracked as a skill once its artifact " + "has been unregistered/reverted (#2948)" + ) + assert "speckit-plan" in tracked_skills, ( + "plan must keep being tracked as a skill since its old " + "artifact is still on disk (#2948)" + ) + assert (copilot_commands_dir / "speckit.specify.agent.md").exists(), ( + "sanity: specify's new command artifact should exist" + ) + + def test_toggle_command_to_skills_retires_alias_group_when_primary_skill_lands( + self, project_dir, temp_dir + ): + """A command's aliases must be retired together with its primary + once the primary's skill replacement lands. + + ``CommandRegistrar.register_commands()`` tracks and returns + primary + alias names flattened together, but ``_register_skills()`` + only ever renders/returns the *primary* command name's skill. The + alias's own name run through ``_skill_names_for_command()`` never + matches anything real, so without grouping by primary, the alias + command artifact and its tracking entry would survive forever even + after mutual exclusion is otherwise enforced for the primary (#2948). + """ + self._write_init_options(project_dir, ai="copilot", ai_skills=False) + copilot_commands_dir = project_dir / ".github" / "agents" + copilot_commands_dir.mkdir(parents=True) + + preset_dir = self._create_multi_command_preset_with_aliases( + temp_dir, "alias-group-success-preset", + [("speckit.specify", ["speckit.spec"])], + ) + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "0.1.5") + + primary_cmd_file = copilot_commands_dir / "speckit.specify.agent.md" + alias_cmd_file = copilot_commands_dir / "speckit.spec.agent.md" + assert primary_cmd_file.exists() and alias_cmd_file.exists(), ( + "sanity: command mode should have written both the primary " + "and alias command files" + ) + metadata = manager.registry.get("alias-group-success-preset") + assert set(metadata["registered_commands"].get("copilot", [])) == { + "speckit.specify", "speckit.spec", + }, "sanity: both primary and alias should be tracked for copilot" + + self._write_init_options(project_dir, ai="copilot", ai_skills=True) + manager.register_enabled_presets_for_agent("copilot") + + assert not primary_cmd_file.exists(), ( + "the primary's old command artifact must be retired once its " + "skill replacement lands (#2948)" + ) + assert not alias_cmd_file.exists(), ( + "the alias's old command artifact must be retired together " + "with its primary once the primary's skill replacement lands " + "(#2948)" + ) + metadata = manager.registry.get("alias-group-success-preset") + registered_commands = metadata.get("registered_commands", {}) + assert not registered_commands.get("copilot"), ( + "neither the primary nor the alias should remain tracked as " + "commands once both artifacts are retired (#2948)" + ) + skill_file = project_dir / ".github" / "skills" / "speckit-specify" / "SKILL.md" + assert skill_file.exists(), "sanity: the primary's skill should have been written" + + def test_toggle_command_to_skills_keeps_alias_group_when_primary_skill_missing( + self, project_dir, temp_dir + ): + """A command's aliases must survive together with its primary when + the primary's skill replacement never lands. + + Mirror image of the group-retirement case: deleting the preset's + own installed command source makes ``_register_skills`` genuinely + return nothing for ``speckit.specify``, so neither the primary nor + its alias have a real replacement — both old command artifacts and + their tracking must survive (#2948). + """ + self._write_init_options(project_dir, ai="copilot", ai_skills=False) + copilot_commands_dir = project_dir / ".github" / "agents" + copilot_commands_dir.mkdir(parents=True) + + preset_dir = self._create_multi_command_preset_with_aliases( + temp_dir, "alias-group-failure-preset", + [("speckit.specify", ["speckit.spec"])], + ) + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "0.1.5") + + primary_cmd_file = copilot_commands_dir / "speckit.specify.agent.md" + alias_cmd_file = copilot_commands_dir / "speckit.spec.agent.md" + assert primary_cmd_file.exists() and alias_cmd_file.exists(), ( + "sanity: command mode should have written both the primary " + "and alias command files" + ) + + # Remove the installed source so _register_skills can find nothing + # to render for the primary — a real "missing source" case. + (manager.presets_dir / "alias-group-failure-preset" / "commands" / "speckit.specify.md").unlink() + + self._write_init_options(project_dir, ai="copilot", ai_skills=True) + manager.register_enabled_presets_for_agent("copilot") + + assert primary_cmd_file.exists(), ( + "the primary's old command artifact must survive since its " + "skill replacement never landed (#2948)" + ) + assert alias_cmd_file.exists(), ( + "the alias's old command artifact must survive together with " + "its primary since neither has a real replacement (#2948)" + ) + metadata = manager.registry.get("alias-group-failure-preset") + assert set(metadata["registered_commands"].get("copilot", [])) == { + "speckit.specify", "speckit.spec", + }, ( + "both the primary and alias must keep being tracked since " + "nothing was actually replaced (#2948)" + ) + skill_file = project_dir / ".github" / "skills" / "speckit-specify" / "SKILL.md" + assert not skill_file.exists(), ( + "sanity: no skill should have been written when the source " + "was missing" + ) + + def test_toggle_command_to_skills_partial_multi_group_only_retires_successful_group( + self, project_dir, temp_dir + ): + """With two independent alias groups, only the group whose primary + skill actually lands is retired; the other survives intact. + + A preset with two commands (``speckit.specify`` with alias + ``speckit.spec``, and ``speckit.plan`` with alias + ``speckit.plan-alt``) where only ``speckit.plan``'s installed + source goes missing: ``speckit.specify``'s group (primary + alias) + must be fully retired, while ``speckit.plan``'s entire group + (primary + alias) must survive together, since grouping is + per-primary, not per-individual-name (#2948). + """ + self._write_init_options(project_dir, ai="copilot", ai_skills=False) + copilot_commands_dir = project_dir / ".github" / "agents" + copilot_commands_dir.mkdir(parents=True) + + preset_dir = self._create_multi_command_preset_with_aliases( + temp_dir, "alias-group-partial-preset", + [ + ("speckit.specify", ["speckit.spec"]), + ("speckit.plan", ["speckit.plan-alt"]), + ], + ) + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "0.1.5") + + specify_cmd = copilot_commands_dir / "speckit.specify.agent.md" + spec_alias_cmd = copilot_commands_dir / "speckit.spec.agent.md" + plan_cmd = copilot_commands_dir / "speckit.plan.agent.md" + plan_alias_cmd = copilot_commands_dir / "speckit.plan-alt.agent.md" + assert all( + f.exists() for f in (specify_cmd, spec_alias_cmd, plan_cmd, plan_alias_cmd) + ), "sanity: command mode should have written all four command files" + + # Remove only plan's installed source so its group's skill + # replacement is silently skipped, while specify's group succeeds. + (manager.presets_dir / "alias-group-partial-preset" / "commands" / "speckit.plan.md").unlink() + + self._write_init_options(project_dir, ai="copilot", ai_skills=True) + manager.register_enabled_presets_for_agent("copilot") + + assert not specify_cmd.exists() and not spec_alias_cmd.exists(), ( + "specify's whole group (primary + alias) must be retired " + "since its skill replacement landed (#2948)" + ) + assert plan_cmd.exists() and plan_alias_cmd.exists(), ( + "plan's whole group (primary + alias) must survive together " + "since its skill replacement never landed (#2948)" + ) + metadata = manager.registry.get("alias-group-partial-preset") + tracked_commands = set(metadata["registered_commands"].get("copilot", [])) + assert tracked_commands == {"speckit.plan", "speckit.plan-alt"}, ( + "only plan's group should remain tracked as commands; " + "specify's group must be fully untracked (#2948)" + ) + + def test_rescaffold_toggle_skills_to_command_removes_stale_skill_file( + self, project_dir, temp_dir + ): + """Toggling the *same* agent from skills mode to command mode must + remove the stale skills-mode artifact, not just add the new one. + + Mirror image of the command-to-skills toggle: once ``ai_skills`` is + turned off for copilot (still the active agent), ``_get_skills_dir`` + stops resolving a skills directory for it, so ``_register_skills`` + becomes a no-op — but the ``SKILL.md`` written while skills mode was + on, and its ``registered_skills`` entry, were left behind even + though ``_register_commands`` went on to (re)write the ``.agent.md`` + command file, again breaking mutual exclusion (#2948). + """ + self._write_init_options(project_dir, ai="copilot", ai_skills=True) + copilot_commands_dir = project_dir / ".github" / "agents" + copilot_commands_dir.mkdir(parents=True) + skills_dir = project_dir / ".github" / "skills" + self._create_skill(skills_dir, "speckit-specify") + + # A core template lets the stale skill restore to core content + # (instead of being removed entirely, since it has nothing to fall + # back to), matching how `_unregister_skills` behaves elsewhere. + core_cmds = project_dir / ".specify" / "templates" / "commands" + core_cmds.mkdir(parents=True, exist_ok=True) + (core_cmds / "specify.md").write_text( + "---\ndescription: Core specify command\n---\n\nCore specify body\n", + encoding="utf-8", + ) + + preset_dir = self._create_command_preset( + temp_dir, "toggle-skill-to-cmd-preset", "speckit.specify", + "Toggle test", "preset body", + ) + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "0.1.5") + + skill_file = skills_dir / "speckit-specify" / "SKILL.md" + assert "preset:toggle-skill-to-cmd-preset" in skill_file.read_text(), ( + "sanity: skills mode should have written the SKILL.md mirror" + ) + metadata = manager.registry.get("toggle-skill-to-cmd-preset") + assert metadata["registered_skills"].get("copilot"), ( + "sanity: the skills-mode write should be tracked for copilot" + ) + + # Flip ai_skills off for the *same* active agent and rescaffold, as + # `integration upgrade copilot` would after the mode toggle. + self._write_init_options(project_dir, ai="copilot", ai_skills=False) + manager.register_enabled_presets_for_agent("copilot") + + restored_content = skill_file.read_text() + assert "preset:toggle-skill-to-cmd-preset" not in restored_content, ( + "the stale skills-mode artifact must be reverted once copilot " + "has toggled to command mode for the same agent (#2948)" + ) + assert "Core specify body" in restored_content, ( + "sanity: the skill should fall back to core content, not just " + "lose the preset's override" + ) + metadata = manager.registry.get("toggle-skill-to-cmd-preset") + assert not metadata["registered_skills"].get("copilot"), ( + "registered_skills must stop tracking copilot once its " + "artifact has been unregistered/restored (#2948)" + ) + cmd_file = copilot_commands_dir / "speckit.specify.agent.md" + assert cmd_file.exists(), ( + "sanity: the new command-mode artifact should still be written" + ) + assert "preset body" in cmd_file.read_text() + + def test_native_skill_activation_recreates_deleted_skills_root( + self, project_dir, temp_dir + ): + self._write_init_options(project_dir, ai="claude", ai_skills=True) + claude_skills_dir = project_dir / ".claude" / "skills" + self._create_skill(claude_skills_dir, "speckit-specify") + preset_dir = self._create_command_preset( + temp_dir, + "native-root-recovery-preset", + "speckit.specify", + "Native root recovery", + "preset body", + ) + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "0.1.5") + + codex_skills_dir = project_dir / ".agents" / "skills" + assert not codex_skills_dir.exists() + self._write_init_options(project_dir, ai="codex", ai_skills=True) + manager.register_enabled_presets_for_agent("codex") + + skill_file = codex_skills_dir / "speckit-specify" / "SKILL.md" + assert "preset:native-root-recovery-preset" in skill_file.read_text() + metadata = manager.registry.get("native-root-recovery-preset") + assert "speckit.specify" in metadata["registered_commands"]["codex"] + + def test_rescaffold_migrates_legacy_flat_list_registered_skills( + self, project_dir, temp_dir + ): + """Rescaffolding a preset with a legacy flat-list ``registered_skills`` + entry must persist the migrated per-agent dict even when the + rescaffolded skill names are unchanged from before. + + ``_normalize_registered_skills`` converts a legacy flat ``List[str]`` + (predating per-agent provenance) into ``{agent_name: [...]}`` in + memory, but the persistence check compared only the *normalized* + ``merged_skills`` against the *normalized* ``existing_skills`` — + both derived from the same raw legacy list. When the freshly + registered names are identical to what the legacy list already + held (the common case: nothing about the preset or skill actually + changed), that comparison is a no-op and ``registry.update()`` is + skipped, leaving the *raw* on-disk value as the un-migrated flat + list. A later switch to a different skill-mode agent and removal + then follows the legacy best-effort restore path (only the + currently active agent's directory) instead of the per-agent + provenance path, orphaning the first agent's override (#2948). + """ + self._write_init_options(project_dir, ai="claude", ai_skills=True) + + core_cmds = project_dir / ".specify" / "templates" / "commands" + core_cmds.mkdir(parents=True, exist_ok=True) + (core_cmds / "specify.md").write_text( + "---\ndescription: Core specify command\n---\n\nCore specify body\n", + encoding="utf-8", + ) + + claude_skills_dir = project_dir / ".claude" / "skills" + self._create_skill(claude_skills_dir, "speckit-specify") + codex_skills_dir = project_dir / ".agents" / "skills" + self._create_skill(codex_skills_dir, "speckit-specify") + + preset_dir = self._create_command_preset( + temp_dir, "legacy-skills-preset", "speckit.specify", + "Legacy skills test", "preset body", + ) + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "0.1.5") + + # Simulate a registry entry written by a pre-#2948 spec-kit version: + # registered_skills stored as a flat list with no per-agent + # provenance, rather than the dict shape install_from_directory + # writes today. + manager.registry.update( + "legacy-skills-preset", {"registered_skills": ["speckit-specify"]}, + ) + metadata = manager.registry.get("legacy-skills-preset") + assert isinstance(metadata["registered_skills"], list), ( + "sanity: the injected legacy format is a flat list" + ) + + # Rescaffold for the *same* active agent (claude) with no actual + # change to the registered skill names, mirroring `integration + # upgrade claude` re-running registration for the active + # integration. + manager.register_enabled_presets_for_agent("claude") + + metadata = manager.registry.get("legacy-skills-preset") + registered_skills = metadata.get("registered_skills") + assert isinstance(registered_skills, dict), ( + "rescaffold must migrate a legacy flat-list registered_skills " + "entry to the per-agent dict format even when the " + "rescaffolded names are unchanged, or the raw registry stays " + "un-migrated and later removal loses per-agent provenance " + "(#2948)" + ) + assert registered_skills.get("claude") == ["speckit-specify"] + + # Switch to a different skill-mode agent and rescaffold again — + # with the dict format now in place, both directories should be + # tracked and therefore restorable on removal. + self._write_init_options(project_dir, ai="codex", ai_skills=True) + manager.register_enabled_presets_for_agent("codex") + + metadata = manager.registry.get("legacy-skills-preset") + assert set(metadata.get("registered_skills", {})) == {"claude", "codex"}, ( + "the migrated dict must keep recording every agent directory " + "the preset actually wrote to, exactly like a preset that was " + "always in dict format (#2948)" + ) + + assert manager.remove("legacy-skills-preset") is True + + claude_skill = claude_skills_dir / "speckit-specify" / "SKILL.md" + codex_skill = codex_skills_dir / "speckit-specify" / "SKILL.md" + for skill_file, label in ((claude_skill, "claude"), (codex_skill, "codex")): + assert skill_file.exists(), f"{label} skill file should still exist after removal" + content = skill_file.read_text() + assert "preset:legacy-skills-preset" not in content, ( + f"{label}'s preset override must be restored on removal, " + "not orphaned because the registry stayed in legacy " + "flat-list format (#2948)" + ) + assert "Core specify body" in content + + def test_rescaffold_legacy_flat_list_direct_switch_preserves_original_agent( + self, project_dir, temp_dir + ): + """A legacy flat-list ``registered_skills`` entry must not be + misattributed to the wrong agent when the *first* post-upgrade + operation is a direct switch to a different skill-mode agent. + + Blindly attributing every legacy flat-list name to ``agent_name`` — + the agent currently being (re)activated — loses the actual writer + whenever that first operation is ``integration use codex`` (or + ``switch``) run directly against a legacy Claude override, without + an intervening same-agent rescaffold for Claude first. The + migrated dict then only records ``{"codex": [...]}``, so a later + ``remove()`` restores Codex but permanently orphans the Claude + override that was never in the registry to begin with (#2948). + """ + self._write_init_options(project_dir, ai="claude", ai_skills=True) + + core_cmds = project_dir / ".specify" / "templates" / "commands" + core_cmds.mkdir(parents=True, exist_ok=True) + (core_cmds / "specify.md").write_text( + "---\ndescription: Core specify command\n---\n\nCore specify body\n", + encoding="utf-8", + ) + + claude_skills_dir = project_dir / ".claude" / "skills" + self._create_skill(claude_skills_dir, "speckit-specify") + codex_skills_dir = project_dir / ".agents" / "skills" + self._create_skill(codex_skills_dir, "speckit-specify") + + preset_dir = self._create_command_preset( + temp_dir, "legacy-direct-switch-preset", "speckit.specify", + "Legacy direct switch test", "preset body", + ) + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "0.1.5") + + # install_from_directory wrote the preset's override to Claude's + # skill directory (the active agent at install time) — sanity-check + # that the marker is actually there before simulating the legacy + # registry format. + claude_skill = claude_skills_dir / "speckit-specify" / "SKILL.md" + assert "preset:legacy-direct-switch-preset" in claude_skill.read_text(), ( + "sanity: install should have written the override under claude" + ) + + # Simulate a pre-#2948 registry: a flat list with no per-agent + # provenance, even though the file on disk was actually written + # under claude's directory. + manager.registry.update( + "legacy-direct-switch-preset", + {"registered_skills": ["speckit-specify"]}, + ) + + # Directly switch to codex — no intervening rescaffold for claude — + # mirroring `integration use codex` / `switch codex` run right after + # upgrading spec-kit versions. + self._write_init_options(project_dir, ai="codex", ai_skills=True) + manager.register_enabled_presets_for_agent("codex") + + metadata = manager.registry.get("legacy-direct-switch-preset") + registered_skills = metadata.get("registered_skills") + assert isinstance(registered_skills, dict) + assert set(registered_skills) == {"claude", "codex"}, ( + "migrating a legacy flat-list entry on a direct switch must " + "infer the actual writer (claude) from the existing on-disk " + "SKILL.md provenance, not attribute every name to whichever " + "agent happens to be activated first after the upgrade " + "(#2948)" + ) + + assert manager.remove("legacy-direct-switch-preset") is True + + codex_skill = codex_skills_dir / "speckit-specify" / "SKILL.md" + for skill_file, label in ((claude_skill, "claude"), (codex_skill, "codex")): + assert skill_file.exists(), f"{label} skill file should still exist after removal" + content = skill_file.read_text() + assert "preset:legacy-direct-switch-preset" not in content, ( + f"{label}'s preset override must be restored on removal, " + "not permanently orphaned by a misattributed legacy " + "migration (#2948)" + ) + assert "Core specify body" in content + + def test_rescaffold_legacy_flat_list_infers_command_backed_skills_owner( + self, project_dir, temp_dir + ): + """Legacy provenance inference must also probe command-backed agents + that were running in skills mode, not only agents whose command + registrar config is statically ``/SKILL.md``-only. + + Copilot is command-backed (``extension: ".agent.md"``), but with + ``ai_skills`` enabled its preset overrides render as ``SKILL.md`` + files under ``.github/skills`` exactly like a native skill-only + agent (claude, codex, ...). Before the fix, + ``_infer_legacy_skill_provenance`` only probed agents whose + registrar config has a static ``extension == "/SKILL.md"``, so a + real preset-owned ``.github/skills/.../SKILL.md`` written while + Copilot was the active, skills-mode agent was never found — the + legacy flat list was misattributed entirely to whichever agent the + first post-upgrade switch happened to activate, permanently + orphaning Copilot's override on later removal (#2948). + """ + self._write_init_options(project_dir, ai="copilot", ai_skills=True) + + core_cmds = project_dir / ".specify" / "templates" / "commands" + core_cmds.mkdir(parents=True, exist_ok=True) + (core_cmds / "specify.md").write_text( + "---\ndescription: Core specify command\n---\n\nCore specify body\n", + encoding="utf-8", + ) + + copilot_skills_dir = project_dir / ".github" / "skills" + self._create_skill(copilot_skills_dir, "speckit-specify") + claude_skills_dir = project_dir / ".claude" / "skills" + self._create_skill(claude_skills_dir, "speckit-specify") + + preset_dir = self._create_command_preset( + temp_dir, "legacy-copilot-skills-preset", "speckit.specify", + "Legacy copilot skills test", "preset body", + ) + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "0.1.5") + + copilot_skill = copilot_skills_dir / "speckit-specify" / "SKILL.md" + assert "preset:legacy-copilot-skills-preset" in copilot_skill.read_text(), ( + "sanity: install should have written the override under " + "copilot's skills directory while copilot was active in " + "skills mode" + ) + # Sanity: no command-mode artifact was written either — copilot's + # command file and skills file are mutually exclusive. + assert not list((project_dir / ".github" / "agents").glob("*specify*")), ( + "sanity: copilot in skills mode must not also write a command " + "file that could be falsely attributed instead" + ) + + # Simulate a pre-#2948 registry: a flat list with no per-agent + # provenance, even though the file on disk was actually written + # under copilot's skills directory. + manager.registry.update( + "legacy-copilot-skills-preset", + {"registered_skills": ["speckit-specify"]}, + ) + + # Directly switch to claude — no intervening rescaffold for + # copilot — mirroring `integration use claude` run right after + # upgrading spec-kit versions. + self._write_init_options(project_dir, ai="claude", ai_skills=True) + manager.register_enabled_presets_for_agent("claude") + + metadata = manager.registry.get("legacy-copilot-skills-preset") + registered_skills = metadata.get("registered_skills") + assert isinstance(registered_skills, dict) + assert set(registered_skills) == {"copilot", "claude"}, ( + "migrating a legacy flat-list entry on a direct switch must " + "infer the actual writer (copilot, running in skills mode) " + "even though copilot's registrar config is command-backed, " + "not just agents with a static /SKILL.md extension (#2948)" + ) + + assert manager.remove("legacy-copilot-skills-preset") is True + + claude_skill = claude_skills_dir / "speckit-specify" / "SKILL.md" + for skill_file, label in ((copilot_skill, "copilot"), (claude_skill, "claude")): + assert skill_file.exists(), f"{label} skill file should still exist after removal" + content = skill_file.read_text() + assert "preset:legacy-copilot-skills-preset" not in content, ( + f"{label}'s preset override must be restored on removal, " + "not permanently orphaned by a legacy migration that " + "failed to probe command-backed skills-mode agents (#2948)" + ) + assert "Core specify body" in content + + def test_composed_none_unregister_respects_active_agent( + self, project_dir, temp_dir + ): + """Unregistering a stale composed command must only touch the + active agent's directory, not every configured non-skill agent. + + When a wrap preset's base layer is removed, ``resolve_content`` can + no longer find a replace layer to compose onto and returns + ``None``, triggering the "composed is None" branch of + ``_reconcile_composed_commands``. Before the fix, that + unregistration mapping covered every configured non-skill agent + regardless of ``only_agent``, deleting historical artifacts from + integrations that were never active for this preset (#2948). + """ + self._write_init_options(project_dir, ai="gemini", ai_skills=False) + gemini_commands_dir = project_dir / ".gemini" / "commands" + gemini_commands_dir.mkdir(parents=True) + + # A made-up command name with no bundled/core equivalent, so the + # *only* base layer is the "compose-base" preset installed below — + # once it's removed, no base remains for the wrap preset to compose + # onto. + cmd_name = "speckit.fake-compose-test" + base_dir = self._create_command_preset( + temp_dir, "compose-base", cmd_name, "Base", "base body", + ) + manager = PresetManager(project_dir) + manager.install_from_directory(base_dir, "0.1.5", priority=10) + + wrap_dir = temp_dir / "compose-wrap" + wrap_dir.mkdir() + (wrap_dir / "commands").mkdir() + (wrap_dir / "commands" / f"{cmd_name}.md").write_text( + "---\ndescription: Wrap\nstrategy: wrap\n---\n\n" + "wrap start\n{CORE_TEMPLATE}\nwrap end\n" + ) + manifest_data = { + "schema_version": "1.0", + "preset": { + "id": "compose-wrap", + "name": "compose-wrap", + "version": "1.0.0", + "description": "Test", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "templates": [ + { + "type": "command", + "name": cmd_name, + "file": f"commands/{cmd_name}.md", + "strategy": "wrap", + } + ] + }, + } + with open(wrap_dir / "preset.yml", "w") as f: + yaml.dump(manifest_data, f) + manager.install_from_directory(wrap_dir, "0.1.5", priority=5) + + cmd_file = gemini_commands_dir / f"{cmd_name}.toml" + assert cmd_file.exists(), ( + "sanity: the composed command should register for the active agent" + ) + + # Simulate a pre-existing artifact for an inactive agent, predating + # this preset entirely — active-only unregistration must never + # touch it. + opencode_dir = project_dir / ".opencode" / "commands" + opencode_dir.mkdir(parents=True, exist_ok=True) + opencode_stale_file = opencode_dir / f"{cmd_name}.md" + opencode_stale_file.write_text("stale opencode content\n") + + assert manager.remove("compose-base") is True + + assert not cmd_file.exists(), ( + "sanity: the active agent's now-uncomposable command file must " + "be unregistered" + ) + assert opencode_stale_file.read_text() == "stale opencode content\n", ( + "unregistering a stale composed command must not touch an " + "inactive agent's directory (#2948)" + ) + + def test_remove_reconciles_command_for_every_historical_agent( + self, project_dir, temp_dir + ): + """Removing a preset must reconcile every historical agent its + ``registered_commands`` actually targeted, not only the currently + active one. + + Preset B (lower precedence, survives) is installed while gemini is + active, then preset A (higher precedence) overrides the same + command while gemini is still active. Switching the active + integration to opencode and rescaffolding re-registers both + presets under opencode too, so preset A's ``registered_commands`` + now spans two agents: gemini (now inactive) and opencode (active). + Removing A deletes its command file from *both* directories via + ``_unregister_commands``, but active-only reconciliation used to + recreate the surviving preset B's content only for the active + agent (opencode), leaving gemini's directory with a stale/missing + file (#2948). + """ + self._write_init_options(project_dir, ai="gemini", ai_skills=False) + gemini_dir = project_dir / ".gemini" / "commands" + gemini_dir.mkdir(parents=True) + + preset_b_dir = self._create_command_preset( + temp_dir, "hist-preset-b", "speckit.specify", + "Preset B", "preset B body", + ) + preset_a_dir = self._create_command_preset( + temp_dir, "hist-preset-a", "speckit.specify", + "Preset A", "preset A body", + ) + + manager = PresetManager(project_dir) + manager.install_from_directory(preset_b_dir, "0.1.5", priority=10) + manager.install_from_directory(preset_a_dir, "0.1.5", priority=1) + + gemini_cmd_files = list(gemini_dir.glob("*specify*")) + assert gemini_cmd_files, "sanity: gemini should have the command file" + assert "preset A body" in gemini_cmd_files[0].read_text(), ( + "sanity: preset A (higher precedence) should win initially" + ) + + # Switch the active integration to opencode and rescaffold, mirroring + # `integration use opencode`. This merges opencode into both + # presets' registered_commands alongside the pre-existing gemini + # entry recorded while gemini was active. + self._write_init_options(project_dir, ai="opencode", ai_skills=False) + opencode_dir = project_dir / ".opencode" / "commands" + opencode_dir.mkdir(parents=True, exist_ok=True) + manager.register_enabled_presets_for_agent("opencode") + + metadata_a = manager.registry.get("hist-preset-a") + assert set(metadata_a.get("registered_commands", {})) == {"gemini", "opencode"}, ( + "sanity: preset A's registered_commands must span both the " + "historical (gemini) and currently active (opencode) agents" + ) + + assert manager.remove("hist-preset-a") is True + + gemini_cmd_files = list(gemini_dir.glob("*specify*")) + opencode_cmd_files = list(opencode_dir.glob("*specify*")) + assert gemini_cmd_files, "gemini's command file must still exist after removal" + assert opencode_cmd_files, "opencode's command file must still exist after removal" + assert "preset B body" in gemini_cmd_files[0].read_text(), ( + "removing the higher-precedence preset must restore the " + "surviving preset's content in the historical (inactive) " + "agent's directory too, not only the active agent's (#2948)" + ) + assert "preset B body" in opencode_cmd_files[0].read_text(), ( + "the surviving preset's content must also be restored for the " + "currently active agent" + ) + + def test_remove_reconciliation_tracks_new_historical_agent_for_survivor( + self, project_dir, temp_dir + ): + """Historical-agent reconciliation writes must be recorded in the + surviving preset's own ``registered_commands``, not just written + to disk and forgotten. + + Preset A is installed while gemini is active, then survives to be + active under opencode too (so A's ``registered_commands`` spans + both gemini and opencode). Preset B is installed *only* while + opencode is active — B's ``registered_commands`` is + ``{"opencode": [...]}`` and never mentions gemini. Removing A + triggers reconciliation that writes B's content into gemini's + directory (an agent B never wrote to before) via ``extra_agents``, + but if that write isn't merged back into B's own + ``registered_commands``, B's registry entry still only says + ``{"opencode": [...]}`` even though B's content now lives in + gemini's directory too. A later ``remove('b')`` then only cleans + up opencode, leaving the gemini file — reconciled there entirely + by side effect of removing A — as a permanent orphan with no + preset tracking it (#2948). + """ + self._write_init_options(project_dir, ai="gemini", ai_skills=False) + gemini_dir = project_dir / ".gemini" / "commands" + gemini_dir.mkdir(parents=True) + + preset_a_dir = self._create_command_preset( + temp_dir, "orphan-preset-a", "speckit.specify", + "Preset A", "preset A body", + ) + manager = PresetManager(project_dir) + manager.install_from_directory(preset_a_dir, "0.1.5", priority=1) + + self._write_init_options(project_dir, ai="opencode", ai_skills=False) + opencode_dir = project_dir / ".opencode" / "commands" + opencode_dir.mkdir(parents=True, exist_ok=True) + manager.register_enabled_presets_for_agent("opencode") + + metadata_a = manager.registry.get("orphan-preset-a") + assert set(metadata_a.get("registered_commands", {})) == {"gemini", "opencode"}, ( + "sanity: preset A must be tracked under both agents" + ) + + # Preset B is installed only now, while opencode is the sole + # active agent — it never writes to or tracks gemini. + preset_b_dir = self._create_command_preset( + temp_dir, "orphan-preset-b", "speckit.specify", + "Preset B", "preset B body", + ) + manager.install_from_directory(preset_b_dir, "0.1.5", priority=10) + + metadata_b = manager.registry.get("orphan-preset-b") + assert set(metadata_b.get("registered_commands", {})) == {"opencode"}, ( + "sanity: preset B must only be tracked for opencode before " + "preset A is removed" + ) + + assert manager.remove("orphan-preset-a") is True + + # B is now written into gemini's directory as a side effect of + # reconciling A's removal, via the historical-agent extra_agents + # pass. + gemini_cmd_files = list(gemini_dir.glob("*specify*")) + assert gemini_cmd_files, "sanity: gemini's directory must have B's restored content" + assert "preset B body" in gemini_cmd_files[0].read_text(encoding="utf-8") + + metadata_b = manager.registry.get("orphan-preset-b") + assert set(metadata_b.get("registered_commands", {})) == {"gemini", "opencode"}, ( + "preset B's own registered_commands must be updated to " + "include gemini once reconciliation actually writes content " + "there on its behalf — otherwise B's registry entry silently " + "lies about which directories it owns (#2948)" + ) + + assert manager.remove("orphan-preset-b") is True + + # No preset is installed any more, so gemini's file must have been + # reconciled down to the core bundled template (or removed + # entirely) — but it must NOT still contain B's stale content, + # which would mean B's write there was never tracked for cleanup. + remaining_gemini_files = list(gemini_dir.glob("*specify*")) + for f in remaining_gemini_files: + assert "preset B body" not in f.read_text(encoding="utf-8"), ( + "removing preset B must clean up gemini's directory too, " + "since B's registered_commands was updated to include it " + "— otherwise B's stale content is orphaned there forever " + "with no preset left to track or clean it up (#2948)" + ) + + def test_extension_reconciliation_tracks_new_historical_agent( + self, project_dir + ): + from specify_cli.extensions import ExtensionRegistry + + self._write_init_options(project_dir, ai="opencode", ai_skills=False) + (project_dir / ".opencode" / "commands").mkdir(parents=True) + (project_dir / ".gemini" / "commands").mkdir(parents=True) + + ext_dir = project_dir / ".specify" / "extensions" / "tracked-ext" + (ext_dir / "commands").mkdir(parents=True) + (ext_dir / "commands" / "tracked.md").write_text( + "---\ndescription: tracked\n---\n\nExtension body\n", + encoding="utf-8", + ) + (ext_dir / "extension.yml").write_text( + "schema_version: '1.0'\n" + "extension:\n id: tracked-ext\n name: Tracked\n version: 1.0.0\n" + " description: test\n author: test\n repository: https://example.com\n" + " license: MIT\n" + "requires:\n speckit_version: '>=0.2.0'\n" + "provides:\n" + " commands:\n" + " - name: speckit.tracked\n" + " file: commands/tracked.md\n" + " description: Tracked command\n", + encoding="utf-8", + ) + ExtensionRegistry(ext_dir.parent).add( + "tracked-ext", + { + "version": "1.0.0", + "source": "dev", + "enabled": True, + "registered_commands": { + "opencode": ["speckit.tracked-ext.tracked"] + }, + }, + ) + + manager = PresetManager(project_dir) + manager._reconcile_composed_commands( + ["speckit.tracked-ext.tracked"], extra_agents={"gemini"} + ) + + assert list((project_dir / ".gemini" / "commands").glob("*tracked*")) + metadata = ExtensionRegistry(ext_dir.parent).get("tracked-ext") + assert set(metadata["registered_commands"]) == {"gemini", "opencode"} + + def test_copilot_skills_registration_restored_after_process_restart( + self, project_dir, temp_dir + ): + """Copilot skills-mode registrations must restore even when the + transient ``_skills_mode`` integration attribute has been reset, + simulating a fresh CLI process. + + ``_skills_mode`` is set during ``setup()`` and is never persisted; + after switching the active agent and running ``preset remove`` in + a brand-new process, a naive "is this integration currently in + skills mode" check would be False even though Copilot's + ``.github/skills`` directory holds a live override this preset + wrote. Restoration must rely on the persisted per-agent provenance + recorded at write time, not on runtime integration state (#2948). + """ + self._write_init_options(project_dir, ai="copilot", ai_skills=True) + core_cmds = project_dir / ".specify" / "templates" / "commands" + core_cmds.mkdir(parents=True, exist_ok=True) + (core_cmds / "specify.md").write_text( + "---\ndescription: Core specify command\n---\n\nCore specify body\n", + encoding="utf-8", + ) + copilot_skills_dir = project_dir / ".github" / "skills" + self._create_skill(copilot_skills_dir, "speckit-specify") + + preset_dir = self._create_command_preset( + temp_dir, "copilot-fresh-process-preset", "speckit.specify", + "Copilot fresh process test", "preset body", + ) + + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "0.1.5") + + skill_file = copilot_skills_dir / "speckit-specify" / "SKILL.md" + assert "preset:copilot-fresh-process-preset" in skill_file.read_text() + + metadata = manager.registry.get("copilot-fresh-process-preset") + assert "copilot" in metadata.get("registered_skills", {}) + + # Switch the active agent away from copilot, then simulate a fresh + # CLI process (a brand-new PresetManager, so any transient + # `_skills_mode` state set during a prior setup() call is gone) + # removing the preset. + self._write_init_options(project_dir, ai="claude", ai_skills=True) + fresh_manager = PresetManager(project_dir) + + assert fresh_manager.remove("copilot-fresh-process-preset") is True + + assert "preset:copilot-fresh-process-preset" not in skill_file.read_text(), ( + "removal must restore copilot's .github/skills override even " + "when copilot's transient skills-mode state isn't set in this " + "process (#2948)" + ) + assert "Core specify body" in skill_file.read_text() + + def test_unregister_agent_artifacts_scoped_to_target_agent_only( + self, project_dir, temp_dir + ): + """``unregister_agent_artifacts`` must remove only the target + agent's own tracked command/skill artifacts. + + Used by ``integration switch`` when deactivating the previous + integration for a not-yet-installed target (#2948): without this, + a preset's command override -- including a custom preset command -- + and skill mirror rendered for the old agent remain orphaned once a + different integration becomes active. Another agent's own + registrations (files and registry tracking) must survive + untouched, and no priority-stack reconciliation should run as a + side effect. + """ + self._write_init_options(project_dir, ai="auggie", ai_skills=False) + # Registration only writes to an agent's directory once it's + # "detected" on disk (mirroring a real `integration install` + # having already created it), so pre-create both agents' + # directories before installing the preset. + (project_dir / ".augment" / "commands").mkdir(parents=True) + (project_dir / ".opencode" / "commands").mkdir(parents=True) + preset_dir = self._create_command_preset( + temp_dir, "switch-cleanup-preset", "speckit.specify", + "Custom preset command", "preset body", + ) + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "0.1.5") + + auggie_cmd = project_dir / ".augment" / "commands" / "speckit.specify.md" + assert auggie_cmd.exists(), "sanity: preset command registered for auggie" + + # Simulate a later `integration use opencode` rescaffold that also + # registered the preset for opencode, while auggie's own + # registration (from before the switch) is still present in the + # registry. + self._write_init_options(project_dir, ai="opencode", ai_skills=False) + manager.register_enabled_presets_for_agent("opencode") + + opencode_cmd = project_dir / ".opencode" / "commands" / "speckit.specify.md" + assert opencode_cmd.exists(), "sanity: preset command registered for opencode" + + metadata = manager.registry.get("switch-cleanup-preset") + registered_commands = metadata.get("registered_commands", {}) + assert "auggie" in registered_commands and "opencode" in registered_commands + + manager.unregister_agent_artifacts("auggie") + + assert not auggie_cmd.exists(), ( + "auggie's own preset command must be removed when switching " + "away from auggie to a not-yet-installed integration (#2948)" + ) + assert opencode_cmd.exists(), ( + "opencode's preset command must survive unregistering auggie's " + "artifacts -- cleanup must stay scoped to the target agent" + ) + + metadata = manager.registry.get("switch-cleanup-preset") + registered_commands = metadata.get("registered_commands", {}) + assert "auggie" not in registered_commands, ( + "auggie's tracking must be dropped after unregistering its artifacts" + ) + assert "opencode" in registered_commands, ( + "opencode's tracking must be preserved untouched" + ) + + def test_unregister_agent_artifacts_deletes_marker_owned_skill( + self, project_dir, temp_dir + ): + self._write_init_options(project_dir, ai="copilot", ai_skills=True) + core_cmds = project_dir / ".specify" / "templates" / "commands" + core_cmds.mkdir(parents=True, exist_ok=True) + (core_cmds / "specify.md").write_text( + "---\ndescription: Core specify\n---\n\nCore body\n", + encoding="utf-8", + ) + skills_dir = project_dir / ".github" / "skills" + self._create_skill(skills_dir, "speckit-specify") + preset_dir = self._create_command_preset( + temp_dir, + "deactivated-skill-preset", + "speckit.specify", + "Deactivation cleanup", + "preset body", + ) + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "0.1.5") + + skill_dir = skills_dir / "speckit-specify" + assert "preset:deactivated-skill-preset" in ( + skill_dir / "SKILL.md" + ).read_text() + + manager.unregister_agent_artifacts("copilot") + + assert not skill_dir.exists() + + def test_unregister_agent_artifacts_deletes_reconciled_override_skill( + self, project_dir, temp_dir + ): + self._write_init_options(project_dir, ai="copilot", ai_skills=True) + skills_dir = project_dir / ".github" / "skills" + self._create_skill(skills_dir, "speckit-specify") + preset_dir = self._create_command_preset( + temp_dir, + "deactivated-override-preset", + "speckit.specify", + "Deactivation override cleanup", + "preset body", + ) + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "0.1.5") + + overrides_dir = ( + project_dir / ".specify" / "templates" / "overrides" + ) + overrides_dir.mkdir(parents=True) + (overrides_dir / "speckit.specify.md").write_text( + "---\ndescription: Project override\n---\n\nOverride body\n", + encoding="utf-8", + ) + ( + manager.presets_dir + / "deactivated-override-preset" + / "commands" + / "speckit.specify.md" + ).unlink() + manager.register_enabled_presets_for_agent("copilot") + + skill_dir = skills_dir / "speckit-specify" + assert "override:speckit.specify" in ( + skill_dir / "SKILL.md" + ).read_text(encoding="utf-8") + + manager.unregister_agent_artifacts("copilot") + + assert not skill_dir.exists() + metadata = manager.registry.get("deactivated-override-preset") + assert "copilot" not in metadata.get("registered_skills", {}) + + def test_unregister_native_agent_persists_skills_metadata_pop( + self, project_dir, temp_dir + ): + self._write_init_options(project_dir, ai="agy", ai_skills=True) + (project_dir / ".agents" / "skills").mkdir(parents=True) + preset_dir = self._create_command_preset( + temp_dir, + "native-metadata-cleanup-preset", + "speckit.shared-cleanup", + "Shared cleanup", + "preset body", + ) + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "0.1.5") + + metadata = manager.registry.get("native-metadata-cleanup-preset") + assert "agy" in metadata.get("registered_commands", {}) + manager.registry.update( + "native-metadata-cleanup-preset", + {"registered_skills": {"agy": ["speckit-shared-cleanup"]}}, + ) + + manager.unregister_agent_artifacts("agy") + + metadata = manager.registry.get("native-metadata-cleanup-preset") + assert "agy" not in metadata.get("registered_commands", {}) + assert "agy" not in metadata.get("registered_skills", {}) + + def test_unregister_native_agent_preserves_shared_output_owner( + self, project_dir, temp_dir + ): + self._write_init_options(project_dir, ai="agy", ai_skills=True) + (project_dir / ".agents" / "skills").mkdir(parents=True) + preset_dir = self._create_command_preset( + temp_dir, + "shared-native-output-preset", + "speckit.shared-owner", + "Shared owner", + "preset body", + ) + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "0.1.5") + + self._write_init_options(project_dir, ai="codex", ai_skills=True) + manager.register_enabled_presets_for_agent("codex") + skill_file = ( + project_dir + / ".agents" + / "skills" + / "speckit-shared-owner" + / "SKILL.md" + ) + assert skill_file.exists() + metadata = manager.registry.get("shared-native-output-preset") + registered_commands = metadata.get("registered_commands", {}) + assert "agy" in registered_commands and "codex" in registered_commands + + manager.unregister_agent_artifacts("agy") + + assert skill_file.exists(), ( + "shared SKILL.md must survive while codex still owns the same " + "physical output" + ) + metadata = manager.registry.get("shared-native-output-preset") + registered_commands = metadata.get("registered_commands", {}) + assert "agy" not in registered_commands + assert "codex" in registered_commands + + def test_unregister_agent_artifacts_migrates_legacy_skill_list_scoped( + self, project_dir, temp_dir + ): + """Unregistering an agent's artifacts from a legacy flat-list + ``registered_skills`` entry must infer real per-agent ownership + before removing anything, so only the target agent's own share is + cleaned up and any other agent's still-live mirror survives, + rather than either guessing every name belongs to the target agent + or dropping all tracking wholesale (#2948). + + Uses Copilot (command-backed, rendering skills via ``ai_skills``) + as the agent being switched away from, and Claude (a native + SKILL.md agent) as the separate, still-live owner — mirroring the + established legacy-provenance test pattern used elsewhere for + this exact registry shape. + """ + self._write_init_options(project_dir, ai="copilot", ai_skills=True) + core_cmds = project_dir / ".specify" / "templates" / "commands" + core_cmds.mkdir(parents=True, exist_ok=True) + (core_cmds / "specify.md").write_text( + "---\ndescription: Core specify command\n---\n\nCore specify body\n", + encoding="utf-8", + ) + + copilot_skills_dir = project_dir / ".github" / "skills" + claude_skills_dir = project_dir / ".claude" / "skills" + self._create_skill(copilot_skills_dir, "speckit-specify") + self._create_skill(claude_skills_dir, "speckit-specify") + + preset_dir = self._create_command_preset( + temp_dir, "switch-legacy-skill-preset", "speckit.specify", + "Legacy skill switch test", "preset body", + ) + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "0.1.5") + + copilot_skill = copilot_skills_dir / "speckit-specify" / "SKILL.md" + assert "preset:switch-legacy-skill-preset" in copilot_skill.read_text(), ( + "sanity: install wrote the override under copilot" + ) + + # A separate activation under claude (before provenance tracking + # existed) also left a live, marker-verified mirror there. + claude_skill = claude_skills_dir / "speckit-specify" / "SKILL.md" + claude_skill.write_text( + "---\nname: speckit-specify\nmetadata:\n source: preset:switch-legacy-skill-preset\n" + "---\n\npreset body\n", + encoding="utf-8", + ) + + # Simulate a pre-#2948 registry: a flat list with no per-agent + # provenance for either writer. + manager.registry.update( + "switch-legacy-skill-preset", + {"registered_skills": ["speckit-specify"]}, + ) + + manager.unregister_agent_artifacts("copilot") + + assert not copilot_skill.parent.exists(), ( + "copilot's marker-owned preset skill must be deleted when " + "switching away from copilot" + ) + + assert "preset:switch-legacy-skill-preset" in claude_skill.read_text(), ( + "claude's own, separately-written mirror must survive " + "unregistering copilot's artifacts -- legacy-list inference " + "must not misattribute or drop claude's real ownership (#2948)" + ) + + metadata = manager.registry.get("switch-legacy-skill-preset") + registered_skills = metadata.get("registered_skills") + assert isinstance(registered_skills, dict), ( + "legacy flat-list value must migrate to per-agent form" + ) + assert "copilot" not in registered_skills + assert "claude" in registered_skills and "speckit-specify" in registered_skills["claude"], ( + "claude's real ownership must be preserved in the migrated tracking" + ) + + def test_short_and_namespaced_commands_scaffold_consistently( + self, project_dir, temp_dir + ): + """A preset's ``speckit.`` and ``speckit..`` commands must + scaffold identically in command mode, with no installed extension. + + Regression: the 3-part (``speckit..``) form was silently + dropped by a name-shape guard whenever ``.specify/extensions//`` + was absent, even though the preset ships the command body itself. The + 2-part form always scaffolded. Both are self-contained and must behave + the same (#4076). + """ + self._write_init_options(project_dir, ai="gemini", ai_skills=False) + gemini_commands_dir = project_dir / ".gemini" / "commands" + gemini_commands_dir.mkdir(parents=True) + + short_preset = self._create_command_preset( + temp_dir, "short-cmd", "speckit.newcmd", "Short", "short body", + ) + ns_preset = self._create_command_preset( + temp_dir, "ns-cmd", "speckit.fakeext.newcmd", "Namespaced", "ns body", + ) + + manager = PresetManager(project_dir) + manager.install_from_directory(short_preset, "0.1.5") + manager.install_from_directory(ns_preset, "0.1.5") + + short_file = gemini_commands_dir / "speckit.newcmd.toml" + ns_file = gemini_commands_dir / "speckit.fakeext.newcmd.toml" + assert short_file.exists(), "2-part command should scaffold" + assert ns_file.exists(), ( + "3-part namespaced command must scaffold too, even without the " + "matching extension installed" + ) + assert manager.registry.get("short-cmd")["registered_commands"] != {} + assert manager.registry.get("ns-cmd")["registered_commands"] != {} + + +class TestWrapStrategy: + """Tests for strategy: wrap preset command substitution.""" + + def test_substitute_core_template_replaces_placeholder(self, project_dir): + """Core template body replaces {CORE_TEMPLATE} in preset command body.""" + from specify_cli.presets import _substitute_core_template + from specify_cli.agents import CommandRegistrar + + # Set up a core command template + core_dir = project_dir / ".specify" / "templates" / "commands" + core_dir.mkdir(parents=True, exist_ok=True) + (core_dir / "specify.md").write_text( + "---\ndescription: core\n---\n\n# Core Specify\n\nDo the thing.\n" + ) + + registrar = CommandRegistrar() + body = "## Pre-Logic\n\nBefore stuff.\n\n{CORE_TEMPLATE}\n\n## Post-Logic\n\nAfter stuff.\n" + result, core_fm = _substitute_core_template(body, "specify", project_dir, registrar) + + assert "{CORE_TEMPLATE}" not in result + assert "# Core Specify" in result + assert "## Pre-Logic" in result + assert "## Post-Logic" in result + assert core_fm.get("description") == "core" + + def test_substitute_core_template_no_op_when_placeholder_absent(self, project_dir): + """Returns body unchanged when {CORE_TEMPLATE} is not present.""" + from specify_cli.presets import _substitute_core_template + from specify_cli.agents import CommandRegistrar + + core_dir = project_dir / ".specify" / "templates" / "commands" + core_dir.mkdir(parents=True, exist_ok=True) + (core_dir / "specify.md").write_text("---\ndescription: core\n---\n\nCore body.\n") + + registrar = CommandRegistrar() + body = "## No placeholder here.\n" + result, core_fm = _substitute_core_template(body, "specify", project_dir, registrar) + assert result == body + assert core_fm == {} + + def test_substitute_core_template_no_op_when_core_missing(self, project_dir): + """Returns body unchanged when core template file does not exist.""" + from specify_cli.presets import _substitute_core_template + from specify_cli.agents import CommandRegistrar + + registrar = CommandRegistrar() + body = "Pre.\n\n{CORE_TEMPLATE}\n\nPost.\n" + result, core_fm = _substitute_core_template(body, "nonexistent", project_dir, registrar) + assert result == body + assert "{CORE_TEMPLATE}" in result + assert core_fm == {} + + def test_substitute_core_template_unreadable_core_treated_as_missing( + self, project_dir + ): + """An undecodable core template must not crash substitution. + + The wrap-strategy callers (``CommandRegistrar.register_pack`` and + ``_register_commands``) skip an unreadable preset source with a + warning, but the core template read inside + ``_substitute_core_template`` had no boundary, so one corrupted + project-owned override in ``.specify/templates/commands/`` crashed + the whole registration with a raw ``UnicodeDecodeError``. An + unreadable core is treated like a missing one. + """ + from specify_cli.presets import _substitute_core_template + from specify_cli.agents import CommandRegistrar + + core_dir = project_dir / ".specify" / "templates" / "commands" + core_dir.mkdir(parents=True, exist_ok=True) + (core_dir / "specify.md").write_bytes(b"\xff\xfe not utf-8") + + registrar = CommandRegistrar() + body = "Pre.\n\n{CORE_TEMPLATE}\n\nPost.\n" + with pytest.warns(UserWarning, match="Ignoring core template"): + result, core_fm = _substitute_core_template( + body, "specify", project_dir, registrar + ) + assert result == body + assert core_fm == {} + + def test_register_commands_substitutes_core_template_for_wrap_strategy(self, project_dir): + """register_commands substitutes {CORE_TEMPLATE} when strategy: wrap.""" + from specify_cli.agents import CommandRegistrar + + # Set up core command template + core_dir = project_dir / ".specify" / "templates" / "commands" + core_dir.mkdir(parents=True, exist_ok=True) + (core_dir / "specify.md").write_text( + "---\ndescription: core\n---\n\n# Core Specify\n\nCore body here.\n" + ) + + # Create a preset command dir with a wrap-strategy command + cmd_dir = project_dir / "preset" / "commands" + cmd_dir.mkdir(parents=True, exist_ok=True) + (cmd_dir / "speckit.specify.md").write_text( + "---\ndescription: wrap test\nstrategy: wrap\n---\n\n" + "## Pre\n\n{CORE_TEMPLATE}\n\n## Post\n" + ) + + commands = [{"name": "speckit.specify", "file": "commands/speckit.specify.md"}] + registrar = CommandRegistrar() + + # Use a generic agent that writes markdown to commands/ + agent_dir = project_dir / ".claude" / "commands" + agent_dir.mkdir(parents=True, exist_ok=True) + + # Patch AGENT_CONFIGS to use a simple markdown agent pointing at our dir + import copy + original = copy.deepcopy(registrar.AGENT_CONFIGS) + registrar.AGENT_CONFIGS["test-agent"] = { + "dir": str(agent_dir.relative_to(project_dir)), + "format": "markdown", + "args": "$ARGUMENTS", + "extension": ".md", + "strip_frontmatter_keys": [], + } + try: + registrar.register_commands( + "test-agent", commands, "test-preset", + project_dir / "preset", project_dir + ) + finally: + CommandRegistrar.AGENT_CONFIGS.clear() + CommandRegistrar.AGENT_CONFIGS.update(original) + + written = (agent_dir / "speckit.specify.md").read_text() + assert "{CORE_TEMPLATE}" not in written + assert "# Core Specify" in written + assert "## Pre" in written + assert "## Post" in written + + def test_end_to_end_wrap_via_self_test_preset(self, project_dir): + """Installing self-test preset with a wrap command substitutes {CORE_TEMPLATE}.""" + from specify_cli.presets import PresetManager + + # Install a core template that wrap-test will wrap around + core_dir = project_dir / ".specify" / "templates" / "commands" + core_dir.mkdir(parents=True, exist_ok=True) + (core_dir / "wrap-test.md").write_text( + "---\ndescription: core wrap-test\n---\n\n# Core Wrap-Test Body\n" + ) + + # Set up skills dir (simulating --integration claude) + skills_dir = project_dir / ".claude" / "skills" + skills_dir.mkdir(parents=True, exist_ok=True) + skill_subdir = skills_dir / "speckit-wrap-test" + skill_subdir.mkdir() + (skill_subdir / "SKILL.md").write_text("---\nname: speckit-wrap-test\n---\n\nold content\n") + + # Write init-options so _register_skills finds the claude skills dir + import json + (project_dir / ".specify" / "init-options.json").write_text( + json.dumps({"ai": "claude", "ai_skills": True}) + ) + + manager = PresetManager(project_dir) + install_self_test_preset(manager) + + written = (skill_subdir / "SKILL.md").read_text() + assert "{CORE_TEMPLATE}" not in written + assert "# Core Wrap-Test Body" in written + assert "preset:self-test wrap-pre" in written + assert "preset:self-test wrap-post" in written + + def test_substitute_core_template_returns_core_scripts(self, project_dir): + """core_frontmatter in the returned tuple includes scripts/agent_scripts.""" + from specify_cli.presets import _substitute_core_template + from specify_cli.agents import CommandRegistrar + + core_dir = project_dir / ".specify" / "templates" / "commands" + core_dir.mkdir(parents=True, exist_ok=True) + (core_dir / "specify.md").write_text( + "---\ndescription: core\nscripts:\n sh: run.sh\nagent_scripts:\n sh: agent-run.sh\n---\n\n# Body\n" + ) + + registrar = CommandRegistrar() + body = "## Wrapper\n\n{CORE_TEMPLATE}\n" + result, core_fm = _substitute_core_template(body, "specify", project_dir, registrar) + + assert "# Body" in result + assert core_fm.get("scripts") == {"sh": "run.sh"} + assert core_fm.get("agent_scripts") == {"sh": "agent-run.sh"} + + def test_register_commands_inherits_scripts_from_core(self, project_dir): + """register_commands merges scripts/agent_scripts from core and normalizes paths.""" + from specify_cli.agents import CommandRegistrar + import copy + + core_dir = project_dir / ".specify" / "templates" / "commands" + core_dir.mkdir(parents=True, exist_ok=True) + (core_dir / "specify.md").write_text( + "---\ndescription: core\nscripts:\n sh: .specify/scripts/run.sh {ARGS}\n---\n\n" + "Run: {SCRIPT}\n" + ) + + cmd_dir = project_dir / "preset" / "commands" + cmd_dir.mkdir(parents=True, exist_ok=True) + # Preset has strategy: wrap but no scripts of its own + (cmd_dir / "speckit.specify.md").write_text( + "---\ndescription: wrap no scripts\nstrategy: wrap\n---\n\n" + "## Pre\n\n{CORE_TEMPLATE}\n\n## Post\n" + ) + + agent_dir = project_dir / ".claude" / "commands" + agent_dir.mkdir(parents=True, exist_ok=True) + + registrar = CommandRegistrar() + original = copy.deepcopy(registrar.AGENT_CONFIGS) + registrar.AGENT_CONFIGS["test-agent"] = { + "dir": str(agent_dir.relative_to(project_dir)), + "format": "markdown", + "args": "$ARGUMENTS", + "extension": ".md", + "strip_frontmatter_keys": [], + } + try: + registrar.register_commands( + "test-agent", + [{"name": "speckit.specify", "file": "commands/speckit.specify.md"}], + "test-preset", + project_dir / "preset", + project_dir, + ) + finally: + CommandRegistrar.AGENT_CONFIGS.clear() + CommandRegistrar.AGENT_CONFIGS.update(original) + + written = (agent_dir / "speckit.specify.md").read_text() + assert "{CORE_TEMPLATE}" not in written + assert "Run:" in written + assert "scripts:" in written + assert "run.sh" in written + + def test_register_commands_toml_resolves_inherited_scripts(self, project_dir): + """TOML agents resolve {SCRIPT} from inherited core scripts when preset omits them.""" + from specify_cli.agents import CommandRegistrar + import copy + + core_dir = project_dir / ".specify" / "templates" / "commands" + core_dir.mkdir(parents=True, exist_ok=True) + (core_dir / "specify.md").write_text( + "---\ndescription: core\nscripts:\n sh: .specify/scripts/run.sh {ARGS}\n---\n\n" + "Run: {SCRIPT}\n" + ) + + cmd_dir = project_dir / "preset" / "commands" + cmd_dir.mkdir(parents=True, exist_ok=True) + (cmd_dir / "speckit.specify.md").write_text( + "---\ndescription: toml wrap\nstrategy: wrap\n---\n\n" + "## Pre\n\n{CORE_TEMPLATE}\n\n## Post\n" + ) + + toml_dir = project_dir / ".gemini" / "commands" + toml_dir.mkdir(parents=True, exist_ok=True) + + registrar = CommandRegistrar() + original = copy.deepcopy(registrar.AGENT_CONFIGS) + registrar.AGENT_CONFIGS["test-toml-agent"] = { + "dir": str(toml_dir.relative_to(project_dir)), + "format": "toml", + "args": "{{args}}", + "extension": ".toml", + "strip_frontmatter_keys": [], + } + try: + registrar.register_commands( + "test-toml-agent", + [{"name": "speckit.specify", "file": "commands/speckit.specify.md"}], + "test-preset", + project_dir / "preset", + project_dir, + ) + finally: + CommandRegistrar.AGENT_CONFIGS.clear() + CommandRegistrar.AGENT_CONFIGS.update(original) + + written = (toml_dir / "speckit.specify.toml").read_text() + assert "{CORE_TEMPLATE}" not in written + assert "{SCRIPT}" not in written + assert "run.sh" in written + # args token must use TOML format, not the intermediate $ARGUMENTS + assert "$ARGUMENTS" not in written + assert "{{args}}" in written + + def test_register_commands_markdown_resolves_inherited_scripts(self, project_dir): + """Markdown agents resolve {SCRIPT} from inherited core scripts when preset omits them.""" + from specify_cli.agents import CommandRegistrar + import copy + + core_dir = project_dir / ".specify" / "templates" / "commands" + core_dir.mkdir(parents=True, exist_ok=True) + (core_dir / "specify.md").write_text( + "---\ndescription: core\nscripts:\n sh: .specify/scripts/run.sh {ARGS}\n---\n\n" + "Run: {SCRIPT}\n" + ) + + cmd_dir = project_dir / "preset" / "commands" + cmd_dir.mkdir(parents=True, exist_ok=True) + (cmd_dir / "speckit.specify.md").write_text( + "---\ndescription: markdown wrap\nstrategy: wrap\n---\n\n" + "## Pre\n\n{CORE_TEMPLATE}\n\n## Post\n" + ) + + agent_dir = project_dir / ".claude" / "commands" + agent_dir.mkdir(parents=True, exist_ok=True) + + registrar = CommandRegistrar() + original = copy.deepcopy(registrar.AGENT_CONFIGS) + registrar.AGENT_CONFIGS["test-md-agent"] = { + "dir": str(agent_dir.relative_to(project_dir)), + "format": "markdown", + "args": "$ARGUMENTS", + "extension": ".md", + "strip_frontmatter_keys": [], + } + try: + registrar.register_commands( + "test-md-agent", + [{"name": "speckit.specify", "file": "commands/speckit.specify.md"}], + "test-preset", + project_dir / "preset", + project_dir, + ) + finally: + CommandRegistrar.AGENT_CONFIGS.clear() + CommandRegistrar.AGENT_CONFIGS.update(original) + + written = (agent_dir / "speckit.specify.md").read_text() + assert "{CORE_TEMPLATE}" not in written + assert "{SCRIPT}" not in written + assert "run.sh" in written + assert "strategy" not in written + + def test_register_commands_markdown_converts_args_after_script_resolution(self, project_dir): + """Markdown agents re-run arg placeholder conversion after resolve_skill_placeholders. + + resolve_skill_placeholders injects $ARGUMENTS (via {ARGS} expansion). A second + _convert_argument_placeholder call must convert those to the agent's native format. + """ + from specify_cli.agents import CommandRegistrar + import copy + + core_dir = project_dir / ".specify" / "templates" / "commands" + core_dir.mkdir(parents=True, exist_ok=True) + (core_dir / "specify.md").write_text( + "---\ndescription: core\nscripts:\n sh: .specify/scripts/run.sh {ARGS}\n---\n\n" + "Run: {SCRIPT}\n" + ) + + cmd_dir = project_dir / "preset" / "commands" + cmd_dir.mkdir(parents=True, exist_ok=True) + (cmd_dir / "speckit.specify.md").write_text( + "---\ndescription: forge wrap\nstrategy: wrap\n---\n\n" + "## Pre\n\n{CORE_TEMPLATE}\n\n## Post\n" + ) + + agent_dir = project_dir / ".forge" / "commands" + agent_dir.mkdir(parents=True, exist_ok=True) + + registrar = CommandRegistrar() + original = copy.deepcopy(registrar.AGENT_CONFIGS) + registrar.AGENT_CONFIGS["test-forge-agent"] = { + "dir": str(agent_dir.relative_to(project_dir)), + "format": "markdown", + "args": "{{parameters}}", + "extension": ".md", + "strip_frontmatter_keys": [], + } + try: + registrar.register_commands( + "test-forge-agent", + [{"name": "speckit.specify", "file": "commands/speckit.specify.md"}], + "test-preset", + project_dir / "preset", + project_dir, + ) + finally: + CommandRegistrar.AGENT_CONFIGS.clear() + CommandRegistrar.AGENT_CONFIGS.update(original) + + written = (agent_dir / "speckit.specify.md").read_text() + assert "{SCRIPT}" not in written + assert "run.sh" in written + # $ARGUMENTS injected by resolve_skill_placeholders must be re-converted + assert "$ARGUMENTS" not in written + assert "{{parameters}}" in written + + +def _make_wrap_preset_dir( + base: Path, + preset_id: str, + cmd_name: str, + pre: str, + post: str, + aliases: list[str] | None = None, + file_rel: str | None = None, +) -> Path: + """Create a minimal wrap-strategy preset directory for testing.""" + preset_dir = base / preset_id + cmd_dir = preset_dir / "commands" + cmd_dir.mkdir(parents=True) + file_rel = file_rel or f"commands/{cmd_name}.md" + template = { + "type": "command", + "name": cmd_name, + "file": file_rel, + "description": f"{preset_id} wrap", + } + if aliases is not None: + template["aliases"] = aliases + manifest = { + "schema_version": "1.0", + "preset": { + "id": preset_id, + "name": preset_id, + "version": "1.0.0", + "description": f"Preset {preset_id}", + "author": "test", + "repository": "https://example.com", + "license": "MIT", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "templates": [template] + }, + "tags": [], + } + import yaml as _yaml + (preset_dir / "preset.yml").write_text(_yaml.dump(manifest)) + command_path = preset_dir / file_rel + command_path.parent.mkdir(parents=True, exist_ok=True) + command_path.write_text( + f"---\ndescription: {preset_id} wrap\nstrategy: wrap\n---\n\n" + f"[{pre}]\n\n{{CORE_TEMPLATE}}\n\n[{post}]\n" + ) + return preset_dir + + +class TestRemoveReconciliation: + """Test that removing a preset re-registers the next layer's command.""" + + def test_install_composes_extension_command_and_rewrites_subdir_paths_for_non_skill_agent( + self, project_dir, temp_dir + ): + """When a preset overlays (append) an extension-provided base command, + the initial composed non-skill-agent command file must have the + extension's own subdir references rewritten to their installed + location (#2101), matching the live repro: extension body + 'Read agents/control/commander.md', preset appends to + speckit.fakeext.cmd, generated Gemini content retains the bare path.""" + gemini_dir = project_dir / ".gemini" / "commands" + gemini_dir.mkdir(parents=True) + + extension_dir = project_dir / ".specify" / "extensions" / "fakeext" + (extension_dir / "commands").mkdir(parents=True, exist_ok=True) + (extension_dir / "agents" / "control").mkdir(parents=True, exist_ok=True) + (extension_dir / "agents" / "control" / "commander.md").write_text("# Commander\n") + (extension_dir / "commands" / "cmd.md").write_text( + "---\ndescription: Extension fakeext cmd\n---\n\n" + "Read agents/control/commander.md for context.\n" + ) + extension_manifest = { + "schema_version": "1.0", + "extension": { + "id": "fakeext", + "name": "Fake Extension", + "version": "1.0.0", + "description": "Test", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "commands": [ + { + "name": "speckit.fakeext.cmd", + "file": "commands/cmd.md", + "description": "Fake extension command", + } + ] + }, + } + with open(extension_dir / "extension.yml", "w") as f: + yaml.dump(extension_manifest, f) + + preset_dir = temp_dir / "ext-cmd-append" + preset_dir.mkdir() + (preset_dir / "commands").mkdir() + (preset_dir / "commands" / "speckit.fakeext.cmd.md").write_text( + "---\ndescription: Append fakeext cmd\n---\n\n## Extra\n" + ) + preset_manifest = { + "schema_version": "1.0", + "preset": { + "id": "ext-cmd-append", + "name": "Ext Cmd Append", + "version": "1.0.0", + "description": "Test", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "templates": [ + { + "type": "command", + "name": "speckit.fakeext.cmd", + "file": "commands/speckit.fakeext.cmd.md", + "strategy": "append", + } + ] + }, + } + with open(preset_dir / "preset.yml", "w") as f: + yaml.dump(preset_manifest, f) + + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "0.1.5") + + cmd_files = list(gemini_dir.glob("*fakeext*")) + assert cmd_files, "Command file should exist in gemini dir" + content = cmd_files[0].read_text() + assert ".specify/extensions/fakeext/agents/control/commander.md" in content + assert "Read agents/control" not in content + assert "## Extra" in content diff --git a/tests/specify_cli/presets/test_manager_skills.py b/tests/specify_cli/presets/test_manager_skills.py new file mode 100644 index 0000000000..7ae833bfb9 --- /dev/null +++ b/tests/specify_cli/presets/test_manager_skills.py @@ -0,0 +1,2807 @@ +"""Tests for preset skill artifacts in specify_cli.presets._manager_skills.""" + +import shutil +from pathlib import Path + +import pytest +import yaml + +from specify_cli.presets import PresetManager +from tests.specify_cli.presets._helpers import ( + PresetArtifactTestHelpers, + install_self_test_preset, +) + + +class TestPresetSkills(PresetArtifactTestHelpers): + """Tests for preset skill registration and unregistration. + + Tests that install the self-test preset use ``install_self_test_preset`` + which scopes a narrow filter to the expected wrap-strategy warning. + Reconciliation failures remain audible so real regressions surface. + """ + + def test_skill_overridden_on_preset_install(self, project_dir, temp_dir): + """When skills mode was used, a preset command override should update the skill.""" + # Simulate skills mode having been used: write init-options + create skill + self._write_init_options(project_dir, ai="claude") + skills_dir = project_dir / ".claude" / "skills" + self._create_skill(skills_dir, "speckit-specify") + + # Also create the claude commands dir so commands get registered + (project_dir / ".claude" / "skills").mkdir(parents=True, exist_ok=True) + + # Install self-test preset (has a command override for speckit.specify) + manager = PresetManager(project_dir) + install_self_test_preset(manager) + + skill_file = skills_dir / "speckit-specify" / "SKILL.md" + assert skill_file.exists() + content = skill_file.read_text() + assert "preset:self-test" in content, "Skill should reference preset source" + assert "disable-model-invocation: false" in content + + # Verify it was recorded in registry, keyed by the active agent + metadata = manager.registry.get("self-test") + assert "speckit-specify" in metadata.get("registered_skills", {}).get("claude", []) + + def _install_arg_hint_preset(self, project_dir, temp_dir, ai, skills_dir, description, arg_hint): + """Install a preset whose command declares argument-hint; return the SKILL.md path.""" + self._write_init_options(project_dir, ai=ai) + self._create_skill(skills_dir, "speckit-hinttest-cmd") + (project_dir / ".specify" / "extensions" / "hinttest").mkdir(parents=True, exist_ok=True) + + preset_dir = temp_dir / f"hint-preset-{ai}" + preset_dir.mkdir() + (preset_dir / "commands").mkdir() + (preset_dir / "commands" / "speckit.hinttest.cmd.md").write_text( + "---\n" + f'description: "{description}"\n' + f'argument-hint: "{arg_hint}"\n' + "---\n\n" + "Preset command body.\n", + encoding="utf-8", + ) + manifest_data = { + "schema_version": "1.0", + "preset": { + "id": f"hint-preset-{ai}", + "name": "Hint Preset", + "version": "1.0.0", + "description": "Test", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "templates": [ + { + "type": "command", + "name": "speckit.hinttest.cmd", + "file": "commands/speckit.hinttest.cmd.md", + } + ] + }, + } + with open(preset_dir / "preset.yml", "w") as f: + yaml.dump(manifest_data, f) + + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "0.1.5") + return skills_dir / "speckit-hinttest-cmd" / "SKILL.md" + + def test_argument_hint_preserved_for_preset_command(self, project_dir, temp_dir): + """argument-hint from a preset command must survive into the SKILL.md. + + Follow-up to #2903/#2916 for the preset skill generator. The + description is long enough to fold across lines when serialized, + guarding against an in-place string injection that would split the + folded scalar into invalid YAML. + """ + long_description = ( + "Build and maintain a lean, static context/ knowledge folder so " + "coding agents load only what is relevant and save tokens" + ) + arg_hint = " [area] [slug] [-- notes]" + skills_dir = project_dir / ".claude" / "skills" + + skill_file = self._install_arg_hint_preset( + project_dir, temp_dir, "claude", skills_dir, long_description, arg_hint + ) + assert skill_file.exists() + parsed = yaml.safe_load(skill_file.read_text(encoding="utf-8").split("---", 2)[1]) + assert parsed["argument-hint"] == arg_hint + assert parsed["description"] == long_description + + def test_argument_hint_not_added_for_non_claude_preset_command(self, project_dir, temp_dir): + """Non-Claude skills agents must not receive argument-hint in preset skills.""" + arg_hint = " [area]" + skills_dir = project_dir / ".agents" / "skills" + + skill_file = self._install_arg_hint_preset( + project_dir, temp_dir, "codex", skills_dir, "Build context", arg_hint + ) + assert skill_file.exists() + parsed = yaml.safe_load(skill_file.read_text(encoding="utf-8").split("---", 2)[1]) + assert "argument-hint" not in parsed + + def test_wrap_preset_inherits_argument_hint_from_core(self, project_dir, temp_dir): + """A wrap-strategy preset that omits argument-hint must inherit it from the core template. + + Regression for issue #3991: the wrap-composition path in _register_skills + previously inherited only scripts/agent_scripts from core_frontmatter, + silently discarding argument-hint and leaking its value into description. + """ + core_arg_hint = "Describe the feature you want to specify" + preset_description = "Wrapped speckit.specify — extra project context added" + self._write_init_options(project_dir, ai="claude") + skills_dir = project_dir / ".claude" / "skills" + self._create_skill(skills_dir, "speckit-specify") + + # Place a core template that declares argument-hint + core_cmds = project_dir / ".specify" / "templates" / "commands" + core_cmds.mkdir(parents=True, exist_ok=True) + (core_cmds / "specify.md").write_text( + "---\n" + "description: Core specify description.\n" + f'argument-hint: "{core_arg_hint}"\n' + "---\n\n" + "Core specify body.\n", + encoding="utf-8", + ) + + # Wrap preset: only declares description (no argument-hint) + preset_dir = temp_dir / "wrap-hint-preset" + preset_dir.mkdir() + (preset_dir / "commands").mkdir() + (preset_dir / "commands" / "speckit.specify.md").write_text( + "---\n" + f'description: "{preset_description}"\n' + "strategy: wrap\n" + "---\n\n" + "{CORE_TEMPLATE}\n", + encoding="utf-8", + ) + manifest_data = { + "schema_version": "1.0", + "preset": { + "id": "wrap-hint-preset", + "name": "Wrap Hint Preset", + "version": "1.0.0", + "description": "Test wrap hint inheritance", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "templates": [ + { + "type": "command", + "name": "speckit.specify", + "file": "commands/speckit.specify.md", + "strategy": "wrap", + } + ] + }, + } + import yaml as _yaml + with open(preset_dir / "preset.yml", "w") as f: + _yaml.dump(manifest_data, f) + + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "1.0.0") + + skill_file = skills_dir / "speckit-specify" / "SKILL.md" + assert skill_file.exists() + parsed = yaml.safe_load(skill_file.read_text(encoding="utf-8").split("---", 2)[1]) + # argument-hint must be inherited from core, not dropped + assert parsed.get("argument-hint") == core_arg_hint, ( + f"argument-hint was not inherited from core; parsed={parsed}" + ) + # description must be exactly the preset's declared value, not concatenated + assert parsed["description"] == preset_description, ( + f"description was corrupted; parsed={parsed}" + ) + + def test_wrap_preset_inherits_argument_hint_for_unmapped_command(self, project_dir, temp_dir): + """Wrap inheritance must carry argument-hint for a command NOT in ARGUMENT_HINTS. + + Regression guard for issue #3991. The companion test above wraps + ``speckit.specify``, whose stem is in Claude's ``ARGUMENT_HINTS`` map, so + the string-injection fallback in ``post_process_skill_content`` re-adds + ``argument-hint`` even when wrap composition drops it — masking the bug. + This test wraps an extension-like command (``speckit.myfeature``) that is + absent from that map, so the *only* thing that can carry the hint into the + SKILL.md is the wrap-composition inheritance fix itself. Without the fix + the key is dropped and this test fails. + """ + core_arg_hint = "Custom hint that lives only on the core template" + preset_description = "Wrapped speckit.myfeature — extra project context added" + self._write_init_options(project_dir, ai="claude") + skills_dir = project_dir / ".claude" / "skills" + self._create_skill(skills_dir, "speckit-myfeature") + + # Place a core template (extension-like command) that declares argument-hint + core_cmds = project_dir / ".specify" / "templates" / "commands" + core_cmds.mkdir(parents=True, exist_ok=True) + (core_cmds / "myfeature.md").write_text( + "---\n" + "description: Core myfeature description.\n" + f'argument-hint: "{core_arg_hint}"\n' + "---\n\n" + "Core myfeature body.\n", + encoding="utf-8", + ) + + # Wrap preset: only declares description (no argument-hint) + preset_dir = temp_dir / "wrap-hint-preset-unmapped" + preset_dir.mkdir() + (preset_dir / "commands").mkdir() + (preset_dir / "commands" / "speckit.myfeature.md").write_text( + "---\n" + f'description: "{preset_description}"\n' + "strategy: wrap\n" + "---\n\n" + "{CORE_TEMPLATE}\n", + encoding="utf-8", + ) + manifest_data = { + "schema_version": "1.0", + "preset": { + "id": "wrap-hint-preset-unmapped", + "name": "Wrap Hint Preset Unmapped", + "version": "1.0.0", + "description": "Test wrap hint inheritance for an unmapped command", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "templates": [ + { + "type": "command", + "name": "speckit.myfeature", + "file": "commands/speckit.myfeature.md", + "strategy": "wrap", + } + ] + }, + } + import yaml as _yaml + with open(preset_dir / "preset.yml", "w") as f: + _yaml.dump(manifest_data, f) + + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "1.0.0") + + skill_file = skills_dir / "speckit-myfeature" / "SKILL.md" + assert skill_file.exists() + parsed = yaml.safe_load(skill_file.read_text(encoding="utf-8").split("---", 2)[1]) + # argument-hint must be inherited from core, not dropped + assert parsed.get("argument-hint") == core_arg_hint, ( + f"argument-hint was not inherited from core; parsed={parsed}" + ) + # description must be exactly the preset's declared value, not concatenated + assert parsed["description"] == preset_description, ( + f"description was corrupted; parsed={parsed}" + ) + + def test_register_skills_resolves_command_refs(self, project_dir, temp_dir): + """Preset skill overrides must resolve __SPECKIT_COMMAND_*__ tokens (issue #2717). + + ``_register_skills()`` previously ran only ``resolve_skill_placeholders()``, + so command cross-references leaked into SKILL.md as raw placeholders + instead of rendering as ``/speckit-`` like the command layer. + """ + self._write_init_options(project_dir, ai="claude") + skills_dir = project_dir / ".claude" / "skills" + self._create_skill(skills_dir, "speckit-specify") + + preset_dir = self._create_command_preset( + temp_dir, + "cmdref-install", + "speckit.specify", + "Override specify", + "Run `__SPECKIT_COMMAND_SPECIFY__` then `__SPECKIT_COMMAND_PLAN__`.\n", + ) + + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "0.1.5") + + content = (skills_dir / "speckit-specify" / "SKILL.md").read_text() + assert "__SPECKIT_COMMAND_" not in content, "raw command token leaked into SKILL.md" + # Claude's invoke_separator is "-", so tokens render as /speckit-. + assert "/speckit-specify" in content + assert "/speckit-plan" in content + + def test_restore_skill_resolves_command_refs(self, project_dir, temp_dir): + """Skill restore on preset removal must also resolve command tokens (issue #2717).""" + self._write_init_options(project_dir, ai="claude") + skills_dir = project_dir / ".claude" / "skills" + self._create_skill(skills_dir, "speckit-specify") + + core_cmds = project_dir / ".specify" / "templates" / "commands" + core_cmds.mkdir(parents=True, exist_ok=True) + (core_cmds / "specify.md").write_text( + "---\ndescription: Core specify\n---\n\n" + "Then run `__SPECKIT_COMMAND_PLAN__`.\n" + ) + + preset_dir = self._create_command_preset( + temp_dir, + "cmdref-restore", + "speckit.specify", + "Override specify", + "Override body\n", + ) + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "0.1.5") + manager.remove("cmdref-restore") + + content = (skills_dir / "speckit-specify" / "SKILL.md").read_text() + assert "__SPECKIT_COMMAND_" not in content, "raw command token leaked on restore" + assert "/speckit-plan" in content + + def test_restore_skill_preserves_dollar_command_refs(self, project_dir, temp_dir): + """Dollar-style core refs remain native when a preset skill is removed.""" + self._write_init_options(project_dir, ai="zcode") + skills_dir = project_dir / ".zcode" / "skills" + self._create_skill(skills_dir, "speckit-specify") + + core_cmds = project_dir / ".specify" / "templates" / "commands" + core_cmds.mkdir(parents=True, exist_ok=True) + raw_core = ( + "---\ndescription: Core specify\n---\n\n" + "Then run `__SPECKIT_COMMAND_PLAN__`.\n" + ) + (core_cmds / "specify.md").write_text(raw_core) + + preset_dir = self._create_command_preset( + temp_dir, + "dollar-cmdref-restore", + "speckit.specify", + "Override specify", + "Override body\n", + ) + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "0.1.5") + manager.remove("dollar-cmdref-restore") + + content = (skills_dir / "speckit-specify" / "SKILL.md").read_text() + assert "$speckit-plan" in content + assert "/speckit-plan" not in content + + def test_reconcile_override_skill_resolves_command_refs(self, project_dir, temp_dir): + """Reconcile's project-override restore must resolve command tokens (issue #2717). + + When a preset that overrode a command is removed and a project override + becomes the winning layer, ``_reconcile_skills`` rewrites the skill from + the override body — which must also render ``__SPECKIT_COMMAND_*__`` tokens. + """ + self._write_init_options(project_dir, ai="claude") + skills_dir = project_dir / ".claude" / "skills" + self._create_skill(skills_dir, "speckit-specify") + + # Project override wins once the preset is removed; its body carries a + # command cross-reference token. No core template exists for "specify", + # so the skill is restored exclusively via the reconcile override branch. + overrides_dir = project_dir / ".specify" / "templates" / "overrides" + overrides_dir.mkdir(parents=True, exist_ok=True) + (overrides_dir / "speckit.specify.md").write_text( + "---\ndescription: Override specify\n---\n\n" + "Then run `__SPECKIT_COMMAND_PLAN__`.\n" + ) + + preset_dir = self._create_command_preset( + temp_dir, + "cmdref-reconcile", + "speckit.specify", + "Preset specify", + "Preset body\n", + ) + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "0.1.5") + manager.remove("cmdref-reconcile") + + content = (skills_dir / "speckit-specify" / "SKILL.md").read_text() + assert "override:speckit.specify" in content, "skill should be restored from the project override" + assert "__SPECKIT_COMMAND_" not in content, "raw command token leaked on reconcile" + assert "/speckit-plan" in content + + def test_extension_restore_resolves_command_refs(self, project_dir, temp_dir): + """Extension-backed skill restore must resolve command tokens (issue #2717). + + When a preset override is removed and the skill is restored from an + extension command body, ``__SPECKIT_COMMAND_*__`` tokens in that body + must render as slash-command invocations like the core-template path. + """ + self._write_init_options(project_dir, ai="claude") + skills_dir = project_dir / ".claude" / "skills" + self._create_skill(skills_dir, "speckit-fakeext-cmd", body="original extension skill") + + extension_dir = project_dir / ".specify" / "extensions" / "fakeext" + (extension_dir / "commands").mkdir(parents=True, exist_ok=True) + (extension_dir / "commands" / "cmd.md").write_text( + "---\ndescription: Extension fakeext cmd\n---\n\n" + "Then run `__SPECKIT_COMMAND_PLAN__`.\n" + ) + extension_manifest = { + "schema_version": "1.0", + "extension": { + "id": "fakeext", + "name": "Fake Extension", + "version": "1.0.0", + "description": "Test", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "commands": [ + { + "name": "speckit.fakeext.cmd", + "file": "commands/cmd.md", + "description": "Fake extension command", + } + ] + }, + } + with open(extension_dir / "extension.yml", "w") as f: + yaml.dump(extension_manifest, f) + + preset_dir = self._create_command_preset( + temp_dir, + "cmdref-ext-restore", + "speckit.fakeext.cmd", + "Override fakeext cmd", + "Override body\n", + ) + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "0.1.5") + manager.remove("cmdref-ext-restore") + + content = (skills_dir / "speckit-fakeext-cmd" / "SKILL.md").read_text() + assert "source: extension:fakeext" in content, "skill should be restored from the extension" + assert "__SPECKIT_COMMAND_" not in content, "raw command token leaked on extension restore" + assert "/speckit-plan" in content + + def test_core_command_override_skill_uses_preset_command_description(self, project_dir, temp_dir): + """Preset skill overrides for core commands should keep preset frontmatter descriptions.""" + self._write_init_options(project_dir, ai="claude") + skills_dir = project_dir / ".claude" / "skills" + self._create_skill(skills_dir, "speckit-taskstoissues") + + preset_dir = temp_dir / "taskstoissues-description" + preset_dir.mkdir() + (preset_dir / "commands").mkdir() + (preset_dir / "commands" / "speckit.repro.taskstoissues.md").write_text( + "---\n" + "description: COMMAND-FRONTMATTER-DESCRIPTION\n" + "---\n\n" + "# Repro command body\n" + ) + manifest_data = { + "schema_version": "1.0", + "preset": { + "id": "taskstoissues-description", + "name": "Taskstoissues Description", + "version": "1.0.0", + "description": "Test", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "templates": [ + { + "type": "command", + "name": "speckit.taskstoissues", + "file": "commands/speckit.repro.taskstoissues.md", + "description": "MANIFEST-DESCRIPTION", + "replaces": "speckit.taskstoissues", + "strategy": "replace", + } + ] + }, + } + with open(preset_dir / "preset.yml", "w") as f: + yaml.dump(manifest_data, f) + + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "0.1.5") + + skill_file = skills_dir / "speckit-taskstoissues" / "SKILL.md" + content = skill_file.read_text() + assert "description: COMMAND-FRONTMATTER-DESCRIPTION" in content + assert "Convert tasks from tasks.md into GitHub issues." not in content + assert "source: preset:taskstoissues-description" in content + + def test_core_skill_restore_uses_core_command_description(self, project_dir, temp_dir): + """Core skill restore should keep core command frontmatter descriptions.""" + self._write_init_options(project_dir, ai="claude") + skills_dir = project_dir / ".claude" / "skills" + self._create_skill(skills_dir, "speckit-taskstoissues") + + core_cmds = project_dir / ".specify" / "templates" / "commands" + core_cmds.mkdir(parents=True, exist_ok=True) + (core_cmds / "taskstoissues.md").write_text( + "---\n" + "description: CORE-FRONTMATTER-DESCRIPTION\n" + "---\n\n" + "core taskstoissues body\n" + ) + preset_dir = self._create_command_preset( + temp_dir, + "taskstoissues-restore", + "speckit.taskstoissues", + "PRESET-FRONTMATTER-DESCRIPTION", + "preset taskstoissues body\n", + ) + + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "0.1.5") + manager.remove("taskstoissues-restore") + + skill_file = skills_dir / "speckit-taskstoissues" / "SKILL.md" + content = skill_file.read_text() + assert "description: CORE-FRONTMATTER-DESCRIPTION" in content + assert "Convert tasks from tasks.md into GitHub issues." not in content + assert "source: templates/commands/taskstoissues.md" in content + assert "core taskstoissues body" in content + + def test_override_skill_reconcile_uses_override_command_description(self, project_dir, temp_dir): + """Override skill reconciliation should keep override frontmatter descriptions.""" + self._write_init_options(project_dir, ai="claude") + skills_dir = project_dir / ".claude" / "skills" + self._create_skill(skills_dir, "speckit-taskstoissues") + + overrides_dir = project_dir / ".specify" / "templates" / "overrides" + overrides_dir.mkdir(parents=True) + (overrides_dir / "speckit.taskstoissues.md").write_text( + "---\n" + "description: OVERRIDE-FRONTMATTER-DESCRIPTION\n" + "---\n\n" + "override taskstoissues body\n" + ) + preset_dir = self._create_command_preset( + temp_dir, + "taskstoissues-reconcile", + "speckit.taskstoissues", + "PRESET-FRONTMATTER-DESCRIPTION", + "preset taskstoissues body\n", + ) + + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "0.1.5") + + skill_file = skills_dir / "speckit-taskstoissues" / "SKILL.md" + content = skill_file.read_text() + assert "description: OVERRIDE-FRONTMATTER-DESCRIPTION" in content + assert "Convert tasks from tasks.md into GitHub issues." not in content + assert "source: override:speckit.taskstoissues" in content + assert "override taskstoissues body" in content + + def test_skill_not_updated_when_ai_skills_disabled(self, project_dir, temp_dir): + """When skills mode was NOT used, preset install should not touch skills.""" + self._write_init_options(project_dir, ai="qwen", ai_skills=False) + skills_dir = project_dir / ".qwen" / "skills" + self._create_skill(skills_dir, "speckit-specify", body="untouched") + + manager = PresetManager(project_dir) + install_self_test_preset(manager) + + skill_file = skills_dir / "speckit-specify" / "SKILL.md" + content = skill_file.read_text() + assert "untouched" in content, "Skill should not be modified when ai_skills=False" + + def test_get_skills_dir_returns_none_for_non_string_ai(self, project_dir): + """Corrupted init-options ai values should not crash preset skill resolution.""" + init_options = project_dir / ".specify" / "init-options.json" + init_options.parent.mkdir(parents=True, exist_ok=True) + init_options.write_text('{"ai":["codex"],"ai_skills":true,"script":"sh"}') + + manager = PresetManager(project_dir) + + assert manager._get_skills_dir() is None + + def test_get_skills_dir_returns_none_for_non_dict_init_options(self, project_dir): + """Corrupted non-dict init-options payloads should fail closed.""" + init_options = project_dir / ".specify" / "init-options.json" + init_options.parent.mkdir(parents=True, exist_ok=True) + init_options.write_text("[]") + + manager = PresetManager(project_dir) + + assert manager._get_skills_dir() is None + + def test_skill_not_updated_without_init_options(self, project_dir, temp_dir): + """When no init-options.json exists, preset install should not touch skills.""" + skills_dir = project_dir / ".qwen" / "skills" + self._create_skill(skills_dir, "speckit-specify", body="untouched") + + manager = PresetManager(project_dir) + install_self_test_preset(manager) + + skill_file = skills_dir / "speckit-specify" / "SKILL.md" + file_content = skill_file.read_text() + assert "untouched" in file_content + + def test_skill_restored_on_preset_remove(self, project_dir, temp_dir): + """When a preset is removed, skills should be restored from core templates.""" + self._write_init_options(project_dir, ai="claude") + skills_dir = project_dir / ".claude" / "skills" + self._create_skill(skills_dir, "speckit-specify") + + (project_dir / ".claude" / "skills").mkdir(parents=True, exist_ok=True) + + # Set up core command template in the project so restoration works + core_cmds = project_dir / ".specify" / "templates" / "commands" + core_cmds.mkdir(parents=True, exist_ok=True) + (core_cmds / "specify.md").write_text("---\ndescription: Core specify command\n---\n\nCore specify body\n") + + manager = PresetManager(project_dir) + install_self_test_preset(manager) + + # Verify preset content is in the skill + skill_file = skills_dir / "speckit-specify" / "SKILL.md" + assert "preset:self-test" in skill_file.read_text() + + # Remove the preset + manager.remove("self-test") + + # Skill should be restored (core specify.md template exists) + assert skill_file.exists(), "Skill should still exist after preset removal" + content = skill_file.read_text() + assert "preset:self-test" not in content, "Preset content should be gone" + assert "templates/commands/specify.md" in content, "Should reference core template" + assert "disable-model-invocation: false" in content + + def test_skill_restored_on_preset_remove_without_project_core_templates(self, project_dir): + """Removing a preset must restore core skills even when the project + has no ``.specify/templates/commands`` directory of its own — which + is the normal case, since ``specify init`` never populates it. The + real core commands live in the bundled core_pack/repo-root templates + tree, and restoration must fall back there instead of deleting the + skill outright (#3928). + """ + self._write_init_options(project_dir, ai="claude") + skills_dir = project_dir / ".claude" / "skills" + self._create_skill(skills_dir, "speckit-specify") + + # The project_dir fixture's commands dir is empty, matching a real + # project — specify init never populates project-local overrides + # for unmodified core commands. + core_cmds = project_dir / ".specify" / "templates" / "commands" + assert core_cmds.exists() and not any(core_cmds.iterdir()) + + manager = PresetManager(project_dir) + install_self_test_preset(manager) + + skill_file = skills_dir / "speckit-specify" / "SKILL.md" + assert "preset:self-test" in skill_file.read_text(encoding="utf-8") + + manager.remove("self-test") + + assert skill_file.exists(), "Core skill must be restored, not deleted" + content = skill_file.read_text(encoding="utf-8") + assert "preset:self-test" not in content + assert "templates/commands/specify.md" in content + assert "Create or update the feature specification" in content + + def test_extension_wins_over_bundled_core_on_preset_remove( + self, project_dir, monkeypatch + ): + """When an installed extension owns the same skill name as a core + command, removing a preset that overrode that skill must restore it + from the extension, not silently from the bundled core template. + Extensions are resolved ahead of bundled core elsewhere, and the + bundled-core fallback added for #3928 must not replace that + higher-priority layer. + + The extension-command namespace rules (``speckit..``) + make a genuine end-to-end name collision with a core command + cumbersome to construct through real manifests, so this stubs + ``_build_extension_skill_restore_index`` to exercise the priority + ordering in ``_unregister_skills_in_dir`` directly -- the code path + under test doesn't care how the index entry was produced, only that + it wins over the bundled-core fallback when present. + """ + self._write_init_options(project_dir, ai="claude") + skills_dir = project_dir / ".claude" / "skills" + self._create_skill(skills_dir, "speckit-specify") + + # No project-local core template override — the normal case, and + # the one that makes the bundled-core fallback kick in at all. + core_cmds = project_dir / ".specify" / "templates" / "commands" + assert core_cmds.exists() and not any(core_cmds.iterdir()) + + extension_dir = project_dir / ".specify" / "extensions" / "fakeext" + (extension_dir / "commands").mkdir(parents=True, exist_ok=True) + ext_specify_file = extension_dir / "commands" / "specify.md" + ext_specify_file.write_text( + "---\ndescription: Extension specify command\n---\n\n" + "extension:fakeext specify body\n" + ) + + manager = PresetManager(project_dir) + install_self_test_preset(manager) + + skill_file = skills_dir / "speckit-specify" / "SKILL.md" + assert "preset:self-test" in skill_file.read_text(encoding="utf-8") + + fake_restore_index = { + "speckit-specify": { + "command_name": "speckit.fakeext.specify", + "source_file": ext_specify_file, + "source": "extension:fakeext", + "extension_id": "fakeext", + "extension_dir": extension_dir, + } + } + monkeypatch.setattr( + manager, + "_build_extension_skill_restore_index", + lambda: fake_restore_index, + ) + + manager.remove("self-test") + + assert skill_file.exists() + content = skill_file.read_text(encoding="utf-8") + assert "preset:self-test" not in content + assert "source: extension:fakeext" in content + assert "extension:fakeext specify body" in content + assert "templates/commands/specify.md" not in content + + def test_skill_restored_on_remove_resolves_script_placeholders(self, project_dir): + """Core restore should resolve {SCRIPT}/{ARGS} placeholders like other skill paths.""" + self._write_init_options(project_dir, ai="claude", ai_skills=True, script="sh") + skills_dir = project_dir / ".claude" / "skills" + self._create_skill(skills_dir, "speckit-specify", body="old") + (project_dir / ".claude" / "skills").mkdir(parents=True, exist_ok=True) + + core_cmds = project_dir / ".specify" / "templates" / "commands" + core_cmds.mkdir(parents=True, exist_ok=True) + (core_cmds / "specify.md").write_text( + "---\n" + "description: Core specify command\n" + "scripts:\n" + " sh: .specify/scripts/bash/create-new-feature.sh --json \"{ARGS}\"\n" + "---\n\n" + "Run:\n" + "{SCRIPT}\n" + ) + + manager = PresetManager(project_dir) + install_self_test_preset(manager) + manager.remove("self-test") + + content = (skills_dir / "speckit-specify" / "SKILL.md").read_text() + assert "{SCRIPT}" not in content + assert "{ARGS}" not in content + assert ".specify/scripts/bash/create-new-feature.sh --json \"$ARGUMENTS\"" in content + + def test_skill_not_overridden_when_skill_path_is_file(self, project_dir): + """Preset install should skip non-directory skill targets.""" + self._write_init_options(project_dir, ai="qwen") + skills_dir = project_dir / ".qwen" / "skills" + skills_dir.mkdir(parents=True, exist_ok=True) + (skills_dir / "speckit-specify").write_text("not-a-directory") + + manager = PresetManager(project_dir) + install_self_test_preset(manager) + + assert (skills_dir / "speckit-specify").is_file() + metadata = manager.registry.get("self-test") + assert "speckit-specify" not in metadata.get("registered_skills", {}).get("qwen", []) + + def test_no_skills_registered_when_skills_mode_disabled(self, project_dir, temp_dir): + """Skills should not be created when skills mode is disabled.""" + self._write_init_options(project_dir, ai="claude", ai_skills=False) + + manager = PresetManager(project_dir) + install_self_test_preset(manager) + + metadata = manager.registry.get("self-test") + assert metadata.get("registered_skills", {}) == {} + + def test_extension_skill_override_matches_hyphenated_multisegment_name(self, project_dir, temp_dir): + """Preset overrides for speckit.. should target speckit-- skills.""" + self._write_init_options(project_dir, ai="codex") + skills_dir = project_dir / ".agents" / "skills" + self._create_skill(skills_dir, "speckit-fakeext-cmd", body="untouched") + (project_dir / ".specify" / "extensions" / "fakeext").mkdir(parents=True, exist_ok=True) + + preset_dir = temp_dir / "ext-skill-override" + preset_dir.mkdir() + (preset_dir / "commands").mkdir() + (preset_dir / "commands" / "speckit.fakeext.cmd.md").write_text( + "---\ndescription: Override fakeext cmd\n---\n\npreset:ext-skill-override\n" + ) + manifest_data = { + "schema_version": "1.0", + "preset": { + "id": "ext-skill-override", + "name": "Ext Skill Override", + "version": "1.0.0", + "description": "Test", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "templates": [ + { + "type": "command", + "name": "speckit.fakeext.cmd", + "file": "commands/speckit.fakeext.cmd.md", + } + ] + }, + } + with open(preset_dir / "preset.yml", "w") as f: + yaml.dump(manifest_data, f) + + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "0.1.5") + + skill_file = skills_dir / "speckit-fakeext-cmd" / "SKILL.md" + assert skill_file.exists() + content = skill_file.read_text() + assert "preset:ext-skill-override" in content + assert "name: speckit-fakeext-cmd" in content + assert "# Speckit Fakeext Cmd Skill" in content + + metadata = manager.registry.get("ext-skill-override") + assert "speckit-fakeext-cmd" in metadata.get("registered_skills", {}).get("codex", []) + + def test_extension_skill_restored_on_preset_remove(self, project_dir, temp_dir): + """Preset removal should restore an extension-backed skill instead of deleting it.""" + self._write_init_options(project_dir, ai="codex") + skills_dir = project_dir / ".agents" / "skills" + self._create_skill(skills_dir, "speckit-fakeext-cmd", body="original extension skill") + + extension_dir = project_dir / ".specify" / "extensions" / "fakeext" + (extension_dir / "commands").mkdir(parents=True, exist_ok=True) + (extension_dir / "agents" / "control").mkdir(parents=True, exist_ok=True) + (extension_dir / "agents" / "control" / "commander.md").write_text("# Commander\n") + (extension_dir / "commands" / "cmd.md").write_text( + "---\n" + "description: Extension fakeext cmd\n" + "scripts:\n" + " sh: ../../scripts/bash/setup-plan.sh --json \"{ARGS}\"\n" + "---\n\n" + "extension:fakeext\n" + "Run {SCRIPT}\n" + "Read agents/control/commander.md for context.\n" + ) + extension_manifest = { + "schema_version": "1.0", + "extension": { + "id": "fakeext", + "name": "Fake Extension", + "author": "acme-corp", + "version": "1.0.0", + "description": "Test", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "commands": [ + { + "name": "speckit.fakeext.cmd", + "file": "commands/cmd.md", + "description": "Fake extension command", + } + ] + }, + } + with open(extension_dir / "extension.yml", "w") as f: + yaml.dump(extension_manifest, f) + + preset_dir = temp_dir / "ext-skill-restore" + preset_dir.mkdir() + (preset_dir / "commands").mkdir() + (preset_dir / "commands" / "speckit.fakeext.cmd.md").write_text( + "---\ndescription: Override fakeext cmd\n---\n\npreset:ext-skill-restore\n" + ) + preset_manifest = { + "schema_version": "1.0", + "preset": { + "id": "ext-skill-restore", + "name": "Ext Skill Restore", + "version": "1.0.0", + "description": "Test", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "templates": [ + { + "type": "command", + "name": "speckit.fakeext.cmd", + "file": "commands/speckit.fakeext.cmd.md", + } + ] + }, + } + with open(preset_dir / "preset.yml", "w") as f: + yaml.dump(preset_manifest, f) + + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "0.1.5") + + skill_file = skills_dir / "speckit-fakeext-cmd" / "SKILL.md" + assert "preset:ext-skill-restore" in skill_file.read_text() + + manager.remove("ext-skill-restore") + + assert skill_file.exists() + content = skill_file.read_text() + assert "preset:ext-skill-restore" not in content + assert "source: extension:fakeext" in content + assert "extension:fakeext" in content + assert '.specify/scripts/bash/setup-plan.sh --json "$ARGUMENTS"' in content + # Extension-relative subdir references must resolve to their + # installed location on restore too (#2101), not just on first + # registration. + assert ".specify/extensions/fakeext/agents/control/commander.md" in content + assert "Read agents/control" not in content + assert "# Fakeext Cmd Skill" in content + + assert yaml.safe_load(content.split("---", 2)[1])["metadata"]["author"] == "acme-corp" + + def test_skill_composed_over_extension_base_rewrites_subdir_paths( + self, project_dir, temp_dir + ): + """When a preset composes (append) over an extension-provided base + command, the resulting skill (read from the .composed output) must + still resolve the extension's own subdir references (#2101), not + just when the extension wins outright (replace).""" + self._write_init_options(project_dir, ai="codex") + skills_dir = project_dir / ".agents" / "skills" + self._create_skill(skills_dir, "speckit-fakeext-cmd", body="original extension skill") + + extension_dir = project_dir / ".specify" / "extensions" / "fakeext" + (extension_dir / "commands").mkdir(parents=True, exist_ok=True) + (extension_dir / "agents" / "control").mkdir(parents=True, exist_ok=True) + (extension_dir / "agents" / "control" / "commander.md").write_text("# Commander\n") + (extension_dir / "commands" / "cmd.md").write_text( + "---\ndescription: Extension fakeext cmd\n---\n\n" + "Read agents/control/commander.md for context.\n" + ) + extension_manifest = { + "schema_version": "1.0", + "extension": { + "id": "fakeext", + "name": "Fake Extension", + "version": "1.0.0", + "description": "Test", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "commands": [ + { + "name": "speckit.fakeext.cmd", + "file": "commands/cmd.md", + "description": "Fake extension command", + } + ] + }, + } + with open(extension_dir / "extension.yml", "w") as f: + yaml.dump(extension_manifest, f) + + preset_dir = temp_dir / "ext-base-append-skill" + preset_dir.mkdir() + (preset_dir / "commands").mkdir() + (preset_dir / "commands" / "speckit.fakeext.cmd.md").write_text( + "---\ndescription: Preset overlay\n---\n\n## Extra\n" + ) + preset_manifest = { + "schema_version": "1.0", + "preset": { + "id": "ext-base-append-skill", + "name": "Ext Base Append Skill", + "version": "1.0.0", + "description": "Test", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "templates": [ + { + "type": "command", + "name": "speckit.fakeext.cmd", + "file": "commands/speckit.fakeext.cmd.md", + "strategy": "append", + } + ] + }, + } + with open(preset_dir / "preset.yml", "w") as f: + yaml.dump(preset_manifest, f) + + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "0.1.5") + + skill_file = skills_dir / "speckit-fakeext-cmd" / "SKILL.md" + content = skill_file.read_text() + assert ".specify/extensions/fakeext/agents/control/commander.md" in content + assert "Read agents/control" not in content + assert "## Extra" in content + + def test_preset_remove_skips_skill_dir_without_skill_file(self, project_dir, temp_dir): + """Preset removal should not delete arbitrary directories missing SKILL.md.""" + self._write_init_options(project_dir, ai="codex") + skills_dir = project_dir / ".agents" / "skills" + stray_skill_dir = skills_dir / "speckit-fakeext-cmd" + stray_skill_dir.mkdir(parents=True, exist_ok=True) + note_file = stray_skill_dir / "notes.txt" + note_file.write_text("user content", encoding="utf-8") + + preset_dir = temp_dir / "ext-skill-missing-file" + preset_dir.mkdir() + (preset_dir / "commands").mkdir() + (preset_dir / "commands" / "speckit.fakeext.cmd.md").write_text( + "---\ndescription: Override fakeext cmd\n---\n\npreset:ext-skill-missing-file\n" + ) + preset_manifest = { + "schema_version": "1.0", + "preset": { + "id": "ext-skill-missing-file", + "name": "Ext Skill Missing File", + "version": "1.0.0", + "description": "Test", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "templates": [ + { + "type": "command", + "name": "speckit.fakeext.cmd", + "file": "commands/speckit.fakeext.cmd.md", + } + ] + }, + } + with open(preset_dir / "preset.yml", "w") as f: + yaml.dump(preset_manifest, f) + + manager = PresetManager(project_dir) + installed_preset_dir = manager.presets_dir / "ext-skill-missing-file" + shutil.copytree(preset_dir, installed_preset_dir) + manager.registry.add( + "ext-skill-missing-file", + { + "version": "1.0.0", + "source": str(preset_dir), + "provides_templates": ["speckit.fakeext.cmd"], + "registered_skills": ["speckit-fakeext-cmd"], + "priority": 10, + }, + ) + + manager.remove("ext-skill-missing-file") + + assert stray_skill_dir.is_dir() + assert note_file.read_text(encoding="utf-8") == "user content" + + def test_kimi_legacy_dotted_skill_override_still_applies(self, project_dir, temp_dir): + """Preset overrides should still target legacy dotted-named skill dirs. + + This exercises legacy *naming* (``speckit.specify``) under the current + ``.kimi-code/`` base — distinct from the legacy ``.kimi/`` *location*. + """ + self._write_init_options(project_dir, ai="kimi") + skills_dir = project_dir / ".kimi-code" / "skills" + self._create_skill(skills_dir, "speckit.specify", body="untouched") + + (project_dir / ".kimi-code" / "commands").mkdir(parents=True, exist_ok=True) + + manager = PresetManager(project_dir) + install_self_test_preset(manager) + + skill_file = skills_dir / "speckit.specify" / "SKILL.md" + assert skill_file.exists() + content = skill_file.read_text() + assert "preset:self-test" in content + assert "name: speckit.specify" in content + + metadata = manager.registry.get("self-test") + assert "speckit.specify" in metadata.get("registered_skills", {}).get("kimi", []) + + def test_kimi_legacy_dotted_skill_reconciles_priority_winner( + self, project_dir, temp_dir + ): + """Reconciliation must carry forward recorded legacy skill names.""" + self._write_init_options(project_dir, ai="kimi") + skills_dir = project_dir / ".kimi-code" / "skills" + self._create_skill(skills_dir, "speckit.specify", body="untouched") + (project_dir / ".kimi-code" / "commands").mkdir( + parents=True, exist_ok=True + ) + + higher_dir = self._create_command_preset( + temp_dir, + "higher-kimi-preset", + "speckit.specify", + "Higher preset", + "Higher body", + ) + lower_dir = self._create_command_preset( + temp_dir, + "lower-kimi-preset", + "speckit.specify", + "Lower preset", + "Lower body", + ) + manager = PresetManager(project_dir) + manager.install_from_directory(higher_dir, "0.1.5", priority=10) + manager.install_from_directory(lower_dir, "0.1.5", priority=20) + + skill_file = skills_dir / "speckit.specify" / "SKILL.md" + for preset_id in ("higher-kimi-preset", "lower-kimi-preset"): + manager.registry.update( + preset_id, + {"registered_skills": {"kimi": ["speckit.specify"]}}, + ) + skill_file.write_text( + "---\nname: speckit.specify\n---\n\nLower body\n", + encoding="utf-8", + ) + manager._reconcile_skills(["speckit.specify"]) + + assert "Higher body" in skill_file.read_text(encoding="utf-8"), ( + "reconciliation must replace lower-priority raw content in a " + "recorded legacy dotted skill directory" + ) + + def test_kimi_legacy_dotted_skill_receives_project_override( + self, project_dir, temp_dir + ): + """Project overrides must update the recorded legacy path in place.""" + self._write_init_options(project_dir, ai="kimi") + skills_dir = project_dir / ".kimi-code" / "skills" + self._create_skill(skills_dir, "speckit.specify", body="untouched") + (project_dir / ".kimi-code" / "commands").mkdir( + parents=True, exist_ok=True + ) + + preset_dir = self._create_command_preset( + temp_dir, + "kimi-override-preset", + "speckit.specify", + "Preset", + "Preset body", + ) + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "0.1.5") + manager.registry.update( + "kimi-override-preset", + {"registered_skills": {"kimi": ["speckit.specify"]}}, + ) + modern_skill_dir = skills_dir / "speckit-specify" + if modern_skill_dir.exists(): + shutil.rmtree(modern_skill_dir) + + overrides_dir = ( + project_dir / ".specify" / "templates" / "overrides" + ) + overrides_dir.mkdir(parents=True) + (overrides_dir / "speckit.specify.md").write_text( + "---\ndescription: Project override\n---\n\nOverride body\n", + encoding="utf-8", + ) + + manager._reconcile_skills(["speckit.specify"]) + + legacy_file = skills_dir / "speckit.specify" / "SKILL.md" + assert "Override body" in legacy_file.read_text(encoding="utf-8") + assert not modern_skill_dir.exists(), ( + "legacy-only ownership must not create an untracked modern path" + ) + + def test_kimi_skill_updated_even_when_ai_skills_disabled(self, project_dir, temp_dir): + """Kimi presets should still propagate command overrides to existing skills.""" + self._write_init_options(project_dir, ai="kimi", ai_skills=False) + skills_dir = project_dir / ".kimi-code" / "skills" + self._create_skill(skills_dir, "speckit-specify", body="untouched") + + (project_dir / ".kimi-code" / "commands").mkdir(parents=True, exist_ok=True) + + manager = PresetManager(project_dir) + install_self_test_preset(manager) + + skill_file = skills_dir / "speckit-specify" / "SKILL.md" + assert skill_file.exists() + content = skill_file.read_text() + assert "preset:self-test" in content + assert "name: speckit-specify" in content + + metadata = manager.registry.get("self-test") + assert "speckit-specify" in metadata.get("registered_skills", {}).get("kimi", []) + + def test_kimi_new_skill_created_even_when_ai_skills_disabled(self, project_dir, temp_dir): + """Kimi native skills should still receive brand-new preset commands.""" + self._write_init_options(project_dir, ai="kimi", ai_skills=False) + skills_dir = project_dir / ".kimi-code" / "skills" + skills_dir.mkdir(parents=True, exist_ok=True) + + preset_dir = temp_dir / "kimi-new-skill" + preset_dir.mkdir() + (preset_dir / "commands").mkdir() + (preset_dir / "commands" / "speckit.research.md").write_text( + "---\n" + "description: Kimi research workflow\n" + "---\n\n" + "preset:kimi-new-skill\n" + ) + manifest_data = { + "schema_version": "1.0", + "preset": { + "id": "kimi-new-skill", + "name": "Kimi New Skill", + "version": "1.0.0", + "description": "Test", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "templates": [ + { + "type": "command", + "name": "speckit.research", + "file": "commands/speckit.research.md", + } + ] + }, + } + with open(preset_dir / "preset.yml", "w") as f: + yaml.dump(manifest_data, f) + + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "0.1.5") + + skill_file = skills_dir / "speckit-research" / "SKILL.md" + assert skill_file.exists() + content = skill_file.read_text() + assert "preset:kimi-new-skill" in content + assert "name: speckit-research" in content + + metadata = manager.registry.get("kimi-new-skill") + assert "speckit-research" in metadata.get("registered_skills", {}).get("kimi", []) + + def test_kimi_preset_skill_override_resolves_script_placeholders(self, project_dir, temp_dir): + """Kimi preset skill overrides should resolve placeholders and rewrite project paths.""" + self._write_init_options(project_dir, ai="kimi", ai_skills=False, script="sh") + skills_dir = project_dir / ".kimi-code" / "skills" + self._create_skill(skills_dir, "speckit-specify", body="untouched") + (project_dir / ".kimi-code" / "commands").mkdir(parents=True, exist_ok=True) + + preset_dir = temp_dir / "kimi-placeholder-override" + preset_dir.mkdir() + (preset_dir / "commands").mkdir() + (preset_dir / "commands" / "speckit.specify.md").write_text( + "---\n" + "description: Kimi placeholder override\n" + "scripts:\n" + " sh: scripts/bash/create-new-feature.sh --json \"{ARGS}\"\n" + "---\n\n" + "Execute `{SCRIPT}` for __AGENT__\n" + "Review templates/checklist.md and memory/constitution.md\n" + ) + manifest_data = { + "schema_version": "1.0", + "preset": { + "id": "kimi-placeholder-override", + "name": "Kimi Placeholder Override", + "version": "1.0.0", + "description": "Test", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "templates": [ + { + "type": "command", + "name": "speckit.specify", + "file": "commands/speckit.specify.md", + } + ] + }, + } + with open(preset_dir / "preset.yml", "w") as f: + yaml.dump(manifest_data, f) + + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "0.1.5") + + content = (skills_dir / "speckit-specify" / "SKILL.md").read_text() + assert "{SCRIPT}" not in content + assert "__AGENT__" not in content + assert ".specify/scripts/bash/create-new-feature.sh --json \"$ARGUMENTS\"" in content + assert ".specify/templates/checklist.md" in content + assert ".specify/memory/constitution.md" in content + assert "for kimi" in content + + def test_agy_skill_restored_on_preset_remove(self, project_dir, temp_dir): + """Agy preset removal should restore native skills instead of deleting them.""" + self._write_init_options(project_dir, ai="agy", ai_skills=True) + skills_dir = project_dir / ".agents" / "skills" + self._create_skill(skills_dir, "speckit-specify", body="before override") + + core_command = project_dir / ".specify" / "templates" / "commands" / "specify.md" + core_command.write_text( + "---\n" + "description: Restored core specify workflow\n" + "---\n\n" + "restored core body\n" + ) + + preset_dir = temp_dir / "agy-override" + preset_dir.mkdir() + (preset_dir / "commands").mkdir() + (preset_dir / "commands" / "speckit.specify.md").write_text( + "---\n" + "description: Agy override\n" + "---\n\n" + "preset agy body\n" + ) + manifest_data = { + "schema_version": "1.0", + "preset": { + "id": "agy-override", + "name": "Agy Override", + "version": "1.0.0", + "description": "Test", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "templates": [ + { + "type": "command", + "name": "speckit.specify", + "file": "commands/speckit.specify.md", + } + ] + }, + } + with open(preset_dir / "preset.yml", "w") as f: + yaml.dump(manifest_data, f) + + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "0.1.5") + + skill_file = skills_dir / "speckit-specify" / "SKILL.md" + assert "preset agy body" in skill_file.read_text() + + assert manager.remove("agy-override") is True + assert skill_file.exists() + restored = skill_file.read_text() + assert "restored core body" in restored + assert "name: speckit-specify" in restored + + def test_preset_skill_registration_handles_non_dict_init_options(self, project_dir, temp_dir): + """Non-dict init-options payloads should not crash preset install/remove flows.""" + init_options = project_dir / ".specify" / "init-options.json" + init_options.parent.mkdir(parents=True, exist_ok=True) + init_options.write_text("[]") + + skills_dir = project_dir / ".qwen" / "skills" + self._create_skill(skills_dir, "speckit-specify", body="untouched") + + manager = PresetManager(project_dir) + install_self_test_preset(manager) + + skill_content = (skills_dir / "speckit-specify" / "SKILL.md").read_text() + assert "untouched" in skill_content + + def test_partial_skill_registration_is_persisted_before_later_failure( + self, project_dir, temp_dir, monkeypatch + ): + """A successful earlier skill write remains tracked if a later read fails.""" + self._write_init_options(project_dir, ai="copilot", ai_skills=False) + commands_dir = project_dir / ".github" / "agents" + commands_dir.mkdir(parents=True) + + preset_dir = self._create_multi_command_preset( + temp_dir, + "partial-skill-failure-preset", + ["speckit.specify", "speckit.plan"], + ) + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "0.1.5") + self._write_init_options(project_dir, ai="copilot", ai_skills=True) + + original_read_text = Path.read_text + + def fail_plan_source(path, *args, **kwargs): + if ( + path.name == "speckit.plan.md" + and path.parent.name == "commands" + and "partial-skill-failure-preset" in path.parts + ): + raise UnicodeDecodeError("utf-8", b"\xff", 0, 1, "invalid") + return original_read_text(path, *args, **kwargs) + + monkeypatch.setattr(Path, "read_text", fail_plan_source) + manager.register_enabled_presets_for_agent("copilot") + + metadata = manager.registry.get("partial-skill-failure-preset") + assert "speckit-specify" in metadata["registered_skills"].get( + "copilot", [] + ), ( + "the first successful write must be persisted before the later " + "template failure aborts the registration call" + ) + monkeypatch.setattr(Path, "read_text", original_read_text) + assert manager.remove("partial-skill-failure-preset") is True + remaining_skill = ( + project_dir + / ".github" + / "skills" + / "speckit-specify" + / "SKILL.md" + ) + assert ( + not remaining_skill.exists() + or "preset:partial-skill-failure-preset" + not in remaining_skill.read_text(encoding="utf-8") + ), "persisted partial ownership must remain removable" + + def test_remove_cleans_native_skill_missing_from_partial_skill_map( + self, project_dir, temp_dir + ): + """Command cleanup must cover native skills absent from a partial map.""" + self._write_init_options(project_dir, ai="claude", ai_skills=True) + (project_dir / ".claude" / "skills").mkdir(parents=True) + preset_dir = self._create_command_preset( + temp_dir, + "partial-agent-skill-map-preset", + "speckit.partial-native", + "Partial native skill", + "preset body", + ) + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "0.1.5") + + metadata = manager.registry.get("partial-agent-skill-map-preset") + assert metadata["registered_skills"].get("claude") + + self._write_init_options(project_dir, ai="codex", ai_skills=True) + (project_dir / ".agents" / "skills").mkdir(parents=True) + + from unittest.mock import patch + + with patch.object( + PresetManager, + "_register_skills", + side_effect=RuntimeError("simulated skills phase failure"), + ): + manager.register_enabled_presets_for_agent("codex") + + codex_skill = ( + project_dir + / ".agents" + / "skills" + / "speckit-partial-native" + / "SKILL.md" + ) + assert codex_skill.exists() + metadata = manager.registry.get("partial-agent-skill-map-preset") + assert "speckit.partial-native" in metadata[ + "registered_commands" + ].get("codex", []) + assert not metadata["registered_skills"].get("codex") + + assert manager.remove("partial-agent-skill-map-preset") is True + assert not codex_skill.exists(), ( + "native skill written by the commands phase must not be orphaned " + "when another agent makes registered_skills globally non-empty" + ) + + def test_skill_switch_then_remove_restores_every_skill_agent_dir( + self, project_dir, temp_dir + ): + """Switching between two skill-mode agents before removing a preset + must restore both agents' directories, not just the currently + active one. + + ``registered_skills`` records exactly which agent directories the + preset wrote to (``{agent_name: [skill_name, ...]}``); switching to + codex and re-registering adds a "codex" entry alongside the + original "claude" entry, so removal restores both. Before the + provenance fix, ``_unregister_skills`` only restored the currently + active agent's skills directory; a preset used first under Claude + and later switched to Codex would have its Claude override left + behind permanently on removal (#2948). + """ + self._write_init_options(project_dir, ai="claude", ai_skills=True) + + core_cmds = project_dir / ".specify" / "templates" / "commands" + core_cmds.mkdir(parents=True, exist_ok=True) + (core_cmds / "specify.md").write_text( + "---\ndescription: Core specify command\n---\n\nCore specify body\n", + encoding="utf-8", + ) + + # Native skill agents only materialize a *brand-new* preset skill + # when their skills directory already exists (mirrors every other + # skill test in this class); pre-create both agents' directories so + # install and the later switch both find an existing skill to + # overwrite via _register_commands/_register_skills. + claude_skills_dir = project_dir / ".claude" / "skills" + self._create_skill(claude_skills_dir, "speckit-specify") + codex_skills_dir = project_dir / ".agents" / "skills" + self._create_skill(codex_skills_dir, "speckit-specify") + + preset_dir = self._create_command_preset( + temp_dir, "multi-skill-agent-preset", "speckit.specify", + "Multi skill agent test", "preset body", + ) + + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "0.1.5") + + claude_skill = claude_skills_dir / "speckit-specify" / "SKILL.md" + assert "preset:multi-skill-agent-preset" in claude_skill.read_text() + + # Switch the active agent to codex (a different skill-mode agent) + # and re-register enabled presets for it, mirroring what + # `integration use codex` does. + self._write_init_options(project_dir, ai="codex", ai_skills=True) + manager.register_enabled_presets_for_agent("codex") + + codex_skill = codex_skills_dir / "speckit-specify" / "SKILL.md" + assert "preset:multi-skill-agent-preset" in codex_skill.read_text(), ( + "sanity: switching to codex should rescaffold the preset there" + ) + assert "preset:multi-skill-agent-preset" in claude_skill.read_text(), ( + "sanity: the previous agent's registration is preserved on switch" + ) + + metadata = manager.registry.get("multi-skill-agent-preset") + registered_skills = metadata.get("registered_skills", {}) + assert set(registered_skills) == {"claude", "codex"}, ( + "registered_skills must record both agent directories this " + "preset actually wrote to (#2948)" + ) + + assert manager.remove("multi-skill-agent-preset") is True + + for skill_file, label in ((claude_skill, "claude"), (codex_skill, "codex")): + assert skill_file.exists(), f"{label} skill file should still exist after removal" + content = skill_file.read_text() + assert "preset:multi-skill-agent-preset" not in content, ( + f"{label}'s preset override must be restored on removal, " + "not orphaned permanently (#2948)" + ) + assert "Core specify body" in content + + def test_infer_legacy_skill_provenance_does_not_falsely_attribute_command_mode_copilot( + self, project_dir, temp_dir + ): + """Broadening provenance inference to command-backed agents must not + falsely attribute ownership to an agent's directory that has no + preset-owned marker. + + Copilot stays in plain command mode throughout (no skills ever + rendered there), so ``.github/skills`` never receives this + preset's ``SKILL.md``. Probing copilot's skills directory anyway + (now that inference isn't restricted to static ``/SKILL.md`` + agents) must find nothing there and must not invent a false + ``"copilot"`` entry (#2948). + """ + self._write_init_options(project_dir, ai="claude", ai_skills=True) + + core_cmds = project_dir / ".specify" / "templates" / "commands" + core_cmds.mkdir(parents=True, exist_ok=True) + (core_cmds / "specify.md").write_text( + "---\ndescription: Core specify command\n---\n\nCore specify body\n", + encoding="utf-8", + ) + + claude_skills_dir = project_dir / ".claude" / "skills" + self._create_skill(claude_skills_dir, "speckit-specify") + # Copilot has never been active; its command directory holds an + # unrelated file so the directory exists, but no skills directory + # or SKILL.md was ever written for it. + copilot_commands_dir = project_dir / ".github" / "agents" + copilot_commands_dir.mkdir(parents=True) + + preset_dir = self._create_command_preset( + temp_dir, "no-false-attribution-preset", "speckit.specify", + "No false attribution test", "preset body", + ) + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "0.1.5") + + manager.registry.update( + "no-false-attribution-preset", + {"registered_skills": ["speckit-specify"]}, + ) + + # Rescaffold again for the same agent (claude) with unchanged + # names, triggering the legacy migration path. + manager.register_enabled_presets_for_agent("claude") + + metadata = manager.registry.get("no-false-attribution-preset") + registered_skills = metadata.get("registered_skills") + assert isinstance(registered_skills, dict) + assert set(registered_skills) == {"claude"}, ( + "copilot must not appear in the migrated registry when it has " + "never actually rendered this preset's skill — probing its " + "directory for a marker match must not create a false " + "attribution (#2948)" + ) + assert not (project_dir / ".github" / "skills").exists(), ( + "no .github/skills directory should have been created as a " + "side effect of probing for provenance (#2948)" + ) + + def test_infer_legacy_skill_provenance_skips_invalid_utf8( + self, project_dir + ): + self._write_init_options(project_dir, ai="claude", ai_skills=True) + skill_dir = ( + project_dir / ".claude" / "skills" / "speckit-specify" + ) + skill_dir.mkdir(parents=True) + (skill_dir / "SKILL.md").write_bytes(b"\xff") + + manager = PresetManager(project_dir) + + assert manager._infer_legacy_skill_provenance( + ["speckit-specify"], "some-pack", "claude" + ) == {"claude": ["speckit-specify"]} + + def test_infer_legacy_skill_provenance_excludes_home_outputs( + self, project_dir, temp_dir, monkeypatch + ): + home = temp_dir / "home" + monkeypatch.setattr(Path, "home", lambda: home) + skill_dir = home / ".hermes" / "skills" / "speckit-specify" + skill_dir.mkdir(parents=True) + (skill_dir / "SKILL.md").write_text( + "---\n" + "metadata:\n" + " source: preset:some-pack\n" + "---\n\n" + "Other project\n", + encoding="utf-8", + ) + + manager = PresetManager(project_dir) + inferred = manager._infer_legacy_skill_provenance( + ["speckit-specify"], "some-pack", "claude" + ) + + assert inferred == {"claude": ["speckit-specify"]} + assert skill_dir.exists() + + def test_remove_infers_legacy_flat_list_provenance_without_prior_rescaffold( + self, project_dir, temp_dir + ): + """``preset remove`` on a legacy flat-list registry must restore + every previously active agent's directory, not just the currently + active one, even when it is the *very first* post-upgrade + operation (no intervening ``use``/``upgrade``/rescaffold). + + Pre-#2948 registries recorded a flat ``registered_skills`` list + because presets were rendered for every detected skill-mode agent + at once, not just the active one. Migrating that legacy format to + the per-agent dict form previously only happened as a side effect + of ``register_enabled_presets_for_agent`` (i.e. a rescaffold or + ``integration use``/``switch``). If the user's first action after + upgrading is instead directly running ``preset remove``, the + legacy branch of ``_unregister_skills`` restored only the + currently active agent's directory (via ``_get_skills_dir()``), + permanently leaving this preset's override in every other, + previously active agent's directory (#2948). + """ + self._write_init_options(project_dir, ai="claude", ai_skills=True) + + core_cmds = project_dir / ".specify" / "templates" / "commands" + core_cmds.mkdir(parents=True, exist_ok=True) + (core_cmds / "specify.md").write_text( + "---\ndescription: Core specify command\n---\n\nCore specify body\n", + encoding="utf-8", + ) + + claude_skills_dir = project_dir / ".claude" / "skills" + self._create_skill(claude_skills_dir, "speckit-specify") + codex_skills_dir = project_dir / ".agents" / "skills" + self._create_skill(codex_skills_dir, "speckit-specify") + + preset_dir = self._create_command_preset( + temp_dir, "remove-legacy-no-rescaffold-preset", "speckit.specify", + "Remove legacy no rescaffold test", "preset body", + ) + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "0.1.5") + + claude_skill = claude_skills_dir / "speckit-specify" / "SKILL.md" + assert "preset:remove-legacy-no-rescaffold-preset" in claude_skill.read_text(), ( + "sanity: install should have written the override under " + "claude's skill directory" + ) + # Simulate the pre-#2948 "register for every detected agent" + # install behaviour by also placing the marker under codex's + # directory directly (mirroring the old, non-active-only + # rendering that predates this PR). + codex_skill = codex_skills_dir / "speckit-specify" / "SKILL.md" + codex_skill.write_text(claude_skill.read_text(), encoding="utf-8") + + # Simulate a pre-#2948 registry: a flat list with no per-agent + # provenance, even though both directories actually hold this + # preset's marker on disk. + manager.registry.update( + "remove-legacy-no-rescaffold-preset", + {"registered_skills": ["speckit-specify"]}, + ) + + # No intervening use/upgrade/rescaffold: remove() is the very + # first operation run after the legacy registry was written. + assert manager.remove("remove-legacy-no-rescaffold-preset") is True + + for skill_file, label in ((claude_skill, "claude"), (codex_skill, "codex")): + assert skill_file.exists(), f"{label} skill file should still exist after removal" + content = skill_file.read_text() + assert "preset:remove-legacy-no-rescaffold-preset" not in content, ( + f"{label}'s preset override must be restored on removal " + "even with no prior rescaffold to migrate the legacy " + "flat-list format first — remove() must infer real " + "per-agent ownership from on-disk provenance itself " + "(#2948)" + ) + assert "Core specify body" in content + + def test_symlinked_skills_dir_rejected_on_removal(self, project_dir, temp_dir): + """Removal must validate a recorded skill directory before touching it. + + If an agent's skills directory is replaced with a symlink escaping + the project root between install and removal, restoration must + refuse to write/rmtree through it rather than trusting the + recorded agent name blindly. The unsafe directory is skipped + best-effort; removal still succeeds and doesn't crash (#2948). + """ + self._write_init_options(project_dir, ai="claude", ai_skills=True) + claude_skills_dir = project_dir / ".claude" / "skills" + self._create_skill(claude_skills_dir, "speckit-specify") + + preset_dir = self._create_command_preset( + temp_dir, "symlink-guard-preset", "speckit.specify", + "Symlink guard test", "preset body", + ) + + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "0.1.5") + + metadata = manager.registry.get("symlink-guard-preset") + assert "speckit-specify" in metadata.get("registered_skills", {}).get("claude", []) + + # Simulate the claude skills directory being replaced with a symlink + # that escapes the project root, containing an external + # "speckit-specify" directory that must not be touched. + outside_target = temp_dir / "outside-claude-skills" + outside_skill_dir = outside_target / "speckit-specify" + outside_skill_dir.mkdir(parents=True) + sentinel = outside_skill_dir / "SKILL.md" + sentinel.write_text("do-not-touch") + shutil.rmtree(claude_skills_dir) + claude_skills_dir.symlink_to(outside_target, target_is_directory=True) + + assert manager.remove("symlink-guard-preset") is True + + assert sentinel.read_text() == "do-not-touch", ( + "removal must not follow a symlinked skills directory outside " + "the project root (#2948)" + ) + assert outside_skill_dir.is_dir(), ( + "the external directory must not be rmtree'd through a " + "symlinked skills path" + ) + assert claude_skills_dir.is_symlink(), ( + "the symlink itself should be left alone, not rmtree'd through" + ) + + def test_preset_removal_does_not_touch_other_presets_skill_dir( + self, project_dir, temp_dir + ): + """Removing a preset must only touch directories it actually wrote to. + + Preset A is installed while Claude is active and preset B is + installed while Codex is active; both override the same command + name, so both materialize a ``speckit-specify`` skill, but in + *different* agent directories. Before the provenance fix, removing + B enumerated every existing skill-mode directory (including + Claude's) and restored/overwrote anything named ``speckit-specify`` + found there, corrupting A's override even though B never touched + Claude's directory (#2948). + """ + self._write_init_options(project_dir, ai="claude", ai_skills=True) + claude_skills_dir = project_dir / ".claude" / "skills" + self._create_skill(claude_skills_dir, "speckit-specify") + + preset_a_dir = self._create_command_preset( + temp_dir, "preset-a", "speckit.specify", "Preset A", "preset A body", + ) + manager = PresetManager(project_dir) + manager.install_from_directory(preset_a_dir, "0.1.5") + + claude_skill_file = claude_skills_dir / "speckit-specify" / "SKILL.md" + assert "preset:preset-a" in claude_skill_file.read_text() + + # Switch to codex and install a second preset overriding the same + # command; codex's skills directory is entirely separate. + self._write_init_options(project_dir, ai="codex", ai_skills=True) + codex_skills_dir = project_dir / ".agents" / "skills" + self._create_skill(codex_skills_dir, "speckit-specify") + + preset_b_dir = self._create_command_preset( + temp_dir, "preset-b", "speckit.specify", "Preset B", "preset B body", + ) + manager.install_from_directory(preset_b_dir, "0.1.5") + + metadata_b = manager.registry.get("preset-b") + assert "claude" not in metadata_b.get("registered_skills", {}), ( + "preset B never wrote to claude's skills directory and must " + "not record it as touched" + ) + + assert manager.remove("preset-b") is True + + assert "preset:preset-a" in claude_skill_file.read_text(), ( + "removing preset B must not disturb preset A's Claude override (#2948)" + ) + + def test_remove_does_not_recreate_empty_skill_dir( + self, project_dir, temp_dir + ): + self._write_init_options(project_dir, ai="claude", ai_skills=True) + skills_dir = project_dir / ".claude" / "skills" + self._create_skill(skills_dir, "speckit-orphan") + preset_dir = self._create_command_preset( + temp_dir, + "orphan-skill-preset", + "speckit.orphan", + "Orphan", + "Preset-only body", + ) + + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "0.1.5") + + skill_dir = skills_dir / "speckit-orphan" + assert skill_dir.exists() + assert manager.remove("orphan-skill-preset") is True + assert not skill_dir.exists() + + def test_remove_preserves_non_owned_skill_during_reconciliation( + self, project_dir, temp_dir + ): + self._write_init_options(project_dir, ai="claude", ai_skills=True) + skills_dir = project_dir / ".claude" / "skills" + self._create_skill(skills_dir, "speckit-specify") + + lower_dir = self._create_command_preset( + temp_dir, + "non-owned-lower-preset", + "speckit.specify", + "Lower", + "Lower preset body", + ) + higher_dir = self._create_command_preset( + temp_dir, + "non-owned-higher-preset", + "speckit.specify", + "Higher", + "Higher preset body", + ) + manager = PresetManager(project_dir) + manager.install_from_directory(lower_dir, "0.1.5", priority=10) + manager.install_from_directory(higher_dir, "0.1.5", priority=1) + + skill_file = skills_dir / "speckit-specify" / "SKILL.md" + skill_file.write_text( + "---\nname: speckit-specify\n---\n\nUser-owned body\n", + encoding="utf-8", + ) + + assert manager.remove("non-owned-higher-preset") is True + assert skill_file.read_text(encoding="utf-8") == ( + "---\nname: speckit-specify\n---\n\nUser-owned body\n" + ) + + def test_shared_skills_dir_restored_once_using_active_agent( + self, project_dir, temp_dir + ): + """Removal must restore a physical skills directory shared by + multiple agents exactly once, using the active agent's renderer. + + Codex and Antigravity (agy) both resolve their skills directory to + ``.agents/skills``. Registering a preset under codex, switching to + agy, then switching back to codex records provenance for *both* + agent keys even though they share one physical directory. Before + the fix, ``_unregister_skills`` restored once per recorded agent + key rather than once per unique directory, so the directory was + written twice on removal with whichever agent was iterated *last* + silently winning — regardless of which agent is actually active + (#2948). + """ + self._write_init_options(project_dir, ai="codex", ai_skills=True) + shared_skills_dir = project_dir / ".agents" / "skills" + self._create_skill(shared_skills_dir, "speckit-specify") + + core_cmds = project_dir / ".specify" / "templates" / "commands" + core_cmds.mkdir(parents=True, exist_ok=True) + (core_cmds / "specify.md").write_text( + "---\ndescription: Core specify command\n---\n\nCore specify body\n", + encoding="utf-8", + ) + + preset_dir = self._create_command_preset( + temp_dir, "shared-dir-preset", "speckit.specify", + "Shared dir test", "preset body", + ) + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "0.1.5") + + # Switch to agy (shares .agents/skills with codex) and back to + # codex, mirroring `integration use agy` then `integration use + # codex`. Both agent keys end up recorded in registered_skills even + # though they refer to the same physical directory. + self._write_init_options(project_dir, ai="agy", ai_skills=True) + manager.register_enabled_presets_for_agent("agy") + self._write_init_options(project_dir, ai="codex", ai_skills=True) + manager.register_enabled_presets_for_agent("codex") + + metadata = manager.registry.get("shared-dir-preset") + registered_skills = metadata.get("registered_skills", {}) + assert set(registered_skills) == {"codex", "agy"}, ( + "both agent keys must be recorded even though they share one " + "physical directory (#2948)" + ) + + from unittest.mock import patch + + # Exercise `_unregister_skills` directly (the method this fix + # changed) rather than the full `remove()` flow, which separately + # triggers post-removal reconciliation that may also touch the + # active agent's directory — an unrelated call this test isn't + # targeting. + with patch.object( + manager, + "_unregister_skills_in_dir", + wraps=manager._unregister_skills_in_dir, + ) as spy: + manager._unregister_skills(registered_skills, preset_dir) + + assert spy.call_count == 1, ( + "a physical directory shared by multiple recorded agents must " + "be restored exactly once, not once per agent key (#2948)" + ) + (_names, called_dir, called_agent), _kwargs = spy.call_args + assert called_dir == shared_skills_dir + assert called_agent == "codex", ( + "the currently active agent must be used as the renderer when " + "it shares the restored directory, not whichever agent was " + "recorded last (#2948)" + ) + + skill_file = shared_skills_dir / "speckit-specify" / "SKILL.md" + content = skill_file.read_text() + assert "preset:shared-dir-preset" not in content + assert "Core specify body" in content + + def test_remove_higher_priority_skills_only_preset_restores_lower_preset( + self, project_dir, temp_dir + ): + """Removing a skills-mode preset must reconcile against the surviving + stack, not fall back to core/extension content. + + Copilot in skills mode never populates ``registered_commands`` for + its overrides (``_register_commands``'s ``ai_skills`` guard skips + command-file registration entirely), so with two presets overriding + the same command, the removed preset's command name was never added + to ``removed_cmd_names`` and reconciliation was skipped outright. + ``_unregister_skills`` then restored straight to core/extension + content instead of resolving the lower-priority preset that should + now win (#2948). + """ + self._write_init_options(project_dir, ai="copilot", ai_skills=True) + + preset_a_dir = self._create_command_preset( + temp_dir, "skills-preset-a", "speckit.specify", + "Preset A", "preset A body", + ) + preset_b_dir = self._create_command_preset( + temp_dir, "skills-preset-b", "speckit.specify", + "Preset B", "preset B body", + ) + + manager = PresetManager(project_dir) + # Lower priority number = higher precedence. + manager.install_from_directory(preset_a_dir, "0.1.5", priority=5) + manager.install_from_directory(preset_b_dir, "0.1.5", priority=10) + + skills_dir = project_dir / ".github" / "skills" + skill_file = skills_dir / "speckit-specify" / "SKILL.md" + assert "preset:skills-preset-a" in skill_file.read_text(), ( + "sanity: the higher-precedence preset should win initially" + ) + + assert manager.remove("skills-preset-a") is True + + content = skill_file.read_text() + assert "preset:skills-preset-b" in content, ( + "removing the higher-precedence skills-mode preset must " + "restore the surviving lower-precedence preset's override, " + "not fall back to core/extension content (#2948)" + ) + assert "preset:skills-preset-a" not in content + + def test_remove_reconciles_skill_for_every_historical_agent( + self, project_dir, temp_dir + ): + """Removing a preset must reconcile every historical skills + directory its ``registered_skills`` actually targeted, not only + the currently active one. + + Preset B (survives) is installed while claude is active, then + preset A (higher precedence) overrides the same command while + claude is still active. Switching to codex and rescaffolding + records codex too, so preset A's ``registered_skills`` spans both + claude (now inactive) and codex (active) directories. Removing A + restores both directories to core/extension via + ``_unregister_skills``, but ``_reconcile_skills`` used to only + resolve/apply the surviving winner for the currently active + skills directory, leaving claude's directory reverted to + core/extension content instead of preset B's override (#2948). + """ + self._write_init_options(project_dir, ai="claude", ai_skills=True) + claude_skills_dir = project_dir / ".claude" / "skills" + + # A core template fallback is required so unregistering the + # top-priority preset's SKILL.md restores core content rather than + # deleting the skill directory outright when no preset remains to + # apply on top of it (mirrors the pre-existing skills-reconciliation + # fixtures elsewhere in this file). + core_cmds = project_dir / ".specify" / "templates" / "commands" + core_cmds.mkdir(parents=True, exist_ok=True) + (core_cmds / "specify.md").write_text( + "---\ndescription: Core specify command\n---\n\nCore specify body\n", + encoding="utf-8", + ) + + preset_b_dir = self._create_command_preset( + temp_dir, "hist-skill-preset-b", "speckit.specify", + "Preset B", "preset B body", + ) + preset_a_dir = self._create_command_preset( + temp_dir, "hist-skill-preset-a", "speckit.specify", + "Preset A", "preset A body", + ) + + manager = PresetManager(project_dir) + manager.install_from_directory(preset_b_dir, "0.1.5", priority=10) + manager.install_from_directory(preset_a_dir, "0.1.5", priority=1) + + claude_skill_file = claude_skills_dir / "speckit-specify" / "SKILL.md" + assert "preset:hist-skill-preset-a" in claude_skill_file.read_text(), ( + "sanity: preset A (higher precedence) should win initially" + ) + + # Switch the active integration to codex (a distinct skills + # directory) and rescaffold, mirroring `integration use codex`. + self._write_init_options(project_dir, ai="codex", ai_skills=True) + codex_skills_dir = project_dir / ".agents" / "skills" + manager.register_enabled_presets_for_agent("codex") + + metadata_a = manager.registry.get("hist-skill-preset-a") + assert set(metadata_a.get("registered_skills", {})) == {"claude", "codex"}, ( + "sanity: preset A's registered_skills must span both the " + "historical (claude) and currently active (codex) agents" + ) + + assert manager.remove("hist-skill-preset-a") is True + + codex_skill_file = codex_skills_dir / "speckit-specify" / "SKILL.md" + assert claude_skill_file.exists(), "claude's skill file must still exist after removal" + assert codex_skill_file.exists(), "codex's skill file must still exist after removal" + assert "preset:hist-skill-preset-b" in claude_skill_file.read_text(), ( + "removing the higher-precedence preset must restore the " + "surviving preset's override in the historical (inactive) " + "agent's directory too, not only the active agent's (#2948)" + ) + assert "preset:hist-skill-preset-b" in codex_skill_file.read_text(), ( + "the surviving preset's override must also be restored for " + "the currently active agent" + ) + + def test_skill_reconciliation_preserves_per_directory_names( + self, project_dir, temp_dir + ): + self._write_init_options(project_dir, ai="claude", ai_skills=True) + claude_dir = project_dir / ".claude" / "skills" + self._create_skill(claude_dir, "speckit-alpha") + alpha_dir = self._create_command_preset( + temp_dir, "partial-alpha", "speckit.alpha", + "Alpha", "alpha body", + ) + + manager = PresetManager(project_dir) + manager.install_from_directory(alpha_dir, "0.1.5") + + self._write_init_options(project_dir, ai="codex", ai_skills=True) + codex_dir = project_dir / ".agents" / "skills" + self._create_skill(codex_dir, "speckit-beta") + beta_dir = self._create_command_preset( + temp_dir, "partial-beta", "speckit.beta", + "Beta", "beta body", + ) + manager.install_from_directory(beta_dir, "0.1.5") + + affected = manager._unregister_skills( + { + "claude": ["speckit-alpha"], + "codex": ["speckit-beta"], + }, + manager.presets_dir / "partial-alpha", + ) + manager._reconcile_skills( + ["speckit.alpha", "speckit.beta"], + extra_skills_dirs=affected, + ) + + assert (claude_dir / "speckit-alpha" / "SKILL.md").exists() + assert (codex_dir / "speckit-beta" / "SKILL.md").exists() + assert not (claude_dir / "speckit-beta").exists() + assert not (codex_dir / "speckit-alpha").exists() + + def test_skill_reconciliation_rejects_unsafe_managed_names( + self, project_dir, temp_dir + ): + self._write_init_options(project_dir, ai="claude", ai_skills=True) + skills_dir = project_dir / ".claude" / "skills" + skills_dir.mkdir(parents=True) + core_cmds = project_dir / ".specify" / "templates" / "commands" + core_cmds.mkdir(parents=True, exist_ok=True) + (core_cmds / "specify.md").write_text( + "---\ndescription: Core specify\n---\n\nCore body\n", + encoding="utf-8", + ) + absolute_escape = temp_dir / "absolute-escape" + traversal_escape = project_dir / ".claude" / "traversal-escape" + + manager = PresetManager(project_dir) + manager._reconcile_skills( + ["speckit.specify"], + extra_skills_dirs={ + skills_dir: ( + "claude", + [str(absolute_escape), "../traversal-escape"], + ) + }, + ) + + assert not absolute_escape.exists() + assert not traversal_escape.exists() + + def test_remove_reconciliation_tracks_new_historical_skill_agent_for_survivor( + self, project_dir, temp_dir + ): + """Historical-agent skill reconciliation writes must be recorded in + the surviving preset's own ``registered_skills``, mirroring + ``test_remove_reconciliation_tracks_new_historical_agent_for_survivor`` + for the command side. + + Preset A is installed while claude is active, then survives to be + active under codex too (so A's ``registered_skills`` spans both + claude and codex). Preset B is installed *only* while codex is + active — B's ``registered_skills`` is ``{"codex": [...]}`` and + never mentions claude. Removing A triggers reconciliation that + renders B's SKILL.md into claude's directory (an agent B never + wrote to before) via ``extra_skills_dirs``, but if that write + isn't merged back into B's own ``registered_skills``, a later + ``remove('b')`` only cleans up codex, leaving claude's SKILL.md — + rendered there entirely by side effect of removing A — untracked + by any preset (#2948). + """ + self._write_init_options(project_dir, ai="claude", ai_skills=True) + claude_skills_dir = project_dir / ".claude" / "skills" + # Pre-create the skill so _register_commands/_register_skills find + # an existing skill to overwrite (mirrors every other skill test + # in this class — native skill agents only overwrite already + # existing skill directories, they don't materialize brand-new + # ones outside of active-agent creation). + self._create_skill(claude_skills_dir, "speckit-specify") + + core_cmds = project_dir / ".specify" / "templates" / "commands" + core_cmds.mkdir(parents=True, exist_ok=True) + (core_cmds / "specify.md").write_text( + "---\ndescription: Core specify command\n---\n\nCore specify body\n", + encoding="utf-8", + ) + + preset_a_dir = self._create_command_preset( + temp_dir, "orphan-skill-preset-a", "speckit.specify", + "Preset A", "preset A body", + ) + manager = PresetManager(project_dir) + manager.install_from_directory(preset_a_dir, "0.1.5", priority=1) + + self._write_init_options(project_dir, ai="codex", ai_skills=True) + codex_skills_dir = project_dir / ".agents" / "skills" + self._create_skill(codex_skills_dir, "speckit-specify") + manager.register_enabled_presets_for_agent("codex") + + metadata_a = manager.registry.get("orphan-skill-preset-a") + assert set(metadata_a.get("registered_skills", {})) == {"claude", "codex"}, ( + "sanity: preset A must be tracked under both agents" + ) + + # Preset B is installed only now, while codex is the sole active + # agent — it never writes to or tracks claude. + preset_b_dir = self._create_command_preset( + temp_dir, "orphan-skill-preset-b", "speckit.specify", + "Preset B", "preset B body", + ) + manager.install_from_directory(preset_b_dir, "0.1.5", priority=10) + + metadata_b = manager.registry.get("orphan-skill-preset-b") + assert set(metadata_b.get("registered_skills", {})) == {"codex"}, ( + "sanity: preset B must only be tracked for codex before " + "preset A is removed" + ) + + assert manager.remove("orphan-skill-preset-a") is True + + claude_skill_file = claude_skills_dir / "speckit-specify" / "SKILL.md" + assert claude_skill_file.exists(), ( + "sanity: claude's skill file must have been restored by " + "reconciliation" + ) + assert "preset:orphan-skill-preset-b" in claude_skill_file.read_text(), ( + "sanity: claude's SKILL.md must reflect preset B's content " + "after preset A is removed" + ) + + metadata_b = manager.registry.get("orphan-skill-preset-b") + assert set(metadata_b.get("registered_skills", {})) == {"claude", "codex"}, ( + "preset B's own registered_skills must be updated to include " + "claude once reconciliation actually renders content there " + "on its behalf — otherwise B's registry entry silently lies " + "about which directories it owns (#2948)" + ) + + assert manager.remove("orphan-skill-preset-b") is True + + # No preset is installed any more, so claude's SKILL.md must have + # been reconciled down to the core bundled template (or removed + # entirely) — but it must NOT still contain B's stale content, + # which would mean B's write there was never tracked for cleanup. + if claude_skill_file.exists(): + assert "preset:orphan-skill-preset-b" not in claude_skill_file.read_text(), ( + "removing preset B must clean up claude's directory too, " + "since B's registered_skills was updated to include it — " + "otherwise B's stale content is orphaned there forever " + "with no preset left to track or clean it up (#2948)" + ) + + def test_symlinked_skill_subdir_rejected_on_restore(self, project_dir, temp_dir): + """Restore must validate each per-skill subdirectory, not just its parent. + + ``_safe_skills_dir_for_agent`` only validates the parent skills + directory (e.g. ``.claude/skills``); a symlink planted one level + deeper at the individual skill's own subdirectory (e.g. + ``.claude/skills/speckit-specify``) has a perfectly safe parent and + would otherwise slip past that check, since ``is_dir()`` follows + symlinks. Restoration must refuse to write/rmtree through it (#2948). + """ + self._write_init_options(project_dir, ai="claude", ai_skills=True) + claude_skills_dir = project_dir / ".claude" / "skills" + claude_skills_dir.mkdir(parents=True) + + outside_target = temp_dir / "outside-skill-subdir" + outside_target.mkdir() + sentinel = outside_target / "SKILL.md" + sentinel.write_text("do-not-touch") + (claude_skills_dir / "speckit-specify").symlink_to( + outside_target, target_is_directory=True + ) + + manager = PresetManager(project_dir) + manager._unregister_skills_in_dir( + ["speckit-specify"], claude_skills_dir, "claude" + ) + + assert sentinel.read_text() == "do-not-touch", ( + "restoration must not follow a symlinked skill subdirectory " + "to write/delete outside the project (#2948)" + ) + assert (claude_skills_dir / "speckit-specify").is_symlink(), ( + "the symlink itself should be left alone, not rmtree'd through" + ) + + def test_symlinked_skill_subdir_rejected_on_write(self, project_dir, temp_dir): + """Registration must validate each per-skill subdirectory before writing. + + A symlink planted at an individual skill's own subdirectory (safe + parent, unsafe leaf) would otherwise pass the existing + ``skill_subdir.exists() and not skill_subdir.is_dir()`` guard + (``is_dir()`` follows symlinks) and have ``SKILL.md`` written + through it to an arbitrary location (#2948). + """ + self._write_init_options(project_dir, ai="copilot", ai_skills=True) + copilot_commands_dir = project_dir / ".github" / "agents" + copilot_commands_dir.mkdir(parents=True) + skills_dir = project_dir / ".github" / "skills" + skills_dir.mkdir(parents=True) + + outside_target = temp_dir / "outside-skill-write-target" + outside_target.mkdir() + (skills_dir / "speckit-specify").symlink_to( + outside_target, target_is_directory=True + ) + + preset_dir = self._create_command_preset( + temp_dir, "symlink-write-preset", "speckit.specify", + "Symlink write test", "preset body", + ) + + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "0.1.5") + + assert not (outside_target / "SKILL.md").exists(), ( + "registration must not follow a symlinked skill subdirectory " + "to write outside the project (#2948)" + ) + assert (skills_dir / "speckit-specify").is_symlink(), ( + "the symlink itself should be left alone" + ) + + def test_symlinked_skill_file_rejected_on_write(self, project_dir, temp_dir): + """Registration must not follow a symlinked SKILL.md destination.""" + self._write_init_options(project_dir, ai="copilot", ai_skills=True) + (project_dir / ".github" / "agents").mkdir(parents=True) + skill_dir = ( + project_dir / ".github" / "skills" / "speckit-specify" + ) + skill_dir.mkdir(parents=True) + outside_file = temp_dir / "outside-registration.md" + outside_file.write_text("do-not-touch", encoding="utf-8") + (skill_dir / "SKILL.md").symlink_to(outside_file) + + preset_dir = self._create_command_preset( + temp_dir, + "symlink-file-write-preset", + "speckit.specify", + "Symlink file write", + "preset body", + ) + manager = PresetManager(project_dir) + with pytest.raises(ValueError): + manager.install_from_directory(preset_dir, "0.1.5") + + assert outside_file.read_text(encoding="utf-8") == "do-not-touch" + assert (skill_dir / "SKILL.md").is_symlink() + + def test_symlinked_skill_file_rejected_on_restore( + self, project_dir, temp_dir + ): + """Restoration must not follow a symlinked SKILL.md destination.""" + self._write_init_options(project_dir, ai="claude", ai_skills=True) + core_commands = project_dir / ".specify" / "templates" / "commands" + (core_commands / "specify.md").write_text( + "---\ndescription: Core specify\n---\n\nCore body\n", + encoding="utf-8", + ) + skill_dir = ( + project_dir / ".claude" / "skills" / "speckit-specify" + ) + skill_dir.mkdir(parents=True) + outside_file = temp_dir / "outside-restoration.md" + outside_file.write_text("do-not-touch", encoding="utf-8") + (skill_dir / "SKILL.md").symlink_to(outside_file) + + manager = PresetManager(project_dir) + with pytest.raises(ValueError): + manager._unregister_skills_in_dir( + ["speckit-specify"], skill_dir.parent, "claude" + ) + + assert outside_file.read_text(encoding="utf-8") == "do-not-touch" + assert (skill_dir / "SKILL.md").is_symlink() + + def test_symlinked_skill_file_rejected_on_override_reconcile( + self, project_dir, temp_dir + ): + """Project-override reconciliation must not follow SKILL.md symlinks.""" + self._write_init_options(project_dir, ai="copilot", ai_skills=True) + skill_dir = ( + project_dir / ".github" / "skills" / "speckit-specify" + ) + skill_dir.mkdir(parents=True) + outside_file = temp_dir / "outside-reconciliation.md" + outside_file.write_text("do-not-touch", encoding="utf-8") + (skill_dir / "SKILL.md").symlink_to(outside_file) + + preset_dir = self._create_command_preset( + temp_dir, + "symlink-override-preset", + "speckit.specify", + "Preset", + "Preset body", + ) + manager = PresetManager(project_dir) + manager.registry.add( + "symlink-override-preset", + { + "version": "1.0.0", + "source": "local", + "enabled": True, + "priority": 10, + "registered_commands": {}, + "registered_skills": { + "copilot": ["speckit-specify"] + }, + }, + ) + installed_dir = ( + manager.presets_dir / "symlink-override-preset" + ) + shutil.copytree(preset_dir, installed_dir) + overrides_dir = ( + project_dir / ".specify" / "templates" / "overrides" + ) + overrides_dir.mkdir(parents=True) + (overrides_dir / "speckit.specify.md").write_text( + "---\ndescription: Override\n---\n\nOverride body\n", + encoding="utf-8", + ) + + manager._reconcile_skills(["speckit.specify"]) + + assert outside_file.read_text(encoding="utf-8") == "do-not-touch" + assert (skill_dir / "SKILL.md").is_symlink() + + def test_is_safe_registry_skill_name_rejects_unsafe_values(self, project_dir): + """Unit-test the centralized registry skill-name boundary guard. + + ``registered_skills`` entries are persisted registry data, not + manifest-derived, so every preset cleanup/provenance loop that + joins one onto a directory must first reject: non-strings, empty + strings, absolute paths, multi-component paths (containing ``/``), + and the literal traversal components ``"."``/``".."`` — the last + of which is *not* caught by a naive ``is_absolute() or + len(parts) != 1`` check alone, since ``Path("..").parts`` is a + single-element tuple (#2948). + """ + manager = PresetManager(project_dir) + is_safe = manager._is_safe_registry_skill_name + + assert is_safe("speckit-specify") is True + assert is_safe("") is False + assert is_safe(None) is False + assert is_safe(123) is False + assert is_safe(["speckit-specify"]) is False + assert is_safe(".") is False + assert is_safe("..") is False + assert is_safe("/etc/passwd") is False + assert is_safe(str(project_dir / "important-data")) is False + assert is_safe("foo/bar") is False + assert is_safe("foo/..") is False + assert is_safe("../foo") is False + + def test_unregister_skills_rejects_unknown_agent_provenance( + self, project_dir + ): + """Unknown registry agent keys must not fall back to shared skills.""" + shared_skills_dir = project_dir / ".agents" / "skills" + skill_dir = self._create_skill( + shared_skills_dir, "speckit-specify", "user-owned content" + ) + core_commands = project_dir / ".specify" / "templates" / "commands" + (core_commands / "specify.md").write_text( + "---\ndescription: Core specify\n---\n\nCore body\n", + encoding="utf-8", + ) + + manager = PresetManager(project_dir) + manager._unregister_skills( + {"unknown": ["speckit-specify"]}, project_dir + ) + + assert (skill_dir / "SKILL.md").read_text(encoding="utf-8") == ( + "---\nname: speckit-specify\n---\n\nuser-owned content\n" + ) + + def test_unregister_legacy_fallback_skips_non_owned_skill( + self, project_dir + ): + """Legacy fallback provenance must not overwrite a user-owned skill.""" + self._write_init_options(project_dir, ai="claude", ai_skills=True) + skills_dir = project_dir / ".claude" / "skills" + skill_dir = self._create_skill( + skills_dir, "speckit-specify", "user-owned content" + ) + core_commands = project_dir / ".specify" / "templates" / "commands" + (core_commands / "specify.md").write_text( + "---\ndescription: Core specify\n---\n\nCore body\n", + encoding="utf-8", + ) + + manager = PresetManager(project_dir) + manager._unregister_skills( + ["speckit-specify"], "removed-preset" + ) + + assert (skill_dir / "SKILL.md").read_text(encoding="utf-8") == ( + "---\nname: speckit-specify\n---\n\nuser-owned content\n" + ) + + def test_unregister_skills_in_dir_unreadable_core_template_skips( + self, project_dir + ): + """An undecodable core template must not crash `preset remove`. + + Every other failure in the restore loop — an unsafe registry name, + a missing skill subdirectory, a foreign owner — skips the skill + with ``continue``. The core-template read was outside that + boundary, so one non-UTF-8 project-owned override in + ``.specify/templates/commands/`` raised a raw ``UnicodeDecodeError`` + straight out of ``PresetManager.remove()``, which has no handler + for it. Sibling reads of the very same directory are already + guarded (``_substitute_core_template``, the provenance reads in + ``_infer_legacy_skill_provenance``). + """ + self._write_init_options(project_dir, ai="claude", ai_skills=True) + skills_dir = project_dir / ".claude" / "skills" + skill_dir = self._create_skill( + skills_dir, "speckit-specify", "installed content" + ) + core_commands = project_dir / ".specify" / "templates" / "commands" + core_commands.mkdir(parents=True, exist_ok=True) + (core_commands / "specify.md").write_bytes( + b"---\ndescription: \xff\xfe not utf-8\n---\n\nCore body\n" + ) + + manager = PresetManager(project_dir) + with pytest.warns(UserWarning, match="speckit-specify"): + mutated = manager._unregister_skills_in_dir( + ["speckit-specify"], skills_dir, "claude" + ) + + assert mutated == [], ( + "a skill whose restore source could not be read was not " + "restored, so it must not be reported as mutated" + ) + assert (skill_dir / "SKILL.md").read_text(encoding="utf-8") == ( + "---\nname: speckit-specify\n---\n\ninstalled content\n" + ), ( + "an unreadable core template must leave the skill untouched — " + "falling through to the rmtree branch would delete it exactly " + "when its replacement cannot be generated" + ) + + def test_unregister_skills_in_dir_unreadable_core_template_oserror_skips( + self, project_dir, monkeypatch + ): + """The same boundary must cover ``OSError`` (e.g. permission denied). + + Mocked rather than chmod-based so the case also holds under + privileged CI, where permission bits are not enforced. + """ + self._write_init_options(project_dir, ai="claude", ai_skills=True) + skills_dir = project_dir / ".claude" / "skills" + skill_dir = self._create_skill( + skills_dir, "speckit-specify", "installed content" + ) + core_commands = project_dir / ".specify" / "templates" / "commands" + core_commands.mkdir(parents=True, exist_ok=True) + core_template = core_commands / "specify.md" + core_template.write_text( + "---\ndescription: Core specify\n---\n\nCore body\n", + encoding="utf-8", + ) + + original_read_text = Path.read_text + + def failing_read_text(self_path, *args, **kwargs): + if self_path == core_template: + raise PermissionError(13, "Permission denied") + return original_read_text(self_path, *args, **kwargs) + + monkeypatch.setattr(Path, "read_text", failing_read_text) + + manager = PresetManager(project_dir) + with pytest.warns(UserWarning, match="speckit-specify"): + mutated = manager._unregister_skills_in_dir( + ["speckit-specify"], skills_dir, "claude" + ) + + monkeypatch.undo() + + assert mutated == [] + assert (skill_dir / "SKILL.md").read_text(encoding="utf-8") == ( + "---\nname: speckit-specify\n---\n\ninstalled content\n" + ) + + def test_unregister_skills_in_dir_unreadable_extension_source_skips( + self, project_dir + ): + """The extension-restore arm needs the same boundary as the core arm. + + The two restore reads are independent branches — a skill backed by an + installed extension never reaches the core-template read — so this + half of the guard can regress on its own. An undecodable extension + command file must warn, leave the skill byte-for-byte intact, and stay + out of ``mutated_names``. + """ + self._write_init_options(project_dir, ai="claude", ai_skills=True) + skills_dir = project_dir / ".claude" / "skills" + skill_dir = self._create_skill( + skills_dir, "speckit-fakeext-cmd", "installed content" + ) + + extension_dir = project_dir / ".specify" / "extensions" / "fakeext" + (extension_dir / "commands").mkdir(parents=True, exist_ok=True) + (extension_dir / "commands" / "cmd.md").write_bytes( + b"---\ndescription: \xff\xfe not utf-8\n---\n\nExtension body\n" + ) + extension_manifest = { + "schema_version": "1.0", + "extension": { + "id": "fakeext", + "name": "Fake Extension", + "version": "1.0.0", + "description": "Test", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "commands": [ + { + "name": "speckit.fakeext.cmd", + "file": "commands/cmd.md", + "description": "Fake extension command", + } + ] + }, + } + with open(extension_dir / "extension.yml", "w") as f: + yaml.dump(extension_manifest, f) + + manager = PresetManager(project_dir) + with pytest.warns(UserWarning, match="speckit-fakeext-cmd"): + mutated = manager._unregister_skills_in_dir( + ["speckit-fakeext-cmd"], skills_dir, "claude" + ) + + assert mutated == [], ( + "a skill whose extension restore source could not be read was " + "not restored, so it must not be reported as mutated" + ) + assert (skill_dir / "SKILL.md").read_text(encoding="utf-8") == ( + "---\nname: speckit-fakeext-cmd\n---\n\ninstalled content\n" + ), ( + "an unreadable extension source must leave the skill untouched — " + "falling through to the rmtree branch would delete it exactly " + "when its replacement cannot be generated" + ) + + def test_unregister_skills_in_dir_rejects_absolute_registry_name( + self, project_dir + ): + """A corrupted ``registered_skills`` entry with an absolute path must not escape. + + ``Path`` join with an absolute right-hand operand discards the + left side entirely (``skills_dir / "/abs/path"`` == ``"/abs/path"``), + so an absolute in-project path stored in the registry would bypass + ``skills_dir`` altogether if not rejected before the join (#2948). + """ + self._write_init_options(project_dir, ai="claude", ai_skills=True) + claude_skills_dir = project_dir / ".claude" / "skills" + claude_skills_dir.mkdir(parents=True) + + precious_dir = project_dir / "important-data" + precious_dir.mkdir() + precious_file = precious_dir / "SKILL.md" + precious_file.write_text("precious-absolute-target-marker") + + manager = PresetManager(project_dir) + manager._unregister_skills_in_dir( + [str(precious_dir)], claude_skills_dir, "claude" + ) + + assert precious_dir.is_dir(), ( + "an absolute registry entry must not let cleanup escape " + "skills_dir to an unrelated project directory (#2948)" + ) + assert precious_file.read_text() == "precious-absolute-target-marker" + + def test_infer_legacy_skill_provenance_rejects_absolute_registry_name( + self, project_dir + ): + """Legacy provenance inference must reject an absolute registry name. + + ``_infer_legacy_skill_provenance`` receives its ``skill_names`` + directly from a legacy flat-list ``registered_skills`` value — + registry data, not manifest-derived — and joins each name onto a + candidate agent's resolved skills directory the same way + ``_unregister_skills_in_dir`` does. An absolute in-project name + discards the candidate directory entirely (Python's ``/`` operator + drops the left side for an absolute right side), so it can read + an unrelated project directory's ``SKILL.md`` and, if its + frontmatter happens to carry a matching preset source marker, + falsely attribute an unrelated directory as this preset's own + skill override under whichever agent is being probed (#2948). + """ + self._write_init_options(project_dir, ai="claude", ai_skills=True) + claude_skills_dir = project_dir / ".claude" / "skills" + claude_skills_dir.mkdir(parents=True) + + precious_dir = project_dir / "important-data" + precious_dir.mkdir() + (precious_dir / "SKILL.md").write_text( + "---\n" + "metadata:\n" + " source: preset:some-pack\n" + "---\n\n" + "# Unrelated directory, not a real preset skill\n" + ) + + manager = PresetManager(project_dir) + inferred = manager._infer_legacy_skill_provenance( + [str(precious_dir)], "some-pack", "claude" + ) + + for names in inferred.values(): + assert str(precious_dir) not in names, ( + "an absolute registry entry must not be falsely attributed " + "as preset-owned provenance by probing outside the " + "intended skills subtree (#2948)" + ) + + +class TestWrapStrategy: + """Skill registration with inherited wrap metadata.""" + + def test_register_skills_inherits_scripts_from_core_when_preset_omits_them(self, project_dir): + """_register_skills merges scripts/agent_scripts from core when preset lacks them.""" + from specify_cli.presets import PresetManager + import json + + # Core template with scripts + core_dir = project_dir / ".specify" / "templates" / "commands" + core_dir.mkdir(parents=True, exist_ok=True) + (core_dir / "wrap-test.md").write_text( + "---\ndescription: core\nscripts:\n sh: .specify/scripts/run.sh\n---\n\n" + "Run: {SCRIPT}\n" + ) + + # Skills dir for claude + skills_dir = project_dir / ".claude" / "skills" + skills_dir.mkdir(parents=True, exist_ok=True) + skill_subdir = skills_dir / "speckit-wrap-test" + skill_subdir.mkdir() + (skill_subdir / "SKILL.md").write_text("---\nname: speckit-wrap-test\n---\n\nold\n") + + (project_dir / ".specify" / "init-options.json").write_text( + json.dumps({"ai": "claude", "ai_skills": True}) + ) + + manager = PresetManager(project_dir) + install_self_test_preset(manager) + + written = (skill_subdir / "SKILL.md").read_text() + # {SCRIPT} should have been resolved (not left as a literal placeholder) + assert "{SCRIPT}" not in written + + def test_register_skills_preset_scripts_take_precedence_over_core(self, project_dir): + """preset-defined scripts/agent_scripts are not overwritten by core frontmatter.""" + from specify_cli.presets import _substitute_core_template + from specify_cli.agents import CommandRegistrar + + core_dir = project_dir / ".specify" / "templates" / "commands" + core_dir.mkdir(parents=True, exist_ok=True) + (core_dir / "specify.md").write_text( + "---\ndescription: core\nscripts:\n sh: core-run.sh\n---\n\nCore body.\n" + ) + + registrar = CommandRegistrar() + body = "{CORE_TEMPLATE}" + _, core_fm = _substitute_core_template(body, "specify", project_dir, registrar) + + # Simulate preset frontmatter that already defines scripts + preset_fm = {"description": "preset", "strategy": "wrap", "scripts": {"sh": "preset-run.sh"}} + for key in ("scripts", "agent_scripts"): + if key not in preset_fm and key in core_fm: + preset_fm[key] = core_fm[key] + + # Preset's scripts must not be overwritten by core + assert preset_fm["scripts"] == {"sh": "preset-run.sh"} diff --git a/tests/specify_cli/presets/test_manifest.py b/tests/specify_cli/presets/test_manifest.py new file mode 100644 index 0000000000..27ac723daa --- /dev/null +++ b/tests/specify_cli/presets/test_manifest.py @@ -0,0 +1,555 @@ +"""Tests for preset manifest validation in specify_cli.presets._manifest.""" + +import pytest +import yaml + +from specify_cli.presets import ( + VALID_PRESET_TEMPLATE_TYPES, + PresetManifest, + PresetValidationError, +) + + +class TestPresetManifest: + """Test PresetManifest validation and parsing.""" + + def test_valid_manifest(self, pack_dir): + """Test loading a valid manifest.""" + manifest = PresetManifest(pack_dir / "preset.yml") + assert manifest.id == "test-pack" + assert manifest.name == "Test Preset" + assert manifest.version == "1.0.0" + assert manifest.description == "A test preset" + assert manifest.author == "Test Author" + assert manifest.requires_speckit_version == ">=0.1.0" + assert len(manifest.templates) == 1 + assert manifest.tags == ["testing", "example"] + + def test_missing_manifest(self, temp_dir): + """Test that missing manifest raises error.""" + with pytest.raises(PresetValidationError, match="Manifest not found"): + PresetManifest(temp_dir / "nonexistent.yml") + + def test_invalid_yaml(self, temp_dir): + """Test that invalid YAML raises error.""" + bad_file = temp_dir / "bad.yml" + bad_file.write_text(": invalid: yaml: {{{") + with pytest.raises(PresetValidationError, match="Invalid YAML"): + PresetManifest(bad_file) + + def test_utf8_non_ascii_description_loads(self, temp_dir, valid_pack_data): + """Regression for #2325: non-ASCII (UTF-8) description loads on any platform. + + On Windows, Python's default text-mode encoding is the locale codepage + (e.g. cp1252/GBK), which raises UnicodeDecodeError on UTF-8 bytes + outside the ASCII range. The loader must open with encoding='utf-8'. + """ + valid_pack_data["preset"]["description"] = "中文测试 — émojis 🚀" + manifest_path = temp_dir / "preset.yml" + manifest_path.write_bytes( + yaml.safe_dump(valid_pack_data, allow_unicode=True).encode("utf-8") + ) + + manifest = PresetManifest(manifest_path) + assert manifest.description == "中文测试 — émojis 🚀" + + def test_invalid_utf8_bytes_raises_validation_error(self, temp_dir): + """Negative case: file containing invalid UTF-8 bytes raises PresetValidationError, not raw UnicodeDecodeError.""" + manifest_path = temp_dir / "preset.yml" + manifest_path.write_bytes(b"\xff\xfe not valid utf-8 \xff\n") + + with pytest.raises(PresetValidationError, match="not valid UTF-8"): + PresetManifest(manifest_path) + + def test_non_mapping_yaml_raises_validation_error(self, temp_dir): + """Manifest whose YAML root is a scalar or list raises PresetValidationError, not TypeError.""" + manifest_path = temp_dir / "preset.yml" + for bad_content in ("42\n", "[1, 2]\n"): + manifest_path.write_text(bad_content, encoding="utf-8") + with pytest.raises(PresetValidationError, match="YAML mapping"): + PresetManifest(manifest_path) + + @pytest.mark.parametrize("section", ["preset", "requires", "provides"]) + @pytest.mark.parametrize("bad_value", [None, [], "text"]) + def test_required_section_not_mapping_raises_validation_error( + self, temp_dir, valid_pack_data, section, bad_value + ): + """Required manifest sections reject null, list, and scalar values.""" + valid_pack_data[section] = bad_value + manifest_path = temp_dir / "preset.yml" + manifest_path.write_text( + yaml.safe_dump(valid_pack_data), + encoding="utf-8", + ) + + with pytest.raises( + PresetValidationError, + match=rf"Invalid {section}: expected a mapping", + ): + PresetManifest(manifest_path) + + @pytest.mark.parametrize("field", ["id", "name", "version", "description"]) + @pytest.mark.parametrize("bad", [1.0, 5, None, ["a"], {"a": 1}, True]) + def test_preset_metadata_field_not_string_raises_validation_error( + self, temp_dir, valid_pack_data, field, bad + ): + """A non-string preset. raises PresetValidationError, not a raw + TypeError. + + The loop over these four fields only checked key PRESENCE, then fed the + values to ``re.match`` (id) and ``packaging.Version`` (version), both of + which raise a bare TypeError on a non-string. YAML makes that an easy + authoring slip: unquoted ``version: 1.0`` parses as a float and ``id: 2`` + as an int. TypeError is not a PresetValidationError, so it escaped + list_installed()'s "Corrupted preset" fallback and made + `specify preset list` exit 1 with a raw traceback, hiding every healthy + preset too. The sibling IntegrationDescriptor already type-checks the + same four fields. + """ + valid_pack_data["preset"][field] = bad + manifest_path = temp_dir / "preset.yml" + manifest_path.write_text(yaml.safe_dump(valid_pack_data), encoding="utf-8") + + with pytest.raises( + PresetValidationError, + match=rf"Invalid preset\.{field}: expected a string", + ): + PresetManifest(manifest_path) + + @pytest.mark.parametrize("field", ["name", "file"]) + @pytest.mark.parametrize("bad", [1.0, 5, None, ["a"], {"a": 1}, True]) + def test_template_entry_field_not_string_raises_validation_error( + self, temp_dir, valid_pack_data, field, bad + ): + """A non-string template ``name``/``file`` raises PresetValidationError. + + ``name`` reaches ``re.match`` and ``file`` reaches ``os.path.normpath``; + both raise a bare TypeError on a non-string. The sibling extension + manifest already rejects a non-string command ``file`` via + relative_extension_path_violation(). + """ + valid_pack_data["provides"]["templates"][0][field] = bad + manifest_path = temp_dir / "preset.yml" + manifest_path.write_text(yaml.safe_dump(valid_pack_data), encoding="utf-8") + + with pytest.raises( + PresetValidationError, + match=rf"Invalid template {field}: expected a string", + ): + PresetManifest(manifest_path) + + @pytest.mark.parametrize( + "bad", + [ + 5, "oops", {"a": 1}, # truthy non-lists + 0, False, None, "", {}, # FALSY non-lists: must not fall through to + # the misleading "at least one template" + ], + ) + def test_non_list_templates_raises_validation_error( + self, temp_dir, valid_pack_data, bad + ): + """A non-list provides.templates raises the accurate type error, not a raw + 'int object is not iterable' TypeError and not the misleading "must provide + at least one template" (which a falsy non-list hit while the type check + sat behind the emptiness check) — mirrors ExtensionManifest.""" + valid_pack_data["provides"]["templates"] = bad + manifest_path = temp_dir / "preset.yml" + manifest_path.write_text(yaml.dump(valid_pack_data), encoding="utf-8") + with pytest.raises(PresetValidationError, match="templates.*expected a list"): + PresetManifest(manifest_path) + + @pytest.mark.parametrize("bad_entry", [None, 5, "oops", ["nested"]]) + def test_non_mapping_template_entry_raises_validation_error( + self, temp_dir, valid_pack_data, bad_entry + ): + """A non-mapping template entry (null/scalar/list) raises PresetValidationError, + not a raw 'argument of type ... is not iterable' TypeError from the + `"type" not in tmpl` membership test — mirrors ExtensionManifest.""" + valid_pack_data["provides"]["templates"] = [bad_entry] + manifest_path = temp_dir / "preset.yml" + manifest_path.write_text(yaml.dump(valid_pack_data), encoding="utf-8") + with pytest.raises(PresetValidationError, match="must be a mapping"): + PresetManifest(manifest_path) + + def test_missing_schema_version(self, temp_dir, valid_pack_data): + """Test missing schema_version field.""" + del valid_pack_data["schema_version"] + manifest_path = temp_dir / "preset.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_pack_data, f) + with pytest.raises(PresetValidationError, match="Missing required field: schema_version"): + PresetManifest(manifest_path) + + def test_wrong_schema_version(self, temp_dir, valid_pack_data): + """Test unsupported schema version.""" + valid_pack_data["schema_version"] = "2.0" + manifest_path = temp_dir / "preset.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_pack_data, f) + with pytest.raises(PresetValidationError, match="Unsupported schema version"): + PresetManifest(manifest_path) + + def test_missing_pack_id(self, temp_dir, valid_pack_data): + """Test missing preset.id field.""" + del valid_pack_data["preset"]["id"] + manifest_path = temp_dir / "preset.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_pack_data, f) + with pytest.raises(PresetValidationError, match="Missing preset.id"): + PresetManifest(manifest_path) + + def test_invalid_pack_id_format(self, temp_dir, valid_pack_data): + """Test invalid pack ID format.""" + valid_pack_data["preset"]["id"] = "Invalid_ID" + manifest_path = temp_dir / "preset.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_pack_data, f) + with pytest.raises(PresetValidationError, match="Invalid preset ID"): + PresetManifest(manifest_path) + + def test_invalid_version(self, temp_dir, valid_pack_data): + """Test invalid semantic version.""" + valid_pack_data["preset"]["version"] = "not-a-version" + manifest_path = temp_dir / "preset.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_pack_data, f) + with pytest.raises(PresetValidationError, match="Invalid version"): + PresetManifest(manifest_path) + + def test_missing_speckit_version(self, temp_dir, valid_pack_data): + """Test missing requires.speckit_version.""" + del valid_pack_data["requires"]["speckit_version"] + manifest_path = temp_dir / "preset.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_pack_data, f) + with pytest.raises(PresetValidationError, match="Missing requires.speckit_version"): + PresetManifest(manifest_path) + + @pytest.mark.parametrize( + "bad", + [ + 1.0, # unquoted YAML float -- the likeliest authoring slip + 5, # unquoted int + True, # YAML `yes`/`true` + None, # `speckit_version:` written but left empty + [">=0.1.0"], # iterable: slips past SpecifierSet() entirely + {"min": "0.1"}, # iterable: same + " ", # blank string must not mean "any version" + ], + ) + def test_non_string_speckit_version(self, temp_dir, valid_pack_data, bad): + """A non-string requires.speckit_version must be a PresetValidationError. + + It was presence-checked only, so it reached ``SpecifierSet(required)`` in + check_compatibility(), which is guarded by ``except InvalidSpecifier`` + alone. A non-string escapes that guard two ways: scalars raise TypeError + from the constructor, and a list/dict is iterable so SpecifierSet accepts + it and the failure surfaces later as ``AttributeError: 'str' object has no + attribute 'filter'`` from inside .contains(). + """ + valid_pack_data["requires"]["speckit_version"] = bad + manifest_path = temp_dir / "preset.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_pack_data, f) + with pytest.raises( + PresetValidationError, match="Invalid requires.speckit_version" + ): + PresetManifest(manifest_path) + + def test_no_templates_provided(self, temp_dir, valid_pack_data): + """Test pack with no templates.""" + valid_pack_data["provides"]["templates"] = [] + manifest_path = temp_dir / "preset.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_pack_data, f) + with pytest.raises(PresetValidationError, match="must provide at least one template"): + PresetManifest(manifest_path) + + def test_invalid_template_type(self, temp_dir, valid_pack_data): + """Test template with invalid type.""" + valid_pack_data["provides"]["templates"][0]["type"] = "invalid" + manifest_path = temp_dir / "preset.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_pack_data, f) + with pytest.raises(PresetValidationError, match="Invalid template type"): + PresetManifest(manifest_path) + + def test_valid_template_types(self): + """Test that all expected template types are valid.""" + assert "template" in VALID_PRESET_TEMPLATE_TYPES + assert "command" in VALID_PRESET_TEMPLATE_TYPES + assert "script" in VALID_PRESET_TEMPLATE_TYPES + + def test_template_missing_required_fields(self, temp_dir, valid_pack_data): + """Test template missing required fields.""" + valid_pack_data["provides"]["templates"] = [{"type": "template"}] + manifest_path = temp_dir / "preset.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_pack_data, f) + with pytest.raises(PresetValidationError, match="missing 'type', 'name', or 'file'"): + PresetManifest(manifest_path) + + def test_invalid_template_name_format(self, temp_dir, valid_pack_data): + """Test template with invalid name format.""" + valid_pack_data["provides"]["templates"][0]["name"] = "Invalid Name" + manifest_path = temp_dir / "preset.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_pack_data, f) + with pytest.raises(PresetValidationError, match="Invalid template name"): + PresetManifest(manifest_path) + + def test_get_hash(self, pack_dir): + """Test manifest hash calculation.""" + manifest = PresetManifest(pack_dir / "preset.yml") + hash_val = manifest.get_hash() + assert hash_val.startswith("sha256:") + import hashlib + content = (pack_dir / "preset.yml").read_bytes() + expected = f"sha256:{hashlib.sha256(content).hexdigest()}" + assert hash_val == expected + + def test_multiple_templates(self, temp_dir, valid_pack_data): + """Test pack with multiple templates of different types.""" + valid_pack_data["provides"]["templates"] = [ + {"type": "template", "name": "spec-template", "file": "templates/spec-template.md"}, + {"type": "template", "name": "plan-template", "file": "templates/plan-template.md"}, + {"type": "command", "name": "specify", "file": "commands/specify.md"}, + {"type": "script", "name": "create-new-feature", "file": "scripts/create-new-feature.sh"}, + ] + manifest_path = temp_dir / "preset.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_pack_data, f) + manifest = PresetManifest(manifest_path) + assert len(manifest.templates) == 4 + + def test_duplicate_template_name_and_type_raises_validation_error( + self, temp_dir, valid_pack_data + ): + """A later entry with the same (name, type) pair must be rejected. + + ``PresetResolver._manifest_declared_template`` returns the FIRST + 'provides.templates' entry matching a given (name, type) pair, so a + later duplicate would be silently unreachable while still being + counted by ``PresetManifest.templates`` -- mirroring the sibling bug + fixed for ``ExtensionManifest``'s provides.templates/scripts (#4016). + """ + valid_pack_data["provides"]["templates"] = [ + {"type": "command", "name": "specify", "file": "commands/specify-v1.md"}, + {"type": "command", "name": "specify", "file": "commands/specify-v2.md"}, + ] + manifest_path = temp_dir / "preset.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_pack_data, f) + with pytest.raises(PresetValidationError, match="Duplicate template name"): + PresetManifest(manifest_path) + + def test_same_name_different_type_templates_allowed( + self, temp_dir, valid_pack_data + ): + """The same name may recur across different template types.""" + valid_pack_data["provides"]["templates"] = [ + {"type": "template", "name": "specify", "file": "templates/specify.md"}, + {"type": "command", "name": "specify", "file": "commands/specify.md"}, + ] + manifest_path = temp_dir / "preset.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_pack_data, f) + manifest = PresetManifest(manifest_path) + assert len(manifest.templates) == 2 + + def test_requires_extensions_absent_is_valid(self, temp_dir, valid_pack_data): + """A preset with no declared dependencies stays valid and reports none.""" + manifest_path = temp_dir / "preset.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_pack_data, f) + assert PresetManifest(manifest_path).requires_extensions == [] + + def test_requires_extensions_accepts_both_forms(self, temp_dir, valid_pack_data): + """Bare ids and mappings normalize to the same shape.""" + valid_pack_data["requires"]["extensions"] = [ + "speckit-inventory", + {"id": "other-ext", "version": ">=1.2.0", "required": False}, + ] + manifest_path = temp_dir / "preset.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_pack_data, f) + + assert PresetManifest(manifest_path).requires_extensions == [ + {"id": "speckit-inventory", "version": None, "required": True}, + {"id": "other-ext", "version": ">=1.2.0", "required": False}, + ] + + @pytest.mark.parametrize( + "bad, expected", + [ + ("speckit-inventory", "Invalid requires.extensions"), # str, not list + ({"id": "x"}, "Invalid requires.extensions"), # mapping, not list + ([123], r"Invalid requires\.extensions\[0\]"), # member not str/mapping + ([None], r"Invalid requires\.extensions\[0\]"), + ([{"version": ">=1"}], r"Missing requires\.extensions\[0\]\.id"), + ([{"id": 5}], r"Invalid requires\.extensions\[0\]\.id"), + ([{"id": "Bad_ID"}], r"Invalid requires\.extensions\[0\]\.id"), + (["Bad_ID"], r"Invalid requires\.extensions\[0\]\.id"), + ([{"id": "x", "version": 1.0}], r"Invalid requires\.extensions\[0\]\.version"), + ([{"id": "x", "version": " "}], r"Invalid requires\.extensions\[0\]\.version"), + ([{"id": "x", "version": "nonsense"}], r"Invalid requires\.extensions\[0\]\.version"), + ([{"id": "x", "required": "yes"}], r"Invalid requires\.extensions\[0\]\.required"), + # `$` also matches before a trailing newline, so an anchored + # re.match would admit these while the resolver's fullmatch-based + # safe-id check rejects them. + (["demo-ext\n"], r"Invalid requires\.extensions\[0\]\.id"), + ([{"id": "demo-ext\n"}], r"Invalid requires\.extensions\[0\]\.id"), + (["demo\next"], r"Invalid requires\.extensions\[0\]\.id"), + ], + ) + def test_requires_extensions_rejects_malformed( + self, temp_dir, valid_pack_data, bad, expected + ): + """Malformed dependency declarations fail as PresetValidationError. + + Same reasoning as requires.speckit_version: an unvalidated value reaches + ``SpecifierSet`` or ``re.match`` later and surfaces as a bare TypeError + that no caller handles as a malformed manifest. + """ + valid_pack_data["requires"]["extensions"] = bad + manifest_path = temp_dir / "preset.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_pack_data, f) + with pytest.raises(PresetValidationError, match=expected): + PresetManifest(manifest_path) + + +class TestCompositionStrategyValidation: + """Test strategy field validation in PresetManifest.""" + + def test_valid_replace_strategy(self, temp_dir, valid_pack_data): + """Test that replace strategy is accepted.""" + valid_pack_data["provides"]["templates"][0]["strategy"] = "replace" + manifest_path = temp_dir / "preset.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_pack_data, f) + (temp_dir / "templates").mkdir(exist_ok=True) + (temp_dir / "templates" / "spec-template.md").write_text("test") + manifest = PresetManifest(manifest_path) + assert manifest.templates[0]["strategy"] == "replace" + + def test_valid_prepend_strategy(self, temp_dir, valid_pack_data): + """Test that prepend strategy is accepted for templates.""" + valid_pack_data["provides"]["templates"][0]["strategy"] = "prepend" + manifest_path = temp_dir / "preset.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_pack_data, f) + (temp_dir / "templates").mkdir(exist_ok=True) + (temp_dir / "templates" / "spec-template.md").write_text("test") + manifest = PresetManifest(manifest_path) + assert manifest.templates[0]["strategy"] == "prepend" + + def test_valid_append_strategy(self, temp_dir, valid_pack_data): + """Test that append strategy is accepted for templates.""" + valid_pack_data["provides"]["templates"][0]["strategy"] = "append" + manifest_path = temp_dir / "preset.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_pack_data, f) + (temp_dir / "templates").mkdir(exist_ok=True) + (temp_dir / "templates" / "spec-template.md").write_text("test") + manifest = PresetManifest(manifest_path) + assert manifest.templates[0]["strategy"] == "append" + + def test_valid_wrap_strategy(self, temp_dir, valid_pack_data): + """Test that wrap strategy is accepted for templates.""" + valid_pack_data["provides"]["templates"][0]["strategy"] = "wrap" + manifest_path = temp_dir / "preset.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_pack_data, f) + (temp_dir / "templates").mkdir(exist_ok=True) + (temp_dir / "templates" / "spec-template.md").write_text("test") + manifest = PresetManifest(manifest_path) + assert manifest.templates[0]["strategy"] == "wrap" + + def test_default_strategy_is_replace(self, pack_dir): + """Test that omitting strategy defaults to replace (key is absent).""" + manifest = PresetManifest(pack_dir / "preset.yml") + # Strategy key should not be present in the manifest data + assert "strategy" not in manifest.templates[0] + # But consumers should treat missing strategy as "replace" + assert manifest.templates[0].get("strategy", "replace") == "replace" + + def test_invalid_strategy_rejected(self, temp_dir, valid_pack_data): + """Test that invalid strategy values are rejected.""" + valid_pack_data["provides"]["templates"][0]["strategy"] = "merge" + manifest_path = temp_dir / "preset.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_pack_data, f) + with pytest.raises(PresetValidationError, match="Invalid strategy"): + PresetManifest(manifest_path) + + def test_prepend_rejected_for_scripts(self, temp_dir, valid_pack_data): + """Test that prepend strategy is rejected for scripts.""" + valid_pack_data["provides"]["templates"] = [{ + "type": "script", + "name": "create-new-feature", + "file": "scripts/create-new-feature.sh", + "strategy": "prepend", + }] + manifest_path = temp_dir / "preset.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_pack_data, f) + with pytest.raises(PresetValidationError, match="Invalid strategy.*for script"): + PresetManifest(manifest_path) + + def test_append_rejected_for_scripts(self, temp_dir, valid_pack_data): + """Test that append strategy is rejected for scripts.""" + valid_pack_data["provides"]["templates"] = [{ + "type": "script", + "name": "create-new-feature", + "file": "scripts/create-new-feature.sh", + "strategy": "append", + }] + manifest_path = temp_dir / "preset.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_pack_data, f) + with pytest.raises(PresetValidationError, match="Invalid strategy.*for script"): + PresetManifest(manifest_path) + + def test_wrap_accepted_for_scripts(self, temp_dir, valid_pack_data): + """Test that wrap strategy is accepted for scripts.""" + valid_pack_data["provides"]["templates"] = [{ + "type": "script", + "name": "create-new-feature", + "file": "scripts/create-new-feature.sh", + "strategy": "wrap", + }] + manifest_path = temp_dir / "preset.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_pack_data, f) + manifest = PresetManifest(manifest_path) + assert manifest.templates[0]["strategy"] == "wrap" + + def test_replace_accepted_for_scripts(self, temp_dir, valid_pack_data): + """Test that replace strategy is accepted for scripts.""" + valid_pack_data["provides"]["templates"] = [{ + "type": "script", + "name": "create-new-feature", + "file": "scripts/create-new-feature.sh", + "strategy": "replace", + }] + manifest_path = temp_dir / "preset.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_pack_data, f) + manifest = PresetManifest(manifest_path) + assert manifest.templates[0]["strategy"] == "replace" + + def test_prepend_accepted_for_commands(self, temp_dir, valid_pack_data): + """Test that prepend strategy is accepted for commands.""" + valid_pack_data["provides"]["templates"] = [{ + "type": "command", + "name": "speckit.specify", + "file": "commands/speckit.specify.md", + "strategy": "prepend", + }] + manifest_path = temp_dir / "preset.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_pack_data, f) + manifest = PresetManifest(manifest_path) + assert manifest.templates[0]["strategy"] == "prepend" diff --git a/tests/specify_cli/presets/test_registry.py b/tests/specify_cli/presets/test_registry.py new file mode 100644 index 0000000000..07ae8ca8fd --- /dev/null +++ b/tests/specify_cli/presets/test_registry.py @@ -0,0 +1,396 @@ +"""Tests for preset registry persistence and priority in specify_cli.presets._registry.""" + +import pytest + +from specify_cli.presets import PresetRegistry + + +class TestPresetRegistry: + """Test PresetRegistry operations.""" + + def test_empty_registry(self, temp_dir): + """Test empty registry initialization.""" + packs_dir = temp_dir / "packs" + packs_dir.mkdir() + registry = PresetRegistry(packs_dir) + assert registry.list() == {} + assert not registry.is_installed("test-pack") + + def test_load_starts_fresh_for_non_utf8_registry(self, temp_dir): + """A registry file with undecodable bytes must start fresh, not raise. + + ``_load()`` already treats malformed JSON as "corrupted registry, + start fresh", but a registry whose *bytes* cannot be decoded as UTF-8 + raised a raw ``UnicodeDecodeError`` from the same boundary — the same + corruption class reaching a different exception type. + """ + packs_dir = temp_dir / "packs" + packs_dir.mkdir() + (packs_dir / PresetRegistry.REGISTRY_FILE).write_bytes( + b"\xff\xfe not utf-8 \xc3\x28" + ) + + registry = PresetRegistry(packs_dir) + + assert registry.data == { + "schema_version": PresetRegistry.SCHEMA_VERSION, + "presets": {}, + } + + def test_add_and_get(self, temp_dir): + """Test adding and retrieving a pack.""" + packs_dir = temp_dir / "packs" + packs_dir.mkdir() + registry = PresetRegistry(packs_dir) + + registry.add("test-pack", {"version": "1.0.0", "source": "local"}) + assert registry.is_installed("test-pack") + + metadata = registry.get("test-pack") + assert metadata is not None + assert metadata["version"] == "1.0.0" + assert "installed_at" in metadata + + def test_remove(self, temp_dir): + """Test removing a pack.""" + packs_dir = temp_dir / "packs" + packs_dir.mkdir() + registry = PresetRegistry(packs_dir) + + registry.add("test-pack", {"version": "1.0.0"}) + assert registry.is_installed("test-pack") + + registry.remove("test-pack") + assert not registry.is_installed("test-pack") + + def test_remove_nonexistent(self, temp_dir): + """Test removing a pack that doesn't exist.""" + packs_dir = temp_dir / "packs" + packs_dir.mkdir() + registry = PresetRegistry(packs_dir) + registry.remove("nonexistent") # Should not raise + + def test_list(self, temp_dir): + """Test listing all packs.""" + packs_dir = temp_dir / "packs" + packs_dir.mkdir() + registry = PresetRegistry(packs_dir) + + registry.add("pack-a", {"version": "1.0.0"}) + registry.add("pack-b", {"version": "2.0.0"}) + + all_packs = registry.list() + assert len(all_packs) == 2 + assert "pack-a" in all_packs + assert "pack-b" in all_packs + + def test_persistence(self, temp_dir): + """Test that registry data persists across instances.""" + packs_dir = temp_dir / "packs" + packs_dir.mkdir() + + # Add with first instance + registry1 = PresetRegistry(packs_dir) + registry1.add("test-pack", {"version": "1.0.0"}) + + # Load with second instance + registry2 = PresetRegistry(packs_dir) + assert registry2.is_installed("test-pack") + + def test_corrupted_registry(self, temp_dir): + """Test recovery from corrupted registry file.""" + packs_dir = temp_dir / "packs" + packs_dir.mkdir() + + registry_file = packs_dir / ".registry" + registry_file.write_text("not valid json{{{") + + registry = PresetRegistry(packs_dir) + assert registry.list() == {} + + def test_get_nonexistent(self, temp_dir): + """Test getting a nonexistent pack.""" + packs_dir = temp_dir / "packs" + packs_dir.mkdir() + registry = PresetRegistry(packs_dir) + assert registry.get("nonexistent") is None + + def test_restore(self, temp_dir): + """Test restore() preserves timestamps exactly.""" + packs_dir = temp_dir / "packs" + packs_dir.mkdir() + registry = PresetRegistry(packs_dir) + + # Create original entry with a specific timestamp + original_metadata = { + "version": "1.0.0", + "source": "local", + "installed_at": "2025-01-15T10:30:00+00:00", + "enabled": True, + } + registry.restore("test-pack", original_metadata) + + # Verify exact restoration + restored = registry.get("test-pack") + assert restored["installed_at"] == "2025-01-15T10:30:00+00:00" + assert restored["version"] == "1.0.0" + assert restored["enabled"] is True + + def test_restore_rejects_none_metadata(self, temp_dir): + """Test restore() raises ValueError for None metadata.""" + packs_dir = temp_dir / "packs" + packs_dir.mkdir() + registry = PresetRegistry(packs_dir) + + with pytest.raises(ValueError, match="metadata must be a dict"): + registry.restore("test-pack", None) + + def test_restore_rejects_non_dict_metadata(self, temp_dir): + """Test restore() raises ValueError for non-dict metadata.""" + packs_dir = temp_dir / "packs" + packs_dir.mkdir() + registry = PresetRegistry(packs_dir) + + with pytest.raises(ValueError, match="metadata must be a dict"): + registry.restore("test-pack", "not-a-dict") + + with pytest.raises(ValueError, match="metadata must be a dict"): + registry.restore("test-pack", ["list", "not", "dict"]) + + def test_restore_uses_deep_copy(self, temp_dir): + """Test restore() deep copies metadata to prevent mutation.""" + packs_dir = temp_dir / "packs" + packs_dir.mkdir() + registry = PresetRegistry(packs_dir) + + original_metadata = { + "version": "1.0.0", + "nested": {"key": "original"}, + } + registry.restore("test-pack", original_metadata) + + # Mutate the original metadata after restore + original_metadata["version"] = "MUTATED" + original_metadata["nested"]["key"] = "MUTATED" + + # Registry should have the original values + stored = registry.get("test-pack") + assert stored["version"] == "1.0.0" + assert stored["nested"]["key"] == "original" + + def test_get_returns_deep_copy(self, temp_dir): + """Test that get() returns a deep copy to prevent mutation.""" + packs_dir = temp_dir / "packs" + packs_dir.mkdir() + registry = PresetRegistry(packs_dir) + + registry.add("test-pack", {"version": "1.0.0", "nested": {"key": "original"}}) + + # Get and mutate the returned copy + metadata = registry.get("test-pack") + metadata["version"] = "MUTATED" + metadata["nested"]["key"] = "MUTATED" + + # Original should be unchanged + fresh = registry.get("test-pack") + assert fresh["version"] == "1.0.0" + assert fresh["nested"]["key"] == "original" + + def test_get_returns_none_for_corrupted_entry(self, temp_dir): + """Test that get() returns None for corrupted (non-dict) entries.""" + packs_dir = temp_dir / "packs" + packs_dir.mkdir() + registry = PresetRegistry(packs_dir) + + # Directly corrupt the registry with non-dict entries + registry.data["presets"]["corrupted-string"] = "not a dict" + registry.data["presets"]["corrupted-list"] = ["not", "a", "dict"] + registry.data["presets"]["corrupted-int"] = 42 + registry._save() + + # All corrupted entries should return None + assert registry.get("corrupted-string") is None + assert registry.get("corrupted-list") is None + assert registry.get("corrupted-int") is None + # Non-existent should also return None + assert registry.get("nonexistent") is None + + def test_list_returns_deep_copy(self, temp_dir): + """Test that list() returns deep copies to prevent mutation.""" + packs_dir = temp_dir / "packs" + packs_dir.mkdir() + registry = PresetRegistry(packs_dir) + + registry.add("test-pack", {"version": "1.0.0", "nested": {"key": "original"}}) + + # Get list and mutate + all_packs = registry.list() + all_packs["test-pack"]["version"] = "MUTATED" + all_packs["test-pack"]["nested"]["key"] = "MUTATED" + + # Original should be unchanged + fresh = registry.get("test-pack") + assert fresh["version"] == "1.0.0" + assert fresh["nested"]["key"] == "original" + + def test_list_returns_empty_dict_for_corrupted_registry(self, temp_dir): + """Test that list() returns empty dict when presets is not a dict.""" + packs_dir = temp_dir / "packs" + packs_dir.mkdir() + registry = PresetRegistry(packs_dir) + + # Corrupt the registry - presets is a list instead of dict + registry.data["presets"] = ["not", "a", "dict"] + registry._save() + + # list() should return empty dict, not crash + result = registry.list() + assert result == {} + + def test_list_by_priority_excludes_disabled(self, temp_dir): + """Test that list_by_priority excludes disabled presets by default.""" + packs_dir = temp_dir / "packs" + packs_dir.mkdir() + registry = PresetRegistry(packs_dir) + + registry.add("pack-enabled", {"version": "1.0.0", "enabled": True, "priority": 5}) + registry.add("pack-disabled", {"version": "1.0.0", "enabled": False, "priority": 1}) + registry.add("pack-default", {"version": "1.0.0", "priority": 10}) # no enabled field = True + + # Default: exclude disabled + by_priority = registry.list_by_priority() + pack_ids = [p[0] for p in by_priority] + assert "pack-enabled" in pack_ids + assert "pack-default" in pack_ids + assert "pack-disabled" not in pack_ids + + def test_list_by_priority_includes_disabled_when_requested(self, temp_dir): + """Test that list_by_priority includes disabled presets when requested.""" + packs_dir = temp_dir / "packs" + packs_dir.mkdir() + registry = PresetRegistry(packs_dir) + + registry.add("pack-enabled", {"version": "1.0.0", "enabled": True, "priority": 5}) + registry.add("pack-disabled", {"version": "1.0.0", "enabled": False, "priority": 1}) + + # Include disabled + by_priority = registry.list_by_priority(include_disabled=True) + pack_ids = [p[0] for p in by_priority] + assert "pack-enabled" in pack_ids + assert "pack-disabled" in pack_ids + # Disabled pack has lower priority number, so it comes first when included + assert pack_ids[0] == "pack-disabled" + + +class TestRegistryPriority: + """Test registry priority sorting.""" + + def test_list_by_priority(self, temp_dir): + """Test that list_by_priority sorts by priority number.""" + packs_dir = temp_dir / "packs" + packs_dir.mkdir() + registry = PresetRegistry(packs_dir) + + registry.add("pack-high", {"version": "1.0.0", "priority": 1}) + registry.add("pack-low", {"version": "1.0.0", "priority": 20}) + registry.add("pack-mid", {"version": "1.0.0", "priority": 10}) + + sorted_packs = registry.list_by_priority() + assert len(sorted_packs) == 3 + assert sorted_packs[0][0] == "pack-high" + assert sorted_packs[1][0] == "pack-mid" + assert sorted_packs[2][0] == "pack-low" + + def test_list_by_priority_default(self, temp_dir): + """Test that packs without priority default to 10.""" + packs_dir = temp_dir / "packs" + packs_dir.mkdir() + registry = PresetRegistry(packs_dir) + + registry.add("pack-a", {"version": "1.0.0"}) # no priority, defaults to 10 + registry.add("pack-b", {"version": "1.0.0", "priority": 5}) + + sorted_packs = registry.list_by_priority() + assert sorted_packs[0][0] == "pack-b" + assert sorted_packs[1][0] == "pack-a" + + def test_list_by_priority_invalid_priority_defaults(self, temp_dir): + """Malformed priority values fall back to the default priority.""" + packs_dir = temp_dir / "packs" + packs_dir.mkdir() + registry = PresetRegistry(packs_dir) + + registry.add("pack-high", {"version": "1.0.0", "priority": 1}) + registry.data["presets"]["pack-invalid"] = { + "version": "1.0.0", + "priority": "high", + } + registry._save() + + sorted_packs = registry.list_by_priority() + + assert [item[0] for item in sorted_packs] == ["pack-high", "pack-invalid"] + assert sorted_packs[1][1]["priority"] == 10 + + +class TestPresetPriorityBackwardsCompatibility: + """Test backwards compatibility for presets installed before priority feature.""" + + def test_legacy_preset_without_priority_field(self, temp_dir): + """Presets installed before priority feature should default to 10.""" + presets_dir = temp_dir / ".specify" / "presets" + presets_dir.mkdir(parents=True) + + # Simulate legacy registry entry without priority field + registry = PresetRegistry(presets_dir) + registry.data["presets"]["legacy-pack"] = { + "version": "1.0.0", + "source": "local", + "enabled": True, + "installed_at": "2025-01-01T00:00:00Z", + # No "priority" field - simulates pre-feature preset + } + registry._save() + + # Reload registry + registry2 = PresetRegistry(presets_dir) + + # list_by_priority should use default of 10 + result = registry2.list_by_priority() + assert len(result) == 1 + assert result[0][0] == "legacy-pack" + # Priority defaults to 10 and is normalized in returned metadata + assert result[0][1]["priority"] == 10 + + def test_mixed_legacy_and_new_presets_ordering(self, temp_dir): + """Legacy presets (no priority) sort with default=10 among prioritized presets.""" + presets_dir = temp_dir / ".specify" / "presets" + presets_dir.mkdir(parents=True) + + registry = PresetRegistry(presets_dir) + + # Add preset with explicit priority=5 + registry.add("pack-with-priority", {"version": "1.0.0", "priority": 5}) + + # Add legacy preset without priority (manually) + registry.data["presets"]["legacy-pack"] = { + "version": "1.0.0", + "source": "local", + "enabled": True, + # No priority field + } + + # Add another preset with priority=15 + registry.add("low-priority-pack", {"version": "1.0.0", "priority": 15}) + registry._save() + + # Reload and check ordering + registry2 = PresetRegistry(presets_dir) + sorted_presets = registry2.list_by_priority() + + # Should be: pack-with-priority (5), legacy-pack (default 10), low-priority-pack (15) + assert [p[0] for p in sorted_presets] == [ + "pack-with-priority", + "legacy-pack", + "low-priority-pack", + ] diff --git a/tests/specify_cli/presets/test_resolver.py b/tests/specify_cli/presets/test_resolver.py new file mode 100644 index 0000000000..d1e416f7a1 --- /dev/null +++ b/tests/specify_cli/presets/test_resolver.py @@ -0,0 +1,1802 @@ +"""Tests for preset layer resolution and composition in specify_cli.presets._resolver.""" + +import json +from pathlib import Path + +import pytest +import yaml + +from specify_cli.extensions import ExtensionRegistry +from specify_cli.presets import ( + PresetManager, + PresetRegistry, + PresetResolver, + PresetValidationError, +) +from tests.specify_cli.presets._helpers import ( + CORE_TEMPLATE_NAMES, + install_self_test_preset, +) + + +class TestPresetResolver: + """Test PresetResolver priority stack.""" + + def test_resolve_core_template(self, project_dir): + """Test resolving a core template.""" + resolver = PresetResolver(project_dir) + result = resolver.resolve("spec-template") + assert result is not None + assert result.name == "spec-template.md" + assert "Core Spec Template" in result.read_text() + + def test_resolve_nonexistent(self, project_dir): + """Test resolving a nonexistent template returns None.""" + resolver = PresetResolver(project_dir) + result = resolver.resolve("nonexistent-template") + assert result is None + + def test_resolver_ignores_traversing_registry_ids(self, project_dir): + """Registry IDs cannot escape preset or extension install roots.""" + for registry_dir, registry_key, outside_name in ( + ("presets", "presets", "outside-preset"), + ("extensions", "extensions", "outside-extension"), + ): + outside = project_dir.parent / outside_name + (outside / "templates").mkdir(parents=True) + (outside / "templates" / "spec-template.md").write_text( + f"# Sensitive {registry_key}\n", + encoding="utf-8", + ) + installed = project_dir / ".specify" / registry_dir + installed.mkdir(parents=True, exist_ok=True) + (installed / ".registry").write_text( + json.dumps( + { + registry_key: { + f"../../../{outside_name}": { + "enabled": True, + "priority": 1, + } + } + } + ), + encoding="utf-8", + ) + + content = PresetResolver(project_dir).resolve_content("spec-template") + + assert content is not None + assert "Core Spec Template" in content + assert "Sensitive" not in content + + def test_resolve_higher_priority_pack_wins(self, project_dir, temp_dir, valid_pack_data): + """Test that a pack with lower priority number wins over higher number.""" + manager = PresetManager(project_dir) + + # Create pack A (priority 10 — lower precedence) + pack_a_dir = temp_dir / "pack-a" + pack_a_dir.mkdir() + data_a = {**valid_pack_data} + data_a["preset"] = {**valid_pack_data["preset"], "id": "pack-a", "name": "Pack A"} + with open(pack_a_dir / "preset.yml", 'w') as f: + yaml.dump(data_a, f) + (pack_a_dir / "templates").mkdir() + (pack_a_dir / "templates" / "spec-template.md").write_text("# From Pack A\n") + + # Create pack B (priority 1 — higher precedence) + pack_b_dir = temp_dir / "pack-b" + pack_b_dir.mkdir() + data_b = {**valid_pack_data} + data_b["preset"] = {**valid_pack_data["preset"], "id": "pack-b", "name": "Pack B"} + with open(pack_b_dir / "preset.yml", 'w') as f: + yaml.dump(data_b, f) + (pack_b_dir / "templates").mkdir() + (pack_b_dir / "templates" / "spec-template.md").write_text("# From Pack B\n") + + # Install A first (priority 10), B second (priority 1) + manager.install_from_directory(pack_a_dir, "0.1.5", priority=10) + manager.install_from_directory(pack_b_dir, "0.1.5", priority=1) + + # Pack B should win because lower priority number + resolver = PresetResolver(project_dir) + result = resolver.resolve("spec-template") + assert result is not None + assert "From Pack B" in result.read_text() + + def test_resolve_override_takes_priority(self, project_dir): + """Test that project overrides take priority over core.""" + # Create override + overrides_dir = project_dir / ".specify" / "templates" / "overrides" + overrides_dir.mkdir(parents=True) + override = overrides_dir / "spec-template.md" + override.write_text("# Override Spec Template\n") + + resolver = PresetResolver(project_dir) + result = resolver.resolve("spec-template") + assert result is not None + assert "Override Spec Template" in result.read_text() + + def test_resolve_pack_takes_priority_over_core(self, project_dir, pack_dir): + """Test that installed packs take priority over core templates.""" + # Install the pack + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.5") + + resolver = PresetResolver(project_dir) + result = resolver.resolve("spec-template") + assert result is not None + assert "Custom Spec Template" in result.read_text() + + def _install_pack_with_manifest_file(self, project_dir, *, extra_file=False): + """Create a pack whose manifest declares a NON-convention file: path. + + Returns the pack dir under the project. The declared file lives at + custom/spec.md (not the convention templates/spec-template.md). + """ + presets_dir = project_dir / ".specify" / "presets" + pack_dir = presets_dir / "mypack" + (pack_dir / "custom").mkdir(parents=True) + (pack_dir / "custom" / "spec.md").write_text( + "# Manifest-declared Spec\n", encoding="utf-8" + ) + if extra_file: + # An undeclared convention-path file the manifest points away from. + (pack_dir / "templates").mkdir() + (pack_dir / "templates" / "spec-template.md").write_text( + "# Stray Convention Spec\n", encoding="utf-8" + ) + manifest = { + "schema_version": "1.0", + "preset": { + "id": "mypack", + "name": "My Pack", + "version": "1.0.0", + "description": "declares a non-convention file path", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "templates": [ + { + "type": "template", + "name": "spec-template", + "file": "custom/spec.md", + "strategy": "replace", + } + ] + }, + } + with open(pack_dir / "preset.yml", "w") as f: + yaml.dump(manifest, f) + PresetRegistry(presets_dir).add( + "mypack", {"version": "1.0.0", "priority": 10} + ) + return pack_dir + + def test_resolve_uses_manifest_declared_file_path(self, project_dir): + """resolve() must honor a manifest-declared non-convention file: path. + + Previously the tier-2 loop was convention-only, so it returned the + core template and resolve_with_source() misattributed source='core', + diverging from collect_all_layers()/resolve_content(). + """ + pack_dir = self._install_pack_with_manifest_file(project_dir) + resolver = PresetResolver(project_dir) + + result = resolver.resolve("spec-template") + assert result == pack_dir / "custom" / "spec.md" + assert "Manifest-declared Spec" in result.read_text() + + sourced = resolver.resolve_with_source("spec-template") + assert sourced is not None + assert "mypack" in sourced["source"] + # resolve() must agree with collect_all_layers()'s top layer. + layers = resolver.collect_all_layers("spec-template") + assert Path(layers[0]["path"]) == pack_dir / "custom" / "spec.md" + + def test_resolve_manifest_file_wins_over_undeclared_convention_file( + self, project_dir + ): + """A stray convention-path file must not shadow the manifest's file:.""" + pack_dir = self._install_pack_with_manifest_file( + project_dir, extra_file=True + ) + resolver = PresetResolver(project_dir) + result = resolver.resolve("spec-template") + assert result == pack_dir / "custom" / "spec.md" + assert "Manifest-declared Spec" in result.read_text() + + def test_resolve_skips_convention_when_manifest_file_missing(self, project_dir): + """When the manifest declares a file: that does not exist, resolve() + must NOT fall back to a convention file in the same pack (that would + mask a typo) — it skips the pack and resolves core instead.""" + presets_dir = project_dir / ".specify" / "presets" + pack_dir = presets_dir / "mypack" + # Manifest declares custom/spec.md (MISSING); a convention file exists + # in the pack and must NOT be used. + (pack_dir / "templates").mkdir(parents=True) + (pack_dir / "templates" / "spec-template.md").write_text( + "# Stray Convention Spec\n", encoding="utf-8" + ) + manifest = { + "schema_version": "1.0", + "preset": { + "id": "mypack", + "name": "My Pack", + "version": "1.0.0", + "description": "declares a missing file path", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "templates": [ + { + "type": "template", + "name": "spec-template", + "file": "custom/spec.md", + "strategy": "replace", + } + ] + }, + } + with open(pack_dir / "preset.yml", "w") as f: + yaml.dump(manifest, f) + PresetRegistry(presets_dir).add( + "mypack", {"version": "1.0.0", "priority": 10} + ) + + resolver = PresetResolver(project_dir) + result = resolver.resolve("spec-template") + assert result is not None + content = result.read_text() + assert "Stray Convention Spec" not in content # pack convention skipped + assert "Core Spec Template" in content # fell through to core + + def test_resolve_skips_convention_when_manifest_file_is_directory( + self, project_dir + ): + """When the manifest's file: path resolves to a DIRECTORY (not a regular + file), resolve()/collect_all_layers() must treat it as missing — exists() + would accept it and downstream read_text() on a directory would crash. + The pack is skipped (no convention fallback), so core wins.""" + presets_dir = project_dir / ".specify" / "presets" + pack_dir = presets_dir / "mypack" + # Declared file: custom/spec.md is created as a DIRECTORY. + (pack_dir / "custom" / "spec.md").mkdir(parents=True) + # A convention file also exists and must NOT be used. + (pack_dir / "templates").mkdir(parents=True) + (pack_dir / "templates" / "spec-template.md").write_text( + "# Stray Convention Spec\n", encoding="utf-8" + ) + manifest = { + "schema_version": "1.0", + "preset": { + "id": "mypack", + "name": "My Pack", + "version": "1.0.0", + "description": "declares a file: that is actually a directory", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "templates": [ + { + "type": "template", + "name": "spec-template", + "file": "custom/spec.md", + "strategy": "replace", + } + ] + }, + } + with open(pack_dir / "preset.yml", "w") as f: + yaml.dump(manifest, f) + PresetRegistry(presets_dir).add( + "mypack", {"version": "1.0.0", "priority": 10} + ) + + resolver = PresetResolver(project_dir) + result = resolver.resolve("spec-template") + assert result is not None + assert result.is_file() # never a directory + content = result.read_text() + assert "Stray Convention Spec" not in content # pack convention skipped + assert "Core Spec Template" in content # fell through to core + # collect_all_layers() must agree: the directory is not a layer. + layers = resolver.collect_all_layers("spec-template") + assert all(Path(layer["path"]).is_file() for layer in layers) + assert all( + Path(layer["path"]) != pack_dir / "custom" / "spec.md" + for layer in layers + ) + + def test_resolve_override_takes_priority_over_pack(self, project_dir, pack_dir): + """Test that overrides take priority over installed packs.""" + # Install the pack + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.5") + + # Create override + overrides_dir = project_dir / ".specify" / "templates" / "overrides" + overrides_dir.mkdir(parents=True) + override = overrides_dir / "spec-template.md" + override.write_text("# Override Spec Template\n") + + resolver = PresetResolver(project_dir) + result = resolver.resolve("spec-template") + assert result is not None + assert "Override Spec Template" in result.read_text() + + def test_resolve_extension_provided_templates(self, project_dir): + """Test resolving templates provided by extensions.""" + # Create extension with templates + ext_dir = project_dir / ".specify" / "extensions" / "my-ext" + ext_templates_dir = ext_dir / "templates" + ext_templates_dir.mkdir(parents=True) + ext_template = ext_templates_dir / "custom-template.md" + ext_template.write_text("# Extension Custom Template\n") + + # Register extension in registry + extensions_dir = project_dir / ".specify" / "extensions" + ext_registry = ExtensionRegistry(extensions_dir) + ext_registry.add("my-ext", {"version": "1.0.0", "priority": 10}) + + resolver = PresetResolver(project_dir) + result = resolver.resolve("custom-template") + assert result is not None + assert "Extension Custom Template" in result.read_text() + + def test_resolve_disabled_extension_templates_skipped(self, project_dir): + """Test that disabled extension templates are not resolved.""" + # Create extension with templates + ext_dir = project_dir / ".specify" / "extensions" / "disabled-ext" + ext_templates_dir = ext_dir / "templates" + ext_templates_dir.mkdir(parents=True) + ext_template = ext_templates_dir / "disabled-template.md" + ext_template.write_text("# Disabled Extension Template\n") + + # Register extension as disabled + extensions_dir = project_dir / ".specify" / "extensions" + ext_registry = ExtensionRegistry(extensions_dir) + ext_registry.add("disabled-ext", {"version": "1.0.0", "priority": 1, "enabled": False}) + + # Template should NOT be resolved because extension is disabled + resolver = PresetResolver(project_dir) + result = resolver.resolve("disabled-template") + assert result is None, "Disabled extension template should not be resolved" + + def test_resolve_disabled_extension_not_picked_up_as_unregistered(self, project_dir): + """Test that disabled extensions are not picked up via unregistered dir scan.""" + # Create extension directory with templates + ext_dir = project_dir / ".specify" / "extensions" / "test-disabled-ext" + ext_templates_dir = ext_dir / "templates" + ext_templates_dir.mkdir(parents=True) + ext_template = ext_templates_dir / "unique-disabled-template.md" + ext_template.write_text("# Should Not Resolve\n") + + # Register the extension but disable it + extensions_dir = project_dir / ".specify" / "extensions" + ext_registry = ExtensionRegistry(extensions_dir) + ext_registry.add("test-disabled-ext", {"version": "1.0.0", "enabled": False}) + + # Verify the template is NOT resolved (even though the directory exists) + resolver = PresetResolver(project_dir) + result = resolver.resolve("unique-disabled-template") + assert result is None, "Disabled extension should not be picked up as unregistered" + + @pytest.mark.parametrize( + "registry_bytes", + [b"{ not valid json", b'{"extensions": []}', b"[]"], + ids=["invalid_json", "non_mapping_extensions", "non_mapping_root"], + ) + def test_resolve_fails_closed_on_corrupt_extension_registry( + self, project_dir, registry_bytes + ): + """A corrupt extension registry must fail closed rather than let the + directory scan admit every on-disk extension as enabled.""" + extensions_dir = project_dir / ".specify" / "extensions" + ext_templates_dir = extensions_dir / "sneaky-ext" / "templates" + ext_templates_dir.mkdir(parents=True) + (ext_templates_dir / "custom-template.md").write_text( + "# Should not be served\n" + ) + (extensions_dir / ".registry").write_bytes(registry_bytes) + + resolver = PresetResolver(project_dir) + with pytest.raises(PresetValidationError, match="Invalid extension registry"): + resolver._get_all_extensions_by_priority() + with pytest.raises(PresetValidationError, match="Invalid extension registry"): + resolver.resolve("custom-template") + + def test_resolve_fails_closed_when_registry_is_directory(self, project_dir): + """A directory at the registry path must fail closed, not be treated as + an absent registry that enables every on-disk extension.""" + extensions_dir = project_dir / ".specify" / "extensions" + ext_templates_dir = extensions_dir / "sneaky-ext" / "templates" + ext_templates_dir.mkdir(parents=True) + (ext_templates_dir / "custom-template.md").write_text( + "# Should not be served\n" + ) + (extensions_dir / ".registry").mkdir() + + resolver = PresetResolver(project_dir) + with pytest.raises(PresetValidationError, match="Invalid extension registry"): + resolver.resolve("custom-template") + + def test_resolve_fails_closed_when_registry_is_broken_symlink(self, project_dir): + """A dangling ``.registry`` symlink must fail closed. ``Path.exists()`` + follows symlinks and would mistake it for an absent registry, reopening + the fail-open directory scan.""" + extensions_dir = project_dir / ".specify" / "extensions" + ext_templates_dir = extensions_dir / "sneaky-ext" / "templates" + ext_templates_dir.mkdir(parents=True) + (ext_templates_dir / "custom-template.md").write_text( + "# Should not be served\n" + ) + (extensions_dir / ".registry").symlink_to( + extensions_dir / "does-not-exist" + ) + + registry = ExtensionRegistry(extensions_dir) + assert registry.is_corrupt() + resolver = PresetResolver(project_dir) + with pytest.raises(PresetValidationError, match="Invalid extension registry"): + resolver.resolve("custom-template") + + def test_resolve_pack_over_extension(self, project_dir, pack_dir, temp_dir, valid_pack_data): + """Test that pack templates take priority over extension templates.""" + # Create extension with templates + ext_dir = project_dir / ".specify" / "extensions" / "my-ext" + ext_templates_dir = ext_dir / "templates" + ext_templates_dir.mkdir(parents=True) + ext_template = ext_templates_dir / "spec-template.md" + ext_template.write_text("# Extension Spec Template\n") + + # Install a pack with the same template + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.5") + + resolver = PresetResolver(project_dir) + result = resolver.resolve("spec-template") + assert result is not None + # Pack should win over extension + assert "Custom Spec Template" in result.read_text() + + def test_resolve_with_source_core(self, project_dir): + """Test resolve_with_source for core template.""" + resolver = PresetResolver(project_dir) + result = resolver.resolve_with_source("spec-template") + assert result is not None + assert result["source"] == "core" + assert "spec-template.md" in result["path"] + + def test_resolve_with_source_override(self, project_dir): + """Test resolve_with_source for override template.""" + overrides_dir = project_dir / ".specify" / "templates" / "overrides" + overrides_dir.mkdir(parents=True) + override = overrides_dir / "spec-template.md" + override.write_text("# Override\n") + + resolver = PresetResolver(project_dir) + result = resolver.resolve_with_source("spec-template") + assert result is not None + assert result["source"] == "project override" + + def test_resolve_with_source_pack(self, project_dir, pack_dir): + """Test resolve_with_source for pack template.""" + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.5") + + resolver = PresetResolver(project_dir) + result = resolver.resolve_with_source("spec-template") + assert result is not None + assert "test-pack" in result["source"] + assert "v1.0.0" in result["source"] + + def test_resolve_with_source_extension(self, project_dir): + """Test resolve_with_source for extension-provided template.""" + ext_dir = project_dir / ".specify" / "extensions" / "my-ext" + ext_templates_dir = ext_dir / "templates" + ext_templates_dir.mkdir(parents=True) + ext_template = ext_templates_dir / "unique-template.md" + ext_template.write_text("# Unique\n") + + # Register extension in registry + extensions_dir = project_dir / ".specify" / "extensions" + ext_registry = ExtensionRegistry(extensions_dir) + ext_registry.add("my-ext", {"version": "1.0.0", "priority": 10}) + + resolver = PresetResolver(project_dir) + result = resolver.resolve_with_source("unique-template") + assert result is not None + assert result["source"] == "extension:my-ext v1.0.0" + + def test_resolve_with_source_not_found(self, project_dir): + """Test resolve_with_source for nonexistent template.""" + resolver = PresetResolver(project_dir) + result = resolver.resolve_with_source("nonexistent") + assert result is None + + def test_resolve_skips_hidden_extension_dirs(self, project_dir): + """Test that hidden directories in extensions are skipped.""" + ext_dir = project_dir / ".specify" / "extensions" / ".backup" + ext_templates_dir = ext_dir / "templates" + ext_templates_dir.mkdir(parents=True) + ext_template = ext_templates_dir / "hidden-template.md" + ext_template.write_text("# Hidden\n") + + resolver = PresetResolver(project_dir) + result = resolver.resolve("hidden-template") + assert result is None + + def test_collect_all_layers_finds_bundled_core_without_specify_commands( + self, project_dir + ): + """Tier-5 fallback locates the bundled core command when + .specify/templates/commands/ has no matching file. + + Regression test for #3086: a stale ``.parent`` chain made the + source-checkout fallback resolve to ``src/templates/...`` (which does + not exist), so ``wrap`` presets found no base layer. The fallback must + resolve against the real repo-root ``templates/commands`` tree. + """ + # project_dir's commands dir is empty, so tier-4 cannot satisfy this. + resolver = PresetResolver(project_dir) + layers = resolver.collect_all_layers("speckit.implement", "command") + assert layers, "expected a bundled core base layer to be found" + assert layers[-1]["source"] == "core (bundled)" + assert layers[-1]["path"].parts[-2:] == ("commands", "implement.md") + + def test_resolve_command_falls_back_to_bundled_core(self, project_dir): + """resolve() tier-5 returns the bundled core command when + .specify/templates/commands/ lacks it (regression for #3086).""" + resolver = PresetResolver(project_dir) + result = resolver.resolve("speckit.implement", "command") + assert result is not None + assert result.parts[-2:] == ("commands", "implement.md") + + +class TestResolveCore: + """Test PresetResolver.resolve_core() skips the installed-presets tier.""" + + def test_resolve_core_does_not_return_preset_files(self, project_dir): + """resolve_core must not return files from .specify/presets/.""" + preset_cmd_dir = project_dir / ".specify" / "presets" / "my-preset" / "commands" + preset_cmd_dir.mkdir(parents=True) + (preset_cmd_dir / "specify.md").write_text("---\ndescription: preset wrap\n---\n\nwrap body\n") + + resolver = PresetResolver(project_dir) + result = resolver.resolve_core("specify", "command") + # The preset file must never be returned — but the bundled core may be. + if result is not None: + assert "presets" not in result.parts + + def test_resolve_core_returns_core_template(self, project_dir): + """resolve_core falls through to core templates (tier 4).""" + core_cmd_dir = project_dir / ".specify" / "templates" / "commands" + core_cmd_dir.mkdir(parents=True, exist_ok=True) + (core_cmd_dir / "specify.md").write_text("---\ndescription: core\n---\n\ncore body\n") + + # Also place a preset file — resolve_core must still return the core + preset_cmd_dir = project_dir / ".specify" / "presets" / "my-preset" / "commands" + preset_cmd_dir.mkdir(parents=True) + (preset_cmd_dir / "specify.md").write_text("---\ndescription: preset wrap\n---\n\nwrap body\n") + + resolver = PresetResolver(project_dir) + result = resolver.resolve_core("specify", "command") + assert result is not None + assert "presets" not in result.parts + assert result.parts[-3:] == ("templates", "commands", "specify.md") + + def test_resolve_core_returns_override(self, project_dir): + """resolve_core returns tier-1 override if present.""" + override_dir = project_dir / ".specify" / "templates" / "overrides" + override_dir.mkdir(parents=True) + (override_dir / "specify.md").write_text("---\ndescription: override\n---\n\noverride body\n") + + resolver = PresetResolver(project_dir) + result = resolver.resolve_core("specify", "command") + assert result is not None + assert result.parts[-2:] == ("overrides", "specify.md") + + def test_resolve_core_returns_extension_template(self, project_dir): + """resolve_core returns extension templates (tier 3).""" + ext_cmd_dir = project_dir / ".specify" / "extensions" / "myext" / "commands" + ext_cmd_dir.mkdir(parents=True) + (ext_cmd_dir / "myext-cmd.md").write_text("---\ndescription: ext\n---\n\next body\n") + + resolver = PresetResolver(project_dir) + result = resolver.resolve_core("myext-cmd", "command") + assert result is not None + assert result.parts[-4:-1] == ("extensions", "myext", "commands") + + def test_resolve_core_returns_none_when_nothing_found(self, project_dir): + """resolve_core returns None when no file found in tiers 1/3/4.""" + resolver = PresetResolver(project_dir) + result = resolver.resolve_core("nonexistent", "command") + assert result is None + + def test_resolve_extension_command_via_manifest_skips_oserror_manifests(self, project_dir): + """resolve_extension_command_via_manifest skips extensions whose manifest raises OSError.""" + import unittest.mock as mock + + ext_dir = project_dir / ".specify" / "extensions" / "bad-ext" + cmd_dir = ext_dir / "commands" + cmd_dir.mkdir(parents=True) + (cmd_dir / "mycmd.md").write_text("---\ndescription: d\n---\n\nbody\n") + (ext_dir / "extension.yml").write_text( + "schema_version: '1.0'\n" + "extension:\n id: bad-ext\n name: Bad\n version: 1.0.0\n" + " description: d\n author: a\n repository: https://example.com\n" + " license: MIT\n" + "requires:\n speckit_version: '>=0.2.0'\n" + "provides:\n commands:\n" + " - name: speckit.bad-ext.mycmd\n" + " file: commands/mycmd.md\n" + " description: My command\n" + ) + + resolver = PresetResolver(project_dir) + # Simulate a permission error when opening the manifest file. + with mock.patch("builtins.open", side_effect=PermissionError("denied")): + result = resolver.resolve_extension_command_via_manifest("speckit.bad-ext.mycmd") + + assert result is None, "OSError during manifest load must be silently skipped" + + +class TestExtensionPriorityResolution: + """Test extension priority resolution with registered and unregistered extensions.""" + + def test_unregistered_beats_registered_with_lower_precedence(self, project_dir): + """Unregistered extension (implicit priority 10) beats registered with priority 20.""" + extensions_dir = project_dir / ".specify" / "extensions" + extensions_dir.mkdir(parents=True, exist_ok=True) + + # Create registered extension with priority 20 (lower precedence than 10) + registered_dir = extensions_dir / "registered-ext" + (registered_dir / "templates").mkdir(parents=True) + (registered_dir / "templates" / "test-template.md").write_text("# From Registered\n") + + ext_registry = ExtensionRegistry(extensions_dir) + ext_registry.add("registered-ext", {"version": "1.0.0", "priority": 20}) + + # Create unregistered extension directory (implicit priority 10) + unregistered_dir = extensions_dir / "unregistered-ext" + (unregistered_dir / "templates").mkdir(parents=True) + (unregistered_dir / "templates" / "test-template.md").write_text("# From Unregistered\n") + + # Unregistered (priority 10) should beat registered (priority 20) + resolver = PresetResolver(project_dir) + result = resolver.resolve("test-template") + assert result is not None + assert "From Unregistered" in result.read_text() + + def test_registered_with_higher_precedence_beats_unregistered(self, project_dir): + """Registered extension with priority 5 beats unregistered (implicit priority 10).""" + extensions_dir = project_dir / ".specify" / "extensions" + extensions_dir.mkdir(parents=True, exist_ok=True) + + # Create registered extension with priority 5 (higher precedence than 10) + registered_dir = extensions_dir / "registered-ext" + (registered_dir / "templates").mkdir(parents=True) + (registered_dir / "templates" / "test-template.md").write_text("# From Registered\n") + + ext_registry = ExtensionRegistry(extensions_dir) + ext_registry.add("registered-ext", {"version": "1.0.0", "priority": 5}) + + # Create unregistered extension directory (implicit priority 10) + unregistered_dir = extensions_dir / "unregistered-ext" + (unregistered_dir / "templates").mkdir(parents=True) + (unregistered_dir / "templates" / "test-template.md").write_text("# From Unregistered\n") + + # Registered (priority 5) should beat unregistered (priority 10) + resolver = PresetResolver(project_dir) + result = resolver.resolve("test-template") + assert result is not None + assert "From Registered" in result.read_text() + + def test_unregistered_attribution_with_priority_ordering(self, project_dir): + """Test resolve_with_source correctly attributes unregistered extension.""" + extensions_dir = project_dir / ".specify" / "extensions" + extensions_dir.mkdir(parents=True, exist_ok=True) + + # Create registered extension with priority 20 + registered_dir = extensions_dir / "registered-ext" + (registered_dir / "templates").mkdir(parents=True) + (registered_dir / "templates" / "test-template.md").write_text("# From Registered\n") + + ext_registry = ExtensionRegistry(extensions_dir) + ext_registry.add("registered-ext", {"version": "1.0.0", "priority": 20}) + + # Create unregistered extension (implicit priority 10) + unregistered_dir = extensions_dir / "unregistered-ext" + (unregistered_dir / "templates").mkdir(parents=True) + (unregistered_dir / "templates" / "test-template.md").write_text("# From Unregistered\n") + + # Attribution should show unregistered extension + resolver = PresetResolver(project_dir) + result = resolver.resolve_with_source("test-template") + assert result is not None + assert "unregistered-ext" in result["source"] + assert "(unregistered)" in result["source"] + + def test_same_priority_sorted_alphabetically(self, project_dir): + """Extensions with same priority are sorted alphabetically by ID.""" + extensions_dir = project_dir / ".specify" / "extensions" + extensions_dir.mkdir(parents=True, exist_ok=True) + + # Create two unregistered extensions (both implicit priority 10) + # "aaa-ext" should come before "zzz-ext" alphabetically + zzz_dir = extensions_dir / "zzz-ext" + (zzz_dir / "templates").mkdir(parents=True) + (zzz_dir / "templates" / "test-template.md").write_text("# From ZZZ\n") + + aaa_dir = extensions_dir / "aaa-ext" + (aaa_dir / "templates").mkdir(parents=True) + (aaa_dir / "templates" / "test-template.md").write_text("# From AAA\n") + + # AAA should win due to alphabetical ordering at same priority + resolver = PresetResolver(project_dir) + result = resolver.resolve("test-template") + assert result is not None + assert "From AAA" in result.read_text() + + +class TestSelfTestPreset: + """Template resolution using the bundled self-test preset.""" + + def test_self_test_overrides_all_core_templates(self, project_dir): + """Test that installing self-test overrides every core template.""" + # Set up core templates in the project + templates_dir = project_dir / ".specify" / "templates" + for name in CORE_TEMPLATE_NAMES: + (templates_dir / f"{name}.md").write_text(f"# Core {name}\n") + + # Install self-test preset + manager = PresetManager(project_dir) + install_self_test_preset(manager) + + # Every core template should now resolve from the preset + resolver = PresetResolver(project_dir) + for name in CORE_TEMPLATE_NAMES: + result = resolver.resolve(name) + assert result is not None, f"{name} did not resolve" + content = result.read_text() + assert "preset:self-test" in content, ( + f"{name} resolved but not from self-test preset" + ) + + def test_self_test_resolve_with_source(self, project_dir): + """Test that resolve_with_source attributes templates to self-test.""" + templates_dir = project_dir / ".specify" / "templates" + for name in CORE_TEMPLATE_NAMES: + (templates_dir / f"{name}.md").write_text(f"# Core {name}\n") + + manager = PresetManager(project_dir) + install_self_test_preset(manager) + + resolver = PresetResolver(project_dir) + for name in CORE_TEMPLATE_NAMES: + result = resolver.resolve_with_source(name) + assert result is not None, f"{name} did not resolve" + assert "self-test" in result["source"], ( + f"{name} source is '{result['source']}', expected self-test" + ) + + def test_self_test_override_resolves_constitution_template(self, project_dir): + """The preset override of constitution-template resolves to the preset file.""" + templates_dir = project_dir / ".specify" / "templates" + (templates_dir / "constitution-template.md").write_text("# Core constitution\n") + + manager = PresetManager(project_dir) + install_self_test_preset(manager) + + resolver = PresetResolver(project_dir) + result = resolver.resolve("constitution-template", "template") + assert result is not None + assert "preset:self-test" in result.read_text() + + +class TestPresetEnableDisable: + """Disabled presets are excluded from template resolution.""" + + + + + + + + + + def test_disabled_preset_excluded_from_resolution(self, project_dir, pack_dir): + """Test that disabled presets are excluded from template resolution.""" + # Install preset with a template + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.5") + + # Create a template in the preset directory + preset_template = project_dir / ".specify" / "presets" / "test-pack" / "templates" / "test-template.md" + preset_template.parent.mkdir(parents=True, exist_ok=True) + preset_template.write_text("# Template from test-pack") + + resolver = PresetResolver(project_dir) + + # Template should be found when enabled + result = resolver.resolve("test-template", "template") + assert result is not None + assert "test-pack" in str(result) + + # Disable the preset + manager.registry.update("test-pack", {"enabled": False}) + + # Template should NOT be found when disabled + resolver2 = PresetResolver(project_dir) + result2 = resolver2.resolve("test-template", "template") + assert result2 is None + + +class TestWrapStrategy: + """Resolution of extension command, template, and script layers.""" + + def test_extension_command_resolves_via_extension_directory(self, project_dir): + """Extension commands (e.g. speckit.git.feature) resolve from the extension directory. + + Both _register_skills and register_commands pass the full cmd_name to + _substitute_core_template, which tries the full name first via PresetResolver + and finds speckit.git.feature.md in the extension commands directory. + """ + from specify_cli.presets import _substitute_core_template + from specify_cli.agents import CommandRegistrar + + # Place the template where a real extension would install it + ext_cmd_dir = project_dir / ".specify" / "extensions" / "git" / "commands" + ext_cmd_dir.mkdir(parents=True, exist_ok=True) + (ext_cmd_dir / "speckit.git.feature.md").write_text( + "---\ndescription: git feature core\n---\n\n# Git Feature Core\n" + ) + # Ensure a hyphenated or dot-separated fallback does NOT exist + assert not (project_dir / ".specify" / "templates" / "commands" / "git.feature.md").exists() + assert not (project_dir / ".specify" / "templates" / "commands" / "git-feature.md").exists() + + registrar = CommandRegistrar() + body = "## Wrapper\n\n{CORE_TEMPLATE}\n" + + # Both call sites now pass the full cmd_name + result, _ = _substitute_core_template(body, "speckit.git.feature", project_dir, registrar) + + assert "# Git Feature Core" in result + assert "{CORE_TEMPLATE}" not in result + + def test_extension_command_resolves_via_manifest_when_filename_differs(self, project_dir): + """Extension commands whose filename differs from the command name resolve via extension.yml. + + The selftest extension maps speckit.selftest.extension → commands/selftest.md. + Name-based lookup would look for commands/speckit.selftest.extension.md and fail; + manifest-based lookup must find the actual file declared in the manifest. + """ + from specify_cli.presets import _substitute_core_template + from specify_cli.agents import CommandRegistrar + + ext_dir = project_dir / ".specify" / "extensions" / "selftest" + cmd_dir = ext_dir / "commands" + cmd_dir.mkdir(parents=True, exist_ok=True) + + # File is named selftest.md, NOT speckit.selftest.extension.md + (cmd_dir / "selftest.md").write_text( + "---\ndescription: selftest core\n---\n\n# Selftest Core\n" + ) + # Manifest maps the command name to the actual file + (ext_dir / "extension.yml").write_text( + "schema_version: '1.0'\n" + "extension:\n id: selftest\n name: Self-Test\n version: 1.0.0\n" + " description: test\n author: test\n repository: https://example.com\n" + " license: MIT\n" + "requires:\n speckit_version: '>=0.2.0'\n" + "provides:\n" + " commands:\n" + " - name: speckit.selftest.extension\n" + " file: commands/selftest.md\n" + " description: Selftest command\n" + ) + + registrar = CommandRegistrar() + body = "## Wrapper\n\n{CORE_TEMPLATE}\n" + result, _ = _substitute_core_template(body, "speckit.selftest.extension", project_dir, registrar) + + assert "# Selftest Core" in result + assert "{CORE_TEMPLATE}" not in result + + def test_extension_template_resolves_via_manifest_when_filename_differs(self, project_dir): + """provides.templates entries resolve via extension.yml when the file + doesn't sit at the conventional path. + + Regression coverage for #4010: manifest-declared templates/scripts + must actually be consulted by the resolver, not just accepted by + manifest validation. + """ + ext_dir = project_dir / ".specify" / "extensions" / "reportext" + tmpl_dir = ext_dir / "templates" / "nested" + tmpl_dir.mkdir(parents=True, exist_ok=True) + + # File lives at a path convention-based lookup (templates/.md) + # would never find. + (tmpl_dir / "actual.md").write_text("# Report Scaffold\n") + (ext_dir / "extension.yml").write_text( + "schema_version: '1.0'\n" + "extension:\n id: reportext\n name: Report Ext\n version: 1.0.0\n" + " description: test\n author: test\n repository: https://example.com\n" + " license: MIT\n" + "requires:\n speckit_version: '>=0.2.0'\n" + "provides:\n" + " templates:\n" + " - name: report-scaffold\n" + " file: templates/nested/actual.md\n" + " description: Report scaffold\n" + ) + + resolver = PresetResolver(project_dir) + layers = resolver.collect_all_layers("report-scaffold", "template") + assert layers, "expected the manifest-declared template to resolve" + assert layers[0]["path"] == tmpl_dir / "actual.md" + assert layers[0]["strategy"] == "replace" + + def test_extension_script_resolves_via_manifest_when_filename_differs(self, project_dir): + """provides.scripts entries resolve via extension.yml when the file + doesn't sit at the conventional path.""" + ext_dir = project_dir / ".specify" / "extensions" / "collectext" + script_dir = ext_dir / "scripts" / "bash" + script_dir.mkdir(parents=True, exist_ok=True) + + # File is under scripts/bash/, not directly under scripts/, so + # convention-based lookup (scripts/.sh) would never find it. + (script_dir / "collect.sh").write_text("#!/usr/bin/env bash\necho collect\n") + (ext_dir / "extension.yml").write_text( + "schema_version: '1.0'\n" + "extension:\n id: collectext\n name: Collect Ext\n version: 1.0.0\n" + " description: test\n author: test\n repository: https://example.com\n" + " license: MIT\n" + "requires:\n speckit_version: '>=0.2.0'\n" + "provides:\n" + " scripts:\n" + " - name: myext-collect\n" + " file: scripts/bash/collect.sh\n" + " description: Data-collection helper\n" + " runtimes: [bash]\n" + ) + + resolver = PresetResolver(project_dir) + layers = resolver.collect_all_layers("myext-collect", "script") + assert layers, "expected the manifest-declared script to resolve" + assert layers[0]["path"] == script_dir / "collect.sh" + assert layers[0]["strategy"] == "replace" + + def test_extension_template_convention_lookup_unaffected_when_undeclared(self, project_dir): + """An extension template with no manifest entry still resolves via + the pre-existing filename convention (no regression).""" + ext_dir = project_dir / ".specify" / "extensions" / "conventionext" + tmpl_dir = ext_dir / "templates" + tmpl_dir.mkdir(parents=True, exist_ok=True) + (tmpl_dir / "legacy-template.md").write_text("# Legacy Template\n") + # No extension.yml at all -- purely convention-based, unregistered extension. + + resolver = PresetResolver(project_dir) + layers = resolver.collect_all_layers("legacy-template", "template") + assert layers, "expected convention-based lookup to still find the template" + assert layers[0]["path"] == tmpl_dir / "legacy-template.md" + + def test_extension_manifest_wins_over_stale_conventional_file(self, project_dir): + """A declared entry is authoritative even when a stale file also sits at + the conventional path (templates/.md) — the manifest must win, + not the convention lookup, per #4010's acceptance criteria.""" + ext_dir = project_dir / ".specify" / "extensions" / "bothpathsext" + (ext_dir / "templates").mkdir(parents=True, exist_ok=True) + (ext_dir / "custom").mkdir(parents=True, exist_ok=True) + + # Stale file at the conventional path -- must NOT win. + (ext_dir / "templates" / "report-scaffold.md").write_text("# Stale\n") + # Declared file at a non-conventional path -- must win. + (ext_dir / "custom" / "bar.md").write_text("# Actual\n") + (ext_dir / "extension.yml").write_text( + "schema_version: '1.0'\n" + "extension:\n id: bothpathsext\n name: Both Paths Ext\n version: 1.0.0\n" + " description: test\n author: test\n repository: https://example.com\n" + " license: MIT\n" + "requires:\n speckit_version: '>=0.2.0'\n" + "provides:\n" + " templates:\n" + " - name: report-scaffold\n" + " file: custom/bar.md\n" + " description: Report scaffold\n" + ) + + resolver = PresetResolver(project_dir) + + layers = resolver.collect_all_layers("report-scaffold", "template") + assert layers, "expected the manifest-declared template to resolve" + assert layers[0]["path"] == ext_dir / "custom" / "bar.md" + + resolved = resolver.resolve("report-scaffold", "template") + assert resolved == ext_dir / "custom" / "bar.md" + + with_source = resolver.resolve_with_source("report-scaffold", "template") + assert with_source["path"] == str(ext_dir / "custom" / "bar.md") + + def test_extension_manifest_declared_but_missing_file_does_not_fall_back(self, project_dir): + """A declared entry whose file is missing is authoritative -- the + resolver must not silently mask the typo by falling back to a + conventional file that happens to also exist.""" + ext_dir = project_dir / ".specify" / "extensions" / "missingfileext" + (ext_dir / "scripts").mkdir(parents=True, exist_ok=True) + + # A conventional file exists, but the manifest declares a different, + # non-existent file for the same name. + (ext_dir / "scripts" / "myext-collect.sh").write_text("#!/usr/bin/env bash\necho legacy\n") + (ext_dir / "extension.yml").write_text( + "schema_version: '1.0'\n" + "extension:\n id: missingfileext\n name: Missing File Ext\n version: 1.0.0\n" + " description: test\n author: test\n repository: https://example.com\n" + " license: MIT\n" + "requires:\n speckit_version: '>=0.2.0'\n" + "provides:\n" + " scripts:\n" + " - name: myext-collect\n" + " file: scripts/does-not-exist.sh\n" + " description: Data-collection helper\n" + ) + + resolver = PresetResolver(project_dir) + + assert resolver.collect_all_layers("myext-collect", "script") == [] + assert resolver.resolve("myext-collect", "script") is None + + def test_extension_script_resolve_and_resolve_with_source_parity(self, project_dir): + """resolve() and resolve_with_source() must find a manifest-declared + script at a non-conventional path, matching collect_all_layers().""" + ext_dir = project_dir / ".specify" / "extensions" / "collectext2" + script_dir = ext_dir / "scripts" / "bash" + script_dir.mkdir(parents=True, exist_ok=True) + + (script_dir / "collect.sh").write_text("#!/usr/bin/env bash\necho collect\n") + (ext_dir / "extension.yml").write_text( + "schema_version: '1.0'\n" + "extension:\n id: collectext2\n name: Collect Ext 2\n version: 1.0.0\n" + " description: test\n author: test\n repository: https://example.com\n" + " license: MIT\n" + "requires:\n speckit_version: '>=0.2.0'\n" + "provides:\n" + " scripts:\n" + " - name: myext-collect2\n" + " file: scripts/bash/collect.sh\n" + " description: Data-collection helper\n" + " runtimes: [bash]\n" + ) + + resolver = PresetResolver(project_dir) + + resolved = resolver.resolve("myext-collect2", "script") + assert resolved == script_dir / "collect.sh" + + with_source = resolver.resolve_with_source("myext-collect2", "script") + assert with_source is not None + assert with_source["path"] == str(script_dir / "collect.sh") + assert with_source["source"] == "extension:collectext2 (unregistered)" + + +class TestResolveContent: + """Test PresetResolver.resolve_content() composition.""" + + def test_resolve_content_core_template(self, project_dir): + """Test resolve_content returns core template when no composition.""" + resolver = PresetResolver(project_dir) + content = resolver.resolve_content("spec-template") + assert content is not None + assert "Core Spec Template" in content + + def test_resolve_content_nonexistent(self, project_dir): + """Test resolve_content returns None for nonexistent template.""" + resolver = PresetResolver(project_dir) + content = resolver.resolve_content("nonexistent") + assert content is None + + def test_resolve_content_unreadable_winning_layer_returns_none(self, project_dir): + """An undecodable winning layer must yield None, not a raw traceback. + + ``collect_all_layers`` deliberately keeps a non-UTF-8 legacy command + layer (with its ``replace`` default) so unrelated commands still + resolve. ``resolve_content`` then read that same file without a + boundary, so the tolerated layer crashed with ``UnicodeDecodeError`` + at composition time — reachable from ``specify preset add`` via + ``_register_commands``. The documented contract is "Composed content + string, or None if not found". + """ + presets_dir = project_dir / ".specify" / "presets" + command_path = ( + presets_dir / "legacy-pack" / "commands" / "speckit.legacy.md" + ) + command_path.parent.mkdir(parents=True) + command_path.write_bytes(b"\xff\xfe") + PresetRegistry(presets_dir).add( + "legacy-pack", {"version": "1.0.0", "priority": 10} + ) + + resolver = PresetResolver(project_dir) + content = resolver.resolve_content("speckit.legacy", "command") + assert content is None + + def test_resolve_content_unreadable_base_under_composing_layer( + self, project_dir, temp_dir, valid_pack_data + ): + """An undecodable base beneath a valid composing layer yields None. + + Covers the base-read guard: the winning layer composes (append), so + resolution reads the base layer beneath it — here the core template, + corrupted to non-UTF-8 — and must return None instead of crashing. + """ + pack_data = {**valid_pack_data} + pack_data["preset"] = {**valid_pack_data["preset"], "id": "append-pack", "name": "Append"} + pack_data["provides"] = { + "templates": [{ + "type": "template", + "name": "spec-template", + "file": "templates/spec-template.md", + "strategy": "append", + }] + } + pack_dir = temp_dir / "append-pack" + pack_dir.mkdir() + with open(pack_dir / "preset.yml", 'w') as f: + yaml.dump(pack_data, f) + (pack_dir / "templates").mkdir() + (pack_dir / "templates" / "spec-template.md").write_text("## Appended Section\n") + + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.5") + + core_spec = project_dir / ".specify" / "templates" / "spec-template.md" + core_spec.write_bytes(b"\xff\xfe") + + resolver = PresetResolver(project_dir) + assert resolver.resolve_content("spec-template") is None + + def test_resolve_content_unreadable_composing_layer( + self, project_dir, temp_dir, valid_pack_data, monkeypatch + ): + """An unreadable composing layer over a valid base yields None. + + Covers the composition-loop read and the ``OSError`` half of the + boundary: the base (core template) reads fine, but the append layer + raises a mocked ``PermissionError`` — mocked so the case also holds + under privileged CI where permission bits are not enforced. + """ + pack_data = {**valid_pack_data} + pack_data["preset"] = {**valid_pack_data["preset"], "id": "append-pack", "name": "Append"} + pack_data["provides"] = { + "templates": [{ + "type": "template", + "name": "spec-template", + "file": "templates/spec-template.md", + "strategy": "append", + }] + } + pack_dir = temp_dir / "append-pack" + pack_dir.mkdir() + with open(pack_dir / "preset.yml", 'w') as f: + yaml.dump(pack_data, f) + (pack_dir / "templates").mkdir() + (pack_dir / "templates" / "spec-template.md").write_text("## Appended Section\n") + + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.5") + + layer_path = ( + project_dir / ".specify" / "presets" / "append-pack" + / "templates" / "spec-template.md" + ) + assert layer_path.is_file() + original_read_text = Path.read_text + + def failing_read_text(self_path, *args, **kwargs): + if self_path == layer_path: + raise PermissionError(13, "Permission denied") + return original_read_text(self_path, *args, **kwargs) + + monkeypatch.setattr(Path, "read_text", failing_read_text) + + resolver = PresetResolver(project_dir) + assert resolver.resolve_content("spec-template") is None + + def test_resolve_content_replace_strategy(self, project_dir, temp_dir, valid_pack_data): + """Test resolve_content with default replace strategy.""" + manager = PresetManager(project_dir) + manager.install_from_directory( + _create_pack(temp_dir, valid_pack_data, "replace-pack", + "# Replaced Content\n"), + "0.1.5" + ) + + resolver = PresetResolver(project_dir) + content = resolver.resolve_content("spec-template") + assert content is not None + assert "Replaced Content" in content + assert "Core Spec Template" not in content + + def test_resolve_content_append_strategy(self, project_dir, temp_dir, valid_pack_data): + """Test resolve_content with append strategy.""" + pack_data = {**valid_pack_data} + pack_data["preset"] = {**valid_pack_data["preset"], "id": "append-pack", "name": "Append"} + pack_data["provides"] = { + "templates": [{ + "type": "template", + "name": "spec-template", + "file": "templates/spec-template.md", + "strategy": "append", + }] + } + pack_dir = temp_dir / "append-pack" + pack_dir.mkdir() + with open(pack_dir / "preset.yml", 'w') as f: + yaml.dump(pack_data, f) + (pack_dir / "templates").mkdir() + (pack_dir / "templates" / "spec-template.md").write_text("## Appended Section\n") + + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.5") + + resolver = PresetResolver(project_dir) + content = resolver.resolve_content("spec-template") + assert content is not None + assert "Core Spec Template" in content + assert "Appended Section" in content + # Core should come first, appended after + assert content.index("Core Spec Template") < content.index("Appended Section") + + def test_resolve_content_prepend_strategy(self, project_dir, temp_dir, valid_pack_data): + """Test resolve_content with prepend strategy.""" + pack_data = {**valid_pack_data} + pack_data["preset"] = {**valid_pack_data["preset"], "id": "prepend-pack", "name": "Prepend"} + pack_data["provides"] = { + "templates": [{ + "type": "template", + "name": "spec-template", + "file": "templates/spec-template.md", + "strategy": "prepend", + }] + } + pack_dir = temp_dir / "prepend-pack" + pack_dir.mkdir() + with open(pack_dir / "preset.yml", 'w') as f: + yaml.dump(pack_data, f) + (pack_dir / "templates").mkdir() + (pack_dir / "templates" / "spec-template.md").write_text("## Security Header\n") + + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.5") + + resolver = PresetResolver(project_dir) + content = resolver.resolve_content("spec-template") + assert content is not None + assert "Security Header" in content + assert "Core Spec Template" in content + # Prepended content should come first + assert content.index("Security Header") < content.index("Core Spec Template") + + def test_resolve_content_wrap_strategy(self, project_dir, temp_dir, valid_pack_data): + """Test resolve_content with wrap strategy for templates.""" + pack_data = {**valid_pack_data} + pack_data["preset"] = {**valid_pack_data["preset"], "id": "wrap-pack", "name": "Wrap"} + pack_data["provides"] = { + "templates": [{ + "type": "template", + "name": "spec-template", + "file": "templates/spec-template.md", + "strategy": "wrap", + }] + } + pack_dir = temp_dir / "wrap-pack" + pack_dir.mkdir() + with open(pack_dir / "preset.yml", 'w') as f: + yaml.dump(pack_data, f) + (pack_dir / "templates").mkdir() + (pack_dir / "templates" / "spec-template.md").write_text( + "# Wrapper Start\n\n{CORE_TEMPLATE}\n\n# Wrapper End\n" + ) + + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.5") + + resolver = PresetResolver(project_dir) + content = resolver.resolve_content("spec-template") + assert content is not None + assert "Wrapper Start" in content + assert "Core Spec Template" in content + assert "Wrapper End" in content + # Wrapper should surround core + assert content.index("Wrapper Start") < content.index("Core Spec Template") + assert content.index("Core Spec Template") < content.index("Wrapper End") + + def test_resolve_content_wrap_strategy_script(self, project_dir, temp_dir, valid_pack_data): + """Test resolve_content with wrap strategy for scripts uses $CORE_SCRIPT.""" + # Create core script + scripts_dir = project_dir / ".specify" / "templates" / "scripts" + scripts_dir.mkdir(parents=True, exist_ok=True) + (scripts_dir / "test-script.sh").write_text("echo 'core script'\n") + + pack_data = {**valid_pack_data} + pack_data["preset"] = {**valid_pack_data["preset"], "id": "script-wrap", "name": "Script Wrap"} + pack_data["provides"] = { + "templates": [{ + "type": "script", + "name": "test-script", + "file": "scripts/test-script.sh", + "strategy": "wrap", + }] + } + pack_dir = temp_dir / "script-wrap" + pack_dir.mkdir() + with open(pack_dir / "preset.yml", 'w') as f: + yaml.dump(pack_data, f) + (pack_dir / "scripts").mkdir() + (pack_dir / "scripts" / "test-script.sh").write_text( + "#!/bin/bash\necho 'before'\n$CORE_SCRIPT\necho 'after'\n" + ) + + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.5") + + resolver = PresetResolver(project_dir) + content = resolver.resolve_content("test-script", "script") + assert content is not None + assert "echo 'before'" in content + assert "echo 'core script'" in content + assert "echo 'after'" in content + + def test_resolve_content_multi_preset_chain(self, project_dir, temp_dir, valid_pack_data): + """Test multi-preset composition chain: prepend + append stacking.""" + # Create preset A (priority 1): prepend security header + pack_a_data = {**valid_pack_data} + pack_a_data["preset"] = {**valid_pack_data["preset"], "id": "preset-a", "name": "A"} + pack_a_data["provides"] = { + "templates": [{ + "type": "template", + "name": "spec-template", + "file": "templates/spec-template.md", + "strategy": "prepend", + }] + } + pack_a_dir = temp_dir / "preset-a" + pack_a_dir.mkdir() + with open(pack_a_dir / "preset.yml", 'w') as f: + yaml.dump(pack_a_data, f) + (pack_a_dir / "templates").mkdir() + (pack_a_dir / "templates" / "spec-template.md").write_text("## Security Header\n") + + # Create preset B (priority 2): append compliance footer + pack_b_data = {**valid_pack_data} + pack_b_data["preset"] = {**valid_pack_data["preset"], "id": "preset-b", "name": "B"} + pack_b_data["provides"] = { + "templates": [{ + "type": "template", + "name": "spec-template", + "file": "templates/spec-template.md", + "strategy": "append", + }] + } + pack_b_dir = temp_dir / "preset-b" + pack_b_dir.mkdir() + with open(pack_b_dir / "preset.yml", 'w') as f: + yaml.dump(pack_b_data, f) + (pack_b_dir / "templates").mkdir() + (pack_b_dir / "templates" / "spec-template.md").write_text("## Compliance Footer\n") + + manager = PresetManager(project_dir) + manager.install_from_directory(pack_a_dir, "0.1.5", priority=1) + manager.install_from_directory(pack_b_dir, "0.1.5", priority=2) + + resolver = PresetResolver(project_dir) + content = resolver.resolve_content("spec-template") + assert content is not None + # Result: + + + assert "Security Header" in content + assert "Core Spec Template" in content + assert "Compliance Footer" in content + assert content.index("Security Header") < content.index("Core Spec Template") + assert content.index("Core Spec Template") < content.index("Compliance Footer") + + def test_resolve_content_override_trumps_composition(self, project_dir, temp_dir, valid_pack_data): + """Test that project overrides trump composition (replace at top priority).""" + # Install a composing preset + pack_data = {**valid_pack_data} + pack_data["preset"] = {**valid_pack_data["preset"], "id": "append-pack", "name": "Append"} + pack_data["provides"] = { + "templates": [{ + "type": "template", + "name": "spec-template", + "file": "templates/spec-template.md", + "strategy": "append", + }] + } + pack_dir = temp_dir / "append-pack" + pack_dir.mkdir() + with open(pack_dir / "preset.yml", 'w') as f: + yaml.dump(pack_data, f) + (pack_dir / "templates").mkdir() + (pack_dir / "templates" / "spec-template.md").write_text("## Appended\n") + + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.5") + + # Create project override (replaces everything) + overrides_dir = project_dir / ".specify" / "templates" / "overrides" + overrides_dir.mkdir(parents=True) + (overrides_dir / "spec-template.md").write_text("# Override Only\n") + + resolver = PresetResolver(project_dir) + content = resolver.resolve_content("spec-template") + assert content is not None + assert "Override Only" in content + # Override replaces, so appended content should not be visible + assert "Core Spec Template" not in content + + def test_resolve_content_command_type(self, project_dir, temp_dir, valid_pack_data): + """Test resolve_content with command template type.""" + # Create core command using stem naming (matches real layout: plan.md, not speckit.plan.md) + commands_dir = project_dir / ".specify" / "templates" / "commands" + commands_dir.mkdir(parents=True, exist_ok=True) + (commands_dir / "plan.md").write_text("# Core Plan Command\n") + + pack_data = {**valid_pack_data} + pack_data["preset"] = {**valid_pack_data["preset"], "id": "cmd-append", "name": "CmdAppend"} + pack_data["provides"] = { + "templates": [{ + "type": "command", + "name": "speckit.plan", + "file": "commands/speckit.plan.md", + "strategy": "append", + }] + } + pack_dir = temp_dir / "cmd-append" + pack_dir.mkdir() + with open(pack_dir / "preset.yml", 'w') as f: + yaml.dump(pack_data, f) + (pack_dir / "commands").mkdir() + (pack_dir / "commands" / "speckit.plan.md").write_text("## Additional Instructions\n") + + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.5") + + resolver = PresetResolver(project_dir) + content = resolver.resolve_content("speckit.plan", "command") + assert content is not None + assert "Core Plan Command" in content + assert "Additional Instructions" in content + + def test_resolve_content_command_frontmatter_stripping(self, project_dir, temp_dir, valid_pack_data): + """Test that command composition strips frontmatter from lower layers + and reattaches only the highest-priority frontmatter.""" + # Create core command with frontmatter + commands_dir = project_dir / ".specify" / "templates" / "commands" + commands_dir.mkdir(parents=True, exist_ok=True) + (commands_dir / "check.md").write_text( + "---\ndescription: Core check command\n---\nCore body content\n" + ) + + pack_data = {**valid_pack_data} + pack_data["preset"] = {**valid_pack_data["preset"], "id": "fm-test", "name": "FmTest"} + pack_data["provides"] = { + "templates": [{ + "type": "command", + "name": "speckit.check", + "file": "commands/speckit.check.md", + "strategy": "append", + }] + } + pack_dir = temp_dir / "fm-test" + pack_dir.mkdir() + with open(pack_dir / "preset.yml", 'w') as f: + yaml.dump(pack_data, f) + (pack_dir / "commands").mkdir() + (pack_dir / "commands" / "speckit.check.md").write_text( + "---\ndescription: Preset check override\n---\nPreset body content\n" + ) + + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.5") + + resolver = PresetResolver(project_dir) + content = resolver.resolve_content("speckit.check", "command") + assert content is not None + # Should have the preset (highest-priority) frontmatter + assert "Preset check override" in content + # Should have both bodies + assert "Core body content" in content + assert "Preset body content" in content + # Core frontmatter should NOT appear in the body + assert content.count("---") == 2 # only one frontmatter block (opening + closing) + + def test_resolve_content_blank_line_separator(self, project_dir, temp_dir, valid_pack_data): + """Test that prepend/append use blank line separator.""" + pack_data = {**valid_pack_data} + pack_data["preset"] = {**valid_pack_data["preset"], "id": "sep-test", "name": "SepTest"} + pack_data["provides"] = { + "templates": [{ + "type": "template", + "name": "spec-template", + "file": "templates/spec-template.md", + "strategy": "append", + }] + } + pack_dir = temp_dir / "sep-test" + pack_dir.mkdir() + with open(pack_dir / "preset.yml", 'w') as f: + yaml.dump(pack_data, f) + (pack_dir / "templates").mkdir() + (pack_dir / "templates" / "spec-template.md").write_text("appended") + + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.5") + + resolver = PresetResolver(project_dir) + content = resolver.resolve_content("spec-template") + # Should have blank line separator + assert "\n\n" in content + + def test_resolve_content_replace_over_wrap(self, project_dir, temp_dir, valid_pack_data): + """Top-priority replace layer should win even if a lower layer uses wrap.""" + # Install a low-priority wrap preset (with no placeholder — would fail if evaluated) + wrap_data = {**valid_pack_data} + wrap_data["preset"] = {**valid_pack_data["preset"], "id": "wrap-lo", "name": "WrapLo"} + wrap_data["provides"] = { + "templates": [{ + "type": "template", + "name": "spec-template", + "file": "templates/spec-template.md", + "strategy": "wrap", + }] + } + wrap_dir = temp_dir / "wrap-lo" + wrap_dir.mkdir() + with open(wrap_dir / "preset.yml", "w") as f: + yaml.dump(wrap_data, f) + (wrap_dir / "templates").mkdir() + # Intentionally missing {CORE_TEMPLATE} — would error if composition ran + (wrap_dir / "templates" / "spec-template.md").write_text("wrapper without placeholder") + + manager = PresetManager(project_dir) + manager.install_from_directory(wrap_dir, "0.1.5", priority=10) + + # Install a high-priority replace preset + rep_data = {**valid_pack_data} + rep_data["preset"] = {**valid_pack_data["preset"], "id": "rep-hi", "name": "RepHi"} + rep_data["provides"] = { + "templates": [{ + "type": "template", + "name": "spec-template", + "file": "templates/spec-template.md", + }] + } + rep_dir = temp_dir / "rep-hi" + rep_dir.mkdir() + with open(rep_dir / "preset.yml", "w") as f: + yaml.dump(rep_data, f) + (rep_dir / "templates").mkdir() + (rep_dir / "templates" / "spec-template.md").write_text("# Replaced content\n") + + manager.install_from_directory(rep_dir, "0.1.5", priority=1) + + resolver = PresetResolver(project_dir) + content = resolver.resolve_content("spec-template") + assert content == "# Replaced content\n" + + @pytest.mark.parametrize("strategy", ["append", "prepend", "wrap"]) + def test_resolve_content_rewrites_extension_base_subdir_paths( + self, project_dir, temp_dir, strategy + ): + """Composing over an extension-provided base command must resolve the + extension's own subdir references (agents/, knowledge-base/) to their + installed location (#2101), not just when the extension wins outright. + """ + extension_dir = project_dir / ".specify" / "extensions" / "fakeext" + (extension_dir / "commands").mkdir(parents=True, exist_ok=True) + (extension_dir / "agents" / "control").mkdir(parents=True, exist_ok=True) + (extension_dir / "agents" / "control" / "commander.md").write_text("# Commander\n") + (extension_dir / "commands" / "cmd.md").write_text( + "---\ndescription: Extension fakeext cmd\n---\n\n" + "Read agents/control/commander.md for context.\n" + ) + extension_manifest = { + "schema_version": "1.0", + "extension": { + "id": "fakeext", + "name": "Fake Extension", + "version": "1.0.0", + "description": "Test", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "commands": [ + { + "name": "speckit.fakeext.cmd", + "file": "commands/cmd.md", + "description": "Fake extension command", + } + ] + }, + } + with open(extension_dir / "extension.yml", "w") as f: + yaml.dump(extension_manifest, f) + + preset_dir = temp_dir / f"ext-base-{strategy}" + preset_dir.mkdir() + (preset_dir / "commands").mkdir() + overlay_body = ( + "{CORE_TEMPLATE}\n## Extra\n" if strategy == "wrap" else "## Extra\n" + ) + (preset_dir / "commands" / "speckit.fakeext.cmd.md").write_text( + f"---\ndescription: Preset overlay\n---\n\n{overlay_body}" + ) + preset_manifest = { + "schema_version": "1.0", + "preset": { + "id": f"ext-base-{strategy}", + "name": "Ext Base", + "version": "1.0.0", + "description": "Test", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "templates": [ + { + "type": "command", + "name": "speckit.fakeext.cmd", + "file": "commands/speckit.fakeext.cmd.md", + "strategy": strategy, + } + ] + }, + } + with open(preset_dir / "preset.yml", "w") as f: + yaml.dump(preset_manifest, f) + + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "0.1.5") + + resolver = PresetResolver(project_dir) + content = resolver.resolve_content("speckit.fakeext.cmd", "command") + assert content is not None + assert ".specify/extensions/fakeext/agents/control/commander.md" in content + assert "Read agents/control" not in content + assert "## Extra" in content + + +class TestCollectAllLayers: + """Test PresetResolver.collect_all_layers() method.""" + + def test_non_utf8_legacy_command_keeps_replace_strategy(self, project_dir): + presets_dir = project_dir / ".specify" / "presets" + command_path = ( + presets_dir / "legacy-pack" / "commands" / "speckit.legacy.md" + ) + command_path.parent.mkdir(parents=True) + command_path.write_bytes(b"\xff\xfe") + PresetRegistry(presets_dir).add( + "legacy-pack", {"version": "1.0.0", "priority": 10} + ) + + layers = PresetResolver(project_dir).collect_all_layers( + "speckit.legacy", "command" + ) + + assert layers[0]["path"] == command_path + assert layers[0]["strategy"] == "replace" + + def test_single_core_layer(self, project_dir): + """Test collecting layers with only core template.""" + resolver = PresetResolver(project_dir) + layers = resolver.collect_all_layers("spec-template") + assert len(layers) == 1 + assert layers[0]["source"] == "core" + assert layers[0]["strategy"] == "replace" + + def test_layers_include_presets(self, project_dir, temp_dir, valid_pack_data): + """Test that layers include installed preset.""" + manager = PresetManager(project_dir) + pack_dir = _create_pack(temp_dir, valid_pack_data, "test-pack", + "# From Pack\n") + manager.install_from_directory(pack_dir, "0.1.5") + + resolver = PresetResolver(project_dir) + layers = resolver.collect_all_layers("spec-template") + assert len(layers) == 2 + # Highest priority first + assert "test-pack" in layers[0]["source"] + assert layers[1]["source"] == "core" + + def test_layers_order_matches_priority(self, project_dir, temp_dir, valid_pack_data): + """Test that layers are ordered by priority (highest first).""" + manager = PresetManager(project_dir) + for pid, prio in [("pack-lo", 10), ("pack-hi", 1)]: + d = {**valid_pack_data} + d["preset"] = {**valid_pack_data["preset"], "id": pid, "name": pid} + p = temp_dir / pid + p.mkdir() + with open(p / "preset.yml", 'w') as f: + yaml.dump(d, f) + (p / "templates").mkdir() + (p / "templates" / "spec-template.md").write_text(f"# {pid}\n") + manager.install_from_directory(p, "0.1.5", priority=prio) + + resolver = PresetResolver(project_dir) + layers = resolver.collect_all_layers("spec-template") + assert len(layers) == 3 # pack-hi, pack-lo, core + assert "pack-hi" in layers[0]["source"] + assert "pack-lo" in layers[1]["source"] + assert layers[2]["source"] == "core" + + def test_layers_read_strategy_from_manifest(self, project_dir, temp_dir, valid_pack_data): + """Test that layers read strategy from preset manifest.""" + pack_data = {**valid_pack_data} + pack_data["preset"] = {**valid_pack_data["preset"], "id": "strat-pack", "name": "Strat"} + pack_data["provides"] = { + "templates": [{ + "type": "template", + "name": "spec-template", + "file": "templates/spec-template.md", + "strategy": "append", + }] + } + pack_dir = temp_dir / "strat-pack" + pack_dir.mkdir() + with open(pack_dir / "preset.yml", 'w') as f: + yaml.dump(pack_data, f) + (pack_dir / "templates").mkdir() + (pack_dir / "templates" / "spec-template.md").write_text("## Footer\n") + + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.5") + + resolver = PresetResolver(project_dir) + layers = resolver.collect_all_layers("spec-template") + # Preset layer should have strategy=append + assert layers[0]["strategy"] == "append" + # Core layer should be replace + assert layers[1]["strategy"] == "replace" + + +def _create_pack(temp_dir, valid_pack_data, pack_id, content, + strategy="replace", template_type="template", + template_name="spec-template"): + """Helper to create a preset pack directory.""" + pack_data = {**valid_pack_data} + pack_data["preset"] = {**valid_pack_data["preset"], "id": pack_id, "name": pack_id} + + tmpl_entry = { + "type": template_type, + "name": template_name, + } + if template_type == "script": + tmpl_entry["file"] = f"scripts/{template_name}.sh" + elif template_type == "command": + tmpl_entry["file"] = f"commands/{template_name}.md" + else: + tmpl_entry["file"] = f"templates/{template_name}.md" + if strategy != "replace": + tmpl_entry["strategy"] = strategy + pack_data["provides"] = {"templates": [tmpl_entry]} + + pack_dir = temp_dir / pack_id + pack_dir.mkdir(exist_ok=True) + with open(pack_dir / "preset.yml", 'w') as f: + yaml.dump(pack_data, f) + + if template_type == "script": + subdir = pack_dir / "scripts" + subdir.mkdir(exist_ok=True) + (subdir / f"{template_name}.sh").write_text(content) + elif template_type == "command": + subdir = pack_dir / "commands" + subdir.mkdir(exist_ok=True) + (subdir / f"{template_name}.md").write_text(content) + else: + subdir = pack_dir / "templates" + subdir.mkdir(exist_ok=True) + (subdir / f"{template_name}.md").write_text(content) + + return pack_dir diff --git a/tests/test_extension_skills.py b/tests/test_extension_skills.py index 3622fd994e..3f65585a30 100644 --- a/tests/test_extension_skills.py +++ b/tests/test_extension_skills.py @@ -1717,7 +1717,7 @@ def test_toggle_command_to_skills_preserves_old_extension_command_on_skills_fail Mirrors the analogous preset-side fix (``test_toggle_command_to_skills_preserves_old_command_on_skills_failure`` - in ``tests/test_presets.py``): before the fix, the stale + in ``tests/specify_cli/presets/test_manager_commands.py``): before the fix, the stale command-mode file/tracking was unregistered unconditionally as soon as the commands phase was skipped for ``skills_mode_active``, regardless of whether the subsequent, independently-fallible diff --git a/tests/test_presets.py b/tests/test_presets.py index a7e590b071..11f19f6b91 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -1,48 +1,27 @@ -""" -Unit tests for the preset system. - -Tests cover: -- Preset manifest validation -- Preset registry operations -- Preset manager installation/removal -- Template catalog search -- Template resolver priority stack -- Extension-provided templates +"""Cross-domain preset workflows and bundled-content contracts. + +Domain unit tests mirror src/specify_cli/presets/_*.py under +tests/specify_cli/presets/test_*.py; command tests live alongside them. """ -import pytest -import io import json -import tarfile -import shutil import zipfile -from contextlib import contextmanager from pathlib import Path -from datetime import datetime, timezone -from unittest.mock import MagicMock +import pytest import yaml +from specify_cli.extensions import ExtensionRegistry from specify_cli.presets import ( - PresetManifest, - PresetRegistry, PresetManager, - PresetCatalog, - PresetCatalogEntry, + PresetManifest, PresetResolver, - PresetError, - PresetValidationError, - PresetCompatibilityError, - VALID_PRESET_TEMPLATE_TYPES, ) -from specify_cli.extensions import ExtensionRegistry from tests.specify_cli.presets import _fixtures from tests.specify_cli.presets._helpers import ( CORE_TEMPLATE_NAMES, SELF_TEST_PRESET_DIR, - install_constitution_sync_preset, install_self_test_preset, - make_convention_constitution_preset as _make_convention_constitution_preset, ) temp_dir = _fixtures.temp_dir @@ -51,819 +30,70 @@ project_dir = _fixtures.project_dir -# ===== PresetManifest Tests ===== - - -class TestPresetManifest: - """Test PresetManifest validation and parsing.""" +class TestIntegration: + """Integration tests for complete preset workflows.""" - def test_valid_manifest(self, pack_dir): - """Test loading a valid manifest.""" - manifest = PresetManifest(pack_dir / "preset.yml") + def test_full_install_resolve_remove_cycle(self, project_dir, pack_dir): + """Test complete lifecycle: install → resolve → remove.""" + # Install + manager = PresetManager(project_dir) + manifest = manager.install_from_directory(pack_dir, "0.1.5") assert manifest.id == "test-pack" - assert manifest.name == "Test Preset" - assert manifest.version == "1.0.0" - assert manifest.description == "A test preset" - assert manifest.author == "Test Author" - assert manifest.requires_speckit_version == ">=0.1.0" - assert len(manifest.templates) == 1 - assert manifest.tags == ["testing", "example"] - - def test_missing_manifest(self, temp_dir): - """Test that missing manifest raises error.""" - with pytest.raises(PresetValidationError, match="Manifest not found"): - PresetManifest(temp_dir / "nonexistent.yml") - - def test_invalid_yaml(self, temp_dir): - """Test that invalid YAML raises error.""" - bad_file = temp_dir / "bad.yml" - bad_file.write_text(": invalid: yaml: {{{") - with pytest.raises(PresetValidationError, match="Invalid YAML"): - PresetManifest(bad_file) - - def test_utf8_non_ascii_description_loads(self, temp_dir, valid_pack_data): - """Regression for #2325: non-ASCII (UTF-8) description loads on any platform. - - On Windows, Python's default text-mode encoding is the locale codepage - (e.g. cp1252/GBK), which raises UnicodeDecodeError on UTF-8 bytes - outside the ASCII range. The loader must open with encoding='utf-8'. - """ - valid_pack_data["preset"]["description"] = "中文测试 — émojis 🚀" - manifest_path = temp_dir / "preset.yml" - manifest_path.write_bytes( - yaml.safe_dump(valid_pack_data, allow_unicode=True).encode("utf-8") - ) - - manifest = PresetManifest(manifest_path) - assert manifest.description == "中文测试 — émojis 🚀" - - def test_invalid_utf8_bytes_raises_validation_error(self, temp_dir): - """Negative case: file containing invalid UTF-8 bytes raises PresetValidationError, not raw UnicodeDecodeError.""" - manifest_path = temp_dir / "preset.yml" - manifest_path.write_bytes(b"\xff\xfe not valid utf-8 \xff\n") - - with pytest.raises(PresetValidationError, match="not valid UTF-8"): - PresetManifest(manifest_path) - - def test_non_mapping_yaml_raises_validation_error(self, temp_dir): - """Manifest whose YAML root is a scalar or list raises PresetValidationError, not TypeError.""" - manifest_path = temp_dir / "preset.yml" - for bad_content in ("42\n", "[1, 2]\n"): - manifest_path.write_text(bad_content, encoding="utf-8") - with pytest.raises(PresetValidationError, match="YAML mapping"): - PresetManifest(manifest_path) - - @pytest.mark.parametrize("section", ["preset", "requires", "provides"]) - @pytest.mark.parametrize("bad_value", [None, [], "text"]) - def test_required_section_not_mapping_raises_validation_error( - self, temp_dir, valid_pack_data, section, bad_value - ): - """Required manifest sections reject null, list, and scalar values.""" - valid_pack_data[section] = bad_value - manifest_path = temp_dir / "preset.yml" - manifest_path.write_text( - yaml.safe_dump(valid_pack_data), - encoding="utf-8", - ) - - with pytest.raises( - PresetValidationError, - match=rf"Invalid {section}: expected a mapping", - ): - PresetManifest(manifest_path) - - @pytest.mark.parametrize("field", ["id", "name", "version", "description"]) - @pytest.mark.parametrize("bad", [1.0, 5, None, ["a"], {"a": 1}, True]) - def test_preset_metadata_field_not_string_raises_validation_error( - self, temp_dir, valid_pack_data, field, bad - ): - """A non-string preset. raises PresetValidationError, not a raw - TypeError. - - The loop over these four fields only checked key PRESENCE, then fed the - values to ``re.match`` (id) and ``packaging.Version`` (version), both of - which raise a bare TypeError on a non-string. YAML makes that an easy - authoring slip: unquoted ``version: 1.0`` parses as a float and ``id: 2`` - as an int. TypeError is not a PresetValidationError, so it escaped - list_installed()'s "Corrupted preset" fallback and made - `specify preset list` exit 1 with a raw traceback, hiding every healthy - preset too. The sibling IntegrationDescriptor already type-checks the - same four fields. - """ - valid_pack_data["preset"][field] = bad - manifest_path = temp_dir / "preset.yml" - manifest_path.write_text(yaml.safe_dump(valid_pack_data), encoding="utf-8") - - with pytest.raises( - PresetValidationError, - match=rf"Invalid preset\.{field}: expected a string", - ): - PresetManifest(manifest_path) - - @pytest.mark.parametrize("field", ["name", "file"]) - @pytest.mark.parametrize("bad", [1.0, 5, None, ["a"], {"a": 1}, True]) - def test_template_entry_field_not_string_raises_validation_error( - self, temp_dir, valid_pack_data, field, bad - ): - """A non-string template ``name``/``file`` raises PresetValidationError. - - ``name`` reaches ``re.match`` and ``file`` reaches ``os.path.normpath``; - both raise a bare TypeError on a non-string. The sibling extension - manifest already rejects a non-string command ``file`` via - relative_extension_path_violation(). - """ - valid_pack_data["provides"]["templates"][0][field] = bad - manifest_path = temp_dir / "preset.yml" - manifest_path.write_text(yaml.safe_dump(valid_pack_data), encoding="utf-8") - - with pytest.raises( - PresetValidationError, - match=rf"Invalid template {field}: expected a string", - ): - PresetManifest(manifest_path) - - def test_one_bad_manifest_does_not_hide_healthy_presets(self, temp_dir): - """End-to-end guard for the symptom: an unquoted ``version: 1.0`` in one - installed preset must degrade to "Corrupted preset" and still let - list_installed() report the healthy ones, instead of raising TypeError - out of the whole call. - """ - preset_root = temp_dir / ".specify" / "presets" - for pack_id, version in (("good-pack", '"1.0.0"'), ("bad-pack", "1.0")): - pack_path = preset_root / pack_id - pack_path.mkdir(parents=True, exist_ok=True) - (pack_path / "preset.yml").write_text( - f"""schema_version: "1.0" -preset: - id: {pack_id} - name: {pack_id} - version: {version} - description: desc -requires: - speckit_version: ">=0.1.0" -provides: - templates: - - type: template - name: spec - file: templates/spec.md -""", - encoding="utf-8", - ) - (preset_root / ".registry").write_text( - json.dumps( - { - "schema_version": "1.0", - "presets": { - "good-pack": {"version": "1.0.0", "enabled": True}, - "bad-pack": {"version": "1.0", "enabled": True}, - }, - } - ), - encoding="utf-8", - ) - listed = {row["id"]: row for row in PresetManager(temp_dir).list_installed()} - - assert set(listed) == {"good-pack", "bad-pack"} - assert "Corrupted" not in listed["good-pack"]["description"] - assert "Corrupted" in listed["bad-pack"]["description"] - - @pytest.mark.parametrize( - "bad", - [ - 5, "oops", {"a": 1}, # truthy non-lists - 0, False, None, "", {}, # FALSY non-lists: must not fall through to - # the misleading "at least one template" - ], - ) - def test_non_list_templates_raises_validation_error( - self, temp_dir, valid_pack_data, bad - ): - """A non-list provides.templates raises the accurate type error, not a raw - 'int object is not iterable' TypeError and not the misleading "must provide - at least one template" (which a falsy non-list hit while the type check - sat behind the emptiness check) — mirrors ExtensionManifest.""" - valid_pack_data["provides"]["templates"] = bad - manifest_path = temp_dir / "preset.yml" - manifest_path.write_text(yaml.dump(valid_pack_data), encoding="utf-8") - with pytest.raises(PresetValidationError, match="templates.*expected a list"): - PresetManifest(manifest_path) - - # NOTE: the empty-list case (a well-typed container with no templates, which - # must keep the "must provide at least one template" message after the - # type-before-emptiness reordering) is already covered by - # test_no_templates_provided below. - - @pytest.mark.parametrize("bad_entry", [None, 5, "oops", ["nested"]]) - def test_non_mapping_template_entry_raises_validation_error( - self, temp_dir, valid_pack_data, bad_entry - ): - """A non-mapping template entry (null/scalar/list) raises PresetValidationError, - not a raw 'argument of type ... is not iterable' TypeError from the - `"type" not in tmpl` membership test — mirrors ExtensionManifest.""" - valid_pack_data["provides"]["templates"] = [bad_entry] - manifest_path = temp_dir / "preset.yml" - manifest_path.write_text(yaml.dump(valid_pack_data), encoding="utf-8") - with pytest.raises(PresetValidationError, match="must be a mapping"): - PresetManifest(manifest_path) - - def test_missing_schema_version(self, temp_dir, valid_pack_data): - """Test missing schema_version field.""" - del valid_pack_data["schema_version"] - manifest_path = temp_dir / "preset.yml" - with open(manifest_path, 'w') as f: - yaml.dump(valid_pack_data, f) - with pytest.raises(PresetValidationError, match="Missing required field: schema_version"): - PresetManifest(manifest_path) - - def test_wrong_schema_version(self, temp_dir, valid_pack_data): - """Test unsupported schema version.""" - valid_pack_data["schema_version"] = "2.0" - manifest_path = temp_dir / "preset.yml" - with open(manifest_path, 'w') as f: - yaml.dump(valid_pack_data, f) - with pytest.raises(PresetValidationError, match="Unsupported schema version"): - PresetManifest(manifest_path) - - def test_missing_pack_id(self, temp_dir, valid_pack_data): - """Test missing preset.id field.""" - del valid_pack_data["preset"]["id"] - manifest_path = temp_dir / "preset.yml" - with open(manifest_path, 'w') as f: - yaml.dump(valid_pack_data, f) - with pytest.raises(PresetValidationError, match="Missing preset.id"): - PresetManifest(manifest_path) - - def test_invalid_pack_id_format(self, temp_dir, valid_pack_data): - """Test invalid pack ID format.""" - valid_pack_data["preset"]["id"] = "Invalid_ID" - manifest_path = temp_dir / "preset.yml" - with open(manifest_path, 'w') as f: - yaml.dump(valid_pack_data, f) - with pytest.raises(PresetValidationError, match="Invalid preset ID"): - PresetManifest(manifest_path) - - def test_invalid_version(self, temp_dir, valid_pack_data): - """Test invalid semantic version.""" - valid_pack_data["preset"]["version"] = "not-a-version" - manifest_path = temp_dir / "preset.yml" - with open(manifest_path, 'w') as f: - yaml.dump(valid_pack_data, f) - with pytest.raises(PresetValidationError, match="Invalid version"): - PresetManifest(manifest_path) - - def test_missing_speckit_version(self, temp_dir, valid_pack_data): - """Test missing requires.speckit_version.""" - del valid_pack_data["requires"]["speckit_version"] - manifest_path = temp_dir / "preset.yml" - with open(manifest_path, 'w') as f: - yaml.dump(valid_pack_data, f) - with pytest.raises(PresetValidationError, match="Missing requires.speckit_version"): - PresetManifest(manifest_path) - - @pytest.mark.parametrize( - "bad", - [ - 1.0, # unquoted YAML float -- the likeliest authoring slip - 5, # unquoted int - True, # YAML `yes`/`true` - None, # `speckit_version:` written but left empty - [">=0.1.0"], # iterable: slips past SpecifierSet() entirely - {"min": "0.1"}, # iterable: same - " ", # blank string must not mean "any version" - ], - ) - def test_non_string_speckit_version(self, temp_dir, valid_pack_data, bad): - """A non-string requires.speckit_version must be a PresetValidationError. - - It was presence-checked only, so it reached ``SpecifierSet(required)`` in - check_compatibility(), which is guarded by ``except InvalidSpecifier`` - alone. A non-string escapes that guard two ways: scalars raise TypeError - from the constructor, and a list/dict is iterable so SpecifierSet accepts - it and the failure surfaces later as ``AttributeError: 'str' object has no - attribute 'filter'`` from inside .contains(). - """ - valid_pack_data["requires"]["speckit_version"] = bad - manifest_path = temp_dir / "preset.yml" - with open(manifest_path, 'w') as f: - yaml.dump(valid_pack_data, f) - with pytest.raises( - PresetValidationError, match="Invalid requires.speckit_version" - ): - PresetManifest(manifest_path) - - def test_no_templates_provided(self, temp_dir, valid_pack_data): - """Test pack with no templates.""" - valid_pack_data["provides"]["templates"] = [] - manifest_path = temp_dir / "preset.yml" - with open(manifest_path, 'w') as f: - yaml.dump(valid_pack_data, f) - with pytest.raises(PresetValidationError, match="must provide at least one template"): - PresetManifest(manifest_path) - - def test_invalid_template_type(self, temp_dir, valid_pack_data): - """Test template with invalid type.""" - valid_pack_data["provides"]["templates"][0]["type"] = "invalid" - manifest_path = temp_dir / "preset.yml" - with open(manifest_path, 'w') as f: - yaml.dump(valid_pack_data, f) - with pytest.raises(PresetValidationError, match="Invalid template type"): - PresetManifest(manifest_path) - - def test_valid_template_types(self): - """Test that all expected template types are valid.""" - assert "template" in VALID_PRESET_TEMPLATE_TYPES - assert "command" in VALID_PRESET_TEMPLATE_TYPES - assert "script" in VALID_PRESET_TEMPLATE_TYPES - - def test_template_missing_required_fields(self, temp_dir, valid_pack_data): - """Test template missing required fields.""" - valid_pack_data["provides"]["templates"] = [{"type": "template"}] - manifest_path = temp_dir / "preset.yml" - with open(manifest_path, 'w') as f: - yaml.dump(valid_pack_data, f) - with pytest.raises(PresetValidationError, match="missing 'type', 'name', or 'file'"): - PresetManifest(manifest_path) - - def test_invalid_template_name_format(self, temp_dir, valid_pack_data): - """Test template with invalid name format.""" - valid_pack_data["provides"]["templates"][0]["name"] = "Invalid Name" - manifest_path = temp_dir / "preset.yml" - with open(manifest_path, 'w') as f: - yaml.dump(valid_pack_data, f) - with pytest.raises(PresetValidationError, match="Invalid template name"): - PresetManifest(manifest_path) - - def test_get_hash(self, pack_dir): - """Test manifest hash calculation.""" - manifest = PresetManifest(pack_dir / "preset.yml") - hash_val = manifest.get_hash() - assert hash_val.startswith("sha256:") - import hashlib - content = (pack_dir / "preset.yml").read_bytes() - expected = f"sha256:{hashlib.sha256(content).hexdigest()}" - assert hash_val == expected - - def test_multiple_templates(self, temp_dir, valid_pack_data): - """Test pack with multiple templates of different types.""" - valid_pack_data["provides"]["templates"] = [ - {"type": "template", "name": "spec-template", "file": "templates/spec-template.md"}, - {"type": "template", "name": "plan-template", "file": "templates/plan-template.md"}, - {"type": "command", "name": "specify", "file": "commands/specify.md"}, - {"type": "script", "name": "create-new-feature", "file": "scripts/create-new-feature.sh"}, - ] - manifest_path = temp_dir / "preset.yml" - with open(manifest_path, 'w') as f: - yaml.dump(valid_pack_data, f) - manifest = PresetManifest(manifest_path) - assert len(manifest.templates) == 4 - - def test_duplicate_template_name_and_type_raises_validation_error( - self, temp_dir, valid_pack_data - ): - """A later entry with the same (name, type) pair must be rejected. - - ``PresetResolver._manifest_declared_template`` returns the FIRST - 'provides.templates' entry matching a given (name, type) pair, so a - later duplicate would be silently unreachable while still being - counted by ``PresetManifest.templates`` -- mirroring the sibling bug - fixed for ``ExtensionManifest``'s provides.templates/scripts (#4016). - """ - valid_pack_data["provides"]["templates"] = [ - {"type": "command", "name": "specify", "file": "commands/specify-v1.md"}, - {"type": "command", "name": "specify", "file": "commands/specify-v2.md"}, - ] - manifest_path = temp_dir / "preset.yml" - with open(manifest_path, 'w') as f: - yaml.dump(valid_pack_data, f) - with pytest.raises(PresetValidationError, match="Duplicate template name"): - PresetManifest(manifest_path) - - def test_same_name_different_type_templates_allowed( - self, temp_dir, valid_pack_data - ): - """The same name may recur across different template types.""" - valid_pack_data["provides"]["templates"] = [ - {"type": "template", "name": "specify", "file": "templates/specify.md"}, - {"type": "command", "name": "specify", "file": "commands/specify.md"}, - ] - manifest_path = temp_dir / "preset.yml" - with open(manifest_path, 'w') as f: - yaml.dump(valid_pack_data, f) - manifest = PresetManifest(manifest_path) - assert len(manifest.templates) == 2 - - def test_requires_extensions_absent_is_valid(self, temp_dir, valid_pack_data): - """A preset with no declared dependencies stays valid and reports none.""" - manifest_path = temp_dir / "preset.yml" - with open(manifest_path, 'w') as f: - yaml.dump(valid_pack_data, f) - assert PresetManifest(manifest_path).requires_extensions == [] - - def test_requires_extensions_accepts_both_forms(self, temp_dir, valid_pack_data): - """Bare ids and mappings normalize to the same shape.""" - valid_pack_data["requires"]["extensions"] = [ - "speckit-inventory", - {"id": "other-ext", "version": ">=1.2.0", "required": False}, - ] - manifest_path = temp_dir / "preset.yml" - with open(manifest_path, 'w') as f: - yaml.dump(valid_pack_data, f) - - assert PresetManifest(manifest_path).requires_extensions == [ - {"id": "speckit-inventory", "version": None, "required": True}, - {"id": "other-ext", "version": ">=1.2.0", "required": False}, - ] - - @pytest.mark.parametrize( - "bad, expected", - [ - ("speckit-inventory", "Invalid requires.extensions"), # str, not list - ({"id": "x"}, "Invalid requires.extensions"), # mapping, not list - ([123], r"Invalid requires\.extensions\[0\]"), # member not str/mapping - ([None], r"Invalid requires\.extensions\[0\]"), - ([{"version": ">=1"}], r"Missing requires\.extensions\[0\]\.id"), - ([{"id": 5}], r"Invalid requires\.extensions\[0\]\.id"), - ([{"id": "Bad_ID"}], r"Invalid requires\.extensions\[0\]\.id"), - (["Bad_ID"], r"Invalid requires\.extensions\[0\]\.id"), - ([{"id": "x", "version": 1.0}], r"Invalid requires\.extensions\[0\]\.version"), - ([{"id": "x", "version": " "}], r"Invalid requires\.extensions\[0\]\.version"), - ([{"id": "x", "version": "nonsense"}], r"Invalid requires\.extensions\[0\]\.version"), - ([{"id": "x", "required": "yes"}], r"Invalid requires\.extensions\[0\]\.required"), - # `$` also matches before a trailing newline, so an anchored - # re.match would admit these while the resolver's fullmatch-based - # safe-id check rejects them. - (["demo-ext\n"], r"Invalid requires\.extensions\[0\]\.id"), - ([{"id": "demo-ext\n"}], r"Invalid requires\.extensions\[0\]\.id"), - (["demo\next"], r"Invalid requires\.extensions\[0\]\.id"), - ], - ) - def test_requires_extensions_rejects_malformed( - self, temp_dir, valid_pack_data, bad, expected - ): - """Malformed dependency declarations fail as PresetValidationError. - - Same reasoning as requires.speckit_version: an unvalidated value reaches - ``SpecifierSet`` or ``re.match`` later and surfaces as a bare TypeError - that no caller handles as a malformed manifest. - """ - valid_pack_data["requires"]["extensions"] = bad - manifest_path = temp_dir / "preset.yml" - with open(manifest_path, 'w') as f: - yaml.dump(valid_pack_data, f) - with pytest.raises(PresetValidationError, match=expected): - PresetManifest(manifest_path) - - -# ===== PresetRegistry Tests ===== - - -class TestPresetRegistry: - """Test PresetRegistry operations.""" - - def test_empty_registry(self, temp_dir): - """Test empty registry initialization.""" - packs_dir = temp_dir / "packs" - packs_dir.mkdir() - registry = PresetRegistry(packs_dir) - assert registry.list() == {} - assert not registry.is_installed("test-pack") - - def test_load_starts_fresh_for_non_utf8_registry(self, temp_dir): - """A registry file with undecodable bytes must start fresh, not raise. + # Resolve — pack template should win over core + resolver = PresetResolver(project_dir) + result = resolver.resolve("spec-template") + assert result is not None + assert "Custom Spec Template" in result.read_text() - ``_load()`` already treats malformed JSON as "corrupted registry, - start fresh", but a registry whose *bytes* cannot be decoded as UTF-8 - raised a raw ``UnicodeDecodeError`` from the same boundary — the same - corruption class reaching a different exception type. - """ - packs_dir = temp_dir / "packs" - packs_dir.mkdir() - (packs_dir / PresetRegistry.REGISTRY_FILE).write_bytes( - b"\xff\xfe not utf-8 \xc3\x28" - ) + # Remove + manager.remove("test-pack") - registry = PresetRegistry(packs_dir) - - assert registry.data == { - "schema_version": PresetRegistry.SCHEMA_VERSION, - "presets": {}, - } - - def test_add_and_get(self, temp_dir): - """Test adding and retrieving a pack.""" - packs_dir = temp_dir / "packs" - packs_dir.mkdir() - registry = PresetRegistry(packs_dir) - - registry.add("test-pack", {"version": "1.0.0", "source": "local"}) - assert registry.is_installed("test-pack") - - metadata = registry.get("test-pack") - assert metadata is not None - assert metadata["version"] == "1.0.0" - assert "installed_at" in metadata - - def test_remove(self, temp_dir): - """Test removing a pack.""" - packs_dir = temp_dir / "packs" - packs_dir.mkdir() - registry = PresetRegistry(packs_dir) - - registry.add("test-pack", {"version": "1.0.0"}) - assert registry.is_installed("test-pack") - - registry.remove("test-pack") - assert not registry.is_installed("test-pack") - - def test_remove_nonexistent(self, temp_dir): - """Test removing a pack that doesn't exist.""" - packs_dir = temp_dir / "packs" - packs_dir.mkdir() - registry = PresetRegistry(packs_dir) - registry.remove("nonexistent") # Should not raise - - def test_list(self, temp_dir): - """Test listing all packs.""" - packs_dir = temp_dir / "packs" - packs_dir.mkdir() - registry = PresetRegistry(packs_dir) - - registry.add("pack-a", {"version": "1.0.0"}) - registry.add("pack-b", {"version": "2.0.0"}) - - all_packs = registry.list() - assert len(all_packs) == 2 - assert "pack-a" in all_packs - assert "pack-b" in all_packs - - def test_persistence(self, temp_dir): - """Test that registry data persists across instances.""" - packs_dir = temp_dir / "packs" - packs_dir.mkdir() - - # Add with first instance - registry1 = PresetRegistry(packs_dir) - registry1.add("test-pack", {"version": "1.0.0"}) - - # Load with second instance - registry2 = PresetRegistry(packs_dir) - assert registry2.is_installed("test-pack") - - def test_corrupted_registry(self, temp_dir): - """Test recovery from corrupted registry file.""" - packs_dir = temp_dir / "packs" - packs_dir.mkdir() - - registry_file = packs_dir / ".registry" - registry_file.write_text("not valid json{{{") - - registry = PresetRegistry(packs_dir) - assert registry.list() == {} - - def test_get_nonexistent(self, temp_dir): - """Test getting a nonexistent pack.""" - packs_dir = temp_dir / "packs" - packs_dir.mkdir() - registry = PresetRegistry(packs_dir) - assert registry.get("nonexistent") is None - - def test_restore(self, temp_dir): - """Test restore() preserves timestamps exactly.""" - packs_dir = temp_dir / "packs" - packs_dir.mkdir() - registry = PresetRegistry(packs_dir) - - # Create original entry with a specific timestamp - original_metadata = { - "version": "1.0.0", - "source": "local", - "installed_at": "2025-01-15T10:30:00+00:00", - "enabled": True, - } - registry.restore("test-pack", original_metadata) - - # Verify exact restoration - restored = registry.get("test-pack") - assert restored["installed_at"] == "2025-01-15T10:30:00+00:00" - assert restored["version"] == "1.0.0" - assert restored["enabled"] is True - - def test_restore_rejects_none_metadata(self, temp_dir): - """Test restore() raises ValueError for None metadata.""" - packs_dir = temp_dir / "packs" - packs_dir.mkdir() - registry = PresetRegistry(packs_dir) - - with pytest.raises(ValueError, match="metadata must be a dict"): - registry.restore("test-pack", None) - - def test_restore_rejects_non_dict_metadata(self, temp_dir): - """Test restore() raises ValueError for non-dict metadata.""" - packs_dir = temp_dir / "packs" - packs_dir.mkdir() - registry = PresetRegistry(packs_dir) - - with pytest.raises(ValueError, match="metadata must be a dict"): - registry.restore("test-pack", "not-a-dict") - - with pytest.raises(ValueError, match="metadata must be a dict"): - registry.restore("test-pack", ["list", "not", "dict"]) - - def test_restore_uses_deep_copy(self, temp_dir): - """Test restore() deep copies metadata to prevent mutation.""" - packs_dir = temp_dir / "packs" - packs_dir.mkdir() - registry = PresetRegistry(packs_dir) - - original_metadata = { - "version": "1.0.0", - "nested": {"key": "original"}, - } - registry.restore("test-pack", original_metadata) - - # Mutate the original metadata after restore - original_metadata["version"] = "MUTATED" - original_metadata["nested"]["key"] = "MUTATED" - - # Registry should have the original values - stored = registry.get("test-pack") - assert stored["version"] == "1.0.0" - assert stored["nested"]["key"] == "original" - - def test_get_returns_deep_copy(self, temp_dir): - """Test that get() returns a deep copy to prevent mutation.""" - packs_dir = temp_dir / "packs" - packs_dir.mkdir() - registry = PresetRegistry(packs_dir) - - registry.add("test-pack", {"version": "1.0.0", "nested": {"key": "original"}}) - - # Get and mutate the returned copy - metadata = registry.get("test-pack") - metadata["version"] = "MUTATED" - metadata["nested"]["key"] = "MUTATED" - - # Original should be unchanged - fresh = registry.get("test-pack") - assert fresh["version"] == "1.0.0" - assert fresh["nested"]["key"] == "original" - - def test_get_returns_none_for_corrupted_entry(self, temp_dir): - """Test that get() returns None for corrupted (non-dict) entries.""" - packs_dir = temp_dir / "packs" - packs_dir.mkdir() - registry = PresetRegistry(packs_dir) - - # Directly corrupt the registry with non-dict entries - registry.data["presets"]["corrupted-string"] = "not a dict" - registry.data["presets"]["corrupted-list"] = ["not", "a", "dict"] - registry.data["presets"]["corrupted-int"] = 42 - registry._save() - - # All corrupted entries should return None - assert registry.get("corrupted-string") is None - assert registry.get("corrupted-list") is None - assert registry.get("corrupted-int") is None - # Non-existent should also return None - assert registry.get("nonexistent") is None - - def test_list_returns_deep_copy(self, temp_dir): - """Test that list() returns deep copies to prevent mutation.""" - packs_dir = temp_dir / "packs" - packs_dir.mkdir() - registry = PresetRegistry(packs_dir) - - registry.add("test-pack", {"version": "1.0.0", "nested": {"key": "original"}}) - - # Get list and mutate - all_packs = registry.list() - all_packs["test-pack"]["version"] = "MUTATED" - all_packs["test-pack"]["nested"]["key"] = "MUTATED" - - # Original should be unchanged - fresh = registry.get("test-pack") - assert fresh["version"] == "1.0.0" - assert fresh["nested"]["key"] == "original" - - def test_list_returns_empty_dict_for_corrupted_registry(self, temp_dir): - """Test that list() returns empty dict when presets is not a dict.""" - packs_dir = temp_dir / "packs" - packs_dir.mkdir() - registry = PresetRegistry(packs_dir) - - # Corrupt the registry - presets is a list instead of dict - registry.data["presets"] = ["not", "a", "dict"] - registry._save() - - # list() should return empty dict, not crash - result = registry.list() - assert result == {} - - def test_list_by_priority_excludes_disabled(self, temp_dir): - """Test that list_by_priority excludes disabled presets by default.""" - packs_dir = temp_dir / "packs" - packs_dir.mkdir() - registry = PresetRegistry(packs_dir) - - registry.add("pack-enabled", {"version": "1.0.0", "enabled": True, "priority": 5}) - registry.add("pack-disabled", {"version": "1.0.0", "enabled": False, "priority": 1}) - registry.add("pack-default", {"version": "1.0.0", "priority": 10}) # no enabled field = True - - # Default: exclude disabled - by_priority = registry.list_by_priority() - pack_ids = [p[0] for p in by_priority] - assert "pack-enabled" in pack_ids - assert "pack-default" in pack_ids - assert "pack-disabled" not in pack_ids - - def test_list_by_priority_includes_disabled_when_requested(self, temp_dir): - """Test that list_by_priority includes disabled presets when requested.""" - packs_dir = temp_dir / "packs" - packs_dir.mkdir() - registry = PresetRegistry(packs_dir) - - registry.add("pack-enabled", {"version": "1.0.0", "enabled": True, "priority": 5}) - registry.add("pack-disabled", {"version": "1.0.0", "enabled": False, "priority": 1}) - - # Include disabled - by_priority = registry.list_by_priority(include_disabled=True) - pack_ids = [p[0] for p in by_priority] - assert "pack-enabled" in pack_ids - assert "pack-disabled" in pack_ids - # Disabled pack has lower priority number, so it comes first when included - assert pack_ids[0] == "pack-disabled" - - -# ===== PresetManager Tests ===== - - -def test_unreadable_constitution_provenance_fails_closed( - project_dir, monkeypatch -): - from specify_cli.presets import _constitution_provenance_matches_preset - - memory = project_dir / ".specify" / "memory" / "constitution.md" - memory.parent.mkdir(parents=True, exist_ok=True) - memory.write_text("# Constitution\n", encoding="utf-8") - provenance = memory.parent / ".constitution-template.json" - provenance.write_text("{}", encoding="utf-8") - real_read_text = Path.read_text - - def unreadable(path, *args, **kwargs): - if path == provenance: - raise OSError("simulated read failure") - return real_read_text(path, *args, **kwargs) - - monkeypatch.setattr(Path, "read_text", unreadable) - - assert not _constitution_provenance_matches_preset( - project_dir, memory, "example", "1.0.0" - ) + # Resolve — should fall back to core + result = resolver.resolve("spec-template") + assert result is not None + assert "Core Spec Template" in result.read_text() + def test_override_beats_pack_beats_extension_beats_core(self, project_dir, pack_dir): + """Test the full priority stack: override > pack > extension > core.""" + resolver = PresetResolver(project_dir) -class TestPresetManager: - """Test PresetManager installation and removal.""" + # Core should resolve + result = resolver.resolve_with_source("spec-template") + assert result["source"] == "core" - def test_install_from_directory(self, project_dir, pack_dir): - """Test installing a preset from a directory.""" - manager = PresetManager(project_dir) - manifest = manager.install_from_directory(pack_dir, "0.1.5") + # Add extension template + ext_dir = project_dir / ".specify" / "extensions" / "my-ext" + ext_templates_dir = ext_dir / "templates" + ext_templates_dir.mkdir(parents=True) + (ext_templates_dir / "spec-template.md").write_text("# Extension\n") - assert manifest.id == "test-pack" - assert manager.registry.is_installed("test-pack") + # Register extension in registry + extensions_dir = project_dir / ".specify" / "extensions" + ext_registry = ExtensionRegistry(extensions_dir) + ext_registry.add("my-ext", {"version": "1.0.0", "priority": 10}) - # Verify files are copied - installed_dir = project_dir / ".specify" / "presets" / "test-pack" - assert installed_dir.exists() - assert (installed_dir / "preset.yml").exists() - assert (installed_dir / "templates" / "spec-template.md").exists() + result = resolver.resolve_with_source("spec-template") + assert result["source"] == "extension:my-ext v1.0.0" - def test_install_already_installed(self, project_dir, pack_dir): - """Test installing an already-installed pack raises error.""" + # Install pack — should win over extension manager = PresetManager(project_dir) manager.install_from_directory(pack_dir, "0.1.5") - with pytest.raises(PresetError, match="already installed"): - manager.install_from_directory(pack_dir, "0.1.5") + result = resolver.resolve_with_source("spec-template") + assert "test-pack" in result["source"] - def test_install_incompatible(self, project_dir, temp_dir, valid_pack_data): - """Test installing an incompatible pack raises error.""" - valid_pack_data["requires"]["speckit_version"] = ">=99.0.0" - incompat_dir = temp_dir / "incompat-pack" - incompat_dir.mkdir() - manifest_path = incompat_dir / "preset.yml" - with open(manifest_path, 'w') as f: - yaml.dump(valid_pack_data, f) - (incompat_dir / "templates").mkdir() - (incompat_dir / "templates" / "spec-template.md").write_text("test") + # Add override — should win over pack + overrides_dir = project_dir / ".specify" / "templates" / "overrides" + overrides_dir.mkdir(parents=True) + (overrides_dir / "spec-template.md").write_text("# Override\n") - manager = PresetManager(project_dir) - with pytest.raises(PresetCompatibilityError): - manager.install_from_directory(incompat_dir, "0.1.5") + result = resolver.resolve_with_source("spec-template") + assert result["source"] == "project override" - def test_install_from_zip(self, project_dir, pack_dir, temp_dir): - """Test installing from a ZIP file.""" + def test_install_from_zip_then_resolve(self, project_dir, pack_dir, temp_dir): + """Test installing from ZIP and then resolving.""" + # Create ZIP zip_path = temp_dir / "test-pack.zip" with zipfile.ZipFile(zip_path, 'w') as zf: for file_path in pack_dir.rglob('*'): @@ -871,11805 +101,507 @@ def test_install_from_zip(self, project_dir, pack_dir, temp_dir): arcname = file_path.relative_to(pack_dir) zf.write(file_path, arcname) + # Install manager = PresetManager(project_dir) - manifest = manager.install_from_zip( - zip_path, "0.1.5", catalog_name="preset-catalog" - ) - assert manifest.id == "test-pack" - assert manager.registry.is_installed("test-pack") - assert manager.registry.get("test-pack")["source"] == { - "kind": "catalog", - "catalog": "preset-catalog", - } - - def test_install_from_zip_forwards_force( - self, project_dir, pack_dir, temp_dir - ): - """The compatibility wrapper must retain forced reinstall behavior.""" - zip_path = temp_dir / "test-pack.zip" - with zipfile.ZipFile(zip_path, "w") as zf: - for file_path in pack_dir.rglob("*"): - if file_path.is_file(): - zf.write(file_path, file_path.relative_to(pack_dir)) - - manager = PresetManager(project_dir) - manager.install_from_directory(pack_dir, "0.1.5") - manifest = manager.install_from_zip( - zip_path, - "0.1.5", - force=True, - ) - - assert manifest.id == "test-pack" - assert manager.registry.is_installed("test-pack") - - def test_install_from_zip_nested(self, project_dir, pack_dir, temp_dir): - """Test installing from ZIP with nested directory.""" - zip_path = temp_dir / "test-pack.zip" - with zipfile.ZipFile(zip_path, 'w') as zf: - for file_path in pack_dir.rglob('*'): - if file_path.is_file(): - arcname = Path("test-pack-v1.0.0") / file_path.relative_to(pack_dir) - zf.write(file_path, arcname) + manager.install_from_zip(zip_path, "0.1.5") - manager = PresetManager(project_dir) - manifest = manager.install_from_zip(zip_path, "0.1.5") - assert manifest.id == "test-pack" + # Resolve + resolver = PresetResolver(project_dir) + result = resolver.resolve("spec-template") + assert result is not None + assert "Custom Spec Template" in result.read_text() - def test_install_from_zip_no_manifest(self, project_dir, temp_dir): - """Test installing from ZIP without manifest raises error.""" - zip_path = temp_dir / "bad.zip" - with zipfile.ZipFile(zip_path, 'w') as zf: - zf.writestr("readme.txt", "no manifest here") - manager = PresetManager(project_dir) - with pytest.raises(PresetValidationError, match="No preset.yml found"): - manager.install_from_zip(zip_path, "0.1.5") - - def test_install_from_zip_rejects_symlink_entry( - self, project_dir, pack_dir, temp_dir - ): - """Preset ZIPs delegate to the shared symlink-safe extractor.""" - import stat - - zip_path = temp_dir / "symlink-preset.zip" - link = zipfile.ZipInfo("templates/escape") - link.create_system = 3 - link.external_attr = (stat.S_IFLNK | 0o777) << 16 - with zipfile.ZipFile(zip_path, "w") as zf: - for file_path in pack_dir.rglob("*"): - if file_path.is_file(): - zf.write(file_path, file_path.relative_to(pack_dir)) - zf.writestr(link, "../../outside") +class TestSelfTestPreset: + """Bundled self-test preset contents and catalog visibility.""" - manager = PresetManager(project_dir) - with pytest.raises(PresetValidationError, match="Unsafe symlink"): - manager.install_from_zip(zip_path, "0.1.5") - - assert not manager.registry.is_installed("test-pack") - - @pytest.mark.parametrize("suffix", [".tar.gz", ".tgz"]) - @pytest.mark.parametrize("nested", [False, True]) - def test_install_from_tar_archive( - self, project_dir, pack_dir, temp_dir, suffix, nested - ): - """Tar archives install with the same flat/nested behavior as ZIP.""" - archive_path = temp_dir / f"test-pack{suffix}" - with tarfile.open(archive_path, "w:gz") as archive: - for file_path in pack_dir.rglob("*"): - if file_path.is_file(): - relative = file_path.relative_to(pack_dir) - arcname = Path("test-pack-v1") / relative if nested else relative - archive.add(file_path, arcname=arcname) + def test_self_test_preset_exists(self): + """Verify the self-test preset directory and manifest exist.""" + assert SELF_TEST_PRESET_DIR.exists() + assert (SELF_TEST_PRESET_DIR / "preset.yml").exists() - manager = PresetManager(project_dir) - manifest = manager.install_from_archive( - archive_path, "0.1.5", catalog_name="preset-catalog" - ) + def test_self_test_manifest_valid(self): + """Verify the self-test preset manifest is valid.""" + manifest = PresetManifest(SELF_TEST_PRESET_DIR / "preset.yml") + assert manifest.id == "self-test" + assert manifest.name == "Self-Test Preset" + assert manifest.version == "1.0.0" + assert len(manifest.templates) == 7 # 5 templates + 2 commands - assert manifest.id == "test-pack" - assert manager.registry.is_installed("test-pack") - assert manager.registry.get("test-pack")["source"] == { - "kind": "catalog", - "catalog": "preset-catalog", - } - - def test_install_from_tar_rejects_symlink_entry( - self, project_dir, pack_dir, temp_dir - ): - archive_path = temp_dir / "symlink-preset.tar.gz" - with tarfile.open(archive_path, "w:gz") as archive: - for file_path in pack_dir.rglob("*"): - if file_path.is_file(): - archive.add(file_path, arcname=file_path.relative_to(pack_dir)) - link = tarfile.TarInfo("templates/escape") - link.type = tarfile.SYMTYPE - link.linkname = "../../outside" - archive.addfile(link) + def test_self_test_provides_all_core_templates(self): + """Verify the self-test preset provides an override for every core template.""" + manifest = PresetManifest(SELF_TEST_PRESET_DIR / "preset.yml") + provided_names = {t["name"] for t in manifest.templates} + for name in CORE_TEMPLATE_NAMES: + assert name in provided_names, f"Self-test preset missing template: {name}" - manager = PresetManager(project_dir) - with pytest.raises(PresetValidationError, match="Unsafe symlink"): - manager.install_from_archive(archive_path, "0.1.5") + def test_self_test_template_files_exist(self): + """Verify that all declared template files actually exist on disk.""" + manifest = PresetManifest(SELF_TEST_PRESET_DIR / "preset.yml") + for tmpl in manifest.templates: + tmpl_path = SELF_TEST_PRESET_DIR / tmpl["file"] + assert tmpl_path.exists(), f"Missing template file: {tmpl['file']}" - assert not manager.registry.is_installed("test-pack") + def test_self_test_templates_have_marker(self): + """Verify each template contains the preset:self-test marker.""" + for name in CORE_TEMPLATE_NAMES: + tmpl_path = SELF_TEST_PRESET_DIR / "templates" / f"{name}.md" + content = tmpl_path.read_text() + assert "preset:self-test" in content, f"{name}.md missing preset:self-test marker" - def test_remove(self, project_dir, pack_dir): - """Test removing a preset.""" - manager = PresetManager(project_dir) - manager.install_from_directory(pack_dir, "0.1.5") - assert manager.registry.is_installed("test-pack") + def test_self_test_not_in_catalog(self): + """Verify the self-test preset is NOT in the catalog (it's local-only).""" + catalog_path = Path(__file__).parent.parent / "presets" / "catalog.json" + catalog_data = json.loads(catalog_path.read_text()) + assert "self-test" not in catalog_data["presets"] - result = manager.remove("test-pack") - assert result is True - assert not manager.registry.is_installed("test-pack") + def test_self_test_has_command(self): + """Verify the self-test preset includes a command override.""" + manifest = PresetManifest(SELF_TEST_PRESET_DIR / "preset.yml") + commands = [t for t in manifest.templates if t["type"] == "command"] + assert len(commands) >= 1 + assert commands[0]["name"] == "speckit.specify" - installed_dir = project_dir / ".specify" / "presets" / "test-pack" - assert not installed_dir.exists() + def test_self_test_command_file_exists(self): + """Verify the self-test command file exists on disk.""" + cmd_path = SELF_TEST_PRESET_DIR / "commands" / "speckit.specify.md" + assert cmd_path.exists() + content = cmd_path.read_text() + assert "preset:self-test" in content - def test_remove_nonexistent(self, project_dir): - """Test removing a pack that doesn't exist.""" - manager = PresetManager(project_dir) - result = manager.remove("nonexistent") - assert result is False - def test_list_installed(self, project_dir, pack_dir): - """Test listing installed packs.""" - manager = PresetManager(project_dir) - manager.install_from_directory(pack_dir, "0.1.5") +class TestInitOptions: + """Tests for save_init_options / load_init_options helpers.""" - installed = manager.list_installed() - assert len(installed) == 1 - assert installed[0]["id"] == "test-pack" - assert installed[0]["name"] == "Test Preset" - assert installed[0]["version"] == "1.0.0" - assert installed[0]["template_count"] == 1 + def test_save_and_load_round_trip(self, project_dir): + from specify_cli import save_init_options, load_init_options - def test_list_installed_empty(self, project_dir): - """Test listing when no packs installed.""" - manager = PresetManager(project_dir) - assert manager.list_installed() == [] + opts = {"ai": "claude", "ai_skills": True, "here": False} + save_init_options(project_dir, opts) - def test_get_pack(self, project_dir, pack_dir): - """Test getting a specific installed pack.""" - manager = PresetManager(project_dir) - manager.install_from_directory(pack_dir, "0.1.5") + loaded = load_init_options(project_dir) + assert loaded["ai"] == "claude" + assert loaded["ai_skills"] is True - pack = manager.get_pack("test-pack") - assert pack is not None - assert pack.id == "test-pack" + def test_save_and_load_available_from_init_options_module(self, project_dir): + from specify_cli._init_options import load_init_options, save_init_options - def test_get_pack_not_installed(self, project_dir): - """Test getting a non-installed pack returns None.""" - manager = PresetManager(project_dir) - assert manager.get_pack("nonexistent") is None - - def test_check_compatibility_valid(self, pack_dir, temp_dir): - """Test compatibility check with valid version.""" - manager = PresetManager(temp_dir) - manifest = PresetManifest(pack_dir / "preset.yml") - assert manager.check_compatibility(manifest, "0.1.5") is True - - def test_check_compatibility_prerelease(self, pack_dir, temp_dir): - """Test compatibility check allows prereleases and fails on boundary.""" - manager = PresetManager(temp_dir) - manifest = PresetManifest(pack_dir / "preset.yml") - # manifest requires >=0.1.0 - assert manager.check_compatibility(manifest, "0.8.8.dev0") is True - with pytest.raises(PresetCompatibilityError, match="Preset requires spec-kit"): - manager.check_compatibility(manifest, "0.1.0.dev0") - - def test_check_compatibility_invalid(self, pack_dir, temp_dir): - """Test compatibility check with invalid specifier.""" - manager = PresetManager(temp_dir) - manifest = PresetManifest(pack_dir / "preset.yml") - manifest.data["requires"]["speckit_version"] = "not-a-specifier" - with pytest.raises(PresetCompatibilityError, match="Invalid version specifier"): - manager.check_compatibility(manifest, "0.1.5") + opts = {"ai": "codex", "ai_skills": True, "script": "sh"} + save_init_options(project_dir, opts) - @pytest.mark.parametrize( - "bad", - [1.0, 5, True, None, [">=0.1.0"], {"min": "0.1"}], - ) - def test_check_compatibility_non_string_specifier(self, pack_dir, temp_dir, bad): - """check_compatibility() must report a non-string as a compatibility error. - - Defense in depth for the validator check: this method is public and the - specifier is read back out of mutable manifest data, and ``except - InvalidSpecifier`` does not cover a non-string. Without the guard, scalars - raise a bare TypeError and iterables construct fine only to break inside - .contains() -- neither is a PresetCompatibilityError, so both bypass the - CLI's "Compatibility Error" handler and exit 1 with a raw traceback. - """ - manager = PresetManager(temp_dir) - manifest = PresetManifest(pack_dir / "preset.yml") - manifest.data["requires"]["speckit_version"] = bad - with pytest.raises(PresetCompatibilityError, match="Invalid version specifier"): - manager.check_compatibility(manifest, "0.1.5") - - def test_install_with_priority(self, project_dir, pack_dir): - """Test installing a pack with custom priority.""" - manager = PresetManager(project_dir) - manager.install_from_directory(pack_dir, "0.1.5", priority=5) + assert load_init_options(project_dir) == opts - metadata = manager.registry.get("test-pack") - assert metadata is not None - assert metadata["priority"] == 5 + def test_save_uses_utf8_encoding(self, project_dir, monkeypatch): + from specify_cli import save_init_options - def test_install_default_priority(self, project_dir, pack_dir): - """Test that default priority is 10.""" - manager = PresetManager(project_dir) - manager.install_from_directory(pack_dir, "0.1.5") + original_write_text = Path.write_text + seen: dict[str, str | None] = {} - metadata = manager.registry.get("test-pack") - assert metadata is not None - assert metadata["priority"] == 10 + def spy_write_text(path, data, *args, **kwargs): + if path == project_dir / ".specify" / "init-options.json": + seen["encoding"] = kwargs.get("encoding") + return original_write_text(path, data, *args, **kwargs) - def test_list_installed_includes_priority(self, project_dir, pack_dir): - """Test that list_installed includes priority.""" - manager = PresetManager(project_dir) - manager.install_from_directory(pack_dir, "0.1.5", priority=3) + monkeypatch.setattr(Path, "write_text", spy_write_text) - installed = manager.list_installed() - assert len(installed) == 1 - assert installed[0]["priority"] == 3 + save_init_options(project_dir, {"label": "中文测试"}) + assert seen["encoding"] == "utf-8" -class TestPresetExtensionDependencies: - """Test find_unmet_extension_dependencies (issue #4231).""" + def test_load_uses_utf8_encoding(self, project_dir, monkeypatch): + from specify_cli import load_init_options - @staticmethod - def _install_extension( - project_dir, extension_id, version, enabled=True, with_files=True - ): - """Register an installed extension the way the extension installer does. + opts_file = project_dir / ".specify" / "init-options.json" + opts_file.parent.mkdir(parents=True, exist_ok=True) + opts_file.write_text('{"ai": "codex"}', encoding="utf-8") - ``with_files=False`` leaves the registry entry without its directory, - reproducing the stale state left behind when the files are deleted out - from under the registry. - """ - extensions_dir = project_dir / ".specify" / "extensions" - extensions_dir.mkdir(parents=True, exist_ok=True) - if with_files: - (extensions_dir / extension_id).mkdir(parents=True, exist_ok=True) - registry_path = extensions_dir / ".registry" - data = {"schema_version": "1.0", "extensions": {}} - if registry_path.exists(): - data = json.loads(registry_path.read_text(encoding="utf-8")) - data["extensions"][extension_id] = {"version": version, "enabled": enabled} - registry_path.write_text(json.dumps(data), encoding="utf-8") - - @staticmethod - def _manifest(temp_dir, valid_pack_data, declared): - valid_pack_data["requires"]["extensions"] = declared - manifest_path = temp_dir / "dep-preset.yml" - with open(manifest_path, 'w') as f: - yaml.dump(valid_pack_data, f) - return PresetManifest(manifest_path) - - def test_no_declared_dependencies_is_satisfied( - self, project_dir, temp_dir, valid_pack_data - ): - """A preset declaring nothing never reports an unmet dependency.""" - manifest_path = temp_dir / "plain-preset.yml" - with open(manifest_path, 'w') as f: - yaml.dump(valid_pack_data, f) + original_read_text = Path.read_text + seen: dict[str, str | None] = {} - manager = PresetManager(project_dir) - assert manager.find_unmet_extension_dependencies( - PresetManifest(manifest_path) - ) == [] - - def test_missing_dependency_is_reported( - self, project_dir, temp_dir, valid_pack_data - ): - """An uninstalled required extension is reported as missing.""" - manifest = self._manifest(temp_dir, valid_pack_data, ["speckit-inventory"]) - - unmet = PresetManager(project_dir).find_unmet_extension_dependencies(manifest) - - assert len(unmet) == 1 - assert unmet[0]["id"] == "speckit-inventory" - assert unmet[0]["reason"] == "missing" - assert unmet[0]["installed"] is None - - def test_installed_dependency_is_satisfied( - self, project_dir, temp_dir, valid_pack_data - ): - """An installed extension with no version constraint is satisfied.""" - self._install_extension(project_dir, "speckit-inventory", "0.1.0") - manifest = self._manifest(temp_dir, valid_pack_data, ["speckit-inventory"]) - - assert PresetManager(project_dir).find_unmet_extension_dependencies( - manifest - ) == [] - - def test_satisfied_version_constraint( - self, project_dir, temp_dir, valid_pack_data - ): - """A satisfied version constraint reports nothing.""" - self._install_extension(project_dir, "speckit-inventory", "1.5.0") - manifest = self._manifest( - temp_dir, valid_pack_data, - [{"id": "speckit-inventory", "version": ">=1.2.0"}], - ) + def spy_read_text(path, *args, **kwargs): + if path == opts_file: + seen["encoding"] = kwargs.get("encoding") + return original_read_text(path, *args, **kwargs) - assert PresetManager(project_dir).find_unmet_extension_dependencies( - manifest - ) == [] - - def test_unsatisfied_version_constraint_reports_both_versions( - self, project_dir, temp_dir, valid_pack_data - ): - """A version mismatch reports the installed version alongside the constraint.""" - self._install_extension(project_dir, "speckit-inventory", "0.1.0") - manifest = self._manifest( - temp_dir, valid_pack_data, - [{"id": "speckit-inventory", "version": ">=9.0.0"}], - ) + monkeypatch.setattr(Path, "read_text", spy_read_text) - unmet = PresetManager(project_dir).find_unmet_extension_dependencies(manifest) + assert load_init_options(project_dir) == {"ai": "codex"} + assert seen["encoding"] == "utf-8" - assert len(unmet) == 1 - assert unmet[0]["reason"] == "version" - assert unmet[0]["installed"] == "0.1.0" - assert unmet[0]["version"] == ">=9.0.0" + def test_load_returns_empty_when_missing(self, project_dir): + from specify_cli import load_init_options + assert load_init_options(project_dir) == {} - def test_optional_dependency_is_never_reported( - self, project_dir, temp_dir, valid_pack_data - ): - """`required: false` opts out of the warning even when absent.""" - manifest = self._manifest( - temp_dir, valid_pack_data, - [{"id": "speckit-inventory", "required": False}], - ) + def test_load_returns_empty_on_invalid_json(self, project_dir): + from specify_cli import load_init_options - assert PresetManager(project_dir).find_unmet_extension_dependencies( - manifest - ) == [] + opts_file = project_dir / ".specify" / "init-options.json" + opts_file.parent.mkdir(parents=True, exist_ok=True) + opts_file.write_text("{bad json") - @pytest.mark.parametrize("bad_version", [None, 5, "unknown", "", "latest"]) - def test_uncomparable_registry_version_is_not_a_mismatch( - self, project_dir, temp_dir, valid_pack_data, bad_version - ): - """A version that cannot be evaluated must not be reported as a mismatch. + assert load_init_options(project_dir) == {} - ``version_satisfies()`` returns False for an unparseable version, which - is indistinguishable from a genuine mismatch -- so a string like - "unknown" would otherwise be reported as failing a constraint nobody - can actually evaluate it against. - """ - self._install_extension(project_dir, "speckit-inventory", "0.1.0") - registry_path = project_dir / ".specify" / "extensions" / ".registry" - data = json.loads(registry_path.read_text(encoding="utf-8")) - data["extensions"]["speckit-inventory"]["version"] = bad_version - registry_path.write_text(json.dumps(data), encoding="utf-8") - - manifest = self._manifest( - temp_dir, valid_pack_data, - [{"id": "speckit-inventory", "version": ">=9.0.0"}], - ) - - assert PresetManager(project_dir).find_unmet_extension_dependencies( - manifest - ) == [] - - def test_unregistered_extension_on_disk_is_satisfied( - self, project_dir, temp_dir, valid_pack_data - ): - """A directory with no registry entry still resolves, so it is not missing. - - ``_get_all_extensions_by_priority`` admits safe unregistered - directories at implicit priority 10, so the preset works -- warning - that the dependency is absent would be a false alarm. - """ - (project_dir / ".specify" / "extensions" / "speckit-inventory").mkdir( - parents=True - ) - manifest = self._manifest(temp_dir, valid_pack_data, ["speckit-inventory"]) - - assert PresetManager(project_dir).find_unmet_extension_dependencies( - manifest - ) == [] - - def test_unregistered_extension_cannot_be_version_checked( - self, project_dir, temp_dir, valid_pack_data - ): - """No registry entry means no recorded version, so nothing to compare.""" - (project_dir / ".specify" / "extensions" / "speckit-inventory").mkdir( - parents=True - ) - manifest = self._manifest( - temp_dir, valid_pack_data, - [{"id": "speckit-inventory", "version": ">=9.0.0"}], - ) - - assert PresetManager(project_dir).find_unmet_extension_dependencies( - manifest - ) == [] - - def test_corrupted_registry_entry_with_directory_is_not_satisfied( - self, project_dir, temp_dir, valid_pack_data - ): - """A corrupted entry keeps its id registered, so its directory is excluded. - - ``get()`` returns None for a non-dict entry just as it does for an - absent one, but ``keys()`` retains the id specifically so resolution - does not re-admit the directory as an unregistered extension. The - fallback must not revive what resolution excludes. - """ - extensions_dir = project_dir / ".specify" / "extensions" - (extensions_dir / "speckit-inventory").mkdir(parents=True) - (extensions_dir / ".registry").write_text( - json.dumps( - {"schema_version": "1.0", "extensions": {"speckit-inventory": "corrupt"}} - ), - encoding="utf-8", - ) - manifest = self._manifest(temp_dir, valid_pack_data, ["speckit-inventory"]) - - unmet = PresetManager(project_dir).find_unmet_extension_dependencies(manifest) - - # Reported as corrupt rather than missing: the id is still registered, - # so a plain `extension add` would be refused as already installed. - assert [dep["reason"] for dep in unmet] == ["corrupt"] - - - - - def test_unregistered_extension_with_corrupt_registry_is_missing( - self, project_dir, temp_dir, valid_pack_data - ): - """A corrupt registry makes resolution fail closed, so it is not usable.""" - extensions_dir = project_dir / ".specify" / "extensions" - (extensions_dir / "speckit-inventory").mkdir(parents=True) - (extensions_dir / ".registry").write_text("{not valid json", encoding="utf-8") - manifest = self._manifest(temp_dir, valid_pack_data, ["speckit-inventory"]) - - unmet = PresetManager(project_dir).find_unmet_extension_dependencies(manifest) - - assert [dep["reason"] for dep in unmet] == ["missing"] - - def test_corrupted_entry_gets_a_forced_reinstall_remedy( - self, project_dir, temp_dir, valid_pack_data - ): - """A corrupted entry is not simply absent: `add ` would be refused. - - ``get()`` returns None for it, but ``is_installed()`` still counts the - key, so a plain add reports "already installed". It needs --force. - """ - extensions_dir = project_dir / ".specify" / "extensions" - extensions_dir.mkdir(parents=True) - (extensions_dir / ".registry").write_text( - json.dumps( - {"schema_version": "1.0", "extensions": {"speckit-inventory": "bad"}} - ), - encoding="utf-8", - ) - manifest = self._manifest(temp_dir, valid_pack_data, ["speckit-inventory"]) - - unmet = PresetManager(project_dir).find_unmet_extension_dependencies(manifest) - - assert [dep["reason"] for dep in unmet] == ["corrupt"] - - - def test_unreadable_registry_does_not_raise( - self, project_dir, temp_dir, valid_pack_data, monkeypatch - ): - """An OSError from the registry must not crash an already-completed install. - - ``_load()`` lets OSError through, and ``preset_add`` only handles - preset-domain errors, so raising here would turn a finished install - into a traceback over what is only a warning. - """ - manifest = self._manifest(temp_dir, valid_pack_data, ["speckit-inventory"]) - - import specify_cli.presets as presets_mod - - def _boom(*args, **kwargs): - raise PermissionError("registry unreadable") - - monkeypatch.setattr(presets_mod, "ExtensionRegistry", _boom) - - assert PresetManager(project_dir).find_unmet_extension_dependencies( - manifest - ) == [] - - - - def test_exact_duplicate_declarations_warn_once( - self, project_dir, temp_dir, valid_pack_data - ): - """Naming the same dependency twice must not print the warning twice.""" - manifest = self._manifest( - temp_dir, valid_pack_data, ["speckit-inventory", "speckit-inventory"] - ) - - unmet = PresetManager(project_dir).find_unmet_extension_dependencies(manifest) - - assert [dep["id"] for dep in unmet] == ["speckit-inventory"] - - def test_same_id_with_different_constraints_is_checked_twice( - self, project_dir, temp_dir, valid_pack_data - ): - """Distinct constraints on one id both have to hold, so both are checked.""" - self._install_extension(project_dir, "speckit-inventory", "1.0.0") - manifest = self._manifest( - temp_dir, valid_pack_data, - [ - {"id": "speckit-inventory", "version": ">=9.0.0"}, - {"id": "speckit-inventory", "version": "<0.5"}, - ], - ) - - unmet = PresetManager(project_dir).find_unmet_extension_dependencies(manifest) - - assert [dep["version"] for dep in unmet] == [">=9.0.0", "<0.5"] - - def test_stale_registry_entry_is_reported( - self, project_dir, temp_dir, valid_pack_data - ): - """A registry entry whose extension directory is gone counts as unmet. - - PresetResolver guards on ``ext_dir.is_dir()`` in both template lookup - and layer collection, so a stale entry contributes nothing -- but the - surviving registry entry would otherwise read as satisfied. - """ - self._install_extension( - project_dir, "speckit-inventory", "0.1.0", with_files=False - ) - manifest = self._manifest(temp_dir, valid_pack_data, ["speckit-inventory"]) - - unmet = PresetManager(project_dir).find_unmet_extension_dependencies(manifest) - - assert len(unmet) == 1 - assert unmet[0]["reason"] == "stale" - assert unmet[0]["installed"] == "0.1.0" - - def test_stale_is_reported_ahead_of_disabled_and_version( - self, project_dir, temp_dir, valid_pack_data - ): - """Restoring the files is the prerequisite, so it is reported first.""" - self._install_extension( - project_dir, "speckit-inventory", "0.1.0", - enabled=False, with_files=False, - ) - manifest = self._manifest( - temp_dir, valid_pack_data, - [{"id": "speckit-inventory", "version": ">=9.0.0"}], - ) - - unmet = PresetManager(project_dir).find_unmet_extension_dependencies(manifest) - - assert [dep["reason"] for dep in unmet] == ["stale"] - - - def test_disabled_dependency_is_reported( - self, project_dir, temp_dir, valid_pack_data - ): - """A disabled extension contributes nothing, so it counts as unmet. - - Resolution skips disabled extensions, leaving the preset just as inert - as if the extension were absent -- but the registry entry exists, so a - presence-only check would call it satisfied and stay silent. - """ - self._install_extension(project_dir, "speckit-inventory", "0.1.0", enabled=False) - manifest = self._manifest(temp_dir, valid_pack_data, ["speckit-inventory"]) - - unmet = PresetManager(project_dir).find_unmet_extension_dependencies(manifest) - - assert len(unmet) == 1 - assert unmet[0]["reason"] == "disabled" - assert unmet[0]["installed"] == "0.1.0" - - def test_disabled_is_reported_ahead_of_version_mismatch( - self, project_dir, temp_dir, valid_pack_data - ): - """Enabling is the prerequisite, so it is reported before the version.""" - self._install_extension(project_dir, "speckit-inventory", "0.1.0", enabled=False) - manifest = self._manifest( - temp_dir, valid_pack_data, - [{"id": "speckit-inventory", "version": ">=9.0.0"}], - ) - - unmet = PresetManager(project_dir).find_unmet_extension_dependencies(manifest) - - assert [dep["reason"] for dep in unmet] == ["disabled"] - - def test_multiple_dependencies_report_independently( - self, project_dir, temp_dir, valid_pack_data - ): - """Each declared dependency is evaluated on its own.""" - self._install_extension(project_dir, "present-ext", "1.0.0") - self._install_extension(project_dir, "off-ext", "1.0.0", enabled=False) - manifest = self._manifest( - temp_dir, valid_pack_data, - [ - "present-ext", - "absent-ext", - "off-ext", - {"id": "opt-ext", "required": False}, - ], - ) - - unmet = PresetManager(project_dir).find_unmet_extension_dependencies(manifest) - - assert [(dep["id"], dep["reason"]) for dep in unmet] == [ - ("absent-ext", "missing"), - ("off-ext", "disabled"), - ] - - -class TestRegistryPriority: - """Test registry priority sorting.""" - - def test_list_by_priority(self, temp_dir): - """Test that list_by_priority sorts by priority number.""" - packs_dir = temp_dir / "packs" - packs_dir.mkdir() - registry = PresetRegistry(packs_dir) - - registry.add("pack-high", {"version": "1.0.0", "priority": 1}) - registry.add("pack-low", {"version": "1.0.0", "priority": 20}) - registry.add("pack-mid", {"version": "1.0.0", "priority": 10}) - - sorted_packs = registry.list_by_priority() - assert len(sorted_packs) == 3 - assert sorted_packs[0][0] == "pack-high" - assert sorted_packs[1][0] == "pack-mid" - assert sorted_packs[2][0] == "pack-low" - - def test_list_by_priority_default(self, temp_dir): - """Test that packs without priority default to 10.""" - packs_dir = temp_dir / "packs" - packs_dir.mkdir() - registry = PresetRegistry(packs_dir) - - registry.add("pack-a", {"version": "1.0.0"}) # no priority, defaults to 10 - registry.add("pack-b", {"version": "1.0.0", "priority": 5}) - - sorted_packs = registry.list_by_priority() - assert sorted_packs[0][0] == "pack-b" - assert sorted_packs[1][0] == "pack-a" - - def test_list_by_priority_invalid_priority_defaults(self, temp_dir): - """Malformed priority values fall back to the default priority.""" - packs_dir = temp_dir / "packs" - packs_dir.mkdir() - registry = PresetRegistry(packs_dir) - - registry.add("pack-high", {"version": "1.0.0", "priority": 1}) - registry.data["presets"]["pack-invalid"] = { - "version": "1.0.0", - "priority": "high", - } - registry._save() - - sorted_packs = registry.list_by_priority() - - assert [item[0] for item in sorted_packs] == ["pack-high", "pack-invalid"] - assert sorted_packs[1][1]["priority"] == 10 - - -# ===== PresetResolver Tests ===== - - -class TestPresetResolver: - """Test PresetResolver priority stack.""" - - def test_resolve_core_template(self, project_dir): - """Test resolving a core template.""" - resolver = PresetResolver(project_dir) - result = resolver.resolve("spec-template") - assert result is not None - assert result.name == "spec-template.md" - assert "Core Spec Template" in result.read_text() - - def test_resolve_nonexistent(self, project_dir): - """Test resolving a nonexistent template returns None.""" - resolver = PresetResolver(project_dir) - result = resolver.resolve("nonexistent-template") - assert result is None - - def test_resolver_ignores_traversing_registry_ids(self, project_dir): - """Registry IDs cannot escape preset or extension install roots.""" - for registry_dir, registry_key, outside_name in ( - ("presets", "presets", "outside-preset"), - ("extensions", "extensions", "outside-extension"), - ): - outside = project_dir.parent / outside_name - (outside / "templates").mkdir(parents=True) - (outside / "templates" / "spec-template.md").write_text( - f"# Sensitive {registry_key}\n", - encoding="utf-8", - ) - installed = project_dir / ".specify" / registry_dir - installed.mkdir(parents=True, exist_ok=True) - (installed / ".registry").write_text( - json.dumps( - { - registry_key: { - f"../../../{outside_name}": { - "enabled": True, - "priority": 1, - } - } - } - ), - encoding="utf-8", - ) - - content = PresetResolver(project_dir).resolve_content("spec-template") - - assert content is not None - assert "Core Spec Template" in content - assert "Sensitive" not in content - - def test_resolve_higher_priority_pack_wins(self, project_dir, temp_dir, valid_pack_data): - """Test that a pack with lower priority number wins over higher number.""" - manager = PresetManager(project_dir) - - # Create pack A (priority 10 — lower precedence) - pack_a_dir = temp_dir / "pack-a" - pack_a_dir.mkdir() - data_a = {**valid_pack_data} - data_a["preset"] = {**valid_pack_data["preset"], "id": "pack-a", "name": "Pack A"} - with open(pack_a_dir / "preset.yml", 'w') as f: - yaml.dump(data_a, f) - (pack_a_dir / "templates").mkdir() - (pack_a_dir / "templates" / "spec-template.md").write_text("# From Pack A\n") - - # Create pack B (priority 1 — higher precedence) - pack_b_dir = temp_dir / "pack-b" - pack_b_dir.mkdir() - data_b = {**valid_pack_data} - data_b["preset"] = {**valid_pack_data["preset"], "id": "pack-b", "name": "Pack B"} - with open(pack_b_dir / "preset.yml", 'w') as f: - yaml.dump(data_b, f) - (pack_b_dir / "templates").mkdir() - (pack_b_dir / "templates" / "spec-template.md").write_text("# From Pack B\n") - - # Install A first (priority 10), B second (priority 1) - manager.install_from_directory(pack_a_dir, "0.1.5", priority=10) - manager.install_from_directory(pack_b_dir, "0.1.5", priority=1) - - # Pack B should win because lower priority number - resolver = PresetResolver(project_dir) - result = resolver.resolve("spec-template") - assert result is not None - assert "From Pack B" in result.read_text() - - def test_resolve_override_takes_priority(self, project_dir): - """Test that project overrides take priority over core.""" - # Create override - overrides_dir = project_dir / ".specify" / "templates" / "overrides" - overrides_dir.mkdir(parents=True) - override = overrides_dir / "spec-template.md" - override.write_text("# Override Spec Template\n") - - resolver = PresetResolver(project_dir) - result = resolver.resolve("spec-template") - assert result is not None - assert "Override Spec Template" in result.read_text() - - def test_resolve_pack_takes_priority_over_core(self, project_dir, pack_dir): - """Test that installed packs take priority over core templates.""" - # Install the pack - manager = PresetManager(project_dir) - manager.install_from_directory(pack_dir, "0.1.5") - - resolver = PresetResolver(project_dir) - result = resolver.resolve("spec-template") - assert result is not None - assert "Custom Spec Template" in result.read_text() - - def _install_pack_with_manifest_file(self, project_dir, *, extra_file=False): - """Create a pack whose manifest declares a NON-convention file: path. - - Returns the pack dir under the project. The declared file lives at - custom/spec.md (not the convention templates/spec-template.md). - """ - presets_dir = project_dir / ".specify" / "presets" - pack_dir = presets_dir / "mypack" - (pack_dir / "custom").mkdir(parents=True) - (pack_dir / "custom" / "spec.md").write_text( - "# Manifest-declared Spec\n", encoding="utf-8" - ) - if extra_file: - # An undeclared convention-path file the manifest points away from. - (pack_dir / "templates").mkdir() - (pack_dir / "templates" / "spec-template.md").write_text( - "# Stray Convention Spec\n", encoding="utf-8" - ) - manifest = { - "schema_version": "1.0", - "preset": { - "id": "mypack", - "name": "My Pack", - "version": "1.0.0", - "description": "declares a non-convention file path", - }, - "requires": {"speckit_version": ">=0.1.0"}, - "provides": { - "templates": [ - { - "type": "template", - "name": "spec-template", - "file": "custom/spec.md", - "strategy": "replace", - } - ] - }, - } - with open(pack_dir / "preset.yml", "w") as f: - yaml.dump(manifest, f) - PresetRegistry(presets_dir).add( - "mypack", {"version": "1.0.0", "priority": 10} - ) - return pack_dir - - def test_resolve_uses_manifest_declared_file_path(self, project_dir): - """resolve() must honor a manifest-declared non-convention file: path. - - Previously the tier-2 loop was convention-only, so it returned the - core template and resolve_with_source() misattributed source='core', - diverging from collect_all_layers()/resolve_content(). - """ - pack_dir = self._install_pack_with_manifest_file(project_dir) - resolver = PresetResolver(project_dir) - - result = resolver.resolve("spec-template") - assert result == pack_dir / "custom" / "spec.md" - assert "Manifest-declared Spec" in result.read_text() - - sourced = resolver.resolve_with_source("spec-template") - assert sourced is not None - assert "mypack" in sourced["source"] - # resolve() must agree with collect_all_layers()'s top layer. - layers = resolver.collect_all_layers("spec-template") - assert Path(layers[0]["path"]) == pack_dir / "custom" / "spec.md" - - def test_resolve_manifest_file_wins_over_undeclared_convention_file( - self, project_dir - ): - """A stray convention-path file must not shadow the manifest's file:.""" - pack_dir = self._install_pack_with_manifest_file( - project_dir, extra_file=True - ) - resolver = PresetResolver(project_dir) - result = resolver.resolve("spec-template") - assert result == pack_dir / "custom" / "spec.md" - assert "Manifest-declared Spec" in result.read_text() - - def test_resolve_skips_convention_when_manifest_file_missing(self, project_dir): - """When the manifest declares a file: that does not exist, resolve() - must NOT fall back to a convention file in the same pack (that would - mask a typo) — it skips the pack and resolves core instead.""" - presets_dir = project_dir / ".specify" / "presets" - pack_dir = presets_dir / "mypack" - # Manifest declares custom/spec.md (MISSING); a convention file exists - # in the pack and must NOT be used. - (pack_dir / "templates").mkdir(parents=True) - (pack_dir / "templates" / "spec-template.md").write_text( - "# Stray Convention Spec\n", encoding="utf-8" - ) - manifest = { - "schema_version": "1.0", - "preset": { - "id": "mypack", - "name": "My Pack", - "version": "1.0.0", - "description": "declares a missing file path", - }, - "requires": {"speckit_version": ">=0.1.0"}, - "provides": { - "templates": [ - { - "type": "template", - "name": "spec-template", - "file": "custom/spec.md", - "strategy": "replace", - } - ] - }, - } - with open(pack_dir / "preset.yml", "w") as f: - yaml.dump(manifest, f) - PresetRegistry(presets_dir).add( - "mypack", {"version": "1.0.0", "priority": 10} - ) - - resolver = PresetResolver(project_dir) - result = resolver.resolve("spec-template") - assert result is not None - content = result.read_text() - assert "Stray Convention Spec" not in content # pack convention skipped - assert "Core Spec Template" in content # fell through to core - - def test_resolve_skips_convention_when_manifest_file_is_directory( - self, project_dir - ): - """When the manifest's file: path resolves to a DIRECTORY (not a regular - file), resolve()/collect_all_layers() must treat it as missing — exists() - would accept it and downstream read_text() on a directory would crash. - The pack is skipped (no convention fallback), so core wins.""" - presets_dir = project_dir / ".specify" / "presets" - pack_dir = presets_dir / "mypack" - # Declared file: custom/spec.md is created as a DIRECTORY. - (pack_dir / "custom" / "spec.md").mkdir(parents=True) - # A convention file also exists and must NOT be used. - (pack_dir / "templates").mkdir(parents=True) - (pack_dir / "templates" / "spec-template.md").write_text( - "# Stray Convention Spec\n", encoding="utf-8" - ) - manifest = { - "schema_version": "1.0", - "preset": { - "id": "mypack", - "name": "My Pack", - "version": "1.0.0", - "description": "declares a file: that is actually a directory", - }, - "requires": {"speckit_version": ">=0.1.0"}, - "provides": { - "templates": [ - { - "type": "template", - "name": "spec-template", - "file": "custom/spec.md", - "strategy": "replace", - } - ] - }, - } - with open(pack_dir / "preset.yml", "w") as f: - yaml.dump(manifest, f) - PresetRegistry(presets_dir).add( - "mypack", {"version": "1.0.0", "priority": 10} - ) - - resolver = PresetResolver(project_dir) - result = resolver.resolve("spec-template") - assert result is not None - assert result.is_file() # never a directory - content = result.read_text() - assert "Stray Convention Spec" not in content # pack convention skipped - assert "Core Spec Template" in content # fell through to core - # collect_all_layers() must agree: the directory is not a layer. - layers = resolver.collect_all_layers("spec-template") - assert all(Path(layer["path"]).is_file() for layer in layers) - assert all( - Path(layer["path"]) != pack_dir / "custom" / "spec.md" - for layer in layers - ) - - def test_resolve_override_takes_priority_over_pack(self, project_dir, pack_dir): - """Test that overrides take priority over installed packs.""" - # Install the pack - manager = PresetManager(project_dir) - manager.install_from_directory(pack_dir, "0.1.5") - - # Create override - overrides_dir = project_dir / ".specify" / "templates" / "overrides" - overrides_dir.mkdir(parents=True) - override = overrides_dir / "spec-template.md" - override.write_text("# Override Spec Template\n") - - resolver = PresetResolver(project_dir) - result = resolver.resolve("spec-template") - assert result is not None - assert "Override Spec Template" in result.read_text() - - def test_resolve_extension_provided_templates(self, project_dir): - """Test resolving templates provided by extensions.""" - # Create extension with templates - ext_dir = project_dir / ".specify" / "extensions" / "my-ext" - ext_templates_dir = ext_dir / "templates" - ext_templates_dir.mkdir(parents=True) - ext_template = ext_templates_dir / "custom-template.md" - ext_template.write_text("# Extension Custom Template\n") - - # Register extension in registry - extensions_dir = project_dir / ".specify" / "extensions" - ext_registry = ExtensionRegistry(extensions_dir) - ext_registry.add("my-ext", {"version": "1.0.0", "priority": 10}) - - resolver = PresetResolver(project_dir) - result = resolver.resolve("custom-template") - assert result is not None - assert "Extension Custom Template" in result.read_text() - - def test_resolve_disabled_extension_templates_skipped(self, project_dir): - """Test that disabled extension templates are not resolved.""" - # Create extension with templates - ext_dir = project_dir / ".specify" / "extensions" / "disabled-ext" - ext_templates_dir = ext_dir / "templates" - ext_templates_dir.mkdir(parents=True) - ext_template = ext_templates_dir / "disabled-template.md" - ext_template.write_text("# Disabled Extension Template\n") - - # Register extension as disabled - extensions_dir = project_dir / ".specify" / "extensions" - ext_registry = ExtensionRegistry(extensions_dir) - ext_registry.add("disabled-ext", {"version": "1.0.0", "priority": 1, "enabled": False}) - - # Template should NOT be resolved because extension is disabled - resolver = PresetResolver(project_dir) - result = resolver.resolve("disabled-template") - assert result is None, "Disabled extension template should not be resolved" - - def test_resolve_disabled_extension_not_picked_up_as_unregistered(self, project_dir): - """Test that disabled extensions are not picked up via unregistered dir scan.""" - # Create extension directory with templates - ext_dir = project_dir / ".specify" / "extensions" / "test-disabled-ext" - ext_templates_dir = ext_dir / "templates" - ext_templates_dir.mkdir(parents=True) - ext_template = ext_templates_dir / "unique-disabled-template.md" - ext_template.write_text("# Should Not Resolve\n") - - # Register the extension but disable it - extensions_dir = project_dir / ".specify" / "extensions" - ext_registry = ExtensionRegistry(extensions_dir) - ext_registry.add("test-disabled-ext", {"version": "1.0.0", "enabled": False}) - - # Verify the template is NOT resolved (even though the directory exists) - resolver = PresetResolver(project_dir) - result = resolver.resolve("unique-disabled-template") - assert result is None, "Disabled extension should not be picked up as unregistered" - - @pytest.mark.parametrize( - "registry_bytes", - [b"{ not valid json", b'{"extensions": []}', b"[]"], - ids=["invalid_json", "non_mapping_extensions", "non_mapping_root"], - ) - def test_resolve_fails_closed_on_corrupt_extension_registry( - self, project_dir, registry_bytes - ): - """A corrupt extension registry must fail closed rather than let the - directory scan admit every on-disk extension as enabled.""" - extensions_dir = project_dir / ".specify" / "extensions" - ext_templates_dir = extensions_dir / "sneaky-ext" / "templates" - ext_templates_dir.mkdir(parents=True) - (ext_templates_dir / "custom-template.md").write_text( - "# Should not be served\n" - ) - (extensions_dir / ".registry").write_bytes(registry_bytes) - - resolver = PresetResolver(project_dir) - with pytest.raises(PresetValidationError, match="Invalid extension registry"): - resolver._get_all_extensions_by_priority() - with pytest.raises(PresetValidationError, match="Invalid extension registry"): - resolver.resolve("custom-template") - - def test_resolve_fails_closed_when_registry_is_directory(self, project_dir): - """A directory at the registry path must fail closed, not be treated as - an absent registry that enables every on-disk extension.""" - extensions_dir = project_dir / ".specify" / "extensions" - ext_templates_dir = extensions_dir / "sneaky-ext" / "templates" - ext_templates_dir.mkdir(parents=True) - (ext_templates_dir / "custom-template.md").write_text( - "# Should not be served\n" - ) - (extensions_dir / ".registry").mkdir() - - resolver = PresetResolver(project_dir) - with pytest.raises(PresetValidationError, match="Invalid extension registry"): - resolver.resolve("custom-template") - - def test_resolve_fails_closed_when_registry_is_broken_symlink(self, project_dir): - """A dangling ``.registry`` symlink must fail closed. ``Path.exists()`` - follows symlinks and would mistake it for an absent registry, reopening - the fail-open directory scan.""" - extensions_dir = project_dir / ".specify" / "extensions" - ext_templates_dir = extensions_dir / "sneaky-ext" / "templates" - ext_templates_dir.mkdir(parents=True) - (ext_templates_dir / "custom-template.md").write_text( - "# Should not be served\n" - ) - (extensions_dir / ".registry").symlink_to( - extensions_dir / "does-not-exist" - ) - - registry = ExtensionRegistry(extensions_dir) - assert registry.is_corrupt() - resolver = PresetResolver(project_dir) - with pytest.raises(PresetValidationError, match="Invalid extension registry"): - resolver.resolve("custom-template") - - def test_resolve_pack_over_extension(self, project_dir, pack_dir, temp_dir, valid_pack_data): - """Test that pack templates take priority over extension templates.""" - # Create extension with templates - ext_dir = project_dir / ".specify" / "extensions" / "my-ext" - ext_templates_dir = ext_dir / "templates" - ext_templates_dir.mkdir(parents=True) - ext_template = ext_templates_dir / "spec-template.md" - ext_template.write_text("# Extension Spec Template\n") - - # Install a pack with the same template - manager = PresetManager(project_dir) - manager.install_from_directory(pack_dir, "0.1.5") - - resolver = PresetResolver(project_dir) - result = resolver.resolve("spec-template") - assert result is not None - # Pack should win over extension - assert "Custom Spec Template" in result.read_text() - - def test_resolve_with_source_core(self, project_dir): - """Test resolve_with_source for core template.""" - resolver = PresetResolver(project_dir) - result = resolver.resolve_with_source("spec-template") - assert result is not None - assert result["source"] == "core" - assert "spec-template.md" in result["path"] - - def test_resolve_with_source_override(self, project_dir): - """Test resolve_with_source for override template.""" - overrides_dir = project_dir / ".specify" / "templates" / "overrides" - overrides_dir.mkdir(parents=True) - override = overrides_dir / "spec-template.md" - override.write_text("# Override\n") - - resolver = PresetResolver(project_dir) - result = resolver.resolve_with_source("spec-template") - assert result is not None - assert result["source"] == "project override" - - def test_resolve_with_source_pack(self, project_dir, pack_dir): - """Test resolve_with_source for pack template.""" - manager = PresetManager(project_dir) - manager.install_from_directory(pack_dir, "0.1.5") - - resolver = PresetResolver(project_dir) - result = resolver.resolve_with_source("spec-template") - assert result is not None - assert "test-pack" in result["source"] - assert "v1.0.0" in result["source"] - - def test_resolve_with_source_extension(self, project_dir): - """Test resolve_with_source for extension-provided template.""" - ext_dir = project_dir / ".specify" / "extensions" / "my-ext" - ext_templates_dir = ext_dir / "templates" - ext_templates_dir.mkdir(parents=True) - ext_template = ext_templates_dir / "unique-template.md" - ext_template.write_text("# Unique\n") - - # Register extension in registry - extensions_dir = project_dir / ".specify" / "extensions" - ext_registry = ExtensionRegistry(extensions_dir) - ext_registry.add("my-ext", {"version": "1.0.0", "priority": 10}) - - resolver = PresetResolver(project_dir) - result = resolver.resolve_with_source("unique-template") - assert result is not None - assert result["source"] == "extension:my-ext v1.0.0" - - def test_resolve_with_source_not_found(self, project_dir): - """Test resolve_with_source for nonexistent template.""" - resolver = PresetResolver(project_dir) - result = resolver.resolve_with_source("nonexistent") - assert result is None - - def test_resolve_skips_hidden_extension_dirs(self, project_dir): - """Test that hidden directories in extensions are skipped.""" - ext_dir = project_dir / ".specify" / "extensions" / ".backup" - ext_templates_dir = ext_dir / "templates" - ext_templates_dir.mkdir(parents=True) - ext_template = ext_templates_dir / "hidden-template.md" - ext_template.write_text("# Hidden\n") - - resolver = PresetResolver(project_dir) - result = resolver.resolve("hidden-template") - assert result is None - - def test_collect_all_layers_finds_bundled_core_without_specify_commands( - self, project_dir - ): - """Tier-5 fallback locates the bundled core command when - .specify/templates/commands/ has no matching file. - - Regression test for #3086: a stale ``.parent`` chain made the - source-checkout fallback resolve to ``src/templates/...`` (which does - not exist), so ``wrap`` presets found no base layer. The fallback must - resolve against the real repo-root ``templates/commands`` tree. - """ - # project_dir's commands dir is empty, so tier-4 cannot satisfy this. - resolver = PresetResolver(project_dir) - layers = resolver.collect_all_layers("speckit.implement", "command") - assert layers, "expected a bundled core base layer to be found" - assert layers[-1]["source"] == "core (bundled)" - assert layers[-1]["path"].parts[-2:] == ("commands", "implement.md") - - def test_resolve_command_falls_back_to_bundled_core(self, project_dir): - """resolve() tier-5 returns the bundled core command when - .specify/templates/commands/ lacks it (regression for #3086).""" - resolver = PresetResolver(project_dir) - result = resolver.resolve("speckit.implement", "command") - assert result is not None - assert result.parts[-2:] == ("commands", "implement.md") - - -class TestResolveCore: - """Test PresetResolver.resolve_core() skips the installed-presets tier.""" - - def test_resolve_core_does_not_return_preset_files(self, project_dir): - """resolve_core must not return files from .specify/presets/.""" - preset_cmd_dir = project_dir / ".specify" / "presets" / "my-preset" / "commands" - preset_cmd_dir.mkdir(parents=True) - (preset_cmd_dir / "specify.md").write_text("---\ndescription: preset wrap\n---\n\nwrap body\n") - - resolver = PresetResolver(project_dir) - result = resolver.resolve_core("specify", "command") - # The preset file must never be returned — but the bundled core may be. - if result is not None: - assert "presets" not in result.parts - - def test_resolve_core_returns_core_template(self, project_dir): - """resolve_core falls through to core templates (tier 4).""" - core_cmd_dir = project_dir / ".specify" / "templates" / "commands" - core_cmd_dir.mkdir(parents=True, exist_ok=True) - (core_cmd_dir / "specify.md").write_text("---\ndescription: core\n---\n\ncore body\n") - - # Also place a preset file — resolve_core must still return the core - preset_cmd_dir = project_dir / ".specify" / "presets" / "my-preset" / "commands" - preset_cmd_dir.mkdir(parents=True) - (preset_cmd_dir / "specify.md").write_text("---\ndescription: preset wrap\n---\n\nwrap body\n") - - resolver = PresetResolver(project_dir) - result = resolver.resolve_core("specify", "command") - assert result is not None - assert "presets" not in result.parts - assert result.parts[-3:] == ("templates", "commands", "specify.md") - - def test_resolve_core_returns_override(self, project_dir): - """resolve_core returns tier-1 override if present.""" - override_dir = project_dir / ".specify" / "templates" / "overrides" - override_dir.mkdir(parents=True) - (override_dir / "specify.md").write_text("---\ndescription: override\n---\n\noverride body\n") - - resolver = PresetResolver(project_dir) - result = resolver.resolve_core("specify", "command") - assert result is not None - assert result.parts[-2:] == ("overrides", "specify.md") - - def test_resolve_core_returns_extension_template(self, project_dir): - """resolve_core returns extension templates (tier 3).""" - ext_cmd_dir = project_dir / ".specify" / "extensions" / "myext" / "commands" - ext_cmd_dir.mkdir(parents=True) - (ext_cmd_dir / "myext-cmd.md").write_text("---\ndescription: ext\n---\n\next body\n") - - resolver = PresetResolver(project_dir) - result = resolver.resolve_core("myext-cmd", "command") - assert result is not None - assert result.parts[-4:-1] == ("extensions", "myext", "commands") - - def test_resolve_core_returns_none_when_nothing_found(self, project_dir): - """resolve_core returns None when no file found in tiers 1/3/4.""" - resolver = PresetResolver(project_dir) - result = resolver.resolve_core("nonexistent", "command") - assert result is None - - def test_resolve_extension_command_via_manifest_skips_oserror_manifests(self, project_dir): - """resolve_extension_command_via_manifest skips extensions whose manifest raises OSError.""" - import unittest.mock as mock - - ext_dir = project_dir / ".specify" / "extensions" / "bad-ext" - cmd_dir = ext_dir / "commands" - cmd_dir.mkdir(parents=True) - (cmd_dir / "mycmd.md").write_text("---\ndescription: d\n---\n\nbody\n") - (ext_dir / "extension.yml").write_text( - "schema_version: '1.0'\n" - "extension:\n id: bad-ext\n name: Bad\n version: 1.0.0\n" - " description: d\n author: a\n repository: https://example.com\n" - " license: MIT\n" - "requires:\n speckit_version: '>=0.2.0'\n" - "provides:\n commands:\n" - " - name: speckit.bad-ext.mycmd\n" - " file: commands/mycmd.md\n" - " description: My command\n" - ) - - resolver = PresetResolver(project_dir) - # Simulate a permission error when opening the manifest file. - with mock.patch("builtins.open", side_effect=PermissionError("denied")): - result = resolver.resolve_extension_command_via_manifest("speckit.bad-ext.mycmd") - - assert result is None, "OSError during manifest load must be silently skipped" - - -class TestExtensionPriorityResolution: - """Test extension priority resolution with registered and unregistered extensions.""" - - def test_unregistered_beats_registered_with_lower_precedence(self, project_dir): - """Unregistered extension (implicit priority 10) beats registered with priority 20.""" - extensions_dir = project_dir / ".specify" / "extensions" - extensions_dir.mkdir(parents=True, exist_ok=True) - - # Create registered extension with priority 20 (lower precedence than 10) - registered_dir = extensions_dir / "registered-ext" - (registered_dir / "templates").mkdir(parents=True) - (registered_dir / "templates" / "test-template.md").write_text("# From Registered\n") - - ext_registry = ExtensionRegistry(extensions_dir) - ext_registry.add("registered-ext", {"version": "1.0.0", "priority": 20}) - - # Create unregistered extension directory (implicit priority 10) - unregistered_dir = extensions_dir / "unregistered-ext" - (unregistered_dir / "templates").mkdir(parents=True) - (unregistered_dir / "templates" / "test-template.md").write_text("# From Unregistered\n") - - # Unregistered (priority 10) should beat registered (priority 20) - resolver = PresetResolver(project_dir) - result = resolver.resolve("test-template") - assert result is not None - assert "From Unregistered" in result.read_text() - - def test_registered_with_higher_precedence_beats_unregistered(self, project_dir): - """Registered extension with priority 5 beats unregistered (implicit priority 10).""" - extensions_dir = project_dir / ".specify" / "extensions" - extensions_dir.mkdir(parents=True, exist_ok=True) - - # Create registered extension with priority 5 (higher precedence than 10) - registered_dir = extensions_dir / "registered-ext" - (registered_dir / "templates").mkdir(parents=True) - (registered_dir / "templates" / "test-template.md").write_text("# From Registered\n") - - ext_registry = ExtensionRegistry(extensions_dir) - ext_registry.add("registered-ext", {"version": "1.0.0", "priority": 5}) - - # Create unregistered extension directory (implicit priority 10) - unregistered_dir = extensions_dir / "unregistered-ext" - (unregistered_dir / "templates").mkdir(parents=True) - (unregistered_dir / "templates" / "test-template.md").write_text("# From Unregistered\n") - - # Registered (priority 5) should beat unregistered (priority 10) - resolver = PresetResolver(project_dir) - result = resolver.resolve("test-template") - assert result is not None - assert "From Registered" in result.read_text() - - def test_unregistered_attribution_with_priority_ordering(self, project_dir): - """Test resolve_with_source correctly attributes unregistered extension.""" - extensions_dir = project_dir / ".specify" / "extensions" - extensions_dir.mkdir(parents=True, exist_ok=True) - - # Create registered extension with priority 20 - registered_dir = extensions_dir / "registered-ext" - (registered_dir / "templates").mkdir(parents=True) - (registered_dir / "templates" / "test-template.md").write_text("# From Registered\n") - - ext_registry = ExtensionRegistry(extensions_dir) - ext_registry.add("registered-ext", {"version": "1.0.0", "priority": 20}) - - # Create unregistered extension (implicit priority 10) - unregistered_dir = extensions_dir / "unregistered-ext" - (unregistered_dir / "templates").mkdir(parents=True) - (unregistered_dir / "templates" / "test-template.md").write_text("# From Unregistered\n") - - # Attribution should show unregistered extension - resolver = PresetResolver(project_dir) - result = resolver.resolve_with_source("test-template") - assert result is not None - assert "unregistered-ext" in result["source"] - assert "(unregistered)" in result["source"] - - def test_same_priority_sorted_alphabetically(self, project_dir): - """Extensions with same priority are sorted alphabetically by ID.""" - extensions_dir = project_dir / ".specify" / "extensions" - extensions_dir.mkdir(parents=True, exist_ok=True) - - # Create two unregistered extensions (both implicit priority 10) - # "aaa-ext" should come before "zzz-ext" alphabetically - zzz_dir = extensions_dir / "zzz-ext" - (zzz_dir / "templates").mkdir(parents=True) - (zzz_dir / "templates" / "test-template.md").write_text("# From ZZZ\n") - - aaa_dir = extensions_dir / "aaa-ext" - (aaa_dir / "templates").mkdir(parents=True) - (aaa_dir / "templates" / "test-template.md").write_text("# From AAA\n") - - # AAA should win due to alphabetical ordering at same priority - resolver = PresetResolver(project_dir) - result = resolver.resolve("test-template") - assert result is not None - assert "From AAA" in result.read_text() - - -# ===== PresetCatalog Tests ===== - - -class TestPresetCatalog: - """Test template catalog functionality.""" - - def _inject_github_config(self, monkeypatch, token_env="GH_TOKEN"): - from tests.specify_cli.authentication.helpers import inject_github_config - inject_github_config(monkeypatch, token_env) - - def test_default_catalog_url(self, project_dir): - """Test default catalog URL.""" - catalog = PresetCatalog(project_dir) - assert catalog.DEFAULT_CATALOG_URL.startswith("https://") - assert catalog.DEFAULT_CATALOG_URL.endswith("/presets/catalog.json") - - def test_community_catalog_url(self, project_dir): - """Test community catalog URL.""" - catalog = PresetCatalog(project_dir) - assert "presets/catalog.community.json" in catalog.COMMUNITY_CATALOG_URL - - def test_cache_validation_no_cache(self, project_dir): - """Test cache validation when no cache exists.""" - catalog = PresetCatalog(project_dir) - assert catalog.is_cache_valid() is False - - def test_cache_validation_valid(self, project_dir): - """Test cache validation with valid cache.""" - catalog = PresetCatalog(project_dir) - catalog.cache_dir.mkdir(parents=True, exist_ok=True) - - catalog.cache_file.write_text(json.dumps({ - "schema_version": "1.0", - "presets": {}, - })) - catalog.cache_metadata_file.write_text(json.dumps({ - "cached_at": datetime.now(timezone.utc).isoformat(), - })) - - assert catalog.is_cache_valid() is True - - def test_cache_validation_expired(self, project_dir): - """Test cache validation with expired cache.""" - catalog = PresetCatalog(project_dir) - catalog.cache_dir.mkdir(parents=True, exist_ok=True) - - catalog.cache_file.write_text(json.dumps({ - "schema_version": "1.0", - "presets": {}, - })) - catalog.cache_metadata_file.write_text(json.dumps({ - "cached_at": "2020-01-01T00:00:00+00:00", - })) - - assert catalog.is_cache_valid() is False - - def test_cache_validation_corrupted(self, project_dir): - """Test cache validation with corrupted metadata.""" - catalog = PresetCatalog(project_dir) - catalog.cache_dir.mkdir(parents=True, exist_ok=True) - - catalog.cache_file.write_text("not json") - catalog.cache_metadata_file.write_text("not json") - - assert catalog.is_cache_valid() is False - - def test_clear_cache(self, project_dir): - """Test clearing the cache.""" - catalog = PresetCatalog(project_dir) - catalog.cache_dir.mkdir(parents=True, exist_ok=True) - catalog.cache_file.write_text("{}") - catalog.cache_metadata_file.write_text("{}") - - catalog.clear_cache() - - assert not catalog.cache_file.exists() - assert not catalog.cache_metadata_file.exists() - - def test_search_with_cached_data(self, project_dir, monkeypatch): - """Test search with cached catalog data.""" - from unittest.mock import patch - - monkeypatch.delenv("SPECKIT_PRESET_CATALOG_URL", raising=False) - catalog = PresetCatalog(project_dir) - catalog.cache_dir.mkdir(parents=True, exist_ok=True) - - catalog_data = { - "schema_version": "1.0", - "presets": { - "safe-agile": { - "name": "SAFe Agile Templates", - "description": "SAFe-aligned templates", - "author": "agile-community", - "version": "1.0.0", - "tags": ["safe", "agile"], - }, - "healthcare": { - "name": "Healthcare Compliance", - "description": "HIPAA-compliant templates", - "author": "healthcare-org", - "version": "1.0.0", - "tags": ["healthcare", "hipaa"], - }, - } - } - - catalog.cache_file.write_text(json.dumps(catalog_data)) - catalog.cache_metadata_file.write_text(json.dumps({ - "cached_at": datetime.now(timezone.utc).isoformat(), - })) - - # Isolate from community catalog so results are deterministic - default_only = [PresetCatalogEntry(url=catalog.DEFAULT_CATALOG_URL, name="default", priority=1, install_allowed=True)] - with patch.object(catalog, "get_active_catalogs", return_value=default_only): - # Search by query - results = catalog.search(query="agile") - assert len(results) == 1 - assert results[0]["id"] == "safe-agile" - - # Search by tag - results = catalog.search(tag="hipaa") - assert len(results) == 1 - assert results[0]["id"] == "healthcare" - - # Search by author - results = catalog.search(author="agile-community") - assert len(results) == 1 - - # Search all - results = catalog.search() - assert len(results) == 2 - - def test_get_pack_info(self, project_dir): - """Test getting info for a specific pack.""" - catalog = PresetCatalog(project_dir) - catalog.cache_dir.mkdir(parents=True, exist_ok=True) - - catalog_data = { - "schema_version": "1.0", - "presets": { - "test-pack": { - "name": "Test Pack", - "version": "1.0.0", - }, - } - } - - catalog.cache_file.write_text(json.dumps(catalog_data)) - catalog.cache_metadata_file.write_text(json.dumps({ - "cached_at": datetime.now(timezone.utc).isoformat(), - })) - - info = catalog.get_pack_info("test-pack") - assert info is not None - assert info["name"] == "Test Pack" - assert info["id"] == "test-pack" - - assert catalog.get_pack_info("nonexistent") is None - - def test_validate_catalog_url_https(self, project_dir): - """Test that HTTPS URLs are accepted.""" - catalog = PresetCatalog(project_dir) - catalog._validate_catalog_url("https://example.com/catalog.json") - - def test_validate_catalog_url_http_rejected(self, project_dir): - """Test that HTTP URLs are rejected.""" - catalog = PresetCatalog(project_dir) - with pytest.raises(PresetValidationError, match="must use HTTPS"): - catalog._validate_catalog_url("http://example.com/catalog.json") - - def test_validate_catalog_url_localhost_http_allowed(self, project_dir): - """Test that HTTP is allowed for localhost.""" - catalog = PresetCatalog(project_dir) - catalog._validate_catalog_url("http://localhost:8080/catalog.json") - catalog._validate_catalog_url("http://127.0.0.1:8080/catalog.json") - - @pytest.mark.parametrize( - "url", - [ - "https://:8080", # port only, no host - "https://:8080/catalog.json", # port only, with path - "https://:0", # port only, no host - "https://user@", # userinfo only, no host - "https://user:pass@", # userinfo only, no host - ], - ) - def test_validate_catalog_url_hostless_rejected(self, project_dir, url): - """Reject host-less URLs whose netloc is truthy but hostname is None (#3209). - - ``urlparse('https://:8080').netloc`` is ``':8080'`` (truthy) but its - ``hostname`` is ``None``, so a netloc-based check would accept a URL - with no actual host, contradicting the "valid URL with a host" error. - """ - catalog = PresetCatalog(project_dir) - with pytest.raises(PresetValidationError, match="valid URL with a host"): - catalog._validate_catalog_url(url) - - def test_validate_catalog_url_malformed_rejected(self, project_dir): - """A malformed URL raises PresetValidationError, not a raw ValueError. - - ``urlparse('https://[::1').hostname`` raises ``ValueError: Invalid IPv6 - URL`` (unterminated bracket). Without wrapping, that leaks past callers' - ``except PresetValidationError`` guards and crashes the CLI. Mirrors the - shared ``CatalogStackBase`` (#3435) and ``IntegrationCatalog`` behaviour. - """ - catalog = PresetCatalog(project_dir) - with pytest.raises(PresetValidationError, match="malformed"): - catalog._validate_catalog_url("https://[::1") - - def test_validate_catalog_url_out_of_range_port_rejected(self, project_dir): - """An out-of-range port raises ValueError lazily on ``.port`` access. - - ``urlparse(...).hostname`` alone does not validate the port, so - without a ``_ = parsed.port`` probe inside the try/except, a URL like - ``https://example.com:99999/catalog.json`` sails through this - validator and only fails later, at fetch time, with a raw - untranslated error instead of a clean ``PresetValidationError``. The - sibling ``preset add --from `` download-URL guard already - catches this shape (see - ``test_preset_add_from_url_out_of_range_port_exits_cleanly``); this - catalog-source-URL validator had drifted from it and from the - original guard in ``specify_cli.catalogs``/ - ``bundler/services/adapters.py``. - """ - catalog = PresetCatalog(project_dir) - with pytest.raises(PresetValidationError, match="malformed"): - catalog._validate_catalog_url("https://example.com:99999/catalog.json") - - def test_env_var_catalog_url(self, project_dir, monkeypatch): - """Test catalog URL from environment variable.""" - monkeypatch.setenv("SPECKIT_PRESET_CATALOG_URL", "https://custom.example.com/catalog.json") - catalog = PresetCatalog(project_dir) - assert catalog.get_catalog_url() == "https://custom.example.com/catalog.json" - - # --- _make_request / GitHub auth --- - - def test_make_request_no_token_no_auth_header(self, project_dir, monkeypatch): - """Without a token, requests carry no Authorization header.""" - monkeypatch.delenv("GITHUB_TOKEN", raising=False) - monkeypatch.delenv("GH_TOKEN", raising=False) - catalog = PresetCatalog(project_dir) - req = catalog._make_request("https://raw.githubusercontent.com/org/repo/main/catalog.json") - assert "Authorization" not in req.headers - - def test_make_request_whitespace_only_github_token_ignored(self, project_dir, monkeypatch): - """A whitespace-only GITHUB_TOKEN is treated as unset.""" - monkeypatch.setenv("GITHUB_TOKEN", " ") - monkeypatch.delenv("GH_TOKEN", raising=False) - catalog = PresetCatalog(project_dir) - req = catalog._make_request("https://raw.githubusercontent.com/org/repo/main/catalog.json") - assert "Authorization" not in req.headers - - def test_make_request_whitespace_github_token_falls_back_to_gh_token(self, project_dir, monkeypatch): - """When GITHUB_TOKEN is whitespace-only, GH_TOKEN is used as fallback.""" - monkeypatch.setenv("GITHUB_TOKEN", " ") - monkeypatch.setenv("GH_TOKEN", "ghp_fallback") - self._inject_github_config(monkeypatch, token_env="GH_TOKEN") - catalog = PresetCatalog(project_dir) - req = catalog._make_request("https://raw.githubusercontent.com/org/repo/main/catalog.json") - assert req.get_header("Authorization") == "Bearer ghp_fallback" - - def test_make_request_github_token_added_for_github_url(self, project_dir, monkeypatch): - """GITHUB_TOKEN is attached for raw.githubusercontent.com URLs.""" - monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken") - monkeypatch.delenv("GH_TOKEN", raising=False) - self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN") - catalog = PresetCatalog(project_dir) - req = catalog._make_request("https://raw.githubusercontent.com/org/repo/main/catalog.json") - assert req.get_header("Authorization") == "Bearer ghp_testtoken" - - def test_make_request_gh_token_fallback(self, project_dir, monkeypatch): - """GH_TOKEN is used when GITHUB_TOKEN is absent.""" - monkeypatch.delenv("GITHUB_TOKEN", raising=False) - monkeypatch.setenv("GH_TOKEN", "ghp_ghtoken") - self._inject_github_config(monkeypatch, token_env="GH_TOKEN") - catalog = PresetCatalog(project_dir) - req = catalog._make_request("https://github.com/org/repo/releases/download/v1/pack.zip") - assert req.get_header("Authorization") == "Bearer ghp_ghtoken" - - def test_make_request_gh_token_takes_precedence(self, project_dir, monkeypatch): - """When auth.json uses GH_TOKEN, that token is used regardless of GITHUB_TOKEN.""" - monkeypatch.setenv("GITHUB_TOKEN", "ghp_secondary") - monkeypatch.setenv("GH_TOKEN", "ghp_primary") - self._inject_github_config(monkeypatch, token_env="GH_TOKEN") - catalog = PresetCatalog(project_dir) - req = catalog._make_request("https://api.github.com/repos/org/repo") - assert req.get_header("Authorization") == "Bearer ghp_primary" - - def test_make_request_token_added_for_codeload_github_com(self, project_dir, monkeypatch): - """GITHUB_TOKEN is attached for codeload.github.com URLs.""" - monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken") - self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN") - catalog = PresetCatalog(project_dir) - req = catalog._make_request("https://codeload.github.com/org/repo/zip/refs/tags/v1.0.0") - assert req.get_header("Authorization") == "Bearer ghp_testtoken" - - def test_make_request_no_auth_for_non_matching_host(self, project_dir, monkeypatch): - """Auth is NOT attached to hosts not listed in auth.json.""" - monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken") - self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN") - catalog = PresetCatalog(project_dir) - req = catalog._make_request("https://internal.example.com/catalog.json") - assert "Authorization" not in req.headers - - def test_make_request_no_auth_when_no_config(self, project_dir, monkeypatch): - """No auth header when no auth.json config exists.""" - monkeypatch.delenv("GITHUB_TOKEN", raising=False) - monkeypatch.delenv("GH_TOKEN", raising=False) - catalog = PresetCatalog(project_dir) - req = catalog._make_request("https://github.com/org/repo/releases/download/v1/pack.zip") - assert "Authorization" not in req.headers - - def test_fetch_single_catalog_sends_auth_header(self, project_dir, monkeypatch): - """_fetch_single_catalog passes Authorization header when configured.""" - from unittest.mock import patch, MagicMock - - monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken") - self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN") - catalog = PresetCatalog(project_dir) - - catalog_data = {"schema_version": "1.0", "presets": {}} - mock_response = MagicMock() - mock_response.read.side_effect = io.BytesIO(json.dumps(catalog_data).encode()).read - mock_response.__enter__ = lambda s: s - mock_response.__exit__ = MagicMock(return_value=False) - mock_response.geturl.return_value = "https://raw.githubusercontent.com/org/repo/main/presets/catalog.json" - - captured = {} - mock_opener = MagicMock() - - def fake_open(req, timeout=None): - captured["req"] = req - return mock_response - - mock_opener.open.side_effect = fake_open - - entry = PresetCatalogEntry( - url="https://raw.githubusercontent.com/org/repo/main/presets/catalog.json", - name="private", - priority=1, - install_allowed=True, - ) - - with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener): - catalog._fetch_single_catalog(entry, force_refresh=True) - - assert captured["req"].get_header("Authorization") == "Bearer ghp_testtoken" - - def test_fetch_single_catalog_revalidates_redirected_url(self, project_dir): - """An HTTPS catalog URL that redirects to http:// must be rejected AFTER - the redirect. _open_url follows redirects (auth stripped on downgrade), - so without re-validating response.geturl() the http payload would still - be fetched and trusted — and it supplies each preset's download_url + - sha256, defeating verify_archive_sha256. Parity with the - integrations/workflows catalog fetchers.""" - catalog = PresetCatalog(project_dir) - - class _Resp: - def __enter__(self): - return self - - def __exit__(self, *a): - return False - - def read(self): - return json.dumps({"schema_version": "1.0", "presets": {}}).encode() - - def geturl(self): - return "http://evil.test/catalog.json" # downgraded via redirect - - catalog._open_url = lambda url, timeout=None, redirect_validator=None: _Resp() - - entry = PresetCatalogEntry( - url="https://good.example/catalog.json", - name="c", - priority=1, - install_allowed=True, - ) - with pytest.raises(PresetValidationError, match="HTTPS"): - catalog._fetch_single_catalog(entry, force_refresh=True) - - def test_fetch_single_catalog_validates_every_redirect_hop(self, project_dir): - """A redirect_validator is passed to _open_url and rejects a non-HTTPS - INTERMEDIATE hop — closing the https -> http -> attacker-https chain that - a terminal-URL-only check would miss.""" - catalog = PresetCatalog(project_dir) - captured = {} - - def fake_open(url, timeout=None, redirect_validator=None): - captured["rv"] = redirect_validator - # Simulate the hop urllib validates before following the redirect. - redirect_validator("https://good.example/catalog.json", "http://evil.test/hop") - raise AssertionError("redirect_validator should have raised") - - catalog._open_url = fake_open - entry = PresetCatalogEntry( - url="https://good.example/catalog.json", - name="c", - priority=1, - install_allowed=True, - ) - with pytest.raises(PresetValidationError, match="HTTPS"): - catalog._fetch_single_catalog(entry, force_refresh=True) - assert captured["rv"] is not None - - def test_fetch_catalog_legacy_revalidates_redirected_url(self, project_dir): - """The legacy single-catalog fetch_catalog() path also rejects an - HTTPS -> http redirected payload (final geturl() check), matching - _fetch_single_catalog — it previously parsed the body with no check.""" - catalog = PresetCatalog(project_dir) - - class _Resp: - def __enter__(self): - return self - - def __exit__(self, *a): - return False - - def read(self): - return json.dumps({"schema_version": "1.0", "presets": {}}).encode() - - def geturl(self): - return "http://evil.test/catalog.json" - - catalog._open_url = lambda url, timeout=None, redirect_validator=None: _Resp() - with pytest.raises(PresetError, match="HTTPS"): - catalog.fetch_catalog(force_refresh=True) - - def test_fetch_catalog_legacy_validates_every_redirect_hop(self, project_dir): - """The legacy fetch_catalog() path also validates every INTERMEDIATE hop - (not just the terminal URL): it must supply a redirect_validator that - rejects an insecure hop, so an https -> http -> https chain is caught.""" - catalog = PresetCatalog(project_dir) - captured = {} - - def fake_open(url, timeout=None, redirect_validator=None): - captured["rv"] = redirect_validator - redirect_validator(url, "http://evil.test/hop") - raise AssertionError("redirect_validator should have raised") - - catalog._open_url = fake_open - with pytest.raises(PresetError, match="HTTPS"): - catalog.fetch_catalog(force_refresh=True) - assert captured["rv"] is not None - - @pytest.mark.parametrize( - "payload", - [ - # Root is not a JSON object. - [], - "oops", - 42, - None, - # Root is fine but ``presets`` is the wrong type. - {"schema_version": "1.0", "presets": []}, - {"schema_version": "1.0", "presets": "oops"}, - {"schema_version": "1.0", "presets": None}, - {"schema_version": "1.0", "presets": 42}, - ], - ) - def test_fetch_single_catalog_rejects_malformed_payload(self, project_dir, payload): - """Malformed catalog payloads raise PresetError, not AttributeError. - - Without this guard, a payload like ``{"presets": []}`` would pass the - key-presence check and then crash with ``AttributeError: 'list' object - has no attribute 'items'`` deep inside ``_get_merged_packs``. The - sibling integration catalog reader already validates both the root - object and the nested mapping (see ``integrations/catalog.py``); the - preset catalog must stay consistent. - """ - from unittest.mock import patch, MagicMock - - catalog = PresetCatalog(project_dir) - - mock_response = MagicMock() - mock_response.read.side_effect = io.BytesIO(json.dumps(payload).encode()).read - mock_response.__enter__ = lambda s: s - mock_response.__exit__ = MagicMock(return_value=False) - # A real urllib response reports the final URL (== request URL with no - # redirect); the fetcher re-validates it after redirects. - mock_response.geturl.return_value = "https://example.com/catalog.json" - - entry = PresetCatalogEntry( - url="https://example.com/catalog.json", - name="default", - priority=1, - install_allowed=True, - ) - - with patch.object(catalog, "_open_url", return_value=mock_response): - with pytest.raises(PresetError, match="Invalid preset catalog format"): - catalog._fetch_single_catalog(entry, force_refresh=True) - - @pytest.mark.parametrize( - "cached_payload", - [ - [], - "oops", - 42, - None, - {"schema_version": "1.0", "presets": []}, - {"schema_version": "1.0", "presets": "oops"}, - {"schema_version": "1.0", "presets": None}, - ], - ) - def test_fetch_single_catalog_rejects_malformed_cached_payload( - self, project_dir, cached_payload - ): - """A poisoned cache silently falls back to the network instead of - crashing — cached payloads pass through the same shape validation - as freshly-fetched ones. - - Without this, a cache poisoned by an older spec-kit version (or a - manual edit, or an upstream that briefly served a bad payload - before the network guards landed) would re-crash every invocation - of ``_get_merged_packs`` despite the cache being "valid" by age. - The recovery contract is: if the cached payload fails validation, - drop it and refetch — never propagate ``AttributeError`` to the - caller. - """ - from unittest.mock import patch, MagicMock - - catalog = PresetCatalog(project_dir) - - # Poison the default-URL cache. ``DEFAULT_CATALOG_URL`` and - # non-default URLs both flow through the same cache-load branch. - cache_file, metadata_file = catalog._get_cache_paths( - catalog.DEFAULT_CATALOG_URL - ) - cache_file.parent.mkdir(parents=True, exist_ok=True) - cache_file.write_text(json.dumps(cached_payload)) - metadata_file.write_text( - json.dumps( - { - "cached_at": datetime.now(timezone.utc).isoformat(), - "catalog_url": catalog.DEFAULT_CATALOG_URL, - } - ) - ) - - # Network refetch returns a valid payload so the recovery path - # can complete. - valid = { - "schema_version": "1.0", - "presets": {"foo": {"name": "Foo", "version": "1.0.0"}}, - } - mock_response = MagicMock() - mock_response.read.side_effect = io.BytesIO(json.dumps(valid).encode()).read - mock_response.__enter__ = lambda s: s - mock_response.__exit__ = MagicMock(return_value=False) - mock_response.geturl.return_value = catalog.DEFAULT_CATALOG_URL - - entry = PresetCatalogEntry( - url=catalog.DEFAULT_CATALOG_URL, - name="default", - priority=1, - install_allowed=True, - ) - - with patch.object(catalog, "_open_url", return_value=mock_response): - result = catalog._fetch_single_catalog(entry, force_refresh=False) - - # The poisoned cache was discarded and the network payload returned. - assert result == valid - - @pytest.mark.parametrize( - "payload", - [ - # Root is not a JSON object. - [], - "oops", - 42, - None, - # Root is fine but ``presets`` is the wrong type. - {"schema_version": "1.0", "presets": []}, - {"schema_version": "1.0", "presets": "oops"}, - {"schema_version": "1.0", "presets": None}, - ], - ) - def test_fetch_catalog_rejects_malformed_payload(self, project_dir, payload): - """Legacy ``fetch_catalog`` reuses the same shape-validation helper. - - Before this change ``fetch_catalog`` only checked key presence — - so a payload like ``42`` would crash with - ``TypeError: argument of type 'int' is not iterable`` during the - ``"schema_version" in catalog_data`` check, and an entry mapping - of the wrong type would crash downstream. Reusing - ``_validate_catalog_payload`` keeps the network-side behaviour of - the legacy single-catalog method consistent with the multi-catalog - ``_fetch_single_catalog`` path. - """ - from unittest.mock import patch, MagicMock - - catalog = PresetCatalog(project_dir) - mock_response = MagicMock() - mock_response.read.side_effect = io.BytesIO(json.dumps(payload).encode()).read - mock_response.__enter__ = lambda s: s - mock_response.__exit__ = MagicMock(return_value=False) - mock_response.geturl.return_value = "https://example.com/catalog.json" - - with patch.object(catalog, "_open_url", return_value=mock_response): - with pytest.raises(PresetError, match="Invalid preset catalog format"): - catalog.fetch_catalog(force_refresh=True) - - def test_fetch_catalog_recovers_from_unreadable_cache(self, project_dir): - """An unreadable / wrong-encoded cache file silently refetches. - - The cache contract is best-effort: a JSON-decode failure, an OS - read failure (permissions / disk / handle limit), or an invalid - text encoding on a cache file written by an older client must - all fall through to the network fetch rather than crash the - caller. Covers Copilot's review point that the previous - ``except (json.JSONDecodeError, OSError)`` was missing - ``UnicodeError``. - """ - from unittest.mock import patch, MagicMock - - catalog = PresetCatalog(project_dir) - catalog.cache_dir.mkdir(parents=True, exist_ok=True) - # Invalid UTF-8 bytes so ``read_text`` raises ``UnicodeDecodeError`` - # (a subclass of ``UnicodeError``). - catalog.cache_file.write_bytes(b"\xff\xfe\x00not-utf-8") - catalog.cache_metadata_file.write_text( - json.dumps( - { - "cached_at": datetime.now(timezone.utc).isoformat(), - "catalog_url": catalog.get_catalog_url(), - } - ), - encoding="utf-8", - ) - - valid = { - "schema_version": "1.0", - "presets": {"foo": {"name": "Foo", "version": "1.0.0"}}, - } - mock_response = MagicMock() - mock_response.read.side_effect = io.BytesIO(json.dumps(valid).encode()).read - mock_response.__enter__ = lambda s: s - mock_response.__exit__ = MagicMock(return_value=False) - mock_response.geturl.return_value = "https://example.com/catalog.json" - - with patch.object(catalog, "_open_url", return_value=mock_response): - result = catalog.fetch_catalog(force_refresh=False) - - # Recovered via network rather than crashing on the unreadable cache. - assert result == valid - - def test_fetch_catalog_recovers_from_unreadable_metadata(self, project_dir): - """A wrongly-encoded metadata file degrades to a cache miss. - - ``is_cache_valid`` is consulted *before* the cache payload is - read; if the metadata file itself can't be decoded (e.g. it was - written on a host whose default codec isn't UTF-8) the validity - check must return ``False`` rather than propagate - ``UnicodeDecodeError``. Without that guard, a corrupted metadata - file would crash every invocation instead of falling through to - a network refetch. - """ - from unittest.mock import patch, MagicMock - - catalog = PresetCatalog(project_dir) - catalog.cache_dir.mkdir(parents=True, exist_ok=True) - catalog.cache_file.write_text("{}", encoding="utf-8") - # Bytes that are not valid UTF-8 — ``read_text(encoding="utf-8")`` - # will raise ``UnicodeDecodeError`` (subclass of ``UnicodeError``). - catalog.cache_metadata_file.write_bytes(b"\xff\xfe\x00bad") - - # is_cache_valid must absorb the decode failure, not crash. - assert catalog.is_cache_valid() is False - - valid = { - "schema_version": "1.0", - "presets": {"foo": {"name": "Foo", "version": "1.0.0"}}, - } - mock_response = MagicMock() - mock_response.read.side_effect = io.BytesIO(json.dumps(valid).encode()).read - mock_response.__enter__ = lambda s: s - mock_response.__exit__ = MagicMock(return_value=False) - mock_response.geturl.return_value = "https://example.com/catalog.json" - - with patch.object(catalog, "_open_url", return_value=mock_response): - result = catalog.fetch_catalog(force_refresh=False) - - assert result == valid - - @pytest.mark.parametrize( - "non_mapping_metadata", - [ - "[]", # JSON array - '"oops"', # JSON string - "42", # JSON number - "true", # JSON bool - "null", # JSON null - ], - ) - def test_is_cache_valid_handles_non_mapping_metadata( - self, project_dir, non_mapping_metadata - ): - """Metadata that parses to a non-mapping degrades to cache-invalid. - - The cache-validity check calls ``metadata.get("cached_at", "")`` - immediately after ``json.loads``. If the metadata file is valid - JSON but parses to a non-mapping (``[]``, ``"oops"``, ``42``, - ``true``, ``null``), ``.get`` raises ``AttributeError`` — which - previously slipped past the except tuple and crashed the - caller. The contract documented on ``is_cache_valid`` says any - decode/shape failure should return ``False`` so ``fetch_catalog`` - falls through to a network refetch. This test pins that - contract across every JSON non-mapping root type. - """ - catalog = PresetCatalog(project_dir) - catalog.cache_dir.mkdir(parents=True, exist_ok=True) - catalog.cache_file.write_text("{}", encoding="utf-8") - catalog.cache_metadata_file.write_text( - non_mapping_metadata, encoding="utf-8" - ) - - # Must not raise — the contract is "any decode/shape failure → False". - assert catalog.is_cache_valid() is False - - def test_fetch_catalog_writes_cache_as_utf8(self, project_dir, monkeypatch): - """Cache + metadata writes pass ``encoding="utf-8"``, observably. - - The earlier version of this test claimed to assert UTF-8 at the - byte level but actually only round-tripped a non-ASCII string - through ``json.dumps`` and ``read_text(encoding="utf-8")``. - Because ``json.dumps`` defaults to ``ensure_ascii=True``, "café" - was serialized as the all-ASCII escape ``caf\\u00e9`` before it - ever reached ``write_text`` — the bytes on disk were identical - regardless of the encoding kwarg. The drift Copilot's review - flagged wasn't actually being caught. - - Fix: directly observe the ``encoding`` argument passed to every - ``write_text`` call made against the cache directory. This is - the production code's encoding choice, which is exactly what - the regression guard cares about. - """ - from unittest.mock import patch, MagicMock - from pathlib import Path as _PathCls - - catalog = PresetCatalog(project_dir) - payload = { - "schema_version": "1.0", - "presets": {"foo": {"name": "Foo", "version": "1.0.0"}}, - } - mock_response = MagicMock() - mock_response.read.side_effect = io.BytesIO(json.dumps(payload).encode("utf-8")).read - mock_response.__enter__ = lambda s: s - mock_response.__exit__ = MagicMock(return_value=False) - mock_response.geturl.return_value = "https://example.com/catalog.json" - - # Record every ``write_text`` call's encoding kwarg so the - # assertion observes the production writer's argument directly. - recorded: list[dict] = [] - real_write_text = _PathCls.write_text - - def recording_write_text(self, data, *args, **kwargs): - recorded.append( - {"path": str(self), "encoding": kwargs.get("encoding")} - ) - return real_write_text(self, data, *args, **kwargs) - - monkeypatch.setattr(_PathCls, "write_text", recording_write_text) - - with patch.object(catalog, "_open_url", return_value=mock_response): - catalog.fetch_catalog(force_refresh=True) - - cache_writes = [ - r for r in recorded if str(catalog.cache_dir) in r["path"] - ] - assert cache_writes, "fetch_catalog made no writes to the cache dir" - for record in cache_writes: - assert record["encoding"] == "utf-8", ( - f"write_text on {record['path']} used encoding " - f"{record['encoding']!r}; expected 'utf-8'" - ) - - def test_fetch_catalog_survives_unwritable_cache(self, project_dir, monkeypatch): - """An unwritable cache dir doesn't fail a successful fetch. - - Cache writes are best-effort, mirroring the read side and the - ``integrations/catalog.py`` precedent: if ``mkdir``/``write_text`` - raises ``OSError`` (read-only checkout, permissions), the - already-fetched-and-validated payload must still be returned — - not swallowed into the broad except and re-raised as a - ``PresetError``. - """ - from unittest.mock import patch, MagicMock - from pathlib import Path as _PathCls - - catalog = PresetCatalog(project_dir) - valid = { - "schema_version": "1.0", - "presets": {"foo": {"name": "Foo", "version": "1.0.0"}}, - } - def make_response(): - mock_response = MagicMock() - mock_response.read.side_effect = io.BytesIO(json.dumps(valid).encode()).read - mock_response.__enter__ = lambda s: s - mock_response.__exit__ = MagicMock(return_value=False) - mock_response.geturl.return_value = catalog.DEFAULT_CATALOG_URL - return mock_response - - # Simulate an unwritable cache dir: every write_text under the - # cache directory raises PermissionError (an OSError subclass). - real_write_text = _PathCls.write_text - - def failing_write_text(self, data, *args, **kwargs): - if str(catalog.cache_dir) in str(self): - raise PermissionError("cache dir is read-only") - return real_write_text(self, data, *args, **kwargs) - - monkeypatch.setattr(_PathCls, "write_text", failing_write_text) - - with patch.object(catalog, "_open_url", side_effect=lambda *a, **kw: make_response()): - # Legacy single-catalog path. - assert catalog.fetch_catalog(force_refresh=True) == valid - - # Multi-catalog path. - entry = PresetCatalogEntry( - url=catalog.DEFAULT_CATALOG_URL, - name="default", - priority=1, - install_allowed=True, - ) - assert ( - catalog._fetch_single_catalog(entry, force_refresh=True) == valid - ) - - def test_get_merged_packs_skips_non_mapping_entries(self, project_dir): - """Per-entry guard: one malformed entry shouldn't poison the merge. - - ``_fetch_single_catalog`` validates that ``presets`` is a mapping, - but it doesn't (and shouldn't) validate every entry inside it — a - single bad entry in an otherwise-valid catalog should be skipped, - not crash the whole resolve path. Mirrors the per-entry skip in - ``integrations/catalog.py``: a malformed entry returns no error, - valid entries continue to merge normally. - """ - from unittest.mock import patch, MagicMock - - catalog = PresetCatalog(project_dir) - payload = { - "schema_version": "1.0", - "presets": { - "good": {"name": "Good", "version": "1.0.0"}, - "bad-list": [], - "bad-str": "oops", - }, - } - mock_response = MagicMock() - mock_response.read.side_effect = io.BytesIO(json.dumps(payload).encode()).read - mock_response.__enter__ = lambda s: s - mock_response.__exit__ = MagicMock(return_value=False) - mock_response.geturl.return_value = "https://example.com/catalog.json" - - entry = PresetCatalogEntry( - url="https://example.com/catalog.json", - name="default", - priority=1, - install_allowed=True, - ) - - with patch.object(catalog, "_open_url", return_value=mock_response), \ - patch.object(catalog, "get_active_catalogs", return_value=[entry]): - merged = catalog._get_merged_packs(force_refresh=True) - - # Only the well-formed entry survives; the two malformed entries are - # silently dropped rather than raising or crashing. - assert list(merged.keys()) == ["good"] - - def test_download_pack_sends_auth_header(self, project_dir, monkeypatch): - """download_pack passes Authorization header when configured.""" - from unittest.mock import patch, MagicMock - - monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken") - self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN") - catalog = PresetCatalog(project_dir) - - import io - zip_buf = io.BytesIO() - with zipfile.ZipFile(zip_buf, "w") as zf: - zf.writestr("preset.yml", "id: test-pack\nname: Test\nversion: 1.0.0\n") - zip_bytes = zip_buf.getvalue() - - release_response = MagicMock() - release_response.read.side_effect = io.BytesIO(json.dumps( - { - "assets": [ - { - "name": "test-pack.zip", - "url": "https://api.github.com/repos/org/repo/releases/assets/1", - } - ] - } - ).encode()).read - release_response.__enter__ = lambda s: s - release_response.__exit__ = MagicMock(return_value=False) - - asset_response = MagicMock() - asset_response.read.side_effect = io.BytesIO(zip_bytes).read - asset_response.__enter__ = lambda s: s - asset_response.__exit__ = MagicMock(return_value=False) - - captured = [] - mock_opener = MagicMock() - - def fake_open(req, timeout=None): - captured.append(req) - if req.full_url.endswith("/releases/tags/v1"): - return release_response - return asset_response - - mock_opener.open.side_effect = fake_open - - pack_info = { - "id": "test-pack", - "name": "Test Pack", - "version": "1.0.0", - "download_url": "https://github.com/org/repo/releases/download/v1/test-pack.zip", - "_install_allowed": True, - } - - with patch.object(catalog, "get_pack_info", return_value=pack_info), \ - patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener): - catalog.download_pack("test-pack", target_dir=project_dir) - - assert captured[0].full_url == "https://api.github.com/repos/org/repo/releases/tags/v1" - assert captured[0].get_header("Authorization") == "Bearer ghp_testtoken" - assert captured[1].full_url == "https://api.github.com/repos/org/repo/releases/assets/1" - assert captured[1].get_header("Authorization") == "Bearer ghp_testtoken" - assert captured[1].get_header("Accept") == "application/octet-stream" - - def _pack_zip_and_response(self): - """Build a minimal preset ZIP and a context-manager mock response.""" - from unittest.mock import MagicMock - import io - - zip_buf = io.BytesIO() - with zipfile.ZipFile(zip_buf, "w") as zf: - zf.writestr("preset.yml", "id: test-pack\nname: Test\nversion: 1.0.0\n") - zip_bytes = zip_buf.getvalue() - - resp = MagicMock() - resp.read.side_effect = io.BytesIO(zip_bytes).read - # Configure the context-manager protocol explicitly so `with resp` - # yields `resp` itself, independent of how the protocol is invoked. - resp.__enter__.return_value = resp - resp.__exit__.return_value = False - return zip_bytes, resp - - def test_fetch_single_catalog_rejects_oversized_body_without_cache( - self, project_dir, monkeypatch - ): - """Catalog bounds are enforced at the preset call site.""" - import specify_cli.presets as preset_module - from unittest.mock import patch - - catalog = PresetCatalog(project_dir) - entry = PresetCatalogEntry( - url="https://example.com/catalog.json", - name="default", - priority=1, - install_allowed=True, - ) - body = b'{"schema_version":"1.0","presets":{}}' - response = MagicMock() - response.read.side_effect = io.BytesIO(body).read - response.__enter__.return_value = response - response.__exit__.return_value = False - response.geturl.return_value = entry.url - monkeypatch.setattr( - preset_module, - "MAX_JSON_CATALOG_BYTES", - len(body) - 1, - ) - - with patch.object(catalog, "_open_url", return_value=response): - with pytest.raises(PresetError, match="exceeds maximum size"): - catalog._fetch_single_catalog(entry, force_refresh=True) - - assert not catalog.cache_dir.exists() or not any(catalog.cache_dir.iterdir()) - - def test_download_pack_rejects_oversized_body_without_output( - self, project_dir, monkeypatch - ): - """Package bounds fail before checksum verification or disk writes.""" - import specify_cli.presets as preset_module - from unittest.mock import patch - from specify_cli._download_security import ( - read_response_limited as real_read_response_limited, - ) - - catalog = PresetCatalog(project_dir) - pack_info = { - "id": "test-pack", - "name": "Test Pack", - "version": "1.0.0", - "download_url": "https://example.com/test-pack.zip", - "_install_allowed": True, - } - response = MagicMock() - response.read.side_effect = io.BytesIO(b"12345").read - response.__enter__.return_value = response - response.__exit__.return_value = False - - def read_with_tiny_limit(stream, **kwargs): - kwargs.pop("max_bytes", None) - return real_read_response_limited(stream, max_bytes=4, **kwargs) - - monkeypatch.setattr( - preset_module, - "read_response_limited", - read_with_tiny_limit, - ) - with patch.object(preset_module, "verify_archive_sha256") as verify, \ - patch.object(catalog, "get_pack_info", return_value=pack_info), \ - patch.object(catalog, "_open_url", return_value=response): - with pytest.raises(PresetError, match="exceeds maximum size"): - catalog.download_pack("test-pack", target_dir=project_dir) - - verify.assert_not_called() - assert not (project_dir / "test-pack-1.0.0.zip").exists() - - def test_download_pack_rejects_unsafe_output_filename(self, project_dir): - """Catalog-controlled IDs cannot escape the requested target directory.""" - from unittest.mock import patch - - catalog = PresetCatalog(project_dir) - outside_stem = project_dir.parent / "outside-preset" - pack_id = str(outside_stem) - pack_info = { - "id": pack_id, - "name": "Test Pack", - "version": "1.0.0", - "download_url": "https://example.com/test-pack.zip", - "_install_allowed": True, - } - - with patch.object(catalog, "get_pack_info", return_value=pack_info), \ - patch.object(catalog, "_open_url") as open_url: - with pytest.raises(PresetError, match="filename"): - catalog.download_pack(pack_id, target_dir=project_dir) - - open_url.assert_not_called() - assert not Path(f"{outside_stem}-1.0.0.zip").exists() - - def test_download_pack_accepts_matching_sha256(self, project_dir): - """A catalog ``sha256`` that matches the preset archive is accepted.""" - import hashlib - from unittest.mock import patch - - catalog = PresetCatalog(project_dir) - zip_bytes, resp = self._pack_zip_and_response() - pack_info = { - "id": "test-pack", - "name": "Test Pack", - "version": "1.0.0", - "download_url": "https://example.com/test-pack.zip", - "sha256": hashlib.sha256(zip_bytes).hexdigest(), - "_install_allowed": True, - } - - with patch.object(catalog, "get_pack_info", return_value=pack_info), \ - patch.object(catalog, "_open_url", return_value=resp): - zip_path = catalog.download_pack("test-pack", target_dir=project_dir) - - assert zip_path.read_bytes() == zip_bytes - - def test_download_pack_rejects_sha256_mismatch(self, project_dir): - """A catalog ``sha256`` that does not match the archive aborts install.""" - from unittest.mock import patch - - catalog = PresetCatalog(project_dir) - _zip_bytes, resp = self._pack_zip_and_response() - pack_info = { - "id": "test-pack", - "name": "Test Pack", - "version": "1.0.0", - "download_url": "https://example.com/test-pack.zip", - "sha256": "0" * 64, # deliberately wrong - "_install_allowed": True, - } - - with patch.object(catalog, "get_pack_info", return_value=pack_info), \ - patch.object(catalog, "_open_url", return_value=resp): - with pytest.raises(PresetError, match="[Ii]ntegrity"): - catalog.download_pack("test-pack", target_dir=project_dir) - - def test_download_pack_malformed_url_raises_preset_error(self, project_dir): - """A catalog ``download_url`` with a malformed authority (e.g. an - unterminated IPv6 bracket) surfaces a clean ``PresetError`` rather than - leaking a raw ``ValueError`` from ``urlparse``/``.hostname`` past the - command handler (which only catches ``PresetError``). Mirrors the - extensions coverage. - """ - from unittest.mock import patch - - catalog = PresetCatalog(project_dir) - for bad_url in ( - "https://[::1", - "https://[not-an-ip]/x", - "https://example.com:65536/x", - "https:///x", - 123, - ): - pack_info = { - "id": "test-pack", - "name": "Test Pack", - "version": "1.0.0", - "download_url": bad_url, - "_install_allowed": True, - } - with patch.object(catalog, "get_pack_info", return_value=pack_info), \ - patch.object(catalog, "_open_url") as open_url: - with pytest.raises(PresetError, match="malformed"): - catalog.download_pack("test-pack", target_dir=project_dir) - open_url.assert_not_called() - - def test_download_pack_without_sha256_skips_verification(self, project_dir): - """A catalog entry with no ``sha256`` keeps working: verification is - opt-in, so the backwards-compatible path (``pack_info.get("sha256")`` - is ``None``) must download without aborting — mirrors the extensions - coverage so the helper never silently becomes mandatory for presets. - """ - from unittest.mock import patch - - catalog = PresetCatalog(project_dir) - zip_bytes, resp = self._pack_zip_and_response() - pack_info = { - "id": "test-pack", - "name": "Test Pack", - "version": "1.0.0", - "download_url": "https://example.com/test-pack.zip", - "_install_allowed": True, - } - - with patch.object(catalog, "get_pack_info", return_value=pack_info), \ - patch.object(catalog, "_open_url", return_value=resp): - zip_path = catalog.download_pack("test-pack", target_dir=project_dir) - - assert zip_path.read_bytes() == zip_bytes - - def test_download_pack_accepts_direct_github_rest_asset_url(self, project_dir, monkeypatch): - """download_pack can use a GitHub REST release asset URL directly.""" - from unittest.mock import patch, MagicMock - - monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken") - self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN") - catalog = PresetCatalog(project_dir) - - import io - zip_buf = io.BytesIO() - with zipfile.ZipFile(zip_buf, "w") as zf: - zf.writestr("preset.yml", "id: test-pack\nname: Test\nversion: 1.0.0\n") - zip_bytes = zip_buf.getvalue() - - asset_response = MagicMock() - asset_response.read.side_effect = io.BytesIO(zip_bytes).read - asset_response.__enter__ = lambda s: s - asset_response.__exit__ = MagicMock(return_value=False) - - captured = [] - mock_opener = MagicMock() - - def fake_open(req, timeout=None): - captured.append(req) - return asset_response - - mock_opener.open.side_effect = fake_open - - pack_info = { - "id": "test-pack", - "name": "Test Pack", - "version": "1.0.0", - "download_url": "https://api.github.com/repos/org/repo/releases/assets/1", - "_install_allowed": True, - } - - with patch.object(catalog, "get_pack_info", return_value=pack_info), \ - patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener): - catalog.download_pack("test-pack", target_dir=project_dir) - - assert len(captured) == 1 - assert captured[0].full_url == "https://api.github.com/repos/org/repo/releases/assets/1" - assert captured[0].get_header("Authorization") == "Bearer ghp_testtoken" - assert captured[0].get_header("Accept") == "application/octet-stream" - - @pytest.mark.parametrize("suffix", [".tar.gz", ".tgz"]) - def test_download_pack_preserves_tar_archive_format( - self, project_dir, suffix - ): - from unittest.mock import patch, MagicMock - - archive_buffer = io.BytesIO() - with tarfile.open(fileobj=archive_buffer, mode="w:gz") as archive: - content = b"preset:\n id: test-pack\n" - member = tarfile.TarInfo("preset.yml") - member.size = len(content) - archive.addfile(member, io.BytesIO(content)) - archive_bytes = archive_buffer.getvalue() - response = MagicMock() - response.read.side_effect = io.BytesIO(archive_bytes).read - response.__enter__.return_value = response - response.__exit__.return_value = False - catalog = PresetCatalog(project_dir) - pack_info = { - "id": "test-pack", - "name": "Test Pack", - "version": "1.0.0", - "download_url": f"https://example.com/test-pack{suffix}", - "_install_allowed": True, - } - - with patch.object(catalog, "get_pack_info", return_value=pack_info), \ - patch.object(catalog, "_open_url", return_value=response): - archive_path = catalog.download_pack("test-pack", target_dir=project_dir) - - assert archive_path.name == "test-pack-1.0.0.tar.gz" - assert archive_path.read_bytes() == archive_bytes - - -# ===== Integration Tests ===== - - -class TestIntegration: - """Integration tests for complete preset workflows.""" - - def test_full_install_resolve_remove_cycle(self, project_dir, pack_dir): - """Test complete lifecycle: install → resolve → remove.""" - # Install - manager = PresetManager(project_dir) - manifest = manager.install_from_directory(pack_dir, "0.1.5") - assert manifest.id == "test-pack" - - # Resolve — pack template should win over core - resolver = PresetResolver(project_dir) - result = resolver.resolve("spec-template") - assert result is not None - assert "Custom Spec Template" in result.read_text() - - # Remove - manager.remove("test-pack") - - # Resolve — should fall back to core - result = resolver.resolve("spec-template") - assert result is not None - assert "Core Spec Template" in result.read_text() - - def test_override_beats_pack_beats_extension_beats_core(self, project_dir, pack_dir): - """Test the full priority stack: override > pack > extension > core.""" - resolver = PresetResolver(project_dir) - - # Core should resolve - result = resolver.resolve_with_source("spec-template") - assert result["source"] == "core" - - # Add extension template - ext_dir = project_dir / ".specify" / "extensions" / "my-ext" - ext_templates_dir = ext_dir / "templates" - ext_templates_dir.mkdir(parents=True) - (ext_templates_dir / "spec-template.md").write_text("# Extension\n") - - # Register extension in registry - extensions_dir = project_dir / ".specify" / "extensions" - ext_registry = ExtensionRegistry(extensions_dir) - ext_registry.add("my-ext", {"version": "1.0.0", "priority": 10}) - - result = resolver.resolve_with_source("spec-template") - assert result["source"] == "extension:my-ext v1.0.0" - - # Install pack — should win over extension - manager = PresetManager(project_dir) - manager.install_from_directory(pack_dir, "0.1.5") - - result = resolver.resolve_with_source("spec-template") - assert "test-pack" in result["source"] - - # Add override — should win over pack - overrides_dir = project_dir / ".specify" / "templates" / "overrides" - overrides_dir.mkdir(parents=True) - (overrides_dir / "spec-template.md").write_text("# Override\n") - - result = resolver.resolve_with_source("spec-template") - assert result["source"] == "project override" - - def test_install_from_zip_then_resolve(self, project_dir, pack_dir, temp_dir): - """Test installing from ZIP and then resolving.""" - # Create ZIP - zip_path = temp_dir / "test-pack.zip" - with zipfile.ZipFile(zip_path, 'w') as zf: - for file_path in pack_dir.rglob('*'): - if file_path.is_file(): - arcname = file_path.relative_to(pack_dir) - zf.write(file_path, arcname) - - # Install - manager = PresetManager(project_dir) - manager.install_from_zip(zip_path, "0.1.5") - - # Resolve - resolver = PresetResolver(project_dir) - result = resolver.resolve("spec-template") - assert result is not None - assert "Custom Spec Template" in result.read_text() - - -# ===== PresetCatalogEntry Tests ===== - - -class TestPresetCatalogEntry: - """Test PresetCatalogEntry dataclass.""" - - def test_create_entry(self): - """Test creating a catalog entry.""" - entry = PresetCatalogEntry( - url="https://example.com/catalog.json", - name="test", - priority=1, - install_allowed=True, - description="Test catalog", - ) - assert entry.url == "https://example.com/catalog.json" - assert entry.name == "test" - assert entry.priority == 1 - assert entry.install_allowed is True - assert entry.description == "Test catalog" - - def test_default_description(self): - """Test default empty description.""" - entry = PresetCatalogEntry( - url="https://example.com/catalog.json", - name="test", - priority=1, - install_allowed=False, - ) - assert entry.description == "" - - -# ===== Multi-Catalog Tests ===== - - -class TestPresetCatalogMultiCatalog: - """Test multi-catalog support in PresetCatalog.""" - - def test_default_active_catalogs(self, project_dir): - """Test that default catalogs are returned when no config exists.""" - catalog = PresetCatalog(project_dir) - active = catalog.get_active_catalogs() - assert len(active) == 2 - assert active[0].name == "default" - assert active[0].priority == 1 - assert active[0].install_allowed is True - assert active[1].name == "community" - assert active[1].priority == 2 - assert active[1].install_allowed is False - - - - - - - def test_env_var_overrides_catalogs(self, project_dir, monkeypatch): - """Test that SPECKIT_PRESET_CATALOG_URL env var overrides defaults.""" - monkeypatch.setenv( - "SPECKIT_PRESET_CATALOG_URL", - "https://custom.example.com/catalog.json", - ) - catalog = PresetCatalog(project_dir) - active = catalog.get_active_catalogs() - assert len(active) == 1 - assert active[0].name == "custom" - assert active[0].url == "https://custom.example.com/catalog.json" - assert active[0].install_allowed is True - - def test_project_config_overrides_defaults(self, project_dir): - """Test that project-level config overrides built-in defaults.""" - config_path = project_dir / ".specify" / "preset-catalogs.yml" - config_path.write_text(yaml.dump({ - "catalogs": [ - { - "name": "my-catalog", - "url": "https://my.example.com/catalog.json", - "priority": 1, - "install_allowed": True, - } - ] - })) - - catalog = PresetCatalog(project_dir) - active = catalog.get_active_catalogs() - assert len(active) == 1 - assert active[0].name == "my-catalog" - assert active[0].url == "https://my.example.com/catalog.json" - - def test_load_catalog_config_nonexistent(self, project_dir): - """Test loading config from nonexistent file returns None.""" - catalog = PresetCatalog(project_dir) - result = catalog._load_catalog_config( - project_dir / ".specify" / "nonexistent.yml" - ) - assert result is None - - def test_load_catalog_config_empty(self, project_dir): - """Test loading empty config returns None.""" - config_path = project_dir / ".specify" / "preset-catalogs.yml" - config_path.write_text("") - - catalog = PresetCatalog(project_dir) - result = catalog._load_catalog_config(config_path) - assert result is None - - def test_load_catalog_config_defaults_blank_names(self, project_dir): - """Blank and null names normalize by valid catalog order.""" - config_path = project_dir / ".specify" / "preset-catalogs.yml" - config_path.write_text( - yaml.dump( - { - "catalogs": [ - {"name": "skipped", "url": " "}, - { - "name": None, - "url": "https://one.example.com/catalog.json", - }, - { - "name": " ", - "url": "https://two.example.com/catalog.json", - }, - { - "name": " padded-name ", - "url": "https://three.example.com/catalog.json", - }, - ] - } - ), - encoding="utf-8", - ) - - entries = PresetCatalog(project_dir)._load_catalog_config(config_path) - - assert [entry.name for entry in entries] == [ - "catalog-1", - "catalog-2", - "padded-name", - ] - - @pytest.mark.parametrize("bad", [[], False, 0, ""]) - def test_load_catalog_config_rejects_falsy_non_mapping_root( - self, project_dir, bad - ): - config_path = project_dir / ".specify" / "preset-catalogs.yml" - config_path.write_text(yaml.safe_dump(bad), encoding="utf-8") - - catalog = PresetCatalog(project_dir) - with pytest.raises(PresetValidationError, match="expected a mapping"): - catalog._load_catalog_config(config_path) - - def test_load_catalog_config_invalid_yaml(self, project_dir): - """Test loading invalid YAML raises error.""" - config_path = project_dir / ".specify" / "preset-catalogs.yml" - config_path.write_text(": invalid: {{{") - - catalog = PresetCatalog(project_dir) - with pytest.raises(PresetValidationError, match="Failed to read"): - catalog._load_catalog_config(config_path) - - def test_load_catalog_config_not_a_list(self, project_dir): - """Test that non-list catalogs key raises error.""" - config_path = project_dir / ".specify" / "preset-catalogs.yml" - config_path.write_text(yaml.dump({"catalogs": "not-a-list"})) - - catalog = PresetCatalog(project_dir) - with pytest.raises(PresetValidationError, match="must be a list"): - catalog._load_catalog_config(config_path) - - @pytest.mark.parametrize("body", ["catalogs: {}\n", "catalogs: ''\n", "catalogs: 0\n", "catalogs: false\n"]) - def test_load_catalog_config_rejects_falsy_non_list_catalogs(self, project_dir, body): - """A FALSY non-list ``catalogs:`` value must raise, like a truthy one - (``catalogs: "not-a-list"``) already does. The shape check sat behind - the emptiness check, so these were silently swallowed as "no catalogs".""" - config_path = project_dir / ".specify" / "preset-catalogs.yml" - config_path.write_text(body, encoding="utf-8") - - catalog = PresetCatalog(project_dir) - with pytest.raises(PresetValidationError, match="must be a list"): - catalog._load_catalog_config(config_path) - - def test_load_catalog_config_invalid_entry(self, project_dir): - """Test that non-dict entry raises error.""" - config_path = project_dir / ".specify" / "preset-catalogs.yml" - config_path.write_text(yaml.dump({"catalogs": ["not-a-dict"]})) - - catalog = PresetCatalog(project_dir) - with pytest.raises(PresetValidationError, match="expected a mapping"): - catalog._load_catalog_config(config_path) - - def test_load_catalog_config_http_url_rejected(self, project_dir): - """Test that HTTP URLs are rejected.""" - config_path = project_dir / ".specify" / "preset-catalogs.yml" - config_path.write_text(yaml.dump({ - "catalogs": [ - { - "name": "bad", - "url": "http://insecure.example.com/catalog.json", - "priority": 1, - } - ] - })) - - catalog = PresetCatalog(project_dir) - with pytest.raises(PresetValidationError, match="must use HTTPS"): - catalog._load_catalog_config(config_path) - - def test_load_catalog_config_priority_sorting(self, project_dir): - """Test that catalogs are sorted by priority.""" - config_path = project_dir / ".specify" / "preset-catalogs.yml" - config_path.write_text(yaml.dump({ - "catalogs": [ - { - "name": "low-priority", - "url": "https://low.example.com/catalog.json", - "priority": 10, - "install_allowed": False, - }, - { - "name": "high-priority", - "url": "https://high.example.com/catalog.json", - "priority": 1, - "install_allowed": True, - }, - ] - })) - - catalog = PresetCatalog(project_dir) - entries = catalog._load_catalog_config(config_path) - assert entries is not None - assert len(entries) == 2 - assert entries[0].name == "high-priority" - assert entries[1].name == "low-priority" - - def test_load_catalog_config_invalid_priority(self, project_dir): - """Test that invalid priority raises error.""" - config_path = project_dir / ".specify" / "preset-catalogs.yml" - config_path.write_text(yaml.dump({ - "catalogs": [ - { - "name": "bad", - "url": "https://example.com/catalog.json", - "priority": "not-a-number", - } - ] - })) - - catalog = PresetCatalog(project_dir) - with pytest.raises(PresetValidationError, match="Invalid priority"): - catalog._load_catalog_config(config_path) - - def test_load_catalog_config_rejects_boolean_priority(self, project_dir): - """A YAML ``priority: true`` is a typo, not a request for priority 1. - - ``bool`` is a subclass of ``int`` in Python, so ``int(True)`` silently - returns ``1``. Without an explicit guard a malformed config like - ``priority: yes`` would be accepted as a valid priority of 1 and - silently change catalog ordering. The sibling integration-catalog - reader rejects this case (see ``catalogs.py``); the preset catalog - reader must stay consistent. - """ - config_path = project_dir / ".specify" / "preset-catalogs.yml" - config_path.write_text(yaml.dump({ - "catalogs": [ - { - "name": "bool-priority", - "url": "https://example.com/catalog.json", - "priority": True, - } - ] - })) - - catalog = PresetCatalog(project_dir) - with pytest.raises(PresetValidationError, match="Invalid priority|expected integer"): - catalog._load_catalog_config(config_path) - - def test_load_catalog_config_rejects_infinite_priority(self, project_dir): - """A ``priority: .inf`` yields a clean validation error, not an uncaught - OverflowError from int(float('inf')).""" - config_path = project_dir / ".specify" / "preset-catalogs.yml" - config_path.write_text(yaml.dump({ - "catalogs": [ - { - "name": "inf-priority", - "url": "https://example.com/catalog.json", - "priority": float("inf"), - } - ] - })) - - catalog = PresetCatalog(project_dir) - with pytest.raises(PresetValidationError, match="Invalid priority|expected integer"): - catalog._load_catalog_config(config_path) - - def test_load_catalog_config_install_allowed_string(self, project_dir): - """Test that install_allowed accepts string values.""" - config_path = project_dir / ".specify" / "preset-catalogs.yml" - config_path.write_text(yaml.dump({ - "catalogs": [ - { - "name": "test", - "url": "https://example.com/catalog.json", - "priority": 1, - "install_allowed": "true", - } - ] - })) - - catalog = PresetCatalog(project_dir) - entries = catalog._load_catalog_config(config_path) - assert entries is not None - assert entries[0].install_allowed is True - - def test_get_catalog_url_uses_highest_priority(self, project_dir): - """Test that get_catalog_url returns URL of highest priority catalog.""" - config_path = project_dir / ".specify" / "preset-catalogs.yml" - config_path.write_text(yaml.dump({ - "catalogs": [ - { - "name": "secondary", - "url": "https://secondary.example.com/catalog.json", - "priority": 5, - }, - { - "name": "primary", - "url": "https://primary.example.com/catalog.json", - "priority": 1, - }, - ] - })) - - catalog = PresetCatalog(project_dir) - assert catalog.get_catalog_url() == "https://primary.example.com/catalog.json" - - def test_cache_paths_default_url(self, project_dir): - """Test cache paths for default catalog URL use legacy locations.""" - catalog = PresetCatalog(project_dir) - cache_file, metadata_file = catalog._get_cache_paths( - PresetCatalog.DEFAULT_CATALOG_URL - ) - assert cache_file == catalog.cache_file - assert metadata_file == catalog.cache_metadata_file - - def test_cache_paths_custom_url(self, project_dir): - """Test cache paths for custom URLs use hash-based files.""" - catalog = PresetCatalog(project_dir) - cache_file, metadata_file = catalog._get_cache_paths( - "https://custom.example.com/catalog.json" - ) - assert cache_file != catalog.cache_file - assert "catalog-" in cache_file.name - assert cache_file.name.endswith(".json") - - def test_url_cache_valid(self, project_dir): - """Test URL-specific cache validation.""" - catalog = PresetCatalog(project_dir) - url = "https://custom.example.com/catalog.json" - cache_file, metadata_file = catalog._get_cache_paths(url) - - catalog.cache_dir.mkdir(parents=True, exist_ok=True) - cache_file.write_text(json.dumps({"schema_version": "1.0", "presets": {}})) - metadata_file.write_text(json.dumps({ - "cached_at": datetime.now(timezone.utc).isoformat(), - })) - - assert catalog._is_url_cache_valid(url) is True - - def test_url_cache_expired(self, project_dir): - """Test URL-specific cache expiration.""" - catalog = PresetCatalog(project_dir) - url = "https://custom.example.com/catalog.json" - cache_file, metadata_file = catalog._get_cache_paths(url) - - catalog.cache_dir.mkdir(parents=True, exist_ok=True) - cache_file.write_text(json.dumps({"schema_version": "1.0", "presets": {}})) - metadata_file.write_text(json.dumps({ - "cached_at": "2020-01-01T00:00:00+00:00", - })) - - assert catalog._is_url_cache_valid(url) is False - - -# ===== Self-Test Preset Tests ===== - - -class TestSelfTestPreset: - """Tests using the self-test preset that ships with the repo. - - The self-test preset ships a wrap-strategy command (``speckit.wrap-test``) - without a corresponding core base layer; reconciliation deliberately - surfaces a UserWarning in that case. Tests install via - ``install_self_test_preset`` (defined above), which scopes a narrow - ``warnings.filterwarnings`` block to that specific message and - ``UserWarning`` category — so the expected warning stays quiet without - masking unrelated warnings or real reconciliation failures. - """ - - def test_self_test_preset_exists(self): - """Verify the self-test preset directory and manifest exist.""" - assert SELF_TEST_PRESET_DIR.exists() - assert (SELF_TEST_PRESET_DIR / "preset.yml").exists() - - def test_self_test_manifest_valid(self): - """Verify the self-test preset manifest is valid.""" - manifest = PresetManifest(SELF_TEST_PRESET_DIR / "preset.yml") - assert manifest.id == "self-test" - assert manifest.name == "Self-Test Preset" - assert manifest.version == "1.0.0" - assert len(manifest.templates) == 7 # 5 templates + 2 commands - - def test_self_test_provides_all_core_templates(self): - """Verify the self-test preset provides an override for every core template.""" - manifest = PresetManifest(SELF_TEST_PRESET_DIR / "preset.yml") - provided_names = {t["name"] for t in manifest.templates} - for name in CORE_TEMPLATE_NAMES: - assert name in provided_names, f"Self-test preset missing template: {name}" - - def test_self_test_template_files_exist(self): - """Verify that all declared template files actually exist on disk.""" - manifest = PresetManifest(SELF_TEST_PRESET_DIR / "preset.yml") - for tmpl in manifest.templates: - tmpl_path = SELF_TEST_PRESET_DIR / tmpl["file"] - assert tmpl_path.exists(), f"Missing template file: {tmpl['file']}" - - def test_self_test_templates_have_marker(self): - """Verify each template contains the preset:self-test marker.""" - for name in CORE_TEMPLATE_NAMES: - tmpl_path = SELF_TEST_PRESET_DIR / "templates" / f"{name}.md" - content = tmpl_path.read_text() - assert "preset:self-test" in content, f"{name}.md missing preset:self-test marker" - - def test_install_self_test_preset(self, project_dir): - """Test installing the self-test preset from its directory.""" - manager = PresetManager(project_dir) - manifest = install_self_test_preset(manager) - assert manifest.id == "self-test" - assert manager.registry.is_installed("self-test") - - def test_self_test_overrides_all_core_templates(self, project_dir): - """Test that installing self-test overrides every core template.""" - # Set up core templates in the project - templates_dir = project_dir / ".specify" / "templates" - for name in CORE_TEMPLATE_NAMES: - (templates_dir / f"{name}.md").write_text(f"# Core {name}\n") - - # Install self-test preset - manager = PresetManager(project_dir) - install_self_test_preset(manager) - - # Every core template should now resolve from the preset - resolver = PresetResolver(project_dir) - for name in CORE_TEMPLATE_NAMES: - result = resolver.resolve(name) - assert result is not None, f"{name} did not resolve" - content = result.read_text() - assert "preset:self-test" in content, ( - f"{name} resolved but not from self-test preset" - ) - - def test_self_test_resolve_with_source(self, project_dir): - """Test that resolve_with_source attributes templates to self-test.""" - templates_dir = project_dir / ".specify" / "templates" - for name in CORE_TEMPLATE_NAMES: - (templates_dir / f"{name}.md").write_text(f"# Core {name}\n") - - manager = PresetManager(project_dir) - install_self_test_preset(manager) - - resolver = PresetResolver(project_dir) - for name in CORE_TEMPLATE_NAMES: - result = resolver.resolve_with_source(name) - assert result is not None, f"{name} did not resolve" - assert "self-test" in result["source"], ( - f"{name} source is '{result['source']}', expected self-test" - ) - - def test_self_test_removal_restores_core(self, project_dir): - """Test that removing self-test falls back to core templates.""" - templates_dir = project_dir / ".specify" / "templates" - for name in CORE_TEMPLATE_NAMES: - (templates_dir / f"{name}.md").write_text(f"# Core {name}\n") - - manager = PresetManager(project_dir) - install_constitution_sync_preset(manager) - install_self_test_preset(manager) - manager.remove("self-test") - - resolver = PresetResolver(project_dir) - for name in CORE_TEMPLATE_NAMES: - result = resolver.resolve_with_source(name) - assert result is not None - assert result["source"] == "core" - - memory = project_dir / ".specify" / "memory" / "constitution.md" - assert memory.read_text() == "# Core constitution-template\n" - - def test_self_test_removal_preserves_edited_constitution(self, project_dir): - """Removing a preset does not overwrite an edited generated constitution.""" - templates_dir = project_dir / ".specify" / "templates" - (templates_dir / "constitution-template.md").write_text("# Core Constitution\n") - - manager = PresetManager(project_dir) - install_constitution_sync_preset(manager) - install_self_test_preset(manager) - memory = project_dir / ".specify" / "memory" / "constitution.md" - edited = memory.read_text() + "\n## Authored amendment\n" - memory.write_text(edited) - - manager.remove("self-test") - - assert memory.read_text() == edited - - def test_self_test_not_in_catalog(self): - """Verify the self-test preset is NOT in the catalog (it's local-only).""" - catalog_path = Path(__file__).parent.parent / "presets" / "catalog.json" - catalog_data = json.loads(catalog_path.read_text()) - assert "self-test" not in catalog_data["presets"] - - def test_self_test_has_command(self): - """Verify the self-test preset includes a command override.""" - manifest = PresetManifest(SELF_TEST_PRESET_DIR / "preset.yml") - commands = [t for t in manifest.templates if t["type"] == "command"] - assert len(commands) >= 1 - assert commands[0]["name"] == "speckit.specify" - - def test_self_test_command_file_exists(self): - """Verify the self-test command file exists on disk.""" - cmd_path = SELF_TEST_PRESET_DIR / "commands" / "speckit.specify.md" - assert cmd_path.exists() - content = cmd_path.read_text() - assert "preset:self-test" in content - - def test_self_test_registers_commands_for_claude(self, project_dir): - """Test that installing self-test registers skills in .claude/skills/.""" - # Create Claude skills directory to simulate Claude being set up - claude_dir = project_dir / ".claude" / "skills" - claude_dir.mkdir(parents=True) - - manager = PresetManager(project_dir) - install_self_test_preset(manager) - - # Check the skill was registered - cmd_file = claude_dir / "speckit-specify" / "SKILL.md" - assert cmd_file.exists(), "Skill not registered in .claude/skills/" - content = cmd_file.read_text() - assert "self-test" in content - assert "source:" in content # skill frontmatter includes metadata.source - - def test_self_test_registers_commands_for_gemini(self, project_dir): - """Test that installing self-test registers commands in .gemini/commands/ as TOML.""" - # Create Gemini agent directory - gemini_dir = project_dir / ".gemini" / "commands" - gemini_dir.mkdir(parents=True) - - manager = PresetManager(project_dir) - install_self_test_preset(manager) - - # Check the command was registered in TOML format - cmd_file = gemini_dir / "speckit.specify.toml" - assert cmd_file.exists(), "Command not registered in .gemini/commands/" - content = cmd_file.read_text() - assert "prompt" in content # TOML format has a prompt field - assert "{{args}}" in content # Gemini uses {{args}} placeholder - - def test_self_test_unregisters_commands_on_remove(self, project_dir): - """Test that removing self-test cleans up registered commands.""" - claude_dir = project_dir / ".claude" / "skills" - claude_dir.mkdir(parents=True) - - manager = PresetManager(project_dir) - install_self_test_preset(manager) - - cmd_file = claude_dir / "speckit-specify" / "SKILL.md" - assert cmd_file.exists() - - manager.remove("self-test") - assert not cmd_file.exists(), "Command not cleaned up after preset removal" - - def test_self_test_no_commands_without_agent_dirs(self, project_dir): - """Test that no commands are registered when no agent dirs exist.""" - manager = PresetManager(project_dir) - install_self_test_preset(manager) - - metadata = manager.registry.get("self-test") - assert metadata["registered_commands"] == {} - - def test_self_test_does_not_seed_constitution_without_sync(self, project_dir): - """Installing a preset does not materialize its constitution by default.""" - manager = PresetManager(project_dir) - install_self_test_preset(manager) - - memory = project_dir / ".specify" / "memory" / "constitution.md" - assert not memory.exists() - - def test_self_test_preserves_generated_constitution_without_sync(self, project_dir): - """Preset install and removal preserve generated content without the opt-in.""" - resolver = PresetResolver(project_dir) - bundled_core = resolver._find_bundled_core( - "constitution-template", "template", ".md" - ) - assert bundled_core is not None - core = bundled_core.read_bytes() - memory = project_dir / ".specify" / "memory" / "constitution.md" - memory.parent.mkdir(parents=True, exist_ok=True) - memory.write_bytes(core) - - manager = PresetManager(project_dir) - install_self_test_preset(manager) - manager.remove("self-test") - - assert memory.read_bytes() == core - - def test_self_test_seeds_constitution_with_sync(self, project_dir): - """constitution-sync preserves the previous install-time seeding behavior.""" - manager = PresetManager(project_dir) - install_constitution_sync_preset(manager) - install_self_test_preset(manager) - - memory = project_dir / ".specify" / "memory" / "constitution.md" - assert "preset:self-test" in memory.read_text() - assert "[PROJECT_NAME]" not in memory.read_text() - - @pytest.mark.parametrize( - "provenance_content", - [ - '{"sha256": "does-not-match", "source": "old-preset"}\n', - "{not valid json", - ], - ids=["hash-mismatch", "malformed"], - ) - def test_self_test_preserves_core_content_with_existing_invalid_provenance( - self, project_dir, provenance_content - ): - """A present invalid sidecar disables legacy core-template migration.""" - resolver = PresetResolver(project_dir) - bundled_core = resolver._find_bundled_core( - "constitution-template", "template", ".md" - ) - assert bundled_core is not None - memory = project_dir / ".specify" / "memory" / "constitution.md" - memory.parent.mkdir(parents=True, exist_ok=True) - memory.write_bytes(bundled_core.read_bytes()) - (memory.parent / ".constitution-template.json").write_text( - provenance_content - ) - original = memory.read_bytes() - - manager = PresetManager(project_dir) - install_constitution_sync_preset(manager) - install_self_test_preset(manager) - - assert memory.read_bytes() == original - - def test_self_test_preserves_mutable_project_core_copy(self, project_dir): - """A project template copy does not establish generated provenance.""" - authored = "# Acme Organization Constitution\n\nOrganization policy.\n" - project_template = ( - project_dir / ".specify" / "templates" / "constitution-template.md" - ) - project_template.write_text(authored) - memory = project_dir / ".specify" / "memory" / "constitution.md" - memory.parent.mkdir(parents=True, exist_ok=True) - memory.write_text(authored) - - manager = PresetManager(project_dir) - install_constitution_sync_preset(manager) - install_self_test_preset(manager) - - assert memory.read_text() == authored - assert not (memory.parent / ".constitution-template.json").exists() - - def test_core_prefixed_preset_does_not_establish_generated_provenance( - self, project_dir, temp_dir - ): - """A preset ID beginning with core is not an immutable core source.""" - authored = "# Acme Organization Constitution\n\nOrganization policy.\n" - memory = project_dir / ".specify" / "memory" / "constitution.md" - memory.parent.mkdir(parents=True, exist_ok=True) - memory.write_text(authored) - - preset_dir = temp_dir / "core-company" - (preset_dir / "templates").mkdir(parents=True) - (preset_dir / "templates" / "constitution-template.md").write_text(authored) - (preset_dir / "preset.yml").write_text( - yaml.safe_dump( - { - "schema_version": "1.0", - "preset": { - "id": "core-company", - "name": "Core Company", - "version": "1.0.0", - "description": "Company constitution preset", - "author": "Test Author", - "repository": "https://github.com/test/core-company", - "license": "MIT", - }, - "requires": {"speckit_version": ">=0.1.0"}, - "provides": { - "templates": [ - { - "type": "template", - "name": "constitution-template", - "file": "templates/constitution-template.md", - "description": "Company constitution", - "replaces": "constitution-template", - } - ] - }, - } - ) - ) - - manager = PresetManager(project_dir) - install_constitution_sync_preset(manager) - manager.install_from_directory(preset_dir, "0.1.5") - - assert memory.read_text() == authored - assert not (memory.parent / ".constitution-template.json").exists() - - def test_self_test_preserves_authored_constitution_with_placeholder( - self, project_dir - ): - """A placeholder mention does not establish generated provenance.""" - memory = project_dir / ".specify" / "memory" / "constitution.md" - memory.parent.mkdir(parents=True, exist_ok=True) - authored = "# Acme Constitution\n\nGuidance for [PROJECT_NAME].\n" - memory.write_text(authored) - - manager = PresetManager(project_dir) - install_constitution_sync_preset(manager) - install_self_test_preset(manager) - - assert memory.read_text() == authored - - def test_self_test_preserves_authored_constitution(self, project_dir): - """An authored (placeholder-free) constitution is never overwritten.""" - memory = project_dir / ".specify" / "memory" / "constitution.md" - memory.parent.mkdir(parents=True, exist_ok=True) - authored = "# Acme Constitution\n\n### I. Ship It\nAuthored by a human.\n" - memory.write_text(authored) - - manager = PresetManager(project_dir) - install_constitution_sync_preset(manager) - install_self_test_preset(manager) - - assert memory.read_text() == authored, "authored constitution was overwritten" - - def test_self_test_override_resolves_constitution_template(self, project_dir): - """The preset override of constitution-template resolves to the preset file.""" - templates_dir = project_dir / ".specify" / "templates" - (templates_dir / "constitution-template.md").write_text("# Core constitution\n") - - manager = PresetManager(project_dir) - install_self_test_preset(manager) - - resolver = PresetResolver(project_dir) - result = resolver.resolve("constitution-template", "template") - assert result is not None - assert "preset:self-test" in result.read_text() - - def test_constitution_seed_composes_wrap_strategy(self, project_dir, temp_dir): - """Seeding memory composes wrap constitution-template layers.""" - templates_dir = project_dir / ".specify" / "templates" - templates_dir.mkdir(parents=True, exist_ok=True) - (templates_dir / "constitution-template.md").write_text( - "# Core Constitution\n\n## Core Principle\n" - ) - - preset_dir = temp_dir / "constitution-wrap" - (preset_dir / "templates").mkdir(parents=True) - (preset_dir / "templates" / "constitution-template.md").write_text( - "# Wrapper Constitution\n\n{CORE_TEMPLATE}\n\n## Wrapper Footer\n" - ) - (preset_dir / "preset.yml").write_text( - yaml.dump( - { - "schema_version": "1.0", - "preset": { - "id": "constitution-wrap", - "name": "Constitution Wrap", - "version": "1.0.0", - "description": "Wrap constitution template for testing", - }, - "requires": {"speckit_version": ">=0.1.0"}, - "provides": { - "templates": [ - { - "type": "template", - "name": "constitution-template", - "file": "templates/constitution-template.md", - "strategy": "wrap", - "description": "Wrapped constitution template", - } - ] - }, - } - ) - ) - - manager = PresetManager(project_dir) - install_constitution_sync_preset(manager) - manager.install_from_directory(preset_dir, "0.1.5") - - memory = project_dir / ".specify" / "memory" / "constitution.md" - content = memory.read_text() - assert "{CORE_TEMPLATE}" not in content - assert "# Wrapper Constitution" in content - assert "## Core Principle" in content - - def test_constitution_follows_priority_when_winning_preset_removed( - self, project_dir, temp_dir - ): - """An unchanged generated constitution follows priority and fallback layers.""" - manager = PresetManager(project_dir) - install_constitution_sync_preset(manager) - install_self_test_preset(manager) - - preset_dir = temp_dir / "higher-priority" - (preset_dir / "templates").mkdir(parents=True) - (preset_dir / "templates" / "constitution-template.md").write_text( - "# Higher Priority Constitution\n" - ) - (preset_dir / "preset.yml").write_text( - yaml.dump( - { - "schema_version": "1.0", - "preset": { - "id": "higher-priority", - "name": "Higher Priority", - "version": "1.0.0", - "description": "Higher-priority constitution", - }, - "requires": {"speckit_version": ">=0.1.0"}, - "provides": { - "templates": [ - { - "type": "template", - "name": "constitution-template", - "file": "templates/constitution-template.md", - "strategy": "replace", - "description": "Higher-priority constitution", - } - ] - }, - } - ) - ) - - manager.install_from_directory(preset_dir, "0.1.5", priority=1) - - memory = project_dir / ".specify" / "memory" / "constitution.md" - assert memory.read_text() == "# Higher Priority Constitution\n" - - manager.remove("higher-priority") - - assert "preset:self-test" in memory.read_text() - - def test_convention_constitution_removal_restores_remaining_layer( - self, project_dir, temp_dir - ): - """Removing a convention layer rematerializes the remaining resolver layer.""" - manager = PresetManager(project_dir) - install_constitution_sync_preset(manager) - install_self_test_preset(manager) - manager.install_from_directory( - _make_convention_constitution_preset(temp_dir), "0.1.5", priority=1 - ) - - memory = project_dir / ".specify" / "memory" / "constitution.md" - assert memory.read_text() == "# Convention Constitution\n" - - manager.remove("convention-constitution") - - assert "preset:self-test" in memory.read_text() - - def test_convention_constitution_removal_preserves_edited_content( - self, project_dir, temp_dir - ): - """Removing a convention layer does not overwrite edited generated content.""" - from specify_cli.command_init import ensure_constitution_from_template - - templates_dir = project_dir / ".specify" / "templates" - (templates_dir / "constitution-template.md").write_text("# Core Constitution\n") - manager = PresetManager(project_dir) - install_constitution_sync_preset(manager) - manager.install_from_directory( - _make_convention_constitution_preset(temp_dir), "0.1.5" - ) - ensure_constitution_from_template(project_dir) - memory = project_dir / ".specify" / "memory" / "constitution.md" - edited = memory.read_text() + "\n## Authored amendment\n" - memory.write_text(edited) - - manager.remove("convention-constitution") - - assert memory.read_text() == edited - - def test_custom_constitution_removal_recovers_with_invalid_manifest( - self, project_dir, temp_dir - ): - """Provenance triggers fallback when a custom-path manifest is invalid.""" - manager = PresetManager(project_dir) - install_constitution_sync_preset(manager) - install_self_test_preset(manager) - - preset_dir = temp_dir / "custom-constitution" - (preset_dir / "policy").mkdir(parents=True) - (preset_dir / "policy" / "charter.md").write_text("# Custom Constitution\n") - (preset_dir / "preset.yml").write_text( - yaml.dump( - { - "schema_version": "1.0", - "preset": { - "id": "custom-constitution", - "name": "Custom Constitution", - "version": "1.0.0", - "description": "Custom-path constitution for testing", - }, - "requires": {"speckit_version": ">=0.1.0"}, - "provides": { - "templates": [ - { - "type": "template", - "name": "constitution-template", - "file": "policy/charter.md", - } - ] - }, - } - ) - ) - manager.install_from_directory(preset_dir, "0.1.5", priority=1) - memory = project_dir / ".specify" / "memory" / "constitution.md" - assert memory.read_text() == "# Custom Constitution\n" - - installed_manifest = ( - project_dir - / ".specify" - / "presets" - / "custom-constitution" - / "preset.yml" - ) - installed_manifest.write_text("invalid: [") - - manager.remove("custom-constitution") - - assert "preset:self-test" in memory.read_text() - - def test_constitution_seed_rejects_symlinked_memory_directory( - self, project_dir, temp_dir - ): - """Preset installation cannot seed through a symlinked memory directory.""" - outside = temp_dir / "outside" - outside.mkdir() - try: - (project_dir / ".specify" / "memory").symlink_to( - outside, target_is_directory=True - ) - except OSError: - pytest.skip("symlinks are unavailable") - - manager = PresetManager(project_dir) - with pytest.warns(UserWarning, match="symlinked"): - install_constitution_sync_preset(manager) - - assert manager.registry.is_installed("constitution-sync") - assert not (outside / "constitution.md").exists() - - def test_constitution_seed_rejects_dangling_destination_symlink( - self, project_dir, temp_dir - ): - """Preset installation cannot seed through a dangling destination symlink.""" - memory = project_dir / ".specify" / "memory" - memory.mkdir(parents=True) - outside = temp_dir / "outside-constitution.md" - try: - (memory / "constitution.md").symlink_to(outside) - except OSError: - pytest.skip("symlinks are unavailable") - - manager = PresetManager(project_dir) - with pytest.warns(UserWarning, match="symlinked"): - install_constitution_sync_preset(manager) - - assert manager.registry.is_installed("constitution-sync") - assert not outside.exists() - - def test_constitution_materialization_error_is_nonfatal( - self, project_dir, temp_dir - ): - """An invalid wrap warns without reporting an uninstalled preset.""" - preset_dir = temp_dir / "invalid-wrap" - (preset_dir / "templates").mkdir(parents=True) - (preset_dir / "templates" / "constitution-template.md").write_text( - "# Missing core placeholder\n" - ) - (preset_dir / "preset.yml").write_text( - yaml.dump( - { - "schema_version": "1.0", - "preset": { - "id": "invalid-wrap", - "name": "Invalid Wrap", - "version": "1.0.0", - "description": "Invalid wrapping constitution", - }, - "requires": {"speckit_version": ">=0.1.0"}, - "provides": { - "templates": [ - { - "type": "template", - "name": "constitution-template", - "file": "templates/constitution-template.md", - "strategy": "wrap", - "description": "Invalid wrap", - } - ] - }, - } - ) - ) - - manager = PresetManager(project_dir) - install_constitution_sync_preset(manager) - with pytest.warns(UserWarning, match="Failed to seed constitution"): - manifest = manager.install_from_directory(preset_dir, "0.1.5") - - assert manifest.id == "invalid-wrap" - assert manager.registry.is_installed("invalid-wrap") - - def test_selfcontained_namespaced_command_scaffolds_without_extension(self, project_dir, temp_dir): - """A preset shipping a self-contained ``speckit..`` command - scaffolds even when no matching extension is installed. - - The command template ships its own body, so it is self-contained and - must render just like a short ``speckit.`` command. It is not - dropped merely because ``.specify/extensions/fakeext/`` is absent. - """ - claude_dir = project_dir / ".claude" / "skills" - claude_dir.mkdir(parents=True) - - preset_dir = temp_dir / "ext-override-preset" - preset_dir.mkdir() - (preset_dir / "commands").mkdir() - (preset_dir / "commands" / "speckit.fakeext.cmd.md").write_text( - "---\ndescription: Override fakeext cmd\n---\nOverridden content" - ) - manifest_data = { - "schema_version": "1.0", - "preset": { - "id": "ext-override", - "name": "Ext Override", - "version": "1.0.0", - "description": "Test", - }, - "requires": {"speckit_version": ">=0.1.0"}, - "provides": { - "templates": [ - { - "type": "command", - "name": "speckit.fakeext.cmd", - "file": "commands/speckit.fakeext.cmd.md", - "description": "Override fakeext cmd", - } - ] - }, - } - with open(preset_dir / "preset.yml", "w") as f: - yaml.dump(manifest_data, f) - - manager = PresetManager(project_dir) - manager.install_from_directory(preset_dir, "0.1.5") - - # Extension not installed, but the preset ships its own command body — - # it must scaffold (as a native-skill SKILL.md for claude) and be - # tracked in the preset's registered_commands. - skill_file = claude_dir / "speckit-fakeext-cmd" / "SKILL.md" - assert skill_file.exists(), "Self-contained namespaced command was dropped" - metadata = manager.registry.get("ext-override") - assert metadata["registered_commands"] != {} - - def test_extension_command_registered_when_extension_present(self, project_dir, temp_dir): - """Test that extension command overrides ARE registered when the extension is installed.""" - claude_dir = project_dir / ".claude" / "skills" - claude_dir.mkdir(parents=True) - (project_dir / ".specify" / "extensions" / "fakeext").mkdir(parents=True) - - preset_dir = temp_dir / "ext-override-preset2" - preset_dir.mkdir() - (preset_dir / "commands").mkdir() - (preset_dir / "commands" / "speckit.fakeext.cmd.md").write_text( - "---\ndescription: Override fakeext cmd\n---\nOverridden content" - ) - manifest_data = { - "schema_version": "1.0", - "preset": { - "id": "ext-override2", - "name": "Ext Override", - "version": "1.0.0", - "description": "Test", - }, - "requires": {"speckit_version": ">=0.1.0"}, - "provides": { - "templates": [ - { - "type": "command", - "name": "speckit.fakeext.cmd", - "file": "commands/speckit.fakeext.cmd.md", - "description": "Override fakeext cmd", - } - ] - }, - } - with open(preset_dir / "preset.yml", "w") as f: - yaml.dump(manifest_data, f) - - manager = PresetManager(project_dir) - manager.install_from_directory(preset_dir, "0.1.5") - - cmd_file = claude_dir / "speckit-fakeext-cmd" / "SKILL.md" - assert cmd_file.exists(), "Skill not registered despite extension being present" - - -# ===== Init Options and Skills Tests ===== - - -class TestInitOptions: - """Tests for save_init_options / load_init_options helpers.""" - - def test_save_and_load_round_trip(self, project_dir): - from specify_cli import save_init_options, load_init_options - - opts = {"ai": "claude", "ai_skills": True, "here": False} - save_init_options(project_dir, opts) - - loaded = load_init_options(project_dir) - assert loaded["ai"] == "claude" - assert loaded["ai_skills"] is True - - def test_save_and_load_available_from_init_options_module(self, project_dir): - from specify_cli._init_options import load_init_options, save_init_options - - opts = {"ai": "codex", "ai_skills": True, "script": "sh"} - save_init_options(project_dir, opts) - - assert load_init_options(project_dir) == opts - - def test_save_uses_utf8_encoding(self, project_dir, monkeypatch): - from specify_cli import save_init_options - - original_write_text = Path.write_text - seen: dict[str, str | None] = {} - - def spy_write_text(path, data, *args, **kwargs): - if path == project_dir / ".specify" / "init-options.json": - seen["encoding"] = kwargs.get("encoding") - return original_write_text(path, data, *args, **kwargs) - - monkeypatch.setattr(Path, "write_text", spy_write_text) - - save_init_options(project_dir, {"label": "中文测试"}) - - assert seen["encoding"] == "utf-8" - - def test_load_uses_utf8_encoding(self, project_dir, monkeypatch): - from specify_cli import load_init_options - - opts_file = project_dir / ".specify" / "init-options.json" - opts_file.parent.mkdir(parents=True, exist_ok=True) - opts_file.write_text('{"ai": "codex"}', encoding="utf-8") - - original_read_text = Path.read_text - seen: dict[str, str | None] = {} - - def spy_read_text(path, *args, **kwargs): - if path == opts_file: - seen["encoding"] = kwargs.get("encoding") - return original_read_text(path, *args, **kwargs) - - monkeypatch.setattr(Path, "read_text", spy_read_text) - - assert load_init_options(project_dir) == {"ai": "codex"} - assert seen["encoding"] == "utf-8" - - def test_load_returns_empty_when_missing(self, project_dir): - from specify_cli import load_init_options - - assert load_init_options(project_dir) == {} - - def test_load_returns_empty_on_invalid_json(self, project_dir): - from specify_cli import load_init_options - - opts_file = project_dir / ".specify" / "init-options.json" - opts_file.parent.mkdir(parents=True, exist_ok=True) - opts_file.write_text("{bad json") - - assert load_init_options(project_dir) == {} - - @pytest.mark.parametrize( - "value", - ["名前-プロジェクト", "café-résumé", "Ωmega-Δelta", "🚀-launch"], - ) - def test_save_load_round_trip_preserves_non_ascii(self, project_dir, value): - """Non-ASCII values round-trip via explicit UTF-8 encoding. - - ``Path.write_text`` / ``Path.read_text`` default to the system - locale codec on Windows (cp1252 / gb2312 / cp932). Without - ``encoding="utf-8"`` pinned on both ends, a project name like - ``café`` written on a UTF-8 host becomes garbled or unreadable on - a cp1252 host (and vice versa). Pin UTF-8 explicitly so init - options round-trip across machines and CI. - - Note: this test only meaningfully exercises the encoding pin - because ``save_init_options`` now writes JSON with - ``ensure_ascii=False`` — otherwise ``json.dumps`` would output - ASCII-only ``\\uXXXX`` escapes and the encoding pin would be a - no-op for any value here. ``test_save_writes_real_utf8_bytes`` - below asserts that contract directly. - """ - from specify_cli import save_init_options, load_init_options - - save_init_options(project_dir, {"ai": "claude", "project_name": value}) - - loaded = load_init_options(project_dir) - assert loaded["project_name"] == value - - def test_save_writes_real_utf8_bytes(self, project_dir): - """The on-disk file contains real UTF-8 bytes, not ``\\uXXXX`` escapes. - - Pinning ``encoding="utf-8"`` on ``write_text`` only makes a - difference when the serialiser actually emits non-ASCII - characters. With ``ensure_ascii=False`` on ``json.dumps`` the - non-ASCII bytes hit the file, so the encoding pin is the thing - that decides between cp1252 garbage and clean UTF-8 on Windows. - - This test pins that behaviour: the on-disk bytes are valid UTF-8 - and contain the multi-byte encoding of ``café``, not its - ``\\u00e9`` escape form. Reviewers can verify that removing - ``ensure_ascii=False`` or ``encoding="utf-8"`` from the writer - breaks this test, which is what Copilot's review pointed out the - original round-trip test failed to do. - """ - from specify_cli import save_init_options - - save_init_options(project_dir, {"project_name": "café"}) - - opts_file = project_dir / ".specify" / "init-options.json" - raw = opts_file.read_bytes() - # 'café' in UTF-8 ends with bytes 0xC3 0xA9 ('é'). The cp1252 - # encoding of 'é' is the single byte 0xE9. The JSON-escape form - # would be the 6-byte literal '\\u00e9'. We assert the UTF-8 form - # is present so the test pins the actual contract. - assert b"caf\xc3\xa9" in raw, ( - "Expected UTF-8 bytes for 'café' in the on-disk file, " - f"got: {raw!r}" - ) - # And the whole file decodes cleanly as UTF-8. - raw.decode("utf-8") - - def test_load_returns_empty_on_locale_corrupted_file(self, project_dir): - """A file written in a non-UTF-8 codec falls back to {}, not crash. - - Simulates a file produced by an old client (or by a peer machine - with a different default locale) that contains bytes invalid as - UTF-8. ``load_init_options`` should fall back to ``{}`` per the - existing contract — never propagate a raw ``UnicodeDecodeError`` - to the CLI surface. - """ - from specify_cli import load_init_options - - opts_file = project_dir / ".specify" / "init-options.json" - opts_file.parent.mkdir(parents=True, exist_ok=True) - # 0xE9 is 'é' in cp1252 but an invalid lead byte in UTF-8. - opts_file.write_bytes(b'{"project_name": "caf\xe9"}') - - assert load_init_options(project_dir) == {} - - @pytest.mark.parametrize("payload", ["[]", '"value"', "42", "true", "null"]) - def test_load_returns_empty_on_non_object_json(self, project_dir, payload): - from specify_cli import load_init_options - - opts_file = project_dir / ".specify" / "init-options.json" - opts_file.parent.mkdir(parents=True, exist_ok=True) - opts_file.write_text(payload, encoding="utf-8") - - assert load_init_options(project_dir) == {} - - def test_load_returns_empty_on_unicode_decode_error(self, project_dir, monkeypatch): - from specify_cli import load_init_options - - opts_file = project_dir / ".specify" / "init-options.json" - opts_file.parent.mkdir(parents=True, exist_ok=True) - opts_file.write_bytes(b"{}") - - original_read_text = Path.read_text - - def raise_decode_error(path, *args, **kwargs): - if path == opts_file: - raise UnicodeDecodeError("utf-8", b"\xff", 0, 1, "invalid start byte") - return original_read_text(path, *args, **kwargs) - - monkeypatch.setattr(Path, "read_text", raise_decode_error) - - assert load_init_options(project_dir) == {} - - @pytest.mark.parametrize( - ("value", "expected"), - [ - (True, True), - (False, False), - ("true", False), - ("false", False), - (1, False), - (0, False), - (None, False), - ], - ) - def test_is_ai_skills_enabled_requires_boolean_true(self, value, expected): - from specify_cli._init_options import is_ai_skills_enabled - - assert is_ai_skills_enabled({"ai_skills": value}) is expected - - -class TestResolveActiveAgentForRegistration: - """Tests for the shared #2948 active-agent resolution helper. - - ``load_init_options`` collapses "no file", "corrupted file", and - "valid file with no active agent" into the same ``{}``. Extensions and - presets both need to tell those apart: no file means "legacy project, - fall back to all detected agents"; a corrupted or malformed file means - "fail closed, register nothing" so a corrupted init-options.json can't - silently reintroduce all-agent registration. - """ - - def test_missing_file_returns_sentinel(self, project_dir): - from specify_cli._init_options import ( - MISSING_INIT_OPTIONS_FILE, - resolve_active_agent_for_registration, - ) - - assert ( - resolve_active_agent_for_registration(project_dir) - is MISSING_INIT_OPTIONS_FILE - ) - - def test_valid_active_agent_returns_string(self, project_dir): - from specify_cli import save_init_options - from specify_cli._init_options import resolve_active_agent_for_registration - - save_init_options(project_dir, {"ai": "claude"}) - - assert resolve_active_agent_for_registration(project_dir) == "claude" - - def test_corrupted_json_fails_closed(self, project_dir): - """A present-but-unparseable file must not behave like "no file".""" - from specify_cli._init_options import resolve_active_agent_for_registration - - opts_file = project_dir / ".specify" / "init-options.json" - opts_file.parent.mkdir(parents=True, exist_ok=True) - opts_file.write_text("{bad json", encoding="utf-8") - - assert resolve_active_agent_for_registration(project_dir) is None - - @pytest.mark.parametrize("value", [[], {}, "", 0, None, ["claude"]]) - def test_malformed_ai_value_fails_closed(self, project_dir, value): - """A recorded but non-string/empty ``ai`` value fails closed too.""" - from specify_cli import save_init_options - from specify_cli._init_options import resolve_active_agent_for_registration - - save_init_options(project_dir, {"ai": value}) - - assert resolve_active_agent_for_registration(project_dir) is None - - def test_dangling_symlink_fails_closed(self, project_dir): - """A dangling init-options.json symlink must fail closed, not fall - back to "no file" (#2948). - - ``Path.exists()`` follows symlinks and returns False for a broken - symlink whose target is missing, so a naive presence check treats a - dangling symlink the same as "no file at all" and falls back to - legacy all-agent registration. The path is present (just broken), - so it must be treated as a corrupted file and fail closed instead. - """ - from specify_cli._init_options import resolve_active_agent_for_registration - - opts_file = project_dir / ".specify" / "init-options.json" - opts_file.parent.mkdir(parents=True, exist_ok=True) - opts_file.symlink_to(project_dir / ".specify" / "does-not-exist.json") - - assert not opts_file.exists() # sanity: this is what makes it dangling - assert opts_file.is_symlink() - assert resolve_active_agent_for_registration(project_dir) is None - - -class TestPresetSkills: - """Tests for preset skill registration and unregistration. - - Tests that install the self-test preset use ``install_self_test_preset`` - which scopes a narrow filter to the expected wrap-strategy warning. - Reconciliation failures remain audible so real regressions surface. - """ - - def _write_init_options(self, project_dir, ai="claude", ai_skills=True, script="sh"): - from specify_cli import save_init_options - - save_init_options(project_dir, {"ai": ai, "ai_skills": ai_skills, "script": script}) - - def _create_skill(self, skills_dir, skill_name, body="original body"): - skill_dir = skills_dir / skill_name - skill_dir.mkdir(parents=True, exist_ok=True) - (skill_dir / "SKILL.md").write_text( - f"---\nname: {skill_name}\n---\n\n{body}\n" - ) - return skill_dir - - def _create_command_preset(self, temp_dir, preset_id, command_name, description, body): - preset_dir = temp_dir / preset_id - preset_dir.mkdir() - (preset_dir / "commands").mkdir() - command_file = f"{command_name}.md" - (preset_dir / "commands" / command_file).write_text( - f"---\ndescription: {description}\n---\n\n{body}\n" - ) - manifest_data = { - "schema_version": "1.0", - "preset": { - "id": preset_id, - "name": preset_id, - "version": "1.0.0", - "description": "Test", - }, - "requires": {"speckit_version": ">=0.1.0"}, - "provides": { - "templates": [ - { - "type": "command", - "name": command_name, - "file": f"commands/{command_file}", - } - ] - }, - } - with open(preset_dir / "preset.yml", "w") as f: - yaml.dump(manifest_data, f) - return preset_dir - - def _create_multi_command_preset(self, temp_dir, preset_id, command_names): - """Install-directory helper for a preset with more than one command. - - Used to prove partial-result handling: a command's own template - entry can genuinely be skipped by registration (missing source - file, safety-validation rejection) while sibling commands in the - same preset still succeed. - """ - preset_dir = temp_dir / preset_id - preset_dir.mkdir() - (preset_dir / "commands").mkdir() - templates = [] - for command_name in command_names: - command_file = f"{command_name}.md" - (preset_dir / "commands" / command_file).write_text( - f"---\ndescription: {command_name} test command\n---\n\n" - f"{command_name} body\n" - ) - templates.append({ - "type": "command", - "name": command_name, - "file": f"commands/{command_file}", - }) - manifest_data = { - "schema_version": "1.0", - "preset": { - "id": preset_id, - "name": preset_id, - "version": "1.0.0", - "description": "Test", - }, - "requires": {"speckit_version": ">=0.1.0"}, - "provides": {"templates": templates}, - } - with open(preset_dir / "preset.yml", "w") as f: - yaml.dump(manifest_data, f) - return preset_dir - - def _create_multi_command_preset_with_aliases(self, temp_dir, preset_id, command_specs): - """Install-directory helper for a preset whose commands carry aliases. - - ``command_specs`` is a list of ``(primary_name, [alias, ...])`` - tuples. Each command gets its own source file (aliases share the - same source/content as their primary — CommandRegistrar renders - them from the same command file, just under a different output - name (#2948)). - """ - preset_dir = temp_dir / preset_id - preset_dir.mkdir() - (preset_dir / "commands").mkdir() - templates = [] - for primary_name, aliases in command_specs: - command_file = f"{primary_name}.md" - (preset_dir / "commands" / command_file).write_text( - f"---\ndescription: {primary_name} test command\n---\n\n" - f"{primary_name} body\n" - ) - templates.append({ - "type": "command", - "name": primary_name, - "file": f"commands/{command_file}", - "aliases": list(aliases), - }) - manifest_data = { - "schema_version": "1.0", - "preset": { - "id": preset_id, - "name": preset_id, - "version": "1.0.0", - "description": "Test", - }, - "requires": {"speckit_version": ">=0.1.0"}, - "provides": {"templates": templates}, - } - with open(preset_dir / "preset.yml", "w") as f: - yaml.dump(manifest_data, f) - return preset_dir - - def test_skill_overridden_on_preset_install(self, project_dir, temp_dir): - """When skills mode was used, a preset command override should update the skill.""" - # Simulate skills mode having been used: write init-options + create skill - self._write_init_options(project_dir, ai="claude") - skills_dir = project_dir / ".claude" / "skills" - self._create_skill(skills_dir, "speckit-specify") - - # Also create the claude commands dir so commands get registered - (project_dir / ".claude" / "skills").mkdir(parents=True, exist_ok=True) - - # Install self-test preset (has a command override for speckit.specify) - manager = PresetManager(project_dir) - install_self_test_preset(manager) - - skill_file = skills_dir / "speckit-specify" / "SKILL.md" - assert skill_file.exists() - content = skill_file.read_text() - assert "preset:self-test" in content, "Skill should reference preset source" - assert "disable-model-invocation: false" in content - - # Verify it was recorded in registry, keyed by the active agent - metadata = manager.registry.get("self-test") - assert "speckit-specify" in metadata.get("registered_skills", {}).get("claude", []) - - def _install_arg_hint_preset(self, project_dir, temp_dir, ai, skills_dir, description, arg_hint): - """Install a preset whose command declares argument-hint; return the SKILL.md path.""" - self._write_init_options(project_dir, ai=ai) - self._create_skill(skills_dir, "speckit-hinttest-cmd") - (project_dir / ".specify" / "extensions" / "hinttest").mkdir(parents=True, exist_ok=True) - - preset_dir = temp_dir / f"hint-preset-{ai}" - preset_dir.mkdir() - (preset_dir / "commands").mkdir() - (preset_dir / "commands" / "speckit.hinttest.cmd.md").write_text( - "---\n" - f'description: "{description}"\n' - f'argument-hint: "{arg_hint}"\n' - "---\n\n" - "Preset command body.\n", - encoding="utf-8", - ) - manifest_data = { - "schema_version": "1.0", - "preset": { - "id": f"hint-preset-{ai}", - "name": "Hint Preset", - "version": "1.0.0", - "description": "Test", - }, - "requires": {"speckit_version": ">=0.1.0"}, - "provides": { - "templates": [ - { - "type": "command", - "name": "speckit.hinttest.cmd", - "file": "commands/speckit.hinttest.cmd.md", - } - ] - }, - } - with open(preset_dir / "preset.yml", "w") as f: - yaml.dump(manifest_data, f) - - manager = PresetManager(project_dir) - manager.install_from_directory(preset_dir, "0.1.5") - return skills_dir / "speckit-hinttest-cmd" / "SKILL.md" - - def test_argument_hint_preserved_for_preset_command(self, project_dir, temp_dir): - """argument-hint from a preset command must survive into the SKILL.md. - - Follow-up to #2903/#2916 for the preset skill generator. The - description is long enough to fold across lines when serialized, - guarding against an in-place string injection that would split the - folded scalar into invalid YAML. - """ - long_description = ( - "Build and maintain a lean, static context/ knowledge folder so " - "coding agents load only what is relevant and save tokens" - ) - arg_hint = " [area] [slug] [-- notes]" - skills_dir = project_dir / ".claude" / "skills" - - skill_file = self._install_arg_hint_preset( - project_dir, temp_dir, "claude", skills_dir, long_description, arg_hint - ) - assert skill_file.exists() - parsed = yaml.safe_load(skill_file.read_text(encoding="utf-8").split("---", 2)[1]) - assert parsed["argument-hint"] == arg_hint - assert parsed["description"] == long_description - - def test_argument_hint_not_added_for_non_claude_preset_command(self, project_dir, temp_dir): - """Non-Claude skills agents must not receive argument-hint in preset skills.""" - arg_hint = " [area]" - skills_dir = project_dir / ".agents" / "skills" - - skill_file = self._install_arg_hint_preset( - project_dir, temp_dir, "codex", skills_dir, "Build context", arg_hint - ) - assert skill_file.exists() - parsed = yaml.safe_load(skill_file.read_text(encoding="utf-8").split("---", 2)[1]) - assert "argument-hint" not in parsed - - def test_wrap_preset_inherits_argument_hint_from_core(self, project_dir, temp_dir): - """A wrap-strategy preset that omits argument-hint must inherit it from the core template. - - Regression for issue #3991: the wrap-composition path in _register_skills - previously inherited only scripts/agent_scripts from core_frontmatter, - silently discarding argument-hint and leaking its value into description. - """ - core_arg_hint = "Describe the feature you want to specify" - preset_description = "Wrapped speckit.specify — extra project context added" - self._write_init_options(project_dir, ai="claude") - skills_dir = project_dir / ".claude" / "skills" - self._create_skill(skills_dir, "speckit-specify") - - # Place a core template that declares argument-hint - core_cmds = project_dir / ".specify" / "templates" / "commands" - core_cmds.mkdir(parents=True, exist_ok=True) - (core_cmds / "specify.md").write_text( - "---\n" - "description: Core specify description.\n" - f'argument-hint: "{core_arg_hint}"\n' - "---\n\n" - "Core specify body.\n", - encoding="utf-8", - ) - - # Wrap preset: only declares description (no argument-hint) - preset_dir = temp_dir / "wrap-hint-preset" - preset_dir.mkdir() - (preset_dir / "commands").mkdir() - (preset_dir / "commands" / "speckit.specify.md").write_text( - "---\n" - f'description: "{preset_description}"\n' - "strategy: wrap\n" - "---\n\n" - "{CORE_TEMPLATE}\n", - encoding="utf-8", - ) - manifest_data = { - "schema_version": "1.0", - "preset": { - "id": "wrap-hint-preset", - "name": "Wrap Hint Preset", - "version": "1.0.0", - "description": "Test wrap hint inheritance", - }, - "requires": {"speckit_version": ">=0.1.0"}, - "provides": { - "templates": [ - { - "type": "command", - "name": "speckit.specify", - "file": "commands/speckit.specify.md", - "strategy": "wrap", - } - ] - }, - } - import yaml as _yaml - with open(preset_dir / "preset.yml", "w") as f: - _yaml.dump(manifest_data, f) - - manager = PresetManager(project_dir) - manager.install_from_directory(preset_dir, "1.0.0") - - skill_file = skills_dir / "speckit-specify" / "SKILL.md" - assert skill_file.exists() - parsed = yaml.safe_load(skill_file.read_text(encoding="utf-8").split("---", 2)[1]) - # argument-hint must be inherited from core, not dropped - assert parsed.get("argument-hint") == core_arg_hint, ( - f"argument-hint was not inherited from core; parsed={parsed}" - ) - # description must be exactly the preset's declared value, not concatenated - assert parsed["description"] == preset_description, ( - f"description was corrupted; parsed={parsed}" - ) - - def test_wrap_preset_inherits_argument_hint_for_unmapped_command(self, project_dir, temp_dir): - """Wrap inheritance must carry argument-hint for a command NOT in ARGUMENT_HINTS. - - Regression guard for issue #3991. The companion test above wraps - ``speckit.specify``, whose stem is in Claude's ``ARGUMENT_HINTS`` map, so - the string-injection fallback in ``post_process_skill_content`` re-adds - ``argument-hint`` even when wrap composition drops it — masking the bug. - This test wraps an extension-like command (``speckit.myfeature``) that is - absent from that map, so the *only* thing that can carry the hint into the - SKILL.md is the wrap-composition inheritance fix itself. Without the fix - the key is dropped and this test fails. - """ - core_arg_hint = "Custom hint that lives only on the core template" - preset_description = "Wrapped speckit.myfeature — extra project context added" - self._write_init_options(project_dir, ai="claude") - skills_dir = project_dir / ".claude" / "skills" - self._create_skill(skills_dir, "speckit-myfeature") - - # Place a core template (extension-like command) that declares argument-hint - core_cmds = project_dir / ".specify" / "templates" / "commands" - core_cmds.mkdir(parents=True, exist_ok=True) - (core_cmds / "myfeature.md").write_text( - "---\n" - "description: Core myfeature description.\n" - f'argument-hint: "{core_arg_hint}"\n' - "---\n\n" - "Core myfeature body.\n", - encoding="utf-8", - ) - - # Wrap preset: only declares description (no argument-hint) - preset_dir = temp_dir / "wrap-hint-preset-unmapped" - preset_dir.mkdir() - (preset_dir / "commands").mkdir() - (preset_dir / "commands" / "speckit.myfeature.md").write_text( - "---\n" - f'description: "{preset_description}"\n' - "strategy: wrap\n" - "---\n\n" - "{CORE_TEMPLATE}\n", - encoding="utf-8", - ) - manifest_data = { - "schema_version": "1.0", - "preset": { - "id": "wrap-hint-preset-unmapped", - "name": "Wrap Hint Preset Unmapped", - "version": "1.0.0", - "description": "Test wrap hint inheritance for an unmapped command", - }, - "requires": {"speckit_version": ">=0.1.0"}, - "provides": { - "templates": [ - { - "type": "command", - "name": "speckit.myfeature", - "file": "commands/speckit.myfeature.md", - "strategy": "wrap", - } - ] - }, - } - import yaml as _yaml - with open(preset_dir / "preset.yml", "w") as f: - _yaml.dump(manifest_data, f) - - manager = PresetManager(project_dir) - manager.install_from_directory(preset_dir, "1.0.0") - - skill_file = skills_dir / "speckit-myfeature" / "SKILL.md" - assert skill_file.exists() - parsed = yaml.safe_load(skill_file.read_text(encoding="utf-8").split("---", 2)[1]) - # argument-hint must be inherited from core, not dropped - assert parsed.get("argument-hint") == core_arg_hint, ( - f"argument-hint was not inherited from core; parsed={parsed}" - ) - # description must be exactly the preset's declared value, not concatenated - assert parsed["description"] == preset_description, ( - f"description was corrupted; parsed={parsed}" - ) - - def test_register_skills_resolves_command_refs(self, project_dir, temp_dir): - """Preset skill overrides must resolve __SPECKIT_COMMAND_*__ tokens (issue #2717). - - ``_register_skills()`` previously ran only ``resolve_skill_placeholders()``, - so command cross-references leaked into SKILL.md as raw placeholders - instead of rendering as ``/speckit-`` like the command layer. - """ - self._write_init_options(project_dir, ai="claude") - skills_dir = project_dir / ".claude" / "skills" - self._create_skill(skills_dir, "speckit-specify") - - preset_dir = self._create_command_preset( - temp_dir, - "cmdref-install", - "speckit.specify", - "Override specify", - "Run `__SPECKIT_COMMAND_SPECIFY__` then `__SPECKIT_COMMAND_PLAN__`.\n", - ) - - manager = PresetManager(project_dir) - manager.install_from_directory(preset_dir, "0.1.5") - - content = (skills_dir / "speckit-specify" / "SKILL.md").read_text() - assert "__SPECKIT_COMMAND_" not in content, "raw command token leaked into SKILL.md" - # Claude's invoke_separator is "-", so tokens render as /speckit-. - assert "/speckit-specify" in content - assert "/speckit-plan" in content - - def test_restore_skill_resolves_command_refs(self, project_dir, temp_dir): - """Skill restore on preset removal must also resolve command tokens (issue #2717).""" - self._write_init_options(project_dir, ai="claude") - skills_dir = project_dir / ".claude" / "skills" - self._create_skill(skills_dir, "speckit-specify") - - core_cmds = project_dir / ".specify" / "templates" / "commands" - core_cmds.mkdir(parents=True, exist_ok=True) - (core_cmds / "specify.md").write_text( - "---\ndescription: Core specify\n---\n\n" - "Then run `__SPECKIT_COMMAND_PLAN__`.\n" - ) - - preset_dir = self._create_command_preset( - temp_dir, - "cmdref-restore", - "speckit.specify", - "Override specify", - "Override body\n", - ) - manager = PresetManager(project_dir) - manager.install_from_directory(preset_dir, "0.1.5") - manager.remove("cmdref-restore") - - content = (skills_dir / "speckit-specify" / "SKILL.md").read_text() - assert "__SPECKIT_COMMAND_" not in content, "raw command token leaked on restore" - assert "/speckit-plan" in content - - def test_restore_skill_preserves_dollar_command_refs(self, project_dir, temp_dir): - """Dollar-style core refs remain native when a preset skill is removed.""" - self._write_init_options(project_dir, ai="zcode") - skills_dir = project_dir / ".zcode" / "skills" - self._create_skill(skills_dir, "speckit-specify") - - core_cmds = project_dir / ".specify" / "templates" / "commands" - core_cmds.mkdir(parents=True, exist_ok=True) - raw_core = ( - "---\ndescription: Core specify\n---\n\n" - "Then run `__SPECKIT_COMMAND_PLAN__`.\n" - ) - (core_cmds / "specify.md").write_text(raw_core) - - preset_dir = self._create_command_preset( - temp_dir, - "dollar-cmdref-restore", - "speckit.specify", - "Override specify", - "Override body\n", - ) - manager = PresetManager(project_dir) - manager.install_from_directory(preset_dir, "0.1.5") - manager.remove("dollar-cmdref-restore") - - content = (skills_dir / "speckit-specify" / "SKILL.md").read_text() - assert "$speckit-plan" in content - assert "/speckit-plan" not in content - - def test_reconcile_override_skill_resolves_command_refs(self, project_dir, temp_dir): - """Reconcile's project-override restore must resolve command tokens (issue #2717). - - When a preset that overrode a command is removed and a project override - becomes the winning layer, ``_reconcile_skills`` rewrites the skill from - the override body — which must also render ``__SPECKIT_COMMAND_*__`` tokens. - """ - self._write_init_options(project_dir, ai="claude") - skills_dir = project_dir / ".claude" / "skills" - self._create_skill(skills_dir, "speckit-specify") - - # Project override wins once the preset is removed; its body carries a - # command cross-reference token. No core template exists for "specify", - # so the skill is restored exclusively via the reconcile override branch. - overrides_dir = project_dir / ".specify" / "templates" / "overrides" - overrides_dir.mkdir(parents=True, exist_ok=True) - (overrides_dir / "speckit.specify.md").write_text( - "---\ndescription: Override specify\n---\n\n" - "Then run `__SPECKIT_COMMAND_PLAN__`.\n" - ) - - preset_dir = self._create_command_preset( - temp_dir, - "cmdref-reconcile", - "speckit.specify", - "Preset specify", - "Preset body\n", - ) - manager = PresetManager(project_dir) - manager.install_from_directory(preset_dir, "0.1.5") - manager.remove("cmdref-reconcile") - - content = (skills_dir / "speckit-specify" / "SKILL.md").read_text() - assert "override:speckit.specify" in content, "skill should be restored from the project override" - assert "__SPECKIT_COMMAND_" not in content, "raw command token leaked on reconcile" - assert "/speckit-plan" in content - - def test_extension_restore_resolves_command_refs(self, project_dir, temp_dir): - """Extension-backed skill restore must resolve command tokens (issue #2717). - - When a preset override is removed and the skill is restored from an - extension command body, ``__SPECKIT_COMMAND_*__`` tokens in that body - must render as slash-command invocations like the core-template path. - """ - self._write_init_options(project_dir, ai="claude") - skills_dir = project_dir / ".claude" / "skills" - self._create_skill(skills_dir, "speckit-fakeext-cmd", body="original extension skill") - - extension_dir = project_dir / ".specify" / "extensions" / "fakeext" - (extension_dir / "commands").mkdir(parents=True, exist_ok=True) - (extension_dir / "commands" / "cmd.md").write_text( - "---\ndescription: Extension fakeext cmd\n---\n\n" - "Then run `__SPECKIT_COMMAND_PLAN__`.\n" - ) - extension_manifest = { - "schema_version": "1.0", - "extension": { - "id": "fakeext", - "name": "Fake Extension", - "version": "1.0.0", - "description": "Test", - }, - "requires": {"speckit_version": ">=0.1.0"}, - "provides": { - "commands": [ - { - "name": "speckit.fakeext.cmd", - "file": "commands/cmd.md", - "description": "Fake extension command", - } - ] - }, - } - with open(extension_dir / "extension.yml", "w") as f: - yaml.dump(extension_manifest, f) - - preset_dir = self._create_command_preset( - temp_dir, - "cmdref-ext-restore", - "speckit.fakeext.cmd", - "Override fakeext cmd", - "Override body\n", - ) - manager = PresetManager(project_dir) - manager.install_from_directory(preset_dir, "0.1.5") - manager.remove("cmdref-ext-restore") - - content = (skills_dir / "speckit-fakeext-cmd" / "SKILL.md").read_text() - assert "source: extension:fakeext" in content, "skill should be restored from the extension" - assert "__SPECKIT_COMMAND_" not in content, "raw command token leaked on extension restore" - assert "/speckit-plan" in content - - def test_core_command_override_skill_uses_preset_command_description(self, project_dir, temp_dir): - """Preset skill overrides for core commands should keep preset frontmatter descriptions.""" - self._write_init_options(project_dir, ai="claude") - skills_dir = project_dir / ".claude" / "skills" - self._create_skill(skills_dir, "speckit-taskstoissues") - - preset_dir = temp_dir / "taskstoissues-description" - preset_dir.mkdir() - (preset_dir / "commands").mkdir() - (preset_dir / "commands" / "speckit.repro.taskstoissues.md").write_text( - "---\n" - "description: COMMAND-FRONTMATTER-DESCRIPTION\n" - "---\n\n" - "# Repro command body\n" - ) - manifest_data = { - "schema_version": "1.0", - "preset": { - "id": "taskstoissues-description", - "name": "Taskstoissues Description", - "version": "1.0.0", - "description": "Test", - }, - "requires": {"speckit_version": ">=0.1.0"}, - "provides": { - "templates": [ - { - "type": "command", - "name": "speckit.taskstoissues", - "file": "commands/speckit.repro.taskstoissues.md", - "description": "MANIFEST-DESCRIPTION", - "replaces": "speckit.taskstoissues", - "strategy": "replace", - } - ] - }, - } - with open(preset_dir / "preset.yml", "w") as f: - yaml.dump(manifest_data, f) - - manager = PresetManager(project_dir) - manager.install_from_directory(preset_dir, "0.1.5") - - skill_file = skills_dir / "speckit-taskstoissues" / "SKILL.md" - content = skill_file.read_text() - assert "description: COMMAND-FRONTMATTER-DESCRIPTION" in content - assert "Convert tasks from tasks.md into GitHub issues." not in content - assert "source: preset:taskstoissues-description" in content - - def test_core_skill_restore_uses_core_command_description(self, project_dir, temp_dir): - """Core skill restore should keep core command frontmatter descriptions.""" - self._write_init_options(project_dir, ai="claude") - skills_dir = project_dir / ".claude" / "skills" - self._create_skill(skills_dir, "speckit-taskstoissues") - - core_cmds = project_dir / ".specify" / "templates" / "commands" - core_cmds.mkdir(parents=True, exist_ok=True) - (core_cmds / "taskstoissues.md").write_text( - "---\n" - "description: CORE-FRONTMATTER-DESCRIPTION\n" - "---\n\n" - "core taskstoissues body\n" - ) - preset_dir = self._create_command_preset( - temp_dir, - "taskstoissues-restore", - "speckit.taskstoissues", - "PRESET-FRONTMATTER-DESCRIPTION", - "preset taskstoissues body\n", - ) - - manager = PresetManager(project_dir) - manager.install_from_directory(preset_dir, "0.1.5") - manager.remove("taskstoissues-restore") - - skill_file = skills_dir / "speckit-taskstoissues" / "SKILL.md" - content = skill_file.read_text() - assert "description: CORE-FRONTMATTER-DESCRIPTION" in content - assert "Convert tasks from tasks.md into GitHub issues." not in content - assert "source: templates/commands/taskstoissues.md" in content - assert "core taskstoissues body" in content - - def test_override_skill_reconcile_uses_override_command_description(self, project_dir, temp_dir): - """Override skill reconciliation should keep override frontmatter descriptions.""" - self._write_init_options(project_dir, ai="claude") - skills_dir = project_dir / ".claude" / "skills" - self._create_skill(skills_dir, "speckit-taskstoissues") - - overrides_dir = project_dir / ".specify" / "templates" / "overrides" - overrides_dir.mkdir(parents=True) - (overrides_dir / "speckit.taskstoissues.md").write_text( - "---\n" - "description: OVERRIDE-FRONTMATTER-DESCRIPTION\n" - "---\n\n" - "override taskstoissues body\n" - ) - preset_dir = self._create_command_preset( - temp_dir, - "taskstoissues-reconcile", - "speckit.taskstoissues", - "PRESET-FRONTMATTER-DESCRIPTION", - "preset taskstoissues body\n", - ) - - manager = PresetManager(project_dir) - manager.install_from_directory(preset_dir, "0.1.5") - - skill_file = skills_dir / "speckit-taskstoissues" / "SKILL.md" - content = skill_file.read_text() - assert "description: OVERRIDE-FRONTMATTER-DESCRIPTION" in content - assert "Convert tasks from tasks.md into GitHub issues." not in content - assert "source: override:speckit.taskstoissues" in content - assert "override taskstoissues body" in content - - def test_skill_not_updated_when_ai_skills_disabled(self, project_dir, temp_dir): - """When skills mode was NOT used, preset install should not touch skills.""" - self._write_init_options(project_dir, ai="qwen", ai_skills=False) - skills_dir = project_dir / ".qwen" / "skills" - self._create_skill(skills_dir, "speckit-specify", body="untouched") - - manager = PresetManager(project_dir) - install_self_test_preset(manager) - - skill_file = skills_dir / "speckit-specify" / "SKILL.md" - content = skill_file.read_text() - assert "untouched" in content, "Skill should not be modified when ai_skills=False" - - def test_get_skills_dir_returns_none_for_non_string_ai(self, project_dir): - """Corrupted init-options ai values should not crash preset skill resolution.""" - init_options = project_dir / ".specify" / "init-options.json" - init_options.parent.mkdir(parents=True, exist_ok=True) - init_options.write_text('{"ai":["codex"],"ai_skills":true,"script":"sh"}') - - manager = PresetManager(project_dir) - - assert manager._get_skills_dir() is None - - def test_get_skills_dir_returns_none_for_non_dict_init_options(self, project_dir): - """Corrupted non-dict init-options payloads should fail closed.""" - init_options = project_dir / ".specify" / "init-options.json" - init_options.parent.mkdir(parents=True, exist_ok=True) - init_options.write_text("[]") - - manager = PresetManager(project_dir) - - assert manager._get_skills_dir() is None - - def test_skill_not_updated_without_init_options(self, project_dir, temp_dir): - """When no init-options.json exists, preset install should not touch skills.""" - skills_dir = project_dir / ".qwen" / "skills" - self._create_skill(skills_dir, "speckit-specify", body="untouched") - - manager = PresetManager(project_dir) - install_self_test_preset(manager) - - skill_file = skills_dir / "speckit-specify" / "SKILL.md" - file_content = skill_file.read_text() - assert "untouched" in file_content - - def test_skill_restored_on_preset_remove(self, project_dir, temp_dir): - """When a preset is removed, skills should be restored from core templates.""" - self._write_init_options(project_dir, ai="claude") - skills_dir = project_dir / ".claude" / "skills" - self._create_skill(skills_dir, "speckit-specify") - - (project_dir / ".claude" / "skills").mkdir(parents=True, exist_ok=True) - - # Set up core command template in the project so restoration works - core_cmds = project_dir / ".specify" / "templates" / "commands" - core_cmds.mkdir(parents=True, exist_ok=True) - (core_cmds / "specify.md").write_text("---\ndescription: Core specify command\n---\n\nCore specify body\n") - - manager = PresetManager(project_dir) - install_self_test_preset(manager) - - # Verify preset content is in the skill - skill_file = skills_dir / "speckit-specify" / "SKILL.md" - assert "preset:self-test" in skill_file.read_text() - - # Remove the preset - manager.remove("self-test") - - # Skill should be restored (core specify.md template exists) - assert skill_file.exists(), "Skill should still exist after preset removal" - content = skill_file.read_text() - assert "preset:self-test" not in content, "Preset content should be gone" - assert "templates/commands/specify.md" in content, "Should reference core template" - assert "disable-model-invocation: false" in content - - def test_skill_restored_on_preset_remove_without_project_core_templates(self, project_dir): - """Removing a preset must restore core skills even when the project - has no ``.specify/templates/commands`` directory of its own — which - is the normal case, since ``specify init`` never populates it. The - real core commands live in the bundled core_pack/repo-root templates - tree, and restoration must fall back there instead of deleting the - skill outright (#3928). - """ - self._write_init_options(project_dir, ai="claude") - skills_dir = project_dir / ".claude" / "skills" - self._create_skill(skills_dir, "speckit-specify") - - # The project_dir fixture's commands dir is empty, matching a real - # project — specify init never populates project-local overrides - # for unmodified core commands. - core_cmds = project_dir / ".specify" / "templates" / "commands" - assert core_cmds.exists() and not any(core_cmds.iterdir()) - - manager = PresetManager(project_dir) - install_self_test_preset(manager) - - skill_file = skills_dir / "speckit-specify" / "SKILL.md" - assert "preset:self-test" in skill_file.read_text(encoding="utf-8") - - manager.remove("self-test") - - assert skill_file.exists(), "Core skill must be restored, not deleted" - content = skill_file.read_text(encoding="utf-8") - assert "preset:self-test" not in content - assert "templates/commands/specify.md" in content - assert "Create or update the feature specification" in content - - def test_extension_wins_over_bundled_core_on_preset_remove( - self, project_dir, monkeypatch - ): - """When an installed extension owns the same skill name as a core - command, removing a preset that overrode that skill must restore it - from the extension, not silently from the bundled core template. - Extensions are resolved ahead of bundled core elsewhere, and the - bundled-core fallback added for #3928 must not replace that - higher-priority layer. - - The extension-command namespace rules (``speckit..``) - make a genuine end-to-end name collision with a core command - cumbersome to construct through real manifests, so this stubs - ``_build_extension_skill_restore_index`` to exercise the priority - ordering in ``_unregister_skills_in_dir`` directly -- the code path - under test doesn't care how the index entry was produced, only that - it wins over the bundled-core fallback when present. - """ - self._write_init_options(project_dir, ai="claude") - skills_dir = project_dir / ".claude" / "skills" - self._create_skill(skills_dir, "speckit-specify") - - # No project-local core template override — the normal case, and - # the one that makes the bundled-core fallback kick in at all. - core_cmds = project_dir / ".specify" / "templates" / "commands" - assert core_cmds.exists() and not any(core_cmds.iterdir()) - - extension_dir = project_dir / ".specify" / "extensions" / "fakeext" - (extension_dir / "commands").mkdir(parents=True, exist_ok=True) - ext_specify_file = extension_dir / "commands" / "specify.md" - ext_specify_file.write_text( - "---\ndescription: Extension specify command\n---\n\n" - "extension:fakeext specify body\n" - ) - - manager = PresetManager(project_dir) - install_self_test_preset(manager) - - skill_file = skills_dir / "speckit-specify" / "SKILL.md" - assert "preset:self-test" in skill_file.read_text(encoding="utf-8") - - fake_restore_index = { - "speckit-specify": { - "command_name": "speckit.fakeext.specify", - "source_file": ext_specify_file, - "source": "extension:fakeext", - "extension_id": "fakeext", - "extension_dir": extension_dir, - } - } - monkeypatch.setattr( - manager, - "_build_extension_skill_restore_index", - lambda: fake_restore_index, - ) - - manager.remove("self-test") - - assert skill_file.exists() - content = skill_file.read_text(encoding="utf-8") - assert "preset:self-test" not in content - assert "source: extension:fakeext" in content - assert "extension:fakeext specify body" in content - assert "templates/commands/specify.md" not in content - - def test_skill_restored_on_remove_resolves_script_placeholders(self, project_dir): - """Core restore should resolve {SCRIPT}/{ARGS} placeholders like other skill paths.""" - self._write_init_options(project_dir, ai="claude", ai_skills=True, script="sh") - skills_dir = project_dir / ".claude" / "skills" - self._create_skill(skills_dir, "speckit-specify", body="old") - (project_dir / ".claude" / "skills").mkdir(parents=True, exist_ok=True) - - core_cmds = project_dir / ".specify" / "templates" / "commands" - core_cmds.mkdir(parents=True, exist_ok=True) - (core_cmds / "specify.md").write_text( - "---\n" - "description: Core specify command\n" - "scripts:\n" - " sh: .specify/scripts/bash/create-new-feature.sh --json \"{ARGS}\"\n" - "---\n\n" - "Run:\n" - "{SCRIPT}\n" - ) - - manager = PresetManager(project_dir) - install_self_test_preset(manager) - manager.remove("self-test") - - content = (skills_dir / "speckit-specify" / "SKILL.md").read_text() - assert "{SCRIPT}" not in content - assert "{ARGS}" not in content - assert ".specify/scripts/bash/create-new-feature.sh --json \"$ARGUMENTS\"" in content - - def test_skill_not_overridden_when_skill_path_is_file(self, project_dir): - """Preset install should skip non-directory skill targets.""" - self._write_init_options(project_dir, ai="qwen") - skills_dir = project_dir / ".qwen" / "skills" - skills_dir.mkdir(parents=True, exist_ok=True) - (skills_dir / "speckit-specify").write_text("not-a-directory") - - manager = PresetManager(project_dir) - install_self_test_preset(manager) - - assert (skills_dir / "speckit-specify").is_file() - metadata = manager.registry.get("self-test") - assert "speckit-specify" not in metadata.get("registered_skills", {}).get("qwen", []) - - def test_no_skills_registered_when_skills_mode_disabled(self, project_dir, temp_dir): - """Skills should not be created when skills mode is disabled.""" - self._write_init_options(project_dir, ai="claude", ai_skills=False) - - manager = PresetManager(project_dir) - install_self_test_preset(manager) - - metadata = manager.registry.get("self-test") - assert metadata.get("registered_skills", {}) == {} - - def test_extension_skill_override_matches_hyphenated_multisegment_name(self, project_dir, temp_dir): - """Preset overrides for speckit.. should target speckit-- skills.""" - self._write_init_options(project_dir, ai="codex") - skills_dir = project_dir / ".agents" / "skills" - self._create_skill(skills_dir, "speckit-fakeext-cmd", body="untouched") - (project_dir / ".specify" / "extensions" / "fakeext").mkdir(parents=True, exist_ok=True) - - preset_dir = temp_dir / "ext-skill-override" - preset_dir.mkdir() - (preset_dir / "commands").mkdir() - (preset_dir / "commands" / "speckit.fakeext.cmd.md").write_text( - "---\ndescription: Override fakeext cmd\n---\n\npreset:ext-skill-override\n" - ) - manifest_data = { - "schema_version": "1.0", - "preset": { - "id": "ext-skill-override", - "name": "Ext Skill Override", - "version": "1.0.0", - "description": "Test", - }, - "requires": {"speckit_version": ">=0.1.0"}, - "provides": { - "templates": [ - { - "type": "command", - "name": "speckit.fakeext.cmd", - "file": "commands/speckit.fakeext.cmd.md", - } - ] - }, - } - with open(preset_dir / "preset.yml", "w") as f: - yaml.dump(manifest_data, f) - - manager = PresetManager(project_dir) - manager.install_from_directory(preset_dir, "0.1.5") - - skill_file = skills_dir / "speckit-fakeext-cmd" / "SKILL.md" - assert skill_file.exists() - content = skill_file.read_text() - assert "preset:ext-skill-override" in content - assert "name: speckit-fakeext-cmd" in content - assert "# Speckit Fakeext Cmd Skill" in content - - metadata = manager.registry.get("ext-skill-override") - assert "speckit-fakeext-cmd" in metadata.get("registered_skills", {}).get("codex", []) - - def test_extension_skill_restored_on_preset_remove(self, project_dir, temp_dir): - """Preset removal should restore an extension-backed skill instead of deleting it.""" - self._write_init_options(project_dir, ai="codex") - skills_dir = project_dir / ".agents" / "skills" - self._create_skill(skills_dir, "speckit-fakeext-cmd", body="original extension skill") - - extension_dir = project_dir / ".specify" / "extensions" / "fakeext" - (extension_dir / "commands").mkdir(parents=True, exist_ok=True) - (extension_dir / "agents" / "control").mkdir(parents=True, exist_ok=True) - (extension_dir / "agents" / "control" / "commander.md").write_text("# Commander\n") - (extension_dir / "commands" / "cmd.md").write_text( - "---\n" - "description: Extension fakeext cmd\n" - "scripts:\n" - " sh: ../../scripts/bash/setup-plan.sh --json \"{ARGS}\"\n" - "---\n\n" - "extension:fakeext\n" - "Run {SCRIPT}\n" - "Read agents/control/commander.md for context.\n" - ) - extension_manifest = { - "schema_version": "1.0", - "extension": { - "id": "fakeext", - "name": "Fake Extension", - "author": "acme-corp", - "version": "1.0.0", - "description": "Test", - }, - "requires": {"speckit_version": ">=0.1.0"}, - "provides": { - "commands": [ - { - "name": "speckit.fakeext.cmd", - "file": "commands/cmd.md", - "description": "Fake extension command", - } - ] - }, - } - with open(extension_dir / "extension.yml", "w") as f: - yaml.dump(extension_manifest, f) - - preset_dir = temp_dir / "ext-skill-restore" - preset_dir.mkdir() - (preset_dir / "commands").mkdir() - (preset_dir / "commands" / "speckit.fakeext.cmd.md").write_text( - "---\ndescription: Override fakeext cmd\n---\n\npreset:ext-skill-restore\n" - ) - preset_manifest = { - "schema_version": "1.0", - "preset": { - "id": "ext-skill-restore", - "name": "Ext Skill Restore", - "version": "1.0.0", - "description": "Test", - }, - "requires": {"speckit_version": ">=0.1.0"}, - "provides": { - "templates": [ - { - "type": "command", - "name": "speckit.fakeext.cmd", - "file": "commands/speckit.fakeext.cmd.md", - } - ] - }, - } - with open(preset_dir / "preset.yml", "w") as f: - yaml.dump(preset_manifest, f) - - manager = PresetManager(project_dir) - manager.install_from_directory(preset_dir, "0.1.5") - - skill_file = skills_dir / "speckit-fakeext-cmd" / "SKILL.md" - assert "preset:ext-skill-restore" in skill_file.read_text() - - manager.remove("ext-skill-restore") - - assert skill_file.exists() - content = skill_file.read_text() - assert "preset:ext-skill-restore" not in content - assert "source: extension:fakeext" in content - assert "extension:fakeext" in content - assert '.specify/scripts/bash/setup-plan.sh --json "$ARGUMENTS"' in content - # Extension-relative subdir references must resolve to their - # installed location on restore too (#2101), not just on first - # registration. - assert ".specify/extensions/fakeext/agents/control/commander.md" in content - assert "Read agents/control" not in content - assert "# Fakeext Cmd Skill" in content - - assert yaml.safe_load(content.split("---", 2)[1])["metadata"]["author"] == "acme-corp" - - def test_skill_composed_over_extension_base_rewrites_subdir_paths( - self, project_dir, temp_dir - ): - """When a preset composes (append) over an extension-provided base - command, the resulting skill (read from the .composed output) must - still resolve the extension's own subdir references (#2101), not - just when the extension wins outright (replace).""" - self._write_init_options(project_dir, ai="codex") - skills_dir = project_dir / ".agents" / "skills" - self._create_skill(skills_dir, "speckit-fakeext-cmd", body="original extension skill") - - extension_dir = project_dir / ".specify" / "extensions" / "fakeext" - (extension_dir / "commands").mkdir(parents=True, exist_ok=True) - (extension_dir / "agents" / "control").mkdir(parents=True, exist_ok=True) - (extension_dir / "agents" / "control" / "commander.md").write_text("# Commander\n") - (extension_dir / "commands" / "cmd.md").write_text( - "---\ndescription: Extension fakeext cmd\n---\n\n" - "Read agents/control/commander.md for context.\n" - ) - extension_manifest = { - "schema_version": "1.0", - "extension": { - "id": "fakeext", - "name": "Fake Extension", - "version": "1.0.0", - "description": "Test", - }, - "requires": {"speckit_version": ">=0.1.0"}, - "provides": { - "commands": [ - { - "name": "speckit.fakeext.cmd", - "file": "commands/cmd.md", - "description": "Fake extension command", - } - ] - }, - } - with open(extension_dir / "extension.yml", "w") as f: - yaml.dump(extension_manifest, f) - - preset_dir = temp_dir / "ext-base-append-skill" - preset_dir.mkdir() - (preset_dir / "commands").mkdir() - (preset_dir / "commands" / "speckit.fakeext.cmd.md").write_text( - "---\ndescription: Preset overlay\n---\n\n## Extra\n" - ) - preset_manifest = { - "schema_version": "1.0", - "preset": { - "id": "ext-base-append-skill", - "name": "Ext Base Append Skill", - "version": "1.0.0", - "description": "Test", - }, - "requires": {"speckit_version": ">=0.1.0"}, - "provides": { - "templates": [ - { - "type": "command", - "name": "speckit.fakeext.cmd", - "file": "commands/speckit.fakeext.cmd.md", - "strategy": "append", - } - ] - }, - } - with open(preset_dir / "preset.yml", "w") as f: - yaml.dump(preset_manifest, f) - - manager = PresetManager(project_dir) - manager.install_from_directory(preset_dir, "0.1.5") - - skill_file = skills_dir / "speckit-fakeext-cmd" / "SKILL.md" - content = skill_file.read_text() - assert ".specify/extensions/fakeext/agents/control/commander.md" in content - assert "Read agents/control" not in content - assert "## Extra" in content - - def test_preset_remove_skips_skill_dir_without_skill_file(self, project_dir, temp_dir): - """Preset removal should not delete arbitrary directories missing SKILL.md.""" - self._write_init_options(project_dir, ai="codex") - skills_dir = project_dir / ".agents" / "skills" - stray_skill_dir = skills_dir / "speckit-fakeext-cmd" - stray_skill_dir.mkdir(parents=True, exist_ok=True) - note_file = stray_skill_dir / "notes.txt" - note_file.write_text("user content", encoding="utf-8") - - preset_dir = temp_dir / "ext-skill-missing-file" - preset_dir.mkdir() - (preset_dir / "commands").mkdir() - (preset_dir / "commands" / "speckit.fakeext.cmd.md").write_text( - "---\ndescription: Override fakeext cmd\n---\n\npreset:ext-skill-missing-file\n" - ) - preset_manifest = { - "schema_version": "1.0", - "preset": { - "id": "ext-skill-missing-file", - "name": "Ext Skill Missing File", - "version": "1.0.0", - "description": "Test", - }, - "requires": {"speckit_version": ">=0.1.0"}, - "provides": { - "templates": [ - { - "type": "command", - "name": "speckit.fakeext.cmd", - "file": "commands/speckit.fakeext.cmd.md", - } - ] - }, - } - with open(preset_dir / "preset.yml", "w") as f: - yaml.dump(preset_manifest, f) - - manager = PresetManager(project_dir) - installed_preset_dir = manager.presets_dir / "ext-skill-missing-file" - shutil.copytree(preset_dir, installed_preset_dir) - manager.registry.add( - "ext-skill-missing-file", - { - "version": "1.0.0", - "source": str(preset_dir), - "provides_templates": ["speckit.fakeext.cmd"], - "registered_skills": ["speckit-fakeext-cmd"], - "priority": 10, - }, - ) - - manager.remove("ext-skill-missing-file") - - assert stray_skill_dir.is_dir() - assert note_file.read_text(encoding="utf-8") == "user content" - - def test_kimi_legacy_dotted_skill_override_still_applies(self, project_dir, temp_dir): - """Preset overrides should still target legacy dotted-named skill dirs. - - This exercises legacy *naming* (``speckit.specify``) under the current - ``.kimi-code/`` base — distinct from the legacy ``.kimi/`` *location*. - """ - self._write_init_options(project_dir, ai="kimi") - skills_dir = project_dir / ".kimi-code" / "skills" - self._create_skill(skills_dir, "speckit.specify", body="untouched") - - (project_dir / ".kimi-code" / "commands").mkdir(parents=True, exist_ok=True) - - manager = PresetManager(project_dir) - install_self_test_preset(manager) - - skill_file = skills_dir / "speckit.specify" / "SKILL.md" - assert skill_file.exists() - content = skill_file.read_text() - assert "preset:self-test" in content - assert "name: speckit.specify" in content - - metadata = manager.registry.get("self-test") - assert "speckit.specify" in metadata.get("registered_skills", {}).get("kimi", []) - - def test_kimi_legacy_dotted_skill_reconciles_priority_winner( - self, project_dir, temp_dir - ): - """Reconciliation must carry forward recorded legacy skill names.""" - self._write_init_options(project_dir, ai="kimi") - skills_dir = project_dir / ".kimi-code" / "skills" - self._create_skill(skills_dir, "speckit.specify", body="untouched") - (project_dir / ".kimi-code" / "commands").mkdir( - parents=True, exist_ok=True - ) - - higher_dir = self._create_command_preset( - temp_dir, - "higher-kimi-preset", - "speckit.specify", - "Higher preset", - "Higher body", - ) - lower_dir = self._create_command_preset( - temp_dir, - "lower-kimi-preset", - "speckit.specify", - "Lower preset", - "Lower body", - ) - manager = PresetManager(project_dir) - manager.install_from_directory(higher_dir, "0.1.5", priority=10) - manager.install_from_directory(lower_dir, "0.1.5", priority=20) - - skill_file = skills_dir / "speckit.specify" / "SKILL.md" - for preset_id in ("higher-kimi-preset", "lower-kimi-preset"): - manager.registry.update( - preset_id, - {"registered_skills": {"kimi": ["speckit.specify"]}}, - ) - skill_file.write_text( - "---\nname: speckit.specify\n---\n\nLower body\n", - encoding="utf-8", - ) - manager._reconcile_skills(["speckit.specify"]) - - assert "Higher body" in skill_file.read_text(encoding="utf-8"), ( - "reconciliation must replace lower-priority raw content in a " - "recorded legacy dotted skill directory" - ) - - def test_kimi_legacy_dotted_skill_receives_project_override( - self, project_dir, temp_dir - ): - """Project overrides must update the recorded legacy path in place.""" - self._write_init_options(project_dir, ai="kimi") - skills_dir = project_dir / ".kimi-code" / "skills" - self._create_skill(skills_dir, "speckit.specify", body="untouched") - (project_dir / ".kimi-code" / "commands").mkdir( - parents=True, exist_ok=True - ) - - preset_dir = self._create_command_preset( - temp_dir, - "kimi-override-preset", - "speckit.specify", - "Preset", - "Preset body", - ) - manager = PresetManager(project_dir) - manager.install_from_directory(preset_dir, "0.1.5") - manager.registry.update( - "kimi-override-preset", - {"registered_skills": {"kimi": ["speckit.specify"]}}, - ) - modern_skill_dir = skills_dir / "speckit-specify" - if modern_skill_dir.exists(): - shutil.rmtree(modern_skill_dir) - - overrides_dir = ( - project_dir / ".specify" / "templates" / "overrides" - ) - overrides_dir.mkdir(parents=True) - (overrides_dir / "speckit.specify.md").write_text( - "---\ndescription: Project override\n---\n\nOverride body\n", - encoding="utf-8", - ) - - manager._reconcile_skills(["speckit.specify"]) - - legacy_file = skills_dir / "speckit.specify" / "SKILL.md" - assert "Override body" in legacy_file.read_text(encoding="utf-8") - assert not modern_skill_dir.exists(), ( - "legacy-only ownership must not create an untracked modern path" - ) - - def test_kimi_skill_updated_even_when_ai_skills_disabled(self, project_dir, temp_dir): - """Kimi presets should still propagate command overrides to existing skills.""" - self._write_init_options(project_dir, ai="kimi", ai_skills=False) - skills_dir = project_dir / ".kimi-code" / "skills" - self._create_skill(skills_dir, "speckit-specify", body="untouched") - - (project_dir / ".kimi-code" / "commands").mkdir(parents=True, exist_ok=True) - - manager = PresetManager(project_dir) - install_self_test_preset(manager) - - skill_file = skills_dir / "speckit-specify" / "SKILL.md" - assert skill_file.exists() - content = skill_file.read_text() - assert "preset:self-test" in content - assert "name: speckit-specify" in content - - metadata = manager.registry.get("self-test") - assert "speckit-specify" in metadata.get("registered_skills", {}).get("kimi", []) - - def test_kimi_new_skill_created_even_when_ai_skills_disabled(self, project_dir, temp_dir): - """Kimi native skills should still receive brand-new preset commands.""" - self._write_init_options(project_dir, ai="kimi", ai_skills=False) - skills_dir = project_dir / ".kimi-code" / "skills" - skills_dir.mkdir(parents=True, exist_ok=True) - - preset_dir = temp_dir / "kimi-new-skill" - preset_dir.mkdir() - (preset_dir / "commands").mkdir() - (preset_dir / "commands" / "speckit.research.md").write_text( - "---\n" - "description: Kimi research workflow\n" - "---\n\n" - "preset:kimi-new-skill\n" - ) - manifest_data = { - "schema_version": "1.0", - "preset": { - "id": "kimi-new-skill", - "name": "Kimi New Skill", - "version": "1.0.0", - "description": "Test", - }, - "requires": {"speckit_version": ">=0.1.0"}, - "provides": { - "templates": [ - { - "type": "command", - "name": "speckit.research", - "file": "commands/speckit.research.md", - } - ] - }, - } - with open(preset_dir / "preset.yml", "w") as f: - yaml.dump(manifest_data, f) - - manager = PresetManager(project_dir) - manager.install_from_directory(preset_dir, "0.1.5") - - skill_file = skills_dir / "speckit-research" / "SKILL.md" - assert skill_file.exists() - content = skill_file.read_text() - assert "preset:kimi-new-skill" in content - assert "name: speckit-research" in content - - metadata = manager.registry.get("kimi-new-skill") - assert "speckit-research" in metadata.get("registered_skills", {}).get("kimi", []) - - def test_kimi_preset_skill_override_resolves_script_placeholders(self, project_dir, temp_dir): - """Kimi preset skill overrides should resolve placeholders and rewrite project paths.""" - self._write_init_options(project_dir, ai="kimi", ai_skills=False, script="sh") - skills_dir = project_dir / ".kimi-code" / "skills" - self._create_skill(skills_dir, "speckit-specify", body="untouched") - (project_dir / ".kimi-code" / "commands").mkdir(parents=True, exist_ok=True) - - preset_dir = temp_dir / "kimi-placeholder-override" - preset_dir.mkdir() - (preset_dir / "commands").mkdir() - (preset_dir / "commands" / "speckit.specify.md").write_text( - "---\n" - "description: Kimi placeholder override\n" - "scripts:\n" - " sh: scripts/bash/create-new-feature.sh --json \"{ARGS}\"\n" - "---\n\n" - "Execute `{SCRIPT}` for __AGENT__\n" - "Review templates/checklist.md and memory/constitution.md\n" - ) - manifest_data = { - "schema_version": "1.0", - "preset": { - "id": "kimi-placeholder-override", - "name": "Kimi Placeholder Override", - "version": "1.0.0", - "description": "Test", - }, - "requires": {"speckit_version": ">=0.1.0"}, - "provides": { - "templates": [ - { - "type": "command", - "name": "speckit.specify", - "file": "commands/speckit.specify.md", - } - ] - }, - } - with open(preset_dir / "preset.yml", "w") as f: - yaml.dump(manifest_data, f) - - manager = PresetManager(project_dir) - manager.install_from_directory(preset_dir, "0.1.5") - - content = (skills_dir / "speckit-specify" / "SKILL.md").read_text() - assert "{SCRIPT}" not in content - assert "__AGENT__" not in content - assert ".specify/scripts/bash/create-new-feature.sh --json \"$ARGUMENTS\"" in content - assert ".specify/templates/checklist.md" in content - assert ".specify/memory/constitution.md" in content - assert "for kimi" in content - - def test_agy_skill_restored_on_preset_remove(self, project_dir, temp_dir): - """Agy preset removal should restore native skills instead of deleting them.""" - self._write_init_options(project_dir, ai="agy", ai_skills=True) - skills_dir = project_dir / ".agents" / "skills" - self._create_skill(skills_dir, "speckit-specify", body="before override") - - core_command = project_dir / ".specify" / "templates" / "commands" / "specify.md" - core_command.write_text( - "---\n" - "description: Restored core specify workflow\n" - "---\n\n" - "restored core body\n" - ) - - preset_dir = temp_dir / "agy-override" - preset_dir.mkdir() - (preset_dir / "commands").mkdir() - (preset_dir / "commands" / "speckit.specify.md").write_text( - "---\n" - "description: Agy override\n" - "---\n\n" - "preset agy body\n" - ) - manifest_data = { - "schema_version": "1.0", - "preset": { - "id": "agy-override", - "name": "Agy Override", - "version": "1.0.0", - "description": "Test", - }, - "requires": {"speckit_version": ">=0.1.0"}, - "provides": { - "templates": [ - { - "type": "command", - "name": "speckit.specify", - "file": "commands/speckit.specify.md", - } - ] - }, - } - with open(preset_dir / "preset.yml", "w") as f: - yaml.dump(manifest_data, f) - - manager = PresetManager(project_dir) - manager.install_from_directory(preset_dir, "0.1.5") - - skill_file = skills_dir / "speckit-specify" / "SKILL.md" - assert "preset agy body" in skill_file.read_text() - - assert manager.remove("agy-override") is True - assert skill_file.exists() - restored = skill_file.read_text() - assert "restored core body" in restored - assert "name: speckit-specify" in restored - - def test_preset_skill_registration_handles_non_dict_init_options(self, project_dir, temp_dir): - """Non-dict init-options payloads should not crash preset install/remove flows.""" - init_options = project_dir / ".specify" / "init-options.json" - init_options.parent.mkdir(parents=True, exist_ok=True) - init_options.write_text("[]") - - skills_dir = project_dir / ".qwen" / "skills" - self._create_skill(skills_dir, "speckit-specify", body="untouched") - - manager = PresetManager(project_dir) - install_self_test_preset(manager) - - skill_content = (skills_dir / "speckit-specify" / "SKILL.md").read_text() - assert "untouched" in skill_content - - def test_preset_add_corrupted_init_options_fails_closed(self, project_dir, temp_dir): - """Corrupted (but present) init-options.json must not back-fill every - detected agent for preset command registration. - - Before the shared ``resolve_active_agent_for_registration`` fix, - ``load_init_options`` returning ``{}`` for a corrupted file was - indistinguishable from "no file at all", so ``_register_commands`` - treated it like a legacy pre-init-options project and registered - the preset's command override for every detected agent (#2948). - """ - init_options = project_dir / ".specify" / "init-options.json" - init_options.parent.mkdir(parents=True, exist_ok=True) - init_options.write_text("{not valid json", encoding="utf-8") - - gemini_dir = project_dir / ".gemini" / "commands" - gemini_dir.mkdir(parents=True) - - preset_dir = self._create_command_preset( - temp_dir, "corrupt-init-preset", "speckit.specify", - "Corrupt init test", "preset body", - ) - - manager = PresetManager(project_dir) - manager.install_from_directory(preset_dir, "0.1.5") - - metadata = manager.registry.get("corrupt-init-preset") - assert metadata.get("registered_commands") == {}, ( - "a corrupted init-options.json must fail closed, not " - "back-fill every detected agent (#2948)" - ) - assert not list(gemini_dir.glob("*specify*")), ( - "no command file should be written for any agent when " - "init-options.json is corrupted" - ) - - def test_reconciliation_restricted_to_active_agent(self, project_dir, temp_dir): - """Reconciliation after install/remove must also respect the - single-active rule, not just the initial registration. - - ``_reconcile_composed_commands`` (invoked after - ``install_from_directory``/``remove``) resolves composition winners - via ``register_commands_for_non_skill_agents``, a separate code - path from ``_register_commands``'s initial registration. Before the - fix it ignored the active-agent restriction entirely and wrote the - winning content for every detected non-skill agent, leaving - untracked orphaned artifacts in inactive integrations (#2948). - """ - self._write_init_options(project_dir, ai="claude", ai_skills=True) - gemini_dir = project_dir / ".gemini" / "commands" - gemini_dir.mkdir(parents=True) - - # A non-replace (append) strategy command forces reconciliation to - # run register_commands_for_non_skill_agents for every non-skill - # agent directory it detects. - preset_dir = temp_dir / "reconcile-active-only" - preset_dir.mkdir() - (preset_dir / "commands").mkdir() - (preset_dir / "commands" / "speckit.specify.md").write_text( - "---\ndescription: Appended\nstrategy: append\n---\n\nAppended body\n", - encoding="utf-8", - ) - manifest_data = { - "schema_version": "1.0", - "preset": { - "id": "reconcile-active-only", - "name": "Reconcile Active Only", - "version": "1.0.0", - "description": "Test", - }, - "requires": {"speckit_version": ">=0.1.0"}, - "provides": { - "templates": [{ - "type": "command", - "name": "speckit.specify", - "file": "commands/speckit.specify.md", - "strategy": "append", - }] - }, - } - with open(preset_dir / "preset.yml", "w") as f: - yaml.dump(manifest_data, f) - - manager = PresetManager(project_dir) - manager.install_from_directory(preset_dir, "0.1.5") - - assert not list(gemini_dir.glob("*specify*")), ( - "reconciliation must not write command files for a detected " - "but inactive non-skill agent (#2948)" - ) - - def test_use_rescaffold_reconciles_project_override(self, project_dir, temp_dir): - """``integration use``/``switch`` rescaffolding must reconcile the - full priority stack, not just write each preset's own content. - - Project overrides are the highest-priority layer, above every - preset. ``register_enabled_presets_for_agent`` (invoked by - ``integration use``/``switch``) calls ``_register_commands`` for - each enabled preset directly, the same as ``install_from_directory`` - — but unlike install/remove, it never followed up with - ``_reconcile_composed_commands``. Before the fix, rescaffolding a - newly activated agent could leave the preset's raw content in - place instead of resolving the real winner (the project override) - from the full stack (#2948). - """ - self._write_init_options(project_dir, ai="claude", ai_skills=True) - - overrides_dir = project_dir / ".specify" / "templates" / "overrides" - overrides_dir.mkdir(parents=True, exist_ok=True) - (overrides_dir / "speckit.specify.md").write_text( - "---\ndescription: Override specify\n---\n\nOverride body\n", - encoding="utf-8", - ) - - gemini_dir = project_dir / ".gemini" / "commands" - gemini_dir.mkdir(parents=True) - - preset_dir = self._create_command_preset( - temp_dir, "use-reconcile-preset", "speckit.specify", - "Preset specify", "Preset body", - ) - manager = PresetManager(project_dir) - manager.install_from_directory(preset_dir, "0.1.5") - - # Simulate `integration use gemini`: switch the active agent and - # rescaffold enabled presets for it, mirroring what the CLI does. - self._write_init_options(project_dir, ai="gemini", ai_skills=False) - manager.register_enabled_presets_for_agent("gemini") - - cmd_file = gemini_dir / "speckit.specify.toml" - assert cmd_file.exists(), "sanity: gemini should get a command file at all" - content = cmd_file.read_text() - assert "Override body" in content, ( - "the project override must still win after rescaffold " - "reconciliation, not the preset's raw content (#2948)" - ) - assert "Preset body" not in content - - def test_hermes_rescaffold_reconciles_global_skill_output( - self, project_dir, temp_dir, monkeypatch - ): - home = temp_dir / "home" - home.mkdir() - monkeypatch.setattr(Path, "home", lambda: home) - (home / ".hermes" / "skills").mkdir(parents=True) - self._write_init_options(project_dir, ai="hermes", ai_skills=True) - (project_dir / ".hermes" / "skills").mkdir(parents=True) - - overrides_dir = project_dir / ".specify" / "templates" / "overrides" - overrides_dir.mkdir(parents=True, exist_ok=True) - (overrides_dir / "speckit.specify.md").write_text( - "---\ndescription: Override specify\n---\n\nOverride body\n", - encoding="utf-8", - ) - - preset_dir = self._create_command_preset( - temp_dir, "hermes-reconcile-preset", "speckit.specify", - "Preset specify", "Preset body", - ) - manager = PresetManager(project_dir) - manager.install_from_directory(preset_dir, "0.1.5") - manager.register_enabled_presets_for_agent("hermes") - - skill_file = ( - home / ".hermes" / "skills" / "speckit-specify" / "SKILL.md" - ) - assert skill_file.exists() - content = skill_file.read_text(encoding="utf-8") - assert "Override body" in content - assert "Preset body" not in content - assert not list( - (project_dir / ".hermes" / "skills").glob("speckit-*/SKILL.md") - ) - - (overrides_dir / "speckit.specify.md").unlink() - assert manager.remove("hermes-reconcile-preset") is True - if skill_file.exists(): - restored = skill_file.read_text(encoding="utf-8") - assert "Override body" not in restored - assert "Preset body" not in restored - - def test_rescaffold_persists_commands_before_fallible_skills_phase( - self, project_dir, temp_dir - ): - """A failure in the skills phase must not lose track of command - files the commands phase already wrote to disk. - - ``register_enabled_presets_for_agent`` computes both - ``registered_commands`` and ``registered_skills`` and persists them - together in a single ``registry.update()`` call after both phases - run. If ``_register_skills`` raises, the whole per-preset ``try`` - block is caught and ``registry.update()`` is never reached — even - though ``_register_commands`` already wrote a real command file to - disk. That file becomes untracked and preset removal can no longer - clean it up. ``install_from_directory`` avoids this by persisting - ``registered_commands`` immediately after the commands phase, - before starting the independently fallible skills phase; rescaffold - must do the same (#2948). - """ - self._write_init_options(project_dir, ai="claude", ai_skills=True) - - preset_dir = self._create_command_preset( - temp_dir, "rescaffold-persist-preset", "speckit.specify", - "Rescaffold persist test", "preset body", - ) - manager = PresetManager(project_dir) - manager.install_from_directory(preset_dir, "0.1.5") - - # Switch to gemini (a plain command-file agent, so _register_commands - # writes a real file) and make the *skills* phase blow up. - self._write_init_options(project_dir, ai="gemini", ai_skills=False) - gemini_commands_dir = project_dir / ".gemini" / "commands" - gemini_commands_dir.mkdir(parents=True) - - from unittest.mock import patch - - with patch.object( - PresetManager, "_register_skills", - side_effect=RuntimeError("simulated skills failure"), - ): - manager.register_enabled_presets_for_agent("gemini") - - cmd_file = gemini_commands_dir / "speckit.specify.toml" - assert cmd_file.exists(), ( - "sanity: the commands phase must have written the file before " - "the skills phase raised" - ) - - metadata = manager.registry.get("rescaffold-persist-preset") - assert metadata["registered_commands"].get("gemini"), ( - "registered_commands must be persisted immediately after the " - "commands phase, not only after the (fallible) skills phase " - "also succeeds — otherwise the file written above is untracked " - "and preset removal can't clean it up (#2948)" - ) - - def test_rescaffold_reconciles_override_even_when_skills_phase_fails( - self, project_dir, temp_dir - ): - """A project override must still win after rescaffold even if the - independently-fallible skills phase raises for that preset. - - ``register_enabled_presets_for_agent`` only records a preset's - command names into ``affected_cmd_names`` — the set later passed to - ``_reconcile_composed_commands``/``_reconcile_skills`` — in the - ``for tmpl in manifest.templates`` loop that runs *after* - ``_register_skills`` inside the per-preset ``try`` block. If - ``_register_skills`` raises, the per-preset ``except`` catches it - and ``continue``s before that loop ever runs, so this preset's - command names never make it into ``affected_cmd_names`` even though - ``_register_commands`` already wrote its raw content to disk. The - final reconciliation call is skipped for this preset entirely, - leaving the raw preset content in place instead of the project - override that should win (#2948). - """ - self._write_init_options(project_dir, ai="claude", ai_skills=True) - - overrides_dir = project_dir / ".specify" / "templates" / "overrides" - overrides_dir.mkdir(parents=True, exist_ok=True) - (overrides_dir / "speckit.specify.md").write_text( - "---\ndescription: Override specify\n---\n\nOverride body\n", - encoding="utf-8", - ) - - gemini_dir = project_dir / ".gemini" / "commands" - gemini_dir.mkdir(parents=True) - - preset_dir = self._create_command_preset( - temp_dir, "reconcile-despite-skills-failure", "speckit.specify", - "Preset specify", "Preset body", - ) - manager = PresetManager(project_dir) - manager.install_from_directory(preset_dir, "0.1.5") - - # Simulate `integration use gemini` with the skills phase failing - # for this preset (e.g. a symlink/permission error unrelated to the - # commands phase, which already succeeded). - self._write_init_options(project_dir, ai="gemini", ai_skills=False) - - from unittest.mock import patch - - with patch.object( - PresetManager, "_register_skills", - side_effect=RuntimeError("simulated skills failure"), - ): - manager.register_enabled_presets_for_agent("gemini") - - cmd_file = gemini_dir / "speckit.specify.toml" - assert cmd_file.exists(), "sanity: gemini should get a command file at all" - content = cmd_file.read_text() - assert "Override body" in content, ( - "the project override must still win after rescaffold, even " - "though this preset's skills phase raised — a fallible skills " - "phase must not skip reconciliation for command writes that " - "already succeeded (#2948)" - ) - assert "Preset body" not in content - - def test_rescaffold_reconciles_partial_command_write_after_failure( - self, project_dir, temp_dir, monkeypatch - ): - """A command written before _register_commands raises must still be - included in final priority-stack reconciliation.""" - self._write_init_options(project_dir, ai="claude", ai_skills=True) - - overrides_dir = project_dir / ".specify" / "templates" / "overrides" - overrides_dir.mkdir(parents=True, exist_ok=True) - (overrides_dir / "speckit.specify.md").write_text( - "---\ndescription: Override specify\n---\n\nOverride body\n", - encoding="utf-8", - ) - - preset_dir = self._create_command_preset( - temp_dir, - "partial-command-failure-preset", - "speckit.specify", - "Preset specify", - "Preset body", - ) - manager = PresetManager(project_dir) - manager.install_from_directory(preset_dir, "0.1.5") - - self._write_init_options(project_dir, ai="gemini", ai_skills=False) - gemini_dir = project_dir / ".gemini" / "commands" - gemini_dir.mkdir(parents=True) - cmd_file = gemini_dir / "speckit.specify.toml" - - def partial_register(manifest, pack_dir): - cmd_file.write_text("Partially written preset body\n", encoding="utf-8") - raise RuntimeError("simulated partial command failure") - - monkeypatch.setattr(manager, "_register_commands", partial_register) - manager.register_enabled_presets_for_agent("gemini") - - content = cmd_file.read_text(encoding="utf-8") - assert "Override body" in content - assert "Partially written preset body" not in content - - def test_copilot_skills_mode_skips_command_registration(self, project_dir, temp_dir): - """``integration use copilot`` with skills mode enabled must only - write the SKILL.md mirror, not also copilot's static command file. - - Copilot is command-backed (``extension: ".agent.md"``), but when - ``ai_skills`` is enabled its preset overrides are meant to render - exclusively as skills via ``_register_skills``. Before the fix, - ``_register_commands`` had no ``ai_skills`` guard (unlike the - extensions path), so both a stale ``.agent.md`` command file and - the ``SKILL.md`` mirror were written for the same override (#2948). - """ - self._write_init_options(project_dir, ai="copilot", ai_skills=True) - copilot_commands_dir = project_dir / ".github" / "agents" - copilot_commands_dir.mkdir(parents=True) - skills_dir = project_dir / ".github" / "skills" - self._create_skill(skills_dir, "speckit-specify") - - preset_dir = self._create_command_preset( - temp_dir, "copilot-skills-preset", "speckit.specify", - "Copilot skills test", "preset body", - ) - - manager = PresetManager(project_dir) - manager.install_from_directory(preset_dir, "0.1.5") - - assert not list(copilot_commands_dir.glob("*specify*")), ( - "command-mode and skills-mode artifacts are mutually exclusive: " - "no .agent.md command file should be written when copilot is " - "running in skills mode (#2948)" - ) - skill_file = skills_dir / "speckit-specify" / "SKILL.md" - assert "preset:copilot-skills-preset" in skill_file.read_text() - - def test_rescaffold_toggle_command_to_skills_removes_stale_command_file( - self, project_dir, temp_dir - ): - """Toggling the *same* agent from command mode to skills mode must - remove the stale command-mode artifact, not just add the new one. - - Copilot stays the active agent throughout (``integration upgrade - copilot`` after flipping ``ai_skills``, not a switch to a different - agent). Before the fix, ``_register_commands``'s ``ai_skills`` guard - made rescaffold a no-op for the commands phase once skills mode was - on, leaving the previously written ``.agent.md`` file and its - ``registered_commands`` entry behind even though ``_register_skills`` - went on to also write the ``SKILL.md`` mirror — violating the - command/skill mutual-exclusion invariant this PR otherwise enforces - (#2948). - """ - self._write_init_options(project_dir, ai="copilot", ai_skills=False) - copilot_commands_dir = project_dir / ".github" / "agents" - copilot_commands_dir.mkdir(parents=True) - - preset_dir = self._create_command_preset( - temp_dir, "toggle-cmd-to-skill-preset", "speckit.specify", - "Toggle test", "preset body", - ) - manager = PresetManager(project_dir) - manager.install_from_directory(preset_dir, "0.1.5") - - cmd_file = copilot_commands_dir / "speckit.specify.agent.md" - assert cmd_file.exists(), ( - "sanity: command mode should have written copilot's command file" - ) - metadata = manager.registry.get("toggle-cmd-to-skill-preset") - assert metadata["registered_commands"].get("copilot"), ( - "sanity: the command-mode write should be tracked for copilot" - ) - - # Flip ai_skills on for the *same* active agent and rescaffold, as - # `integration upgrade copilot` would after the mode toggle. - self._write_init_options(project_dir, ai="copilot", ai_skills=True) - manager.register_enabled_presets_for_agent("copilot") - - assert not cmd_file.exists(), ( - "the stale command-mode file must be removed once copilot has " - "toggled to skills mode for the same agent (#2948)" - ) - metadata = manager.registry.get("toggle-cmd-to-skill-preset") - assert not metadata["registered_commands"].get("copilot"), ( - "registered_commands must stop tracking copilot once its " - "artifact has been unregistered, or removal will try to clean " - "up a file that no longer exists (#2948)" - ) - skill_file = project_dir / ".github" / "skills" / "speckit-specify" / "SKILL.md" - assert "preset:toggle-cmd-to-skill-preset" in skill_file.read_text(), ( - "sanity: the new skills-mode artifact should still be written" - ) - - def test_rescaffold_scaffolds_selfcontained_namespaced_commands( - self, project_dir, temp_dir - ): - """A self-contained ``speckit..`` preset command scaffolds and - survives rescaffold, even when no matching extension is installed. - - The preset ships the command body itself, so it is materialized just - like a short ``speckit.`` command — both at install and through a - later reconciliation/rescaffold pass. It is not dropped by the - ``speckit..`` name shape (#4076). - """ - self._write_init_options(project_dir, ai="copilot", ai_skills=False) - commands_dir = project_dir / ".github" / "agents" - commands_dir.mkdir(parents=True) - - preset_dir = self._create_command_preset( - temp_dir, "ext-scoped-preset", "speckit.git.feature", - "Ext override", "ext body", - ) - manager = PresetManager(project_dir) - manager.install_from_directory(preset_dir, "0.1.5") - - ext_cmd = commands_dir / "speckit.git.feature.agent.md" - assert ext_cmd.exists(), ( - "sanity: install must scaffold a self-contained namespaced command " - "even when its like-named extension isn't installed" - ) - - manager.register_enabled_presets_for_agent("copilot") - - assert ext_cmd.exists(), ( - "rescaffold must keep the self-contained namespaced command" - ) - metadata = manager.registry.get("ext-scoped-preset") - assert (metadata.get("registered_commands") or {}).get("copilot") - - def test_rescaffold_scaffolds_selfcontained_namespaced_skills( - self, project_dir, temp_dir - ): - """A self-contained ``speckit..`` preset command renders its - skill even when no matching extension is installed.""" - self._write_init_options(project_dir, ai="copilot", ai_skills=True) - skills_dir = project_dir / ".github" / "skills" - skills_dir.mkdir(parents=True) - - preset_dir = self._create_command_preset( - temp_dir, - "ext-scoped-skill-preset", - "speckit.git.feature", - "Ext override", - "preset body", - ) - manager = PresetManager(project_dir) - manager.install_from_directory(preset_dir, "0.1.5") - - skill_name = "speckit-git-feature" - skill_file = skills_dir / skill_name / "SKILL.md" - assert skill_file.exists(), ( - "install must render a self-contained namespaced command's skill " - "even when its like-named extension isn't installed" - ) - - manager.register_enabled_presets_for_agent("copilot") - - assert skill_file.exists(), ( - "rescaffold must keep the self-contained namespaced command's skill" - ) - - def test_uncomposable_wrap_command_skips_skill_in_skills_mode( - self, project_dir, temp_dir - ): - """A wrap command with no base layer must not materialize a broken - skill in skills mode. - - When ``_register_commands`` skips an uncomposable wrap command (no - base to compose onto — e.g. the command it wraps comes from an - uninstalled extension), ``_register_skills`` must skip it too. Before - this fix, skills mode fell back to the raw preset body and wrote a - SKILL.md containing a literal ``{CORE_TEMPLATE}`` placeholder. - """ - self._write_init_options(project_dir, ai="copilot", ai_skills=True) - skills_dir = project_dir / ".github" / "skills" - skills_dir.mkdir(parents=True) - - preset_dir = temp_dir / "uncomposable-wrap" - preset_dir.mkdir() - (preset_dir / "commands").mkdir() - # speckit.git.feature has no core command template and no installed - # extension, so there is no base layer to wrap. - (preset_dir / "commands" / "speckit.git.feature.md").write_text( - "---\ndescription: Wrap\nstrategy: wrap\n---\n\n" - "wrap start\n{CORE_TEMPLATE}\nwrap end\n" - ) - manifest_data = { - "schema_version": "1.0", - "preset": { - "id": "uncomposable-wrap", - "name": "uncomposable-wrap", - "version": "1.0.0", - "description": "Test", - }, - "requires": {"speckit_version": ">=0.1.0"}, - "provides": { - "templates": [ - { - "type": "command", - "name": "speckit.git.feature", - "file": "commands/speckit.git.feature.md", - "strategy": "wrap", - } - ] - }, - } - with open(preset_dir / "preset.yml", "w") as f: - yaml.dump(manifest_data, f) - - manager = PresetManager(project_dir) - with pytest.warns(UserWarning, match="no base command layer"): - manager.install_from_directory(preset_dir, "0.1.5") - - skill_file = skills_dir / "speckit-git-feature" / "SKILL.md" - assert not skill_file.exists(), ( - "an uncomposable wrap command must not be rendered as a skill" - ) - # Belt-and-suspenders: no artifact anywhere may leak the raw placeholder. - leaked = [ - p for p in skills_dir.rglob("*") - if p.is_file() and "{CORE_TEMPLATE}" in p.read_text(encoding="utf-8") - ] - assert not leaked, f"literal {{CORE_TEMPLATE}} leaked into {leaked}" - - def test_same_mode_partial_command_rescaffold_keeps_skipped_tracking( - self, project_dir, temp_dir - ): - """A partial command refresh must keep still-live skipped artifacts tracked.""" - self._write_init_options(project_dir, ai="copilot", ai_skills=False) - commands_dir = project_dir / ".github" / "agents" - commands_dir.mkdir(parents=True) - preset_dir = self._create_multi_command_preset( - temp_dir, - "same-mode-partial-command-preset", - ["speckit.specify", "speckit.plan"], - ) - manager = PresetManager(project_dir) - manager.install_from_directory(preset_dir, "0.1.5") - - installed_dir = manager.presets_dir / "same-mode-partial-command-preset" - (installed_dir / "commands" / "speckit.plan.md").unlink() - manager.register_enabled_presets_for_agent("copilot") - - metadata = manager.registry.get("same-mode-partial-command-preset") - assert set(metadata["registered_commands"]["copilot"]) == { - "speckit.specify", - "speckit.plan", - } - assert (commands_dir / "speckit.plan.agent.md").exists() - - def test_same_mode_partial_skill_rescaffold_keeps_skipped_tracking( - self, project_dir, temp_dir - ): - """A partial skill refresh must keep still-live skipped artifacts tracked.""" - self._write_init_options(project_dir, ai="copilot", ai_skills=True) - skills_dir = project_dir / ".github" / "skills" - self._create_skill(skills_dir, "speckit-specify") - self._create_skill(skills_dir, "speckit-plan") - preset_dir = self._create_multi_command_preset( - temp_dir, - "same-mode-partial-skill-preset", - ["speckit.specify", "speckit.plan"], - ) - manager = PresetManager(project_dir) - manager.install_from_directory(preset_dir, "0.1.5") - - installed_dir = manager.presets_dir / "same-mode-partial-skill-preset" - (installed_dir / "commands" / "speckit.plan.md").unlink() - manager.register_enabled_presets_for_agent("copilot") - - metadata = manager.registry.get("same-mode-partial-skill-preset") - assert set(metadata["registered_skills"]["copilot"]) == { - "speckit-specify", - "speckit-plan", - } - - def test_toggle_command_to_skills_preserves_old_command_on_skills_failure( - self, project_dir, temp_dir, monkeypatch - ): - """A command->skills toggle must not destroy the old command - artifact before the new skill registration has actually succeeded. - - Before the fix, the stale command-mode file/tracking was - unregistered unconditionally as soon as ``_register_commands``'s - ``ai_skills`` guard made the commands phase a no-op — regardless - of whether the subsequent, independently-fallible - ``_register_skills()`` call actually succeeded. If skills raises - (e.g. a transient I/O error), the per-preset exception handler - just logs and continues, leaving neither the old command file - nor a new skill file — the preset's command override vanishes - entirely from copilot until the next successful rescaffold - (#2948). - """ - self._write_init_options(project_dir, ai="copilot", ai_skills=False) - copilot_commands_dir = project_dir / ".github" / "agents" - copilot_commands_dir.mkdir(parents=True) - - preset_dir = self._create_command_preset( - temp_dir, "toggle-failure-preset", "speckit.specify", - "Toggle failure test", "preset body", - ) - manager = PresetManager(project_dir) - manager.install_from_directory(preset_dir, "0.1.5") - - cmd_file = copilot_commands_dir / "speckit.specify.agent.md" - assert cmd_file.exists(), ( - "sanity: command mode should have written copilot's command file" - ) - metadata = manager.registry.get("toggle-failure-preset") - assert metadata["registered_commands"].get("copilot"), ( - "sanity: the command-mode write should be tracked for copilot" - ) - - # Flip ai_skills on for the *same* active agent and rescaffold, as - # `integration upgrade copilot` would, but with skills registration - # injected to fail. - self._write_init_options(project_dir, ai="copilot", ai_skills=True) - - def _raise_register_skills(*args, **kwargs): - raise OSError("simulated skills-phase failure") - - monkeypatch.setattr(manager, "_register_skills", _raise_register_skills) - manager.register_enabled_presets_for_agent("copilot") - - assert cmd_file.exists(), ( - "the old command-mode artifact must survive when the " - "replacement skills registration fails — deleting it before " - "the new artifact is confirmed leaves neither in place (#2948)" - ) - metadata = manager.registry.get("toggle-failure-preset") - assert metadata["registered_commands"].get("copilot"), ( - "registered_commands must keep tracking copilot's still-live " - "command file when the skills replacement failed, or a later " - "removal/rescaffold will believe there is nothing to clean up " - "even though the file is still on disk (#2948)" - ) - - def test_toggle_command_to_skills_empty_result_preserves_old_command( - self, project_dir, temp_dir - ): - """A non-raising but empty skills result must not delete the old command. - - Before the fix, the stale command-mode artifact was retired as - soon as ``_register_skills()`` completed without raising — - regardless of whether it actually wrote anything for this agent. - Deleting the preset's own command source file (simulating a - missing/corrupted override) makes ``_register_skills`` return - ``{}`` for copilot without raising at all, which must leave the - old command file and its tracking untouched (#2948). - """ - self._write_init_options(project_dir, ai="copilot", ai_skills=False) - copilot_commands_dir = project_dir / ".github" / "agents" - copilot_commands_dir.mkdir(parents=True) - - preset_dir = self._create_command_preset( - temp_dir, "toggle-empty-result-preset", "speckit.specify", - "Toggle empty-result test", "preset body", - ) - manager = PresetManager(project_dir) - manager.install_from_directory(preset_dir, "0.1.5") - - cmd_file = copilot_commands_dir / "speckit.specify.agent.md" - assert cmd_file.exists(), ( - "sanity: command mode should have written copilot's command file" - ) - - # Remove the *installed* copy of the preset's source file (not the - # original temp source) so _register_skills can find nothing to - # render — a real "missing source" case, not an exception — leaving - # registered_skills empty for copilot. - (manager.presets_dir / "toggle-empty-result-preset" / "commands" / "speckit.specify.md").unlink() - - self._write_init_options(project_dir, ai="copilot", ai_skills=True) - manager.register_enabled_presets_for_agent("copilot") - - assert cmd_file.exists(), ( - "an empty (non-raising) skills registration result must not " - "cause the old command-mode artifact to be deleted (#2948)" - ) - metadata = manager.registry.get("toggle-empty-result-preset") - assert metadata["registered_commands"].get("copilot"), ( - "registered_commands must keep tracking copilot's still-live " - "command file when nothing was actually replaced (#2948)" - ) - skill_file = project_dir / ".github" / "skills" / "speckit-specify" / "SKILL.md" - assert not skill_file.exists(), ( - "sanity: no skill should have been written when the source " - "was missing" - ) - - def test_toggle_command_to_skills_partial_result_only_removes_replaced_command( - self, project_dir, temp_dir - ): - """Only the command whose skill replacement actually landed is retired. - - A two-command preset where one command's source file goes missing - right before the toggle: ``_register_skills`` genuinely returns a - partial result (one name present, one silently skipped) without - raising. The command whose skill was written must be retired; the - other must keep both its old command file and its registry - tracking, since no replacement for it actually landed (#2948). - """ - self._write_init_options(project_dir, ai="copilot", ai_skills=False) - copilot_commands_dir = project_dir / ".github" / "agents" - copilot_commands_dir.mkdir(parents=True) - - preset_dir = self._create_multi_command_preset( - temp_dir, "toggle-partial-result-preset", - ["speckit.specify", "speckit.plan"], - ) - manager = PresetManager(project_dir) - manager.install_from_directory(preset_dir, "0.1.5") - - specify_cmd_file = copilot_commands_dir / "speckit.specify.agent.md" - plan_cmd_file = copilot_commands_dir / "speckit.plan.agent.md" - assert specify_cmd_file.exists() and plan_cmd_file.exists(), ( - "sanity: command mode should have written both command files" - ) - metadata = manager.registry.get("toggle-partial-result-preset") - assert set(metadata["registered_commands"].get("copilot", [])) == { - "speckit.specify", "speckit.plan", - }, "sanity: both commands should be tracked for copilot" - - # Remove only the plan command's *installed* source so its skill - # replacement is silently skipped (missing source), while - # specify's succeeds — a genuine partial result, not an injected - # exception. - (manager.presets_dir / "toggle-partial-result-preset" / "commands" / "speckit.plan.md").unlink() - - self._write_init_options(project_dir, ai="copilot", ai_skills=True) - manager.register_enabled_presets_for_agent("copilot") - - assert not specify_cmd_file.exists(), ( - "the specify command's old artifact must be retired since its " - "skill replacement actually landed (#2948)" - ) - assert plan_cmd_file.exists(), ( - "the plan command's old artifact must survive since its skill " - "replacement never landed (missing source) (#2948)" - ) - metadata = manager.registry.get("toggle-partial-result-preset") - tracked_commands = metadata["registered_commands"].get("copilot", []) - assert "speckit.specify" not in tracked_commands, ( - "specify must stop being tracked as a command once its " - "artifact has been unregistered (#2948)" - ) - assert "speckit.plan" in tracked_commands, ( - "plan must keep being tracked as a command since its old " - "artifact is still on disk (#2948)" - ) - specify_skill_file = ( - project_dir / ".github" / "skills" / "speckit-specify" / "SKILL.md" - ) - assert "preset:toggle-partial-result-preset" in specify_skill_file.read_text(), ( - "sanity: specify's new skill artifact should exist" - ) - plan_skill_file = project_dir / ".github" / "skills" / "speckit-plan" - assert not plan_skill_file.exists(), ( - "sanity: no skill should have been written for plan since its " - "source was missing" - ) - - def test_remove_after_partial_command_to_skills_toggle_keeps_skills_mode_agent_command_free( - self, project_dir, temp_dir - ): - """Removal must not recreate a command file for a skills-mode agent. - - A partially failed command→skills toggle leaves the active agent's - stale ``registered_commands`` entry behind. Removing that preset - records the agent in ``extra_agents`` for post-removal - reconciliation, and ``register_commands_for_non_skill_agents`` - admits every ``extra_agents`` member even when the active-only - ``only_agent`` guard excludes the agent — so the surviving - lower-priority preset's command file was recreated for an agent - now running in skills mode (#2948). - """ - self._write_init_options(project_dir, ai="copilot", ai_skills=False) - copilot_commands_dir = project_dir / ".github" / "agents" - copilot_commands_dir.mkdir(parents=True) - - lower_dir = self._create_command_preset( - temp_dir, "stale-toggle-lower-preset", "speckit.plan", - "Lower preset", "Lower body", - ) - higher_dir = self._create_command_preset( - temp_dir, "stale-toggle-higher-preset", "speckit.plan", - "Higher preset", "Higher body", - ) - manager = PresetManager(project_dir) - manager.install_from_directory(lower_dir, "0.1.5", priority=20) - manager.install_from_directory(higher_dir, "0.1.5", priority=10) - - command_file = copilot_commands_dir / "speckit.plan.agent.md" - assert "Higher body" in command_file.read_text(encoding="utf-8"), ( - "sanity: command mode should have written the winning preset" - ) - - # Break the higher preset's installed source so its skill - # replacement is silently skipped during the toggle — a genuine - # partial command→skills toggle that leaves the stale - # registered_commands entry for copilot behind. - (manager.presets_dir / "stale-toggle-higher-preset" / "commands" / "speckit.plan.md").unlink() - self._write_init_options(project_dir, ai="copilot", ai_skills=True) - manager.register_enabled_presets_for_agent("copilot") - - metadata = manager.registry.get("stale-toggle-higher-preset") - assert "speckit.plan" in metadata["registered_commands"].get("copilot", []), ( - "sanity: the partial toggle must leave the stale command " - "tracking behind" - ) - - manager.remove("stale-toggle-higher-preset") - - assert not command_file.exists(), ( - "removing the preset while copilot runs in skills mode must " - "not recreate its command file from the surviving lower " - "preset via the stale extra_agents entry (#2948)" - ) - - def test_remove_after_partial_skills_to_command_toggle_deletes_stale_skill( - self, project_dir, temp_dir - ): - """Removal must delete, not restore, a command-mode agent's stale skill. - - The inverse partial toggle: a skills→command conversion that could - not replace one command leaves that skill tracked in - ``registered_skills``. Removing the preset while the agent is now - in command mode sent it through ``_unregister_skills()``, which - restored a core/extension ``SKILL.md``, and ``extra_skills_dirs`` - then let ``_reconcile_skills`` reapply the surviving lower preset — - leaving the active command-mode agent with a skill artifact it - must not have (#2948). - """ - self._write_init_options(project_dir, ai="copilot", ai_skills=True) - copilot_commands_dir = project_dir / ".github" / "agents" - copilot_commands_dir.mkdir(parents=True) - - lower_dir = self._create_command_preset( - temp_dir, "inverse-toggle-lower-preset", "speckit.plan", - "Lower preset", "Lower body", - ) - higher_dir = self._create_command_preset( - temp_dir, "inverse-toggle-higher-preset", "speckit.plan", - "Higher preset", "Higher body", - ) - manager = PresetManager(project_dir) - manager.install_from_directory(lower_dir, "0.1.5", priority=20) - manager.install_from_directory(higher_dir, "0.1.5", priority=10) - - skill_dir = project_dir / ".github" / "skills" / "speckit-plan" - assert (skill_dir / "SKILL.md").exists(), ( - "sanity: skills mode should have written the skill" - ) - - # Break the higher preset's installed source so its command - # replacement never lands during the skills→command toggle, - # leaving the skill tracked for copilot. - (manager.presets_dir / "inverse-toggle-higher-preset" / "commands" / "speckit.plan.md").unlink() - self._write_init_options(project_dir, ai="copilot", ai_skills=False) - manager.register_enabled_presets_for_agent("copilot") - - metadata = manager.registry.get("inverse-toggle-higher-preset") - registered_skills = metadata.get("registered_skills") or {} - assert registered_skills.get("copilot"), ( - "sanity: the partial toggle must leave the stale skill " - "tracking behind" - ) - assert (skill_dir / "SKILL.md").exists(), ( - "sanity: the stale skill artifact must survive the partial toggle" - ) - - manager.remove("inverse-toggle-higher-preset") - - assert not skill_dir.exists(), ( - "removing the preset while copilot runs in command mode must " - "delete the stale preset-owned skill instead of restoring core " - "content or reapplying the surviving lower preset (#2948)" - ) - - def test_lower_priority_skill_does_not_remove_failed_winner_command( - self, project_dir, temp_dir - ): - """Only a successfully rendered winning layer may retire a command.""" - self._write_init_options(project_dir, ai="copilot", ai_skills=False) - commands_dir = project_dir / ".github" / "agents" - commands_dir.mkdir(parents=True) - - lower_dir = self._create_command_preset( - temp_dir, - "lower-toggle-preset", - "speckit.specify", - "Lower preset", - "Lower body", - ) - higher_dir = self._create_command_preset( - temp_dir, - "higher-toggle-preset", - "speckit.specify", - "Higher preset", - "Higher body", - ) - manager = PresetManager(project_dir) - manager.install_from_directory(lower_dir, "0.1.5", priority=20) - manager.install_from_directory(higher_dir, "0.1.5", priority=10) - - command_file = commands_dir / "speckit.specify.agent.md" - assert "Higher body" in command_file.read_text(encoding="utf-8") - - higher_source = ( - manager.presets_dir - / "higher-toggle-preset" - / "commands" - / "speckit.specify.md" - ) - higher_source.unlink() - self._write_init_options(project_dir, ai="copilot", ai_skills=True) - - manager.register_enabled_presets_for_agent("copilot") - - assert command_file.exists(), ( - "a lower-priority skill replacement must not remove the existing " - "higher-priority command when the winning layer did not render" - ) - assert "Higher body" in command_file.read_text(encoding="utf-8") - - higher_source.write_text( - "---\ndescription: Higher preset\n---\n\nHigher body\n", - encoding="utf-8", - ) - manager.register_enabled_presets_for_agent("copilot") - - assert not command_file.exists(), ( - "the old command should be retired after the winning skill " - "layer renders successfully" - ) - for preset_id in ("lower-toggle-preset", "higher-toggle-preset"): - metadata = manager.registry.get(preset_id) - assert not metadata["registered_commands"].get("copilot"), ( - "all layers sharing the retired command output must drop " - "their stale command tracking" - ) - - def test_successful_winner_without_stale_tracking_retires_lower_command( - self, project_dir, temp_dir - ): - """Winner success is independent of whether that layer tracked a command.""" - self._write_init_options(project_dir, ai="copilot", ai_skills=False) - commands_dir = project_dir / ".github" / "agents" - commands_dir.mkdir(parents=True) - - lower_dir = self._create_command_preset( - temp_dir, - "lower-command-preset", - "speckit.specify", - "Lower preset", - "Lower body", - ) - higher_dir = self._create_command_preset( - temp_dir, - "higher-skill-preset", - "speckit.specify", - "Higher preset", - "Higher body", - ) - manager = PresetManager(project_dir) - manager.install_from_directory(lower_dir, "0.1.5", priority=20) - - command_file = commands_dir / "speckit.specify.agent.md" - assert command_file.exists() - - self._write_init_options(project_dir, ai="copilot", ai_skills=True) - manager.install_from_directory(higher_dir, "0.1.5", priority=10) - higher_metadata = manager.registry.get("higher-skill-preset") - assert not higher_metadata["registered_commands"].get("copilot") - - manager.register_enabled_presets_for_agent("copilot") - - assert not command_file.exists(), ( - "a successfully rendered winning skill must retire a lower " - "layer's stale command even when the winner never tracked one" - ) - lower_metadata = manager.registry.get("lower-command-preset") - assert not lower_metadata["registered_commands"].get("copilot") - - def test_lower_priority_command_does_not_remove_failed_winner_skill( - self, project_dir, temp_dir - ): - """Only a successfully rendered winning command may retire a skill.""" - self._write_init_options(project_dir, ai="copilot", ai_skills=True) - commands_dir = project_dir / ".github" / "agents" - commands_dir.mkdir(parents=True) - - lower_dir = self._create_command_preset( - temp_dir, - "lower-skill-preset", - "speckit.specify", - "Lower preset", - "Lower body", - ) - higher_dir = self._create_command_preset( - temp_dir, - "higher-command-preset", - "speckit.specify", - "Higher preset", - "Higher body", - ) - manager = PresetManager(project_dir) - manager.install_from_directory(lower_dir, "0.1.5", priority=20) - manager.install_from_directory(higher_dir, "0.1.5", priority=10) - - skill_file = ( - project_dir - / ".github" - / "skills" - / "speckit-specify" - / "SKILL.md" - ) - assert "Higher body" in skill_file.read_text(encoding="utf-8") - - higher_source = ( - manager.presets_dir - / "higher-command-preset" - / "commands" - / "speckit.specify.md" - ) - higher_source.unlink() - self._write_init_options(project_dir, ai="copilot", ai_skills=False) - - manager.register_enabled_presets_for_agent("copilot") - - assert skill_file.exists(), ( - "a lower-priority command replacement must not remove the " - "existing higher-priority skill when the winner did not render" - ) - assert "Higher body" in skill_file.read_text(encoding="utf-8") - - higher_source.write_text( - "---\ndescription: Higher preset\n---\n\nHigher body\n", - encoding="utf-8", - ) - manager.register_enabled_presets_for_agent("copilot") - - assert not skill_file.exists(), ( - "the old skill should be retired after the winning command " - "layer renders successfully" - ) - for preset_id in ("lower-skill-preset", "higher-command-preset"): - metadata = manager.registry.get(preset_id) - assert not metadata["registered_skills"].get("copilot"), ( - "all layers sharing the retired skill output must drop " - "their stale skill tracking" - ) - - def test_project_override_command_retires_stale_preset_skill( - self, project_dir, temp_dir - ): - """A reconciled project override can replace a stale preset skill.""" - self._write_init_options(project_dir, ai="copilot", ai_skills=True) - commands_dir = project_dir / ".github" / "agents" - commands_dir.mkdir(parents=True) - - preset_dir = self._create_command_preset( - temp_dir, - "override-toggle-preset", - "speckit.specify", - "Preset", - "Preset body", - ) - manager = PresetManager(project_dir) - manager.install_from_directory(preset_dir, "0.1.5") - - skill_file = ( - project_dir - / ".github" - / "skills" - / "speckit-specify" - / "SKILL.md" - ) - assert skill_file.exists() - - overrides_dir = ( - project_dir / ".specify" / "templates" / "overrides" - ) - overrides_dir.mkdir(parents=True) - (overrides_dir / "speckit.specify.md").write_text( - "---\ndescription: Project override\n---\n\nOverride body\n", - encoding="utf-8", - ) - ( - manager.presets_dir - / "override-toggle-preset" - / "commands" - / "speckit.specify.md" - ).unlink() - self._write_init_options(project_dir, ai="copilot", ai_skills=False) - - manager.register_enabled_presets_for_agent("copilot") - - command_file = commands_dir / "speckit.specify.agent.md" - assert "Override body" in command_file.read_text(encoding="utf-8") - assert not skill_file.exists(), ( - "the stale preset skill must be retired once the project " - "override command is successfully reconciled" - ) - metadata = manager.registry.get("override-toggle-preset") - assert not metadata["registered_skills"].get("copilot") - - def test_project_override_command_retires_reconciled_override_skill( - self, project_dir, temp_dir - ): - self._write_init_options(project_dir, ai="copilot", ai_skills=True) - commands_dir = project_dir / ".github" / "agents" - commands_dir.mkdir(parents=True) - skills_dir = project_dir / ".github" / "skills" - self._create_skill(skills_dir, "speckit-specify") - - preset_dir = self._create_command_preset( - temp_dir, - "reconciled-override-toggle-preset", - "speckit.specify", - "Preset", - "Preset body", - ) - manager = PresetManager(project_dir) - manager.install_from_directory(preset_dir, "0.1.5") - - overrides_dir = ( - project_dir / ".specify" / "templates" / "overrides" - ) - overrides_dir.mkdir(parents=True) - (overrides_dir / "speckit.specify.md").write_text( - "---\ndescription: Project override\n---\n\nOverride body\n", - encoding="utf-8", - ) - ( - manager.presets_dir - / "reconciled-override-toggle-preset" - / "commands" - / "speckit.specify.md" - ).unlink() - - manager.register_enabled_presets_for_agent("copilot") - - skill_file = skills_dir / "speckit-specify" / "SKILL.md" - assert "override:speckit.specify" in skill_file.read_text( - encoding="utf-8" - ) - - self._write_init_options(project_dir, ai="copilot", ai_skills=False) - manager.register_enabled_presets_for_agent("copilot") - - assert "Override body" in ( - commands_dir / "speckit.specify.agent.md" - ).read_text(encoding="utf-8") - assert not skill_file.exists() - metadata = manager.registry.get( - "reconciled-override-toggle-preset" - ) - assert not metadata["registered_skills"].get("copilot") - - def test_project_override_skill_retires_stale_preset_command( - self, project_dir, temp_dir - ): - """A reconciled override skill may retire a stale preset command.""" - self._write_init_options(project_dir, ai="copilot", ai_skills=False) - commands_dir = project_dir / ".github" / "agents" - commands_dir.mkdir(parents=True) - - preset_dir = self._create_command_preset( - temp_dir, - "override-skill-toggle-preset", - "speckit.specify", - "Preset", - "Preset body", - ) - manager = PresetManager(project_dir) - manager.install_from_directory(preset_dir, "0.1.5") - - command_file = commands_dir / "speckit.specify.agent.md" - assert command_file.exists() - skills_dir = project_dir / ".github" / "skills" - self._create_skill(skills_dir, "speckit-specify") - manager.registry.update( - "override-skill-toggle-preset", - {"registered_skills": {"copilot": ["speckit-specify"]}}, - ) - - overrides_dir = ( - project_dir / ".specify" / "templates" / "overrides" - ) - overrides_dir.mkdir(parents=True) - (overrides_dir / "speckit.specify.md").write_text( - "---\ndescription: Project override\n---\n\nOverride body\n", - encoding="utf-8", - ) - ( - manager.presets_dir - / "override-skill-toggle-preset" - / "commands" - / "speckit.specify.md" - ).unlink() - self._write_init_options(project_dir, ai="copilot", ai_skills=True) - - manager.register_enabled_presets_for_agent("copilot") - - skill_file = skills_dir / "speckit-specify" / "SKILL.md" - assert "Override body" in skill_file.read_text(encoding="utf-8") - assert not command_file.exists(), ( - "the stale preset command must be retired after the project " - "override skill is successfully reconciled" - ) - metadata = manager.registry.get("override-skill-toggle-preset") - assert not metadata["registered_commands"].get("copilot") - - def test_partial_skill_registration_is_persisted_before_later_failure( - self, project_dir, temp_dir, monkeypatch - ): - """A successful earlier skill write remains tracked if a later read fails.""" - self._write_init_options(project_dir, ai="copilot", ai_skills=False) - commands_dir = project_dir / ".github" / "agents" - commands_dir.mkdir(parents=True) - - preset_dir = self._create_multi_command_preset( - temp_dir, - "partial-skill-failure-preset", - ["speckit.specify", "speckit.plan"], - ) - manager = PresetManager(project_dir) - manager.install_from_directory(preset_dir, "0.1.5") - self._write_init_options(project_dir, ai="copilot", ai_skills=True) - - original_read_text = Path.read_text - - def fail_plan_source(path, *args, **kwargs): - if ( - path.name == "speckit.plan.md" - and path.parent.name == "commands" - and "partial-skill-failure-preset" in path.parts - ): - raise UnicodeDecodeError("utf-8", b"\xff", 0, 1, "invalid") - return original_read_text(path, *args, **kwargs) - - monkeypatch.setattr(Path, "read_text", fail_plan_source) - manager.register_enabled_presets_for_agent("copilot") - - metadata = manager.registry.get("partial-skill-failure-preset") - assert "speckit-specify" in metadata["registered_skills"].get( - "copilot", [] - ), ( - "the first successful write must be persisted before the later " - "template failure aborts the registration call" - ) - monkeypatch.setattr(Path, "read_text", original_read_text) - assert manager.remove("partial-skill-failure-preset") is True - remaining_skill = ( - project_dir - / ".github" - / "skills" - / "speckit-specify" - / "SKILL.md" - ) - assert ( - not remaining_skill.exists() - or "preset:partial-skill-failure-preset" - not in remaining_skill.read_text(encoding="utf-8") - ), "persisted partial ownership must remain removable" - - def test_remove_cleans_native_skill_missing_from_partial_skill_map( - self, project_dir, temp_dir - ): - """Command cleanup must cover native skills absent from a partial map.""" - self._write_init_options(project_dir, ai="claude", ai_skills=True) - (project_dir / ".claude" / "skills").mkdir(parents=True) - preset_dir = self._create_command_preset( - temp_dir, - "partial-agent-skill-map-preset", - "speckit.partial-native", - "Partial native skill", - "preset body", - ) - manager = PresetManager(project_dir) - manager.install_from_directory(preset_dir, "0.1.5") - - metadata = manager.registry.get("partial-agent-skill-map-preset") - assert metadata["registered_skills"].get("claude") - - self._write_init_options(project_dir, ai="codex", ai_skills=True) - (project_dir / ".agents" / "skills").mkdir(parents=True) - - from unittest.mock import patch - - with patch.object( - PresetManager, - "_register_skills", - side_effect=RuntimeError("simulated skills phase failure"), - ): - manager.register_enabled_presets_for_agent("codex") - - codex_skill = ( - project_dir - / ".agents" - / "skills" - / "speckit-partial-native" - / "SKILL.md" - ) - assert codex_skill.exists() - metadata = manager.registry.get("partial-agent-skill-map-preset") - assert "speckit.partial-native" in metadata[ - "registered_commands" - ].get("codex", []) - assert not metadata["registered_skills"].get("codex") - - assert manager.remove("partial-agent-skill-map-preset") is True - assert not codex_skill.exists(), ( - "native skill written by the commands phase must not be orphaned " - "when another agent makes registered_skills globally non-empty" - ) - - def test_partial_skill_install_failure_rolls_back_persisted_writes( - self, project_dir, temp_dir, monkeypatch - ): - """Install rollback must reload partial skill ownership before removal.""" - self._write_init_options(project_dir, ai="copilot", ai_skills=True) - (project_dir / ".github" / "agents").mkdir(parents=True) - preset_dir = self._create_multi_command_preset( - temp_dir, - "partial-install-failure-preset", - ["speckit.specify", "speckit.plan"], - ) - manager = PresetManager(project_dir) - original_read_text = Path.read_text - - def fail_plan_source(path, *args, **kwargs): - if ( - path.name == "speckit.plan.md" - and path.parent.name == "commands" - and "partial-install-failure-preset" in path.parts - ): - raise UnicodeDecodeError("utf-8", b"\xff", 0, 1, "invalid") - return original_read_text(path, *args, **kwargs) - - monkeypatch.setattr(Path, "read_text", fail_plan_source) - with pytest.raises(UnicodeDecodeError): - manager.install_from_directory(preset_dir, "0.1.5") - - assert not manager.registry.is_installed( - "partial-install-failure-preset" - ) - skill_file = ( - project_dir - / ".github" - / "skills" - / "speckit-specify" - / "SKILL.md" - ) - assert ( - not skill_file.exists() - or "preset:partial-install-failure-preset" - not in original_read_text(skill_file, encoding="utf-8") - ), "rollback must not orphan a skill written before the later failure" - - def test_toggle_skills_to_command_empty_result_preserves_old_skill( - self, project_dir, temp_dir - ): - """A non-raising but empty command result must not delete the old skill. - - Mirror image of the empty-result command->skills case: deleting the - preset's own source file makes ``_register_commands`` return ``{}`` - for copilot without raising, which must leave the old SKILL.md and - its tracking untouched (#2948). - """ - self._write_init_options(project_dir, ai="copilot", ai_skills=True) - copilot_commands_dir = project_dir / ".github" / "agents" - copilot_commands_dir.mkdir(parents=True) - skills_dir = project_dir / ".github" / "skills" - self._create_skill(skills_dir, "speckit-specify") - - preset_dir = self._create_command_preset( - temp_dir, "toggle-skill-empty-result-preset", "speckit.specify", - "Toggle empty-result test", "preset body", - ) - manager = PresetManager(project_dir) - manager.install_from_directory(preset_dir, "0.1.5") - - skill_file = skills_dir / "speckit-specify" / "SKILL.md" - assert "preset:toggle-skill-empty-result-preset" in skill_file.read_text(), ( - "sanity: skills mode should have written the SKILL.md mirror" - ) - - # Remove the preset's own *installed* source file so - # _register_commands can find nothing to render — a real "missing - # source" case, not an exception — leaving registered_commands - # empty for copilot. - (manager.presets_dir / "toggle-skill-empty-result-preset" / "commands" / "speckit.specify.md").unlink() - - self._write_init_options(project_dir, ai="copilot", ai_skills=False) - manager.register_enabled_presets_for_agent("copilot") - - assert "preset:toggle-skill-empty-result-preset" in skill_file.read_text(), ( - "an empty (non-raising) command registration result must not " - "cause the old skills-mode artifact to be deleted/reverted " - "(#2948)" - ) - metadata = manager.registry.get("toggle-skill-empty-result-preset") - assert "speckit-specify" in metadata["registered_skills"].get("copilot", []), ( - "registered_skills must keep tracking copilot's still-live " - "skill file when nothing was actually replaced (#2948)" - ) - # Note: a command file may still exist here — general command - # reconciliation independently restores the next-best (e.g. core) - # layer for the *command name*, regardless of this preset's own - # missing source. That's an orthogonal, existing behaviour; the - # invariant under test is specifically that the *skill* mirror and - # its tracking survive the empty registration result. - - def test_toggle_skills_to_command_partial_result_only_removes_replaced_skill( - self, project_dir, temp_dir - ): - """Only the skill whose command replacement actually landed is retired. - - Mirror image of the partial-result command->skills case: a - two-command preset where one command's source file goes missing - right before the toggle, so ``_register_commands`` genuinely - returns a partial result. The skill whose command was written - must be retired; the other must keep both its old SKILL.md and - its registry tracking, since no replacement for it landed (#2948). - """ - self._write_init_options(project_dir, ai="copilot", ai_skills=True) - copilot_commands_dir = project_dir / ".github" / "agents" - copilot_commands_dir.mkdir(parents=True) - skills_dir = project_dir / ".github" / "skills" - self._create_skill(skills_dir, "speckit-specify") - self._create_skill(skills_dir, "speckit-plan") - - # A core template lets the retired skill restore to core content - # instead of being removed entirely (it has nothing else to fall - # back to), matching _unregister_skills's behaviour elsewhere. - core_cmds = project_dir / ".specify" / "templates" / "commands" - core_cmds.mkdir(parents=True, exist_ok=True) - (core_cmds / "specify.md").write_text( - "---\ndescription: Core specify command\n---\n\nCore specify body\n", - encoding="utf-8", - ) - - preset_dir = self._create_multi_command_preset( - temp_dir, "toggle-skill-partial-result-preset", - ["speckit.specify", "speckit.plan"], - ) - manager = PresetManager(project_dir) - manager.install_from_directory(preset_dir, "0.1.5") - - specify_skill_file = skills_dir / "speckit-specify" / "SKILL.md" - plan_skill_file = skills_dir / "speckit-plan" / "SKILL.md" - assert "preset:toggle-skill-partial-result-preset" in specify_skill_file.read_text() - assert "preset:toggle-skill-partial-result-preset" in plan_skill_file.read_text() - metadata = manager.registry.get("toggle-skill-partial-result-preset") - assert set(metadata["registered_skills"].get("copilot", [])) == { - "speckit-specify", "speckit-plan", - }, "sanity: both skills should be tracked for copilot" - - # Remove only the plan command's *installed* source so its command - # replacement is silently skipped (missing source), while - # specify's succeeds. - (manager.presets_dir / "toggle-skill-partial-result-preset" / "commands" / "speckit.plan.md").unlink() - - self._write_init_options(project_dir, ai="copilot", ai_skills=False) - manager.register_enabled_presets_for_agent("copilot") - - assert "preset:toggle-skill-partial-result-preset" not in specify_skill_file.read_text(), ( - "the specify skill's old artifact must be retired/reverted " - "since its command replacement actually landed (#2948)" - ) - assert "preset:toggle-skill-partial-result-preset" in plan_skill_file.read_text(), ( - "the plan skill's old artifact must survive since its command " - "replacement never landed (missing source) (#2948)" - ) - metadata = manager.registry.get("toggle-skill-partial-result-preset") - tracked_skills = metadata["registered_skills"].get("copilot", []) - assert "speckit-specify" not in tracked_skills, ( - "specify must stop being tracked as a skill once its artifact " - "has been unregistered/reverted (#2948)" - ) - assert "speckit-plan" in tracked_skills, ( - "plan must keep being tracked as a skill since its old " - "artifact is still on disk (#2948)" - ) - assert (copilot_commands_dir / "speckit.specify.agent.md").exists(), ( - "sanity: specify's new command artifact should exist" - ) - - def test_toggle_command_to_skills_retires_alias_group_when_primary_skill_lands( - self, project_dir, temp_dir - ): - """A command's aliases must be retired together with its primary - once the primary's skill replacement lands. - - ``CommandRegistrar.register_commands()`` tracks and returns - primary + alias names flattened together, but ``_register_skills()`` - only ever renders/returns the *primary* command name's skill. The - alias's own name run through ``_skill_names_for_command()`` never - matches anything real, so without grouping by primary, the alias - command artifact and its tracking entry would survive forever even - after mutual exclusion is otherwise enforced for the primary (#2948). - """ - self._write_init_options(project_dir, ai="copilot", ai_skills=False) - copilot_commands_dir = project_dir / ".github" / "agents" - copilot_commands_dir.mkdir(parents=True) - - preset_dir = self._create_multi_command_preset_with_aliases( - temp_dir, "alias-group-success-preset", - [("speckit.specify", ["speckit.spec"])], - ) - manager = PresetManager(project_dir) - manager.install_from_directory(preset_dir, "0.1.5") - - primary_cmd_file = copilot_commands_dir / "speckit.specify.agent.md" - alias_cmd_file = copilot_commands_dir / "speckit.spec.agent.md" - assert primary_cmd_file.exists() and alias_cmd_file.exists(), ( - "sanity: command mode should have written both the primary " - "and alias command files" - ) - metadata = manager.registry.get("alias-group-success-preset") - assert set(metadata["registered_commands"].get("copilot", [])) == { - "speckit.specify", "speckit.spec", - }, "sanity: both primary and alias should be tracked for copilot" - - self._write_init_options(project_dir, ai="copilot", ai_skills=True) - manager.register_enabled_presets_for_agent("copilot") - - assert not primary_cmd_file.exists(), ( - "the primary's old command artifact must be retired once its " - "skill replacement lands (#2948)" - ) - assert not alias_cmd_file.exists(), ( - "the alias's old command artifact must be retired together " - "with its primary once the primary's skill replacement lands " - "(#2948)" - ) - metadata = manager.registry.get("alias-group-success-preset") - registered_commands = metadata.get("registered_commands", {}) - assert not registered_commands.get("copilot"), ( - "neither the primary nor the alias should remain tracked as " - "commands once both artifacts are retired (#2948)" - ) - skill_file = project_dir / ".github" / "skills" / "speckit-specify" / "SKILL.md" - assert skill_file.exists(), "sanity: the primary's skill should have been written" - - def test_toggle_command_to_skills_keeps_alias_group_when_primary_skill_missing( - self, project_dir, temp_dir - ): - """A command's aliases must survive together with its primary when - the primary's skill replacement never lands. - - Mirror image of the group-retirement case: deleting the preset's - own installed command source makes ``_register_skills`` genuinely - return nothing for ``speckit.specify``, so neither the primary nor - its alias have a real replacement — both old command artifacts and - their tracking must survive (#2948). - """ - self._write_init_options(project_dir, ai="copilot", ai_skills=False) - copilot_commands_dir = project_dir / ".github" / "agents" - copilot_commands_dir.mkdir(parents=True) - - preset_dir = self._create_multi_command_preset_with_aliases( - temp_dir, "alias-group-failure-preset", - [("speckit.specify", ["speckit.spec"])], - ) - manager = PresetManager(project_dir) - manager.install_from_directory(preset_dir, "0.1.5") - - primary_cmd_file = copilot_commands_dir / "speckit.specify.agent.md" - alias_cmd_file = copilot_commands_dir / "speckit.spec.agent.md" - assert primary_cmd_file.exists() and alias_cmd_file.exists(), ( - "sanity: command mode should have written both the primary " - "and alias command files" - ) - - # Remove the installed source so _register_skills can find nothing - # to render for the primary — a real "missing source" case. - (manager.presets_dir / "alias-group-failure-preset" / "commands" / "speckit.specify.md").unlink() - - self._write_init_options(project_dir, ai="copilot", ai_skills=True) - manager.register_enabled_presets_for_agent("copilot") - - assert primary_cmd_file.exists(), ( - "the primary's old command artifact must survive since its " - "skill replacement never landed (#2948)" - ) - assert alias_cmd_file.exists(), ( - "the alias's old command artifact must survive together with " - "its primary since neither has a real replacement (#2948)" - ) - metadata = manager.registry.get("alias-group-failure-preset") - assert set(metadata["registered_commands"].get("copilot", [])) == { - "speckit.specify", "speckit.spec", - }, ( - "both the primary and alias must keep being tracked since " - "nothing was actually replaced (#2948)" - ) - skill_file = project_dir / ".github" / "skills" / "speckit-specify" / "SKILL.md" - assert not skill_file.exists(), ( - "sanity: no skill should have been written when the source " - "was missing" - ) - - def test_toggle_command_to_skills_partial_multi_group_only_retires_successful_group( - self, project_dir, temp_dir - ): - """With two independent alias groups, only the group whose primary - skill actually lands is retired; the other survives intact. - - A preset with two commands (``speckit.specify`` with alias - ``speckit.spec``, and ``speckit.plan`` with alias - ``speckit.plan-alt``) where only ``speckit.plan``'s installed - source goes missing: ``speckit.specify``'s group (primary + alias) - must be fully retired, while ``speckit.plan``'s entire group - (primary + alias) must survive together, since grouping is - per-primary, not per-individual-name (#2948). - """ - self._write_init_options(project_dir, ai="copilot", ai_skills=False) - copilot_commands_dir = project_dir / ".github" / "agents" - copilot_commands_dir.mkdir(parents=True) - - preset_dir = self._create_multi_command_preset_with_aliases( - temp_dir, "alias-group-partial-preset", - [ - ("speckit.specify", ["speckit.spec"]), - ("speckit.plan", ["speckit.plan-alt"]), - ], - ) - manager = PresetManager(project_dir) - manager.install_from_directory(preset_dir, "0.1.5") - - specify_cmd = copilot_commands_dir / "speckit.specify.agent.md" - spec_alias_cmd = copilot_commands_dir / "speckit.spec.agent.md" - plan_cmd = copilot_commands_dir / "speckit.plan.agent.md" - plan_alias_cmd = copilot_commands_dir / "speckit.plan-alt.agent.md" - assert all( - f.exists() for f in (specify_cmd, spec_alias_cmd, plan_cmd, plan_alias_cmd) - ), "sanity: command mode should have written all four command files" - - # Remove only plan's installed source so its group's skill - # replacement is silently skipped, while specify's group succeeds. - (manager.presets_dir / "alias-group-partial-preset" / "commands" / "speckit.plan.md").unlink() - - self._write_init_options(project_dir, ai="copilot", ai_skills=True) - manager.register_enabled_presets_for_agent("copilot") - - assert not specify_cmd.exists() and not spec_alias_cmd.exists(), ( - "specify's whole group (primary + alias) must be retired " - "since its skill replacement landed (#2948)" - ) - assert plan_cmd.exists() and plan_alias_cmd.exists(), ( - "plan's whole group (primary + alias) must survive together " - "since its skill replacement never landed (#2948)" - ) - metadata = manager.registry.get("alias-group-partial-preset") - tracked_commands = set(metadata["registered_commands"].get("copilot", [])) - assert tracked_commands == {"speckit.plan", "speckit.plan-alt"}, ( - "only plan's group should remain tracked as commands; " - "specify's group must be fully untracked (#2948)" - ) - - def test_rescaffold_toggle_skills_to_command_removes_stale_skill_file( - self, project_dir, temp_dir - ): - """Toggling the *same* agent from skills mode to command mode must - remove the stale skills-mode artifact, not just add the new one. - - Mirror image of the command-to-skills toggle: once ``ai_skills`` is - turned off for copilot (still the active agent), ``_get_skills_dir`` - stops resolving a skills directory for it, so ``_register_skills`` - becomes a no-op — but the ``SKILL.md`` written while skills mode was - on, and its ``registered_skills`` entry, were left behind even - though ``_register_commands`` went on to (re)write the ``.agent.md`` - command file, again breaking mutual exclusion (#2948). - """ - self._write_init_options(project_dir, ai="copilot", ai_skills=True) - copilot_commands_dir = project_dir / ".github" / "agents" - copilot_commands_dir.mkdir(parents=True) - skills_dir = project_dir / ".github" / "skills" - self._create_skill(skills_dir, "speckit-specify") - - # A core template lets the stale skill restore to core content - # (instead of being removed entirely, since it has nothing to fall - # back to), matching how `_unregister_skills` behaves elsewhere. - core_cmds = project_dir / ".specify" / "templates" / "commands" - core_cmds.mkdir(parents=True, exist_ok=True) - (core_cmds / "specify.md").write_text( - "---\ndescription: Core specify command\n---\n\nCore specify body\n", - encoding="utf-8", - ) - - preset_dir = self._create_command_preset( - temp_dir, "toggle-skill-to-cmd-preset", "speckit.specify", - "Toggle test", "preset body", - ) - manager = PresetManager(project_dir) - manager.install_from_directory(preset_dir, "0.1.5") - - skill_file = skills_dir / "speckit-specify" / "SKILL.md" - assert "preset:toggle-skill-to-cmd-preset" in skill_file.read_text(), ( - "sanity: skills mode should have written the SKILL.md mirror" - ) - metadata = manager.registry.get("toggle-skill-to-cmd-preset") - assert metadata["registered_skills"].get("copilot"), ( - "sanity: the skills-mode write should be tracked for copilot" - ) - - # Flip ai_skills off for the *same* active agent and rescaffold, as - # `integration upgrade copilot` would after the mode toggle. - self._write_init_options(project_dir, ai="copilot", ai_skills=False) - manager.register_enabled_presets_for_agent("copilot") - - restored_content = skill_file.read_text() - assert "preset:toggle-skill-to-cmd-preset" not in restored_content, ( - "the stale skills-mode artifact must be reverted once copilot " - "has toggled to command mode for the same agent (#2948)" - ) - assert "Core specify body" in restored_content, ( - "sanity: the skill should fall back to core content, not just " - "lose the preset's override" - ) - metadata = manager.registry.get("toggle-skill-to-cmd-preset") - assert not metadata["registered_skills"].get("copilot"), ( - "registered_skills must stop tracking copilot once its " - "artifact has been unregistered/restored (#2948)" - ) - cmd_file = copilot_commands_dir / "speckit.specify.agent.md" - assert cmd_file.exists(), ( - "sanity: the new command-mode artifact should still be written" - ) - assert "preset body" in cmd_file.read_text() - - def test_skill_switch_then_remove_restores_every_skill_agent_dir( - self, project_dir, temp_dir - ): - """Switching between two skill-mode agents before removing a preset - must restore both agents' directories, not just the currently - active one. - - ``registered_skills`` records exactly which agent directories the - preset wrote to (``{agent_name: [skill_name, ...]}``); switching to - codex and re-registering adds a "codex" entry alongside the - original "claude" entry, so removal restores both. Before the - provenance fix, ``_unregister_skills`` only restored the currently - active agent's skills directory; a preset used first under Claude - and later switched to Codex would have its Claude override left - behind permanently on removal (#2948). - """ - self._write_init_options(project_dir, ai="claude", ai_skills=True) - - core_cmds = project_dir / ".specify" / "templates" / "commands" - core_cmds.mkdir(parents=True, exist_ok=True) - (core_cmds / "specify.md").write_text( - "---\ndescription: Core specify command\n---\n\nCore specify body\n", - encoding="utf-8", - ) - - # Native skill agents only materialize a *brand-new* preset skill - # when their skills directory already exists (mirrors every other - # skill test in this class); pre-create both agents' directories so - # install and the later switch both find an existing skill to - # overwrite via _register_commands/_register_skills. - claude_skills_dir = project_dir / ".claude" / "skills" - self._create_skill(claude_skills_dir, "speckit-specify") - codex_skills_dir = project_dir / ".agents" / "skills" - self._create_skill(codex_skills_dir, "speckit-specify") - - preset_dir = self._create_command_preset( - temp_dir, "multi-skill-agent-preset", "speckit.specify", - "Multi skill agent test", "preset body", - ) - - manager = PresetManager(project_dir) - manager.install_from_directory(preset_dir, "0.1.5") - - claude_skill = claude_skills_dir / "speckit-specify" / "SKILL.md" - assert "preset:multi-skill-agent-preset" in claude_skill.read_text() - - # Switch the active agent to codex (a different skill-mode agent) - # and re-register enabled presets for it, mirroring what - # `integration use codex` does. - self._write_init_options(project_dir, ai="codex", ai_skills=True) - manager.register_enabled_presets_for_agent("codex") - - codex_skill = codex_skills_dir / "speckit-specify" / "SKILL.md" - assert "preset:multi-skill-agent-preset" in codex_skill.read_text(), ( - "sanity: switching to codex should rescaffold the preset there" - ) - assert "preset:multi-skill-agent-preset" in claude_skill.read_text(), ( - "sanity: the previous agent's registration is preserved on switch" - ) - - metadata = manager.registry.get("multi-skill-agent-preset") - registered_skills = metadata.get("registered_skills", {}) - assert set(registered_skills) == {"claude", "codex"}, ( - "registered_skills must record both agent directories this " - "preset actually wrote to (#2948)" - ) - - assert manager.remove("multi-skill-agent-preset") is True - - for skill_file, label in ((claude_skill, "claude"), (codex_skill, "codex")): - assert skill_file.exists(), f"{label} skill file should still exist after removal" - content = skill_file.read_text() - assert "preset:multi-skill-agent-preset" not in content, ( - f"{label}'s preset override must be restored on removal, " - "not orphaned permanently (#2948)" - ) - assert "Core specify body" in content - - def test_native_skill_activation_recreates_deleted_skills_root( - self, project_dir, temp_dir - ): - self._write_init_options(project_dir, ai="claude", ai_skills=True) - claude_skills_dir = project_dir / ".claude" / "skills" - self._create_skill(claude_skills_dir, "speckit-specify") - preset_dir = self._create_command_preset( - temp_dir, - "native-root-recovery-preset", - "speckit.specify", - "Native root recovery", - "preset body", - ) - manager = PresetManager(project_dir) - manager.install_from_directory(preset_dir, "0.1.5") - - codex_skills_dir = project_dir / ".agents" / "skills" - assert not codex_skills_dir.exists() - self._write_init_options(project_dir, ai="codex", ai_skills=True) - manager.register_enabled_presets_for_agent("codex") - - skill_file = codex_skills_dir / "speckit-specify" / "SKILL.md" - assert "preset:native-root-recovery-preset" in skill_file.read_text() - metadata = manager.registry.get("native-root-recovery-preset") - assert "speckit.specify" in metadata["registered_commands"]["codex"] - - def test_rescaffold_migrates_legacy_flat_list_registered_skills( - self, project_dir, temp_dir - ): - """Rescaffolding a preset with a legacy flat-list ``registered_skills`` - entry must persist the migrated per-agent dict even when the - rescaffolded skill names are unchanged from before. - - ``_normalize_registered_skills`` converts a legacy flat ``List[str]`` - (predating per-agent provenance) into ``{agent_name: [...]}`` in - memory, but the persistence check compared only the *normalized* - ``merged_skills`` against the *normalized* ``existing_skills`` — - both derived from the same raw legacy list. When the freshly - registered names are identical to what the legacy list already - held (the common case: nothing about the preset or skill actually - changed), that comparison is a no-op and ``registry.update()`` is - skipped, leaving the *raw* on-disk value as the un-migrated flat - list. A later switch to a different skill-mode agent and removal - then follows the legacy best-effort restore path (only the - currently active agent's directory) instead of the per-agent - provenance path, orphaning the first agent's override (#2948). - """ - self._write_init_options(project_dir, ai="claude", ai_skills=True) - - core_cmds = project_dir / ".specify" / "templates" / "commands" - core_cmds.mkdir(parents=True, exist_ok=True) - (core_cmds / "specify.md").write_text( - "---\ndescription: Core specify command\n---\n\nCore specify body\n", - encoding="utf-8", - ) - - claude_skills_dir = project_dir / ".claude" / "skills" - self._create_skill(claude_skills_dir, "speckit-specify") - codex_skills_dir = project_dir / ".agents" / "skills" - self._create_skill(codex_skills_dir, "speckit-specify") - - preset_dir = self._create_command_preset( - temp_dir, "legacy-skills-preset", "speckit.specify", - "Legacy skills test", "preset body", - ) - manager = PresetManager(project_dir) - manager.install_from_directory(preset_dir, "0.1.5") - - # Simulate a registry entry written by a pre-#2948 spec-kit version: - # registered_skills stored as a flat list with no per-agent - # provenance, rather than the dict shape install_from_directory - # writes today. - manager.registry.update( - "legacy-skills-preset", {"registered_skills": ["speckit-specify"]}, - ) - metadata = manager.registry.get("legacy-skills-preset") - assert isinstance(metadata["registered_skills"], list), ( - "sanity: the injected legacy format is a flat list" - ) - - # Rescaffold for the *same* active agent (claude) with no actual - # change to the registered skill names, mirroring `integration - # upgrade claude` re-running registration for the active - # integration. - manager.register_enabled_presets_for_agent("claude") - - metadata = manager.registry.get("legacy-skills-preset") - registered_skills = metadata.get("registered_skills") - assert isinstance(registered_skills, dict), ( - "rescaffold must migrate a legacy flat-list registered_skills " - "entry to the per-agent dict format even when the " - "rescaffolded names are unchanged, or the raw registry stays " - "un-migrated and later removal loses per-agent provenance " - "(#2948)" - ) - assert registered_skills.get("claude") == ["speckit-specify"] - - # Switch to a different skill-mode agent and rescaffold again — - # with the dict format now in place, both directories should be - # tracked and therefore restorable on removal. - self._write_init_options(project_dir, ai="codex", ai_skills=True) - manager.register_enabled_presets_for_agent("codex") - - metadata = manager.registry.get("legacy-skills-preset") - assert set(metadata.get("registered_skills", {})) == {"claude", "codex"}, ( - "the migrated dict must keep recording every agent directory " - "the preset actually wrote to, exactly like a preset that was " - "always in dict format (#2948)" - ) - - assert manager.remove("legacy-skills-preset") is True - - claude_skill = claude_skills_dir / "speckit-specify" / "SKILL.md" - codex_skill = codex_skills_dir / "speckit-specify" / "SKILL.md" - for skill_file, label in ((claude_skill, "claude"), (codex_skill, "codex")): - assert skill_file.exists(), f"{label} skill file should still exist after removal" - content = skill_file.read_text() - assert "preset:legacy-skills-preset" not in content, ( - f"{label}'s preset override must be restored on removal, " - "not orphaned because the registry stayed in legacy " - "flat-list format (#2948)" - ) - assert "Core specify body" in content - - def test_rescaffold_legacy_flat_list_direct_switch_preserves_original_agent( - self, project_dir, temp_dir - ): - """A legacy flat-list ``registered_skills`` entry must not be - misattributed to the wrong agent when the *first* post-upgrade - operation is a direct switch to a different skill-mode agent. - - Blindly attributing every legacy flat-list name to ``agent_name`` — - the agent currently being (re)activated — loses the actual writer - whenever that first operation is ``integration use codex`` (or - ``switch``) run directly against a legacy Claude override, without - an intervening same-agent rescaffold for Claude first. The - migrated dict then only records ``{"codex": [...]}``, so a later - ``remove()`` restores Codex but permanently orphans the Claude - override that was never in the registry to begin with (#2948). - """ - self._write_init_options(project_dir, ai="claude", ai_skills=True) - - core_cmds = project_dir / ".specify" / "templates" / "commands" - core_cmds.mkdir(parents=True, exist_ok=True) - (core_cmds / "specify.md").write_text( - "---\ndescription: Core specify command\n---\n\nCore specify body\n", - encoding="utf-8", - ) - - claude_skills_dir = project_dir / ".claude" / "skills" - self._create_skill(claude_skills_dir, "speckit-specify") - codex_skills_dir = project_dir / ".agents" / "skills" - self._create_skill(codex_skills_dir, "speckit-specify") - - preset_dir = self._create_command_preset( - temp_dir, "legacy-direct-switch-preset", "speckit.specify", - "Legacy direct switch test", "preset body", - ) - manager = PresetManager(project_dir) - manager.install_from_directory(preset_dir, "0.1.5") - - # install_from_directory wrote the preset's override to Claude's - # skill directory (the active agent at install time) — sanity-check - # that the marker is actually there before simulating the legacy - # registry format. - claude_skill = claude_skills_dir / "speckit-specify" / "SKILL.md" - assert "preset:legacy-direct-switch-preset" in claude_skill.read_text(), ( - "sanity: install should have written the override under claude" - ) - - # Simulate a pre-#2948 registry: a flat list with no per-agent - # provenance, even though the file on disk was actually written - # under claude's directory. - manager.registry.update( - "legacy-direct-switch-preset", - {"registered_skills": ["speckit-specify"]}, - ) - - # Directly switch to codex — no intervening rescaffold for claude — - # mirroring `integration use codex` / `switch codex` run right after - # upgrading spec-kit versions. - self._write_init_options(project_dir, ai="codex", ai_skills=True) - manager.register_enabled_presets_for_agent("codex") - - metadata = manager.registry.get("legacy-direct-switch-preset") - registered_skills = metadata.get("registered_skills") - assert isinstance(registered_skills, dict) - assert set(registered_skills) == {"claude", "codex"}, ( - "migrating a legacy flat-list entry on a direct switch must " - "infer the actual writer (claude) from the existing on-disk " - "SKILL.md provenance, not attribute every name to whichever " - "agent happens to be activated first after the upgrade " - "(#2948)" - ) - - assert manager.remove("legacy-direct-switch-preset") is True - - codex_skill = codex_skills_dir / "speckit-specify" / "SKILL.md" - for skill_file, label in ((claude_skill, "claude"), (codex_skill, "codex")): - assert skill_file.exists(), f"{label} skill file should still exist after removal" - content = skill_file.read_text() - assert "preset:legacy-direct-switch-preset" not in content, ( - f"{label}'s preset override must be restored on removal, " - "not permanently orphaned by a misattributed legacy " - "migration (#2948)" - ) - assert "Core specify body" in content - - def test_rescaffold_legacy_flat_list_infers_command_backed_skills_owner( - self, project_dir, temp_dir - ): - """Legacy provenance inference must also probe command-backed agents - that were running in skills mode, not only agents whose command - registrar config is statically ``/SKILL.md``-only. - - Copilot is command-backed (``extension: ".agent.md"``), but with - ``ai_skills`` enabled its preset overrides render as ``SKILL.md`` - files under ``.github/skills`` exactly like a native skill-only - agent (claude, codex, ...). Before the fix, - ``_infer_legacy_skill_provenance`` only probed agents whose - registrar config has a static ``extension == "/SKILL.md"``, so a - real preset-owned ``.github/skills/.../SKILL.md`` written while - Copilot was the active, skills-mode agent was never found — the - legacy flat list was misattributed entirely to whichever agent the - first post-upgrade switch happened to activate, permanently - orphaning Copilot's override on later removal (#2948). - """ - self._write_init_options(project_dir, ai="copilot", ai_skills=True) - - core_cmds = project_dir / ".specify" / "templates" / "commands" - core_cmds.mkdir(parents=True, exist_ok=True) - (core_cmds / "specify.md").write_text( - "---\ndescription: Core specify command\n---\n\nCore specify body\n", - encoding="utf-8", - ) - - copilot_skills_dir = project_dir / ".github" / "skills" - self._create_skill(copilot_skills_dir, "speckit-specify") - claude_skills_dir = project_dir / ".claude" / "skills" - self._create_skill(claude_skills_dir, "speckit-specify") - - preset_dir = self._create_command_preset( - temp_dir, "legacy-copilot-skills-preset", "speckit.specify", - "Legacy copilot skills test", "preset body", - ) - manager = PresetManager(project_dir) - manager.install_from_directory(preset_dir, "0.1.5") - - copilot_skill = copilot_skills_dir / "speckit-specify" / "SKILL.md" - assert "preset:legacy-copilot-skills-preset" in copilot_skill.read_text(), ( - "sanity: install should have written the override under " - "copilot's skills directory while copilot was active in " - "skills mode" - ) - # Sanity: no command-mode artifact was written either — copilot's - # command file and skills file are mutually exclusive. - assert not list((project_dir / ".github" / "agents").glob("*specify*")), ( - "sanity: copilot in skills mode must not also write a command " - "file that could be falsely attributed instead" - ) - - # Simulate a pre-#2948 registry: a flat list with no per-agent - # provenance, even though the file on disk was actually written - # under copilot's skills directory. - manager.registry.update( - "legacy-copilot-skills-preset", - {"registered_skills": ["speckit-specify"]}, - ) - - # Directly switch to claude — no intervening rescaffold for - # copilot — mirroring `integration use claude` run right after - # upgrading spec-kit versions. - self._write_init_options(project_dir, ai="claude", ai_skills=True) - manager.register_enabled_presets_for_agent("claude") - - metadata = manager.registry.get("legacy-copilot-skills-preset") - registered_skills = metadata.get("registered_skills") - assert isinstance(registered_skills, dict) - assert set(registered_skills) == {"copilot", "claude"}, ( - "migrating a legacy flat-list entry on a direct switch must " - "infer the actual writer (copilot, running in skills mode) " - "even though copilot's registrar config is command-backed, " - "not just agents with a static /SKILL.md extension (#2948)" - ) - - assert manager.remove("legacy-copilot-skills-preset") is True - - claude_skill = claude_skills_dir / "speckit-specify" / "SKILL.md" - for skill_file, label in ((copilot_skill, "copilot"), (claude_skill, "claude")): - assert skill_file.exists(), f"{label} skill file should still exist after removal" - content = skill_file.read_text() - assert "preset:legacy-copilot-skills-preset" not in content, ( - f"{label}'s preset override must be restored on removal, " - "not permanently orphaned by a legacy migration that " - "failed to probe command-backed skills-mode agents (#2948)" - ) - assert "Core specify body" in content - - def test_infer_legacy_skill_provenance_does_not_falsely_attribute_command_mode_copilot( - self, project_dir, temp_dir - ): - """Broadening provenance inference to command-backed agents must not - falsely attribute ownership to an agent's directory that has no - preset-owned marker. - - Copilot stays in plain command mode throughout (no skills ever - rendered there), so ``.github/skills`` never receives this - preset's ``SKILL.md``. Probing copilot's skills directory anyway - (now that inference isn't restricted to static ``/SKILL.md`` - agents) must find nothing there and must not invent a false - ``"copilot"`` entry (#2948). - """ - self._write_init_options(project_dir, ai="claude", ai_skills=True) - - core_cmds = project_dir / ".specify" / "templates" / "commands" - core_cmds.mkdir(parents=True, exist_ok=True) - (core_cmds / "specify.md").write_text( - "---\ndescription: Core specify command\n---\n\nCore specify body\n", - encoding="utf-8", - ) - - claude_skills_dir = project_dir / ".claude" / "skills" - self._create_skill(claude_skills_dir, "speckit-specify") - # Copilot has never been active; its command directory holds an - # unrelated file so the directory exists, but no skills directory - # or SKILL.md was ever written for it. - copilot_commands_dir = project_dir / ".github" / "agents" - copilot_commands_dir.mkdir(parents=True) - - preset_dir = self._create_command_preset( - temp_dir, "no-false-attribution-preset", "speckit.specify", - "No false attribution test", "preset body", - ) - manager = PresetManager(project_dir) - manager.install_from_directory(preset_dir, "0.1.5") - - manager.registry.update( - "no-false-attribution-preset", - {"registered_skills": ["speckit-specify"]}, - ) - - # Rescaffold again for the same agent (claude) with unchanged - # names, triggering the legacy migration path. - manager.register_enabled_presets_for_agent("claude") - - metadata = manager.registry.get("no-false-attribution-preset") - registered_skills = metadata.get("registered_skills") - assert isinstance(registered_skills, dict) - assert set(registered_skills) == {"claude"}, ( - "copilot must not appear in the migrated registry when it has " - "never actually rendered this preset's skill — probing its " - "directory for a marker match must not create a false " - "attribution (#2948)" - ) - assert not (project_dir / ".github" / "skills").exists(), ( - "no .github/skills directory should have been created as a " - "side effect of probing for provenance (#2948)" - ) - - def test_infer_legacy_skill_provenance_skips_invalid_utf8( - self, project_dir - ): - self._write_init_options(project_dir, ai="claude", ai_skills=True) - skill_dir = ( - project_dir / ".claude" / "skills" / "speckit-specify" - ) - skill_dir.mkdir(parents=True) - (skill_dir / "SKILL.md").write_bytes(b"\xff") - - manager = PresetManager(project_dir) - - assert manager._infer_legacy_skill_provenance( - ["speckit-specify"], "some-pack", "claude" - ) == {"claude": ["speckit-specify"]} - - def test_infer_legacy_skill_provenance_excludes_home_outputs( - self, project_dir, temp_dir, monkeypatch - ): - home = temp_dir / "home" - monkeypatch.setattr(Path, "home", lambda: home) - skill_dir = home / ".hermes" / "skills" / "speckit-specify" - skill_dir.mkdir(parents=True) - (skill_dir / "SKILL.md").write_text( - "---\n" - "metadata:\n" - " source: preset:some-pack\n" - "---\n\n" - "Other project\n", - encoding="utf-8", - ) - - manager = PresetManager(project_dir) - inferred = manager._infer_legacy_skill_provenance( - ["speckit-specify"], "some-pack", "claude" - ) - - assert inferred == {"claude": ["speckit-specify"]} - assert skill_dir.exists() - - def test_remove_infers_legacy_flat_list_provenance_without_prior_rescaffold( - self, project_dir, temp_dir - ): - """``preset remove`` on a legacy flat-list registry must restore - every previously active agent's directory, not just the currently - active one, even when it is the *very first* post-upgrade - operation (no intervening ``use``/``upgrade``/rescaffold). - - Pre-#2948 registries recorded a flat ``registered_skills`` list - because presets were rendered for every detected skill-mode agent - at once, not just the active one. Migrating that legacy format to - the per-agent dict form previously only happened as a side effect - of ``register_enabled_presets_for_agent`` (i.e. a rescaffold or - ``integration use``/``switch``). If the user's first action after - upgrading is instead directly running ``preset remove``, the - legacy branch of ``_unregister_skills`` restored only the - currently active agent's directory (via ``_get_skills_dir()``), - permanently leaving this preset's override in every other, - previously active agent's directory (#2948). - """ - self._write_init_options(project_dir, ai="claude", ai_skills=True) - - core_cmds = project_dir / ".specify" / "templates" / "commands" - core_cmds.mkdir(parents=True, exist_ok=True) - (core_cmds / "specify.md").write_text( - "---\ndescription: Core specify command\n---\n\nCore specify body\n", - encoding="utf-8", - ) - - claude_skills_dir = project_dir / ".claude" / "skills" - self._create_skill(claude_skills_dir, "speckit-specify") - codex_skills_dir = project_dir / ".agents" / "skills" - self._create_skill(codex_skills_dir, "speckit-specify") - - preset_dir = self._create_command_preset( - temp_dir, "remove-legacy-no-rescaffold-preset", "speckit.specify", - "Remove legacy no rescaffold test", "preset body", - ) - manager = PresetManager(project_dir) - manager.install_from_directory(preset_dir, "0.1.5") - - claude_skill = claude_skills_dir / "speckit-specify" / "SKILL.md" - assert "preset:remove-legacy-no-rescaffold-preset" in claude_skill.read_text(), ( - "sanity: install should have written the override under " - "claude's skill directory" - ) - # Simulate the pre-#2948 "register for every detected agent" - # install behaviour by also placing the marker under codex's - # directory directly (mirroring the old, non-active-only - # rendering that predates this PR). - codex_skill = codex_skills_dir / "speckit-specify" / "SKILL.md" - codex_skill.write_text(claude_skill.read_text(), encoding="utf-8") - - # Simulate a pre-#2948 registry: a flat list with no per-agent - # provenance, even though both directories actually hold this - # preset's marker on disk. - manager.registry.update( - "remove-legacy-no-rescaffold-preset", - {"registered_skills": ["speckit-specify"]}, - ) - - # No intervening use/upgrade/rescaffold: remove() is the very - # first operation run after the legacy registry was written. - assert manager.remove("remove-legacy-no-rescaffold-preset") is True - - for skill_file, label in ((claude_skill, "claude"), (codex_skill, "codex")): - assert skill_file.exists(), f"{label} skill file should still exist after removal" - content = skill_file.read_text() - assert "preset:remove-legacy-no-rescaffold-preset" not in content, ( - f"{label}'s preset override must be restored on removal " - "even with no prior rescaffold to migrate the legacy " - "flat-list format first — remove() must infer real " - "per-agent ownership from on-disk provenance itself " - "(#2948)" - ) - assert "Core specify body" in content - - def test_symlinked_skills_dir_rejected_on_removal(self, project_dir, temp_dir): - """Removal must validate a recorded skill directory before touching it. - - If an agent's skills directory is replaced with a symlink escaping - the project root between install and removal, restoration must - refuse to write/rmtree through it rather than trusting the - recorded agent name blindly. The unsafe directory is skipped - best-effort; removal still succeeds and doesn't crash (#2948). - """ - self._write_init_options(project_dir, ai="claude", ai_skills=True) - claude_skills_dir = project_dir / ".claude" / "skills" - self._create_skill(claude_skills_dir, "speckit-specify") - - preset_dir = self._create_command_preset( - temp_dir, "symlink-guard-preset", "speckit.specify", - "Symlink guard test", "preset body", - ) - - manager = PresetManager(project_dir) - manager.install_from_directory(preset_dir, "0.1.5") - - metadata = manager.registry.get("symlink-guard-preset") - assert "speckit-specify" in metadata.get("registered_skills", {}).get("claude", []) - - # Simulate the claude skills directory being replaced with a symlink - # that escapes the project root, containing an external - # "speckit-specify" directory that must not be touched. - outside_target = temp_dir / "outside-claude-skills" - outside_skill_dir = outside_target / "speckit-specify" - outside_skill_dir.mkdir(parents=True) - sentinel = outside_skill_dir / "SKILL.md" - sentinel.write_text("do-not-touch") - shutil.rmtree(claude_skills_dir) - claude_skills_dir.symlink_to(outside_target, target_is_directory=True) - - assert manager.remove("symlink-guard-preset") is True - - assert sentinel.read_text() == "do-not-touch", ( - "removal must not follow a symlinked skills directory outside " - "the project root (#2948)" - ) - assert outside_skill_dir.is_dir(), ( - "the external directory must not be rmtree'd through a " - "symlinked skills path" - ) - assert claude_skills_dir.is_symlink(), ( - "the symlink itself should be left alone, not rmtree'd through" - ) - - def test_preset_removal_does_not_touch_other_presets_skill_dir( - self, project_dir, temp_dir - ): - """Removing a preset must only touch directories it actually wrote to. - - Preset A is installed while Claude is active and preset B is - installed while Codex is active; both override the same command - name, so both materialize a ``speckit-specify`` skill, but in - *different* agent directories. Before the provenance fix, removing - B enumerated every existing skill-mode directory (including - Claude's) and restored/overwrote anything named ``speckit-specify`` - found there, corrupting A's override even though B never touched - Claude's directory (#2948). - """ - self._write_init_options(project_dir, ai="claude", ai_skills=True) - claude_skills_dir = project_dir / ".claude" / "skills" - self._create_skill(claude_skills_dir, "speckit-specify") - - preset_a_dir = self._create_command_preset( - temp_dir, "preset-a", "speckit.specify", "Preset A", "preset A body", - ) - manager = PresetManager(project_dir) - manager.install_from_directory(preset_a_dir, "0.1.5") - - claude_skill_file = claude_skills_dir / "speckit-specify" / "SKILL.md" - assert "preset:preset-a" in claude_skill_file.read_text() - - # Switch to codex and install a second preset overriding the same - # command; codex's skills directory is entirely separate. - self._write_init_options(project_dir, ai="codex", ai_skills=True) - codex_skills_dir = project_dir / ".agents" / "skills" - self._create_skill(codex_skills_dir, "speckit-specify") - - preset_b_dir = self._create_command_preset( - temp_dir, "preset-b", "speckit.specify", "Preset B", "preset B body", - ) - manager.install_from_directory(preset_b_dir, "0.1.5") - - metadata_b = manager.registry.get("preset-b") - assert "claude" not in metadata_b.get("registered_skills", {}), ( - "preset B never wrote to claude's skills directory and must " - "not record it as touched" - ) - - assert manager.remove("preset-b") is True - - assert "preset:preset-a" in claude_skill_file.read_text(), ( - "removing preset B must not disturb preset A's Claude override (#2948)" - ) - - def test_remove_does_not_recreate_empty_skill_dir( - self, project_dir, temp_dir - ): - self._write_init_options(project_dir, ai="claude", ai_skills=True) - skills_dir = project_dir / ".claude" / "skills" - self._create_skill(skills_dir, "speckit-orphan") - preset_dir = self._create_command_preset( - temp_dir, - "orphan-skill-preset", - "speckit.orphan", - "Orphan", - "Preset-only body", - ) - - manager = PresetManager(project_dir) - manager.install_from_directory(preset_dir, "0.1.5") - - skill_dir = skills_dir / "speckit-orphan" - assert skill_dir.exists() - assert manager.remove("orphan-skill-preset") is True - assert not skill_dir.exists() - - def test_remove_preserves_non_owned_skill_during_reconciliation( - self, project_dir, temp_dir - ): - self._write_init_options(project_dir, ai="claude", ai_skills=True) - skills_dir = project_dir / ".claude" / "skills" - self._create_skill(skills_dir, "speckit-specify") - - lower_dir = self._create_command_preset( - temp_dir, - "non-owned-lower-preset", - "speckit.specify", - "Lower", - "Lower preset body", - ) - higher_dir = self._create_command_preset( - temp_dir, - "non-owned-higher-preset", - "speckit.specify", - "Higher", - "Higher preset body", - ) - manager = PresetManager(project_dir) - manager.install_from_directory(lower_dir, "0.1.5", priority=10) - manager.install_from_directory(higher_dir, "0.1.5", priority=1) - - skill_file = skills_dir / "speckit-specify" / "SKILL.md" - skill_file.write_text( - "---\nname: speckit-specify\n---\n\nUser-owned body\n", - encoding="utf-8", - ) - - assert manager.remove("non-owned-higher-preset") is True - assert skill_file.read_text(encoding="utf-8") == ( - "---\nname: speckit-specify\n---\n\nUser-owned body\n" - ) - - def test_shared_skills_dir_restored_once_using_active_agent( - self, project_dir, temp_dir - ): - """Removal must restore a physical skills directory shared by - multiple agents exactly once, using the active agent's renderer. - - Codex and Antigravity (agy) both resolve their skills directory to - ``.agents/skills``. Registering a preset under codex, switching to - agy, then switching back to codex records provenance for *both* - agent keys even though they share one physical directory. Before - the fix, ``_unregister_skills`` restored once per recorded agent - key rather than once per unique directory, so the directory was - written twice on removal with whichever agent was iterated *last* - silently winning — regardless of which agent is actually active - (#2948). - """ - self._write_init_options(project_dir, ai="codex", ai_skills=True) - shared_skills_dir = project_dir / ".agents" / "skills" - self._create_skill(shared_skills_dir, "speckit-specify") - - core_cmds = project_dir / ".specify" / "templates" / "commands" - core_cmds.mkdir(parents=True, exist_ok=True) - (core_cmds / "specify.md").write_text( - "---\ndescription: Core specify command\n---\n\nCore specify body\n", - encoding="utf-8", - ) - - preset_dir = self._create_command_preset( - temp_dir, "shared-dir-preset", "speckit.specify", - "Shared dir test", "preset body", - ) - manager = PresetManager(project_dir) - manager.install_from_directory(preset_dir, "0.1.5") - - # Switch to agy (shares .agents/skills with codex) and back to - # codex, mirroring `integration use agy` then `integration use - # codex`. Both agent keys end up recorded in registered_skills even - # though they refer to the same physical directory. - self._write_init_options(project_dir, ai="agy", ai_skills=True) - manager.register_enabled_presets_for_agent("agy") - self._write_init_options(project_dir, ai="codex", ai_skills=True) - manager.register_enabled_presets_for_agent("codex") - - metadata = manager.registry.get("shared-dir-preset") - registered_skills = metadata.get("registered_skills", {}) - assert set(registered_skills) == {"codex", "agy"}, ( - "both agent keys must be recorded even though they share one " - "physical directory (#2948)" - ) - - from unittest.mock import patch - - # Exercise `_unregister_skills` directly (the method this fix - # changed) rather than the full `remove()` flow, which separately - # triggers post-removal reconciliation that may also touch the - # active agent's directory — an unrelated call this test isn't - # targeting. - with patch.object( - manager, - "_unregister_skills_in_dir", - wraps=manager._unregister_skills_in_dir, - ) as spy: - manager._unregister_skills(registered_skills, preset_dir) - - assert spy.call_count == 1, ( - "a physical directory shared by multiple recorded agents must " - "be restored exactly once, not once per agent key (#2948)" - ) - (_names, called_dir, called_agent), _kwargs = spy.call_args - assert called_dir == shared_skills_dir - assert called_agent == "codex", ( - "the currently active agent must be used as the renderer when " - "it shares the restored directory, not whichever agent was " - "recorded last (#2948)" - ) - - skill_file = shared_skills_dir / "speckit-specify" / "SKILL.md" - content = skill_file.read_text() - assert "preset:shared-dir-preset" not in content - assert "Core specify body" in content - - def test_remove_higher_priority_skills_only_preset_restores_lower_preset( - self, project_dir, temp_dir - ): - """Removing a skills-mode preset must reconcile against the surviving - stack, not fall back to core/extension content. - - Copilot in skills mode never populates ``registered_commands`` for - its overrides (``_register_commands``'s ``ai_skills`` guard skips - command-file registration entirely), so with two presets overriding - the same command, the removed preset's command name was never added - to ``removed_cmd_names`` and reconciliation was skipped outright. - ``_unregister_skills`` then restored straight to core/extension - content instead of resolving the lower-priority preset that should - now win (#2948). - """ - self._write_init_options(project_dir, ai="copilot", ai_skills=True) - - preset_a_dir = self._create_command_preset( - temp_dir, "skills-preset-a", "speckit.specify", - "Preset A", "preset A body", - ) - preset_b_dir = self._create_command_preset( - temp_dir, "skills-preset-b", "speckit.specify", - "Preset B", "preset B body", - ) - - manager = PresetManager(project_dir) - # Lower priority number = higher precedence. - manager.install_from_directory(preset_a_dir, "0.1.5", priority=5) - manager.install_from_directory(preset_b_dir, "0.1.5", priority=10) - - skills_dir = project_dir / ".github" / "skills" - skill_file = skills_dir / "speckit-specify" / "SKILL.md" - assert "preset:skills-preset-a" in skill_file.read_text(), ( - "sanity: the higher-precedence preset should win initially" - ) - - assert manager.remove("skills-preset-a") is True - - content = skill_file.read_text() - assert "preset:skills-preset-b" in content, ( - "removing the higher-precedence skills-mode preset must " - "restore the surviving lower-precedence preset's override, " - "not fall back to core/extension content (#2948)" - ) - assert "preset:skills-preset-a" not in content - - def test_composed_none_unregister_respects_active_agent( - self, project_dir, temp_dir - ): - """Unregistering a stale composed command must only touch the - active agent's directory, not every configured non-skill agent. - - When a wrap preset's base layer is removed, ``resolve_content`` can - no longer find a replace layer to compose onto and returns - ``None``, triggering the "composed is None" branch of - ``_reconcile_composed_commands``. Before the fix, that - unregistration mapping covered every configured non-skill agent - regardless of ``only_agent``, deleting historical artifacts from - integrations that were never active for this preset (#2948). - """ - self._write_init_options(project_dir, ai="gemini", ai_skills=False) - gemini_commands_dir = project_dir / ".gemini" / "commands" - gemini_commands_dir.mkdir(parents=True) - - # A made-up command name with no bundled/core equivalent, so the - # *only* base layer is the "compose-base" preset installed below — - # once it's removed, no base remains for the wrap preset to compose - # onto. - cmd_name = "speckit.fake-compose-test" - base_dir = self._create_command_preset( - temp_dir, "compose-base", cmd_name, "Base", "base body", - ) - manager = PresetManager(project_dir) - manager.install_from_directory(base_dir, "0.1.5", priority=10) - - wrap_dir = temp_dir / "compose-wrap" - wrap_dir.mkdir() - (wrap_dir / "commands").mkdir() - (wrap_dir / "commands" / f"{cmd_name}.md").write_text( - "---\ndescription: Wrap\nstrategy: wrap\n---\n\n" - "wrap start\n{CORE_TEMPLATE}\nwrap end\n" - ) - manifest_data = { - "schema_version": "1.0", - "preset": { - "id": "compose-wrap", - "name": "compose-wrap", - "version": "1.0.0", - "description": "Test", - }, - "requires": {"speckit_version": ">=0.1.0"}, - "provides": { - "templates": [ - { - "type": "command", - "name": cmd_name, - "file": f"commands/{cmd_name}.md", - "strategy": "wrap", - } - ] - }, - } - with open(wrap_dir / "preset.yml", "w") as f: - yaml.dump(manifest_data, f) - manager.install_from_directory(wrap_dir, "0.1.5", priority=5) - - cmd_file = gemini_commands_dir / f"{cmd_name}.toml" - assert cmd_file.exists(), ( - "sanity: the composed command should register for the active agent" - ) - - # Simulate a pre-existing artifact for an inactive agent, predating - # this preset entirely — active-only unregistration must never - # touch it. - opencode_dir = project_dir / ".opencode" / "commands" - opencode_dir.mkdir(parents=True, exist_ok=True) - opencode_stale_file = opencode_dir / f"{cmd_name}.md" - opencode_stale_file.write_text("stale opencode content\n") - - assert manager.remove("compose-base") is True - - assert not cmd_file.exists(), ( - "sanity: the active agent's now-uncomposable command file must " - "be unregistered" - ) - assert opencode_stale_file.read_text() == "stale opencode content\n", ( - "unregistering a stale composed command must not touch an " - "inactive agent's directory (#2948)" - ) - - def test_remove_reconciles_command_for_every_historical_agent( - self, project_dir, temp_dir - ): - """Removing a preset must reconcile every historical agent its - ``registered_commands`` actually targeted, not only the currently - active one. - - Preset B (lower precedence, survives) is installed while gemini is - active, then preset A (higher precedence) overrides the same - command while gemini is still active. Switching the active - integration to opencode and rescaffolding re-registers both - presets under opencode too, so preset A's ``registered_commands`` - now spans two agents: gemini (now inactive) and opencode (active). - Removing A deletes its command file from *both* directories via - ``_unregister_commands``, but active-only reconciliation used to - recreate the surviving preset B's content only for the active - agent (opencode), leaving gemini's directory with a stale/missing - file (#2948). - """ - self._write_init_options(project_dir, ai="gemini", ai_skills=False) - gemini_dir = project_dir / ".gemini" / "commands" - gemini_dir.mkdir(parents=True) - - preset_b_dir = self._create_command_preset( - temp_dir, "hist-preset-b", "speckit.specify", - "Preset B", "preset B body", - ) - preset_a_dir = self._create_command_preset( - temp_dir, "hist-preset-a", "speckit.specify", - "Preset A", "preset A body", - ) - - manager = PresetManager(project_dir) - manager.install_from_directory(preset_b_dir, "0.1.5", priority=10) - manager.install_from_directory(preset_a_dir, "0.1.5", priority=1) - - gemini_cmd_files = list(gemini_dir.glob("*specify*")) - assert gemini_cmd_files, "sanity: gemini should have the command file" - assert "preset A body" in gemini_cmd_files[0].read_text(), ( - "sanity: preset A (higher precedence) should win initially" - ) - - # Switch the active integration to opencode and rescaffold, mirroring - # `integration use opencode`. This merges opencode into both - # presets' registered_commands alongside the pre-existing gemini - # entry recorded while gemini was active. - self._write_init_options(project_dir, ai="opencode", ai_skills=False) - opencode_dir = project_dir / ".opencode" / "commands" - opencode_dir.mkdir(parents=True, exist_ok=True) - manager.register_enabled_presets_for_agent("opencode") - - metadata_a = manager.registry.get("hist-preset-a") - assert set(metadata_a.get("registered_commands", {})) == {"gemini", "opencode"}, ( - "sanity: preset A's registered_commands must span both the " - "historical (gemini) and currently active (opencode) agents" - ) - - assert manager.remove("hist-preset-a") is True - - gemini_cmd_files = list(gemini_dir.glob("*specify*")) - opencode_cmd_files = list(opencode_dir.glob("*specify*")) - assert gemini_cmd_files, "gemini's command file must still exist after removal" - assert opencode_cmd_files, "opencode's command file must still exist after removal" - assert "preset B body" in gemini_cmd_files[0].read_text(), ( - "removing the higher-precedence preset must restore the " - "surviving preset's content in the historical (inactive) " - "agent's directory too, not only the active agent's (#2948)" - ) - assert "preset B body" in opencode_cmd_files[0].read_text(), ( - "the surviving preset's content must also be restored for the " - "currently active agent" - ) - - def test_remove_reconciliation_tracks_new_historical_agent_for_survivor( - self, project_dir, temp_dir - ): - """Historical-agent reconciliation writes must be recorded in the - surviving preset's own ``registered_commands``, not just written - to disk and forgotten. - - Preset A is installed while gemini is active, then survives to be - active under opencode too (so A's ``registered_commands`` spans - both gemini and opencode). Preset B is installed *only* while - opencode is active — B's ``registered_commands`` is - ``{"opencode": [...]}`` and never mentions gemini. Removing A - triggers reconciliation that writes B's content into gemini's - directory (an agent B never wrote to before) via ``extra_agents``, - but if that write isn't merged back into B's own - ``registered_commands``, B's registry entry still only says - ``{"opencode": [...]}`` even though B's content now lives in - gemini's directory too. A later ``remove('b')`` then only cleans - up opencode, leaving the gemini file — reconciled there entirely - by side effect of removing A — as a permanent orphan with no - preset tracking it (#2948). - """ - self._write_init_options(project_dir, ai="gemini", ai_skills=False) - gemini_dir = project_dir / ".gemini" / "commands" - gemini_dir.mkdir(parents=True) - - preset_a_dir = self._create_command_preset( - temp_dir, "orphan-preset-a", "speckit.specify", - "Preset A", "preset A body", - ) - manager = PresetManager(project_dir) - manager.install_from_directory(preset_a_dir, "0.1.5", priority=1) - - self._write_init_options(project_dir, ai="opencode", ai_skills=False) - opencode_dir = project_dir / ".opencode" / "commands" - opencode_dir.mkdir(parents=True, exist_ok=True) - manager.register_enabled_presets_for_agent("opencode") - - metadata_a = manager.registry.get("orphan-preset-a") - assert set(metadata_a.get("registered_commands", {})) == {"gemini", "opencode"}, ( - "sanity: preset A must be tracked under both agents" - ) - - # Preset B is installed only now, while opencode is the sole - # active agent — it never writes to or tracks gemini. - preset_b_dir = self._create_command_preset( - temp_dir, "orphan-preset-b", "speckit.specify", - "Preset B", "preset B body", - ) - manager.install_from_directory(preset_b_dir, "0.1.5", priority=10) - - metadata_b = manager.registry.get("orphan-preset-b") - assert set(metadata_b.get("registered_commands", {})) == {"opencode"}, ( - "sanity: preset B must only be tracked for opencode before " - "preset A is removed" - ) - - assert manager.remove("orphan-preset-a") is True - - # B is now written into gemini's directory as a side effect of - # reconciling A's removal, via the historical-agent extra_agents - # pass. - gemini_cmd_files = list(gemini_dir.glob("*specify*")) - assert gemini_cmd_files, "sanity: gemini's directory must have B's restored content" - assert "preset B body" in gemini_cmd_files[0].read_text(encoding="utf-8") - - metadata_b = manager.registry.get("orphan-preset-b") - assert set(metadata_b.get("registered_commands", {})) == {"gemini", "opencode"}, ( - "preset B's own registered_commands must be updated to " - "include gemini once reconciliation actually writes content " - "there on its behalf — otherwise B's registry entry silently " - "lies about which directories it owns (#2948)" - ) - - assert manager.remove("orphan-preset-b") is True - - # No preset is installed any more, so gemini's file must have been - # reconciled down to the core bundled template (or removed - # entirely) — but it must NOT still contain B's stale content, - # which would mean B's write there was never tracked for cleanup. - remaining_gemini_files = list(gemini_dir.glob("*specify*")) - for f in remaining_gemini_files: - assert "preset B body" not in f.read_text(encoding="utf-8"), ( - "removing preset B must clean up gemini's directory too, " - "since B's registered_commands was updated to include it " - "— otherwise B's stale content is orphaned there forever " - "with no preset left to track or clean it up (#2948)" - ) - - def test_extension_reconciliation_tracks_new_historical_agent( - self, project_dir - ): - from specify_cli.extensions import ExtensionRegistry - - self._write_init_options(project_dir, ai="opencode", ai_skills=False) - (project_dir / ".opencode" / "commands").mkdir(parents=True) - (project_dir / ".gemini" / "commands").mkdir(parents=True) - - ext_dir = project_dir / ".specify" / "extensions" / "tracked-ext" - (ext_dir / "commands").mkdir(parents=True) - (ext_dir / "commands" / "tracked.md").write_text( - "---\ndescription: tracked\n---\n\nExtension body\n", - encoding="utf-8", - ) - (ext_dir / "extension.yml").write_text( - "schema_version: '1.0'\n" - "extension:\n id: tracked-ext\n name: Tracked\n version: 1.0.0\n" - " description: test\n author: test\n repository: https://example.com\n" - " license: MIT\n" - "requires:\n speckit_version: '>=0.2.0'\n" - "provides:\n" - " commands:\n" - " - name: speckit.tracked\n" - " file: commands/tracked.md\n" - " description: Tracked command\n", - encoding="utf-8", - ) - ExtensionRegistry(ext_dir.parent).add( - "tracked-ext", - { - "version": "1.0.0", - "source": "dev", - "enabled": True, - "registered_commands": { - "opencode": ["speckit.tracked-ext.tracked"] - }, - }, - ) - - manager = PresetManager(project_dir) - manager._reconcile_composed_commands( - ["speckit.tracked-ext.tracked"], extra_agents={"gemini"} - ) - - assert list((project_dir / ".gemini" / "commands").glob("*tracked*")) - metadata = ExtensionRegistry(ext_dir.parent).get("tracked-ext") - assert set(metadata["registered_commands"]) == {"gemini", "opencode"} - - def test_remove_reconciles_skill_for_every_historical_agent( - self, project_dir, temp_dir - ): - """Removing a preset must reconcile every historical skills - directory its ``registered_skills`` actually targeted, not only - the currently active one. - - Preset B (survives) is installed while claude is active, then - preset A (higher precedence) overrides the same command while - claude is still active. Switching to codex and rescaffolding - records codex too, so preset A's ``registered_skills`` spans both - claude (now inactive) and codex (active) directories. Removing A - restores both directories to core/extension via - ``_unregister_skills``, but ``_reconcile_skills`` used to only - resolve/apply the surviving winner for the currently active - skills directory, leaving claude's directory reverted to - core/extension content instead of preset B's override (#2948). - """ - self._write_init_options(project_dir, ai="claude", ai_skills=True) - claude_skills_dir = project_dir / ".claude" / "skills" - - # A core template fallback is required so unregistering the - # top-priority preset's SKILL.md restores core content rather than - # deleting the skill directory outright when no preset remains to - # apply on top of it (mirrors the pre-existing skills-reconciliation - # fixtures elsewhere in this file). - core_cmds = project_dir / ".specify" / "templates" / "commands" - core_cmds.mkdir(parents=True, exist_ok=True) - (core_cmds / "specify.md").write_text( - "---\ndescription: Core specify command\n---\n\nCore specify body\n", - encoding="utf-8", - ) - - preset_b_dir = self._create_command_preset( - temp_dir, "hist-skill-preset-b", "speckit.specify", - "Preset B", "preset B body", - ) - preset_a_dir = self._create_command_preset( - temp_dir, "hist-skill-preset-a", "speckit.specify", - "Preset A", "preset A body", - ) - - manager = PresetManager(project_dir) - manager.install_from_directory(preset_b_dir, "0.1.5", priority=10) - manager.install_from_directory(preset_a_dir, "0.1.5", priority=1) - - claude_skill_file = claude_skills_dir / "speckit-specify" / "SKILL.md" - assert "preset:hist-skill-preset-a" in claude_skill_file.read_text(), ( - "sanity: preset A (higher precedence) should win initially" - ) - - # Switch the active integration to codex (a distinct skills - # directory) and rescaffold, mirroring `integration use codex`. - self._write_init_options(project_dir, ai="codex", ai_skills=True) - codex_skills_dir = project_dir / ".agents" / "skills" - manager.register_enabled_presets_for_agent("codex") - - metadata_a = manager.registry.get("hist-skill-preset-a") - assert set(metadata_a.get("registered_skills", {})) == {"claude", "codex"}, ( - "sanity: preset A's registered_skills must span both the " - "historical (claude) and currently active (codex) agents" - ) - - assert manager.remove("hist-skill-preset-a") is True - - codex_skill_file = codex_skills_dir / "speckit-specify" / "SKILL.md" - assert claude_skill_file.exists(), "claude's skill file must still exist after removal" - assert codex_skill_file.exists(), "codex's skill file must still exist after removal" - assert "preset:hist-skill-preset-b" in claude_skill_file.read_text(), ( - "removing the higher-precedence preset must restore the " - "surviving preset's override in the historical (inactive) " - "agent's directory too, not only the active agent's (#2948)" - ) - assert "preset:hist-skill-preset-b" in codex_skill_file.read_text(), ( - "the surviving preset's override must also be restored for " - "the currently active agent" - ) - - def test_skill_reconciliation_preserves_per_directory_names( - self, project_dir, temp_dir - ): - self._write_init_options(project_dir, ai="claude", ai_skills=True) - claude_dir = project_dir / ".claude" / "skills" - self._create_skill(claude_dir, "speckit-alpha") - alpha_dir = self._create_command_preset( - temp_dir, "partial-alpha", "speckit.alpha", - "Alpha", "alpha body", - ) - - manager = PresetManager(project_dir) - manager.install_from_directory(alpha_dir, "0.1.5") - - self._write_init_options(project_dir, ai="codex", ai_skills=True) - codex_dir = project_dir / ".agents" / "skills" - self._create_skill(codex_dir, "speckit-beta") - beta_dir = self._create_command_preset( - temp_dir, "partial-beta", "speckit.beta", - "Beta", "beta body", - ) - manager.install_from_directory(beta_dir, "0.1.5") - - affected = manager._unregister_skills( - { - "claude": ["speckit-alpha"], - "codex": ["speckit-beta"], - }, - manager.presets_dir / "partial-alpha", - ) - manager._reconcile_skills( - ["speckit.alpha", "speckit.beta"], - extra_skills_dirs=affected, - ) - - assert (claude_dir / "speckit-alpha" / "SKILL.md").exists() - assert (codex_dir / "speckit-beta" / "SKILL.md").exists() - assert not (claude_dir / "speckit-beta").exists() - assert not (codex_dir / "speckit-alpha").exists() - - def test_skill_reconciliation_rejects_unsafe_managed_names( - self, project_dir, temp_dir - ): - self._write_init_options(project_dir, ai="claude", ai_skills=True) - skills_dir = project_dir / ".claude" / "skills" - skills_dir.mkdir(parents=True) - core_cmds = project_dir / ".specify" / "templates" / "commands" - core_cmds.mkdir(parents=True, exist_ok=True) - (core_cmds / "specify.md").write_text( - "---\ndescription: Core specify\n---\n\nCore body\n", - encoding="utf-8", - ) - absolute_escape = temp_dir / "absolute-escape" - traversal_escape = project_dir / ".claude" / "traversal-escape" - - manager = PresetManager(project_dir) - manager._reconcile_skills( - ["speckit.specify"], - extra_skills_dirs={ - skills_dir: ( - "claude", - [str(absolute_escape), "../traversal-escape"], - ) - }, - ) - - assert not absolute_escape.exists() - assert not traversal_escape.exists() - - def test_remove_reconciliation_tracks_new_historical_skill_agent_for_survivor( - self, project_dir, temp_dir - ): - """Historical-agent skill reconciliation writes must be recorded in - the surviving preset's own ``registered_skills``, mirroring - ``test_remove_reconciliation_tracks_new_historical_agent_for_survivor`` - for the command side. - - Preset A is installed while claude is active, then survives to be - active under codex too (so A's ``registered_skills`` spans both - claude and codex). Preset B is installed *only* while codex is - active — B's ``registered_skills`` is ``{"codex": [...]}`` and - never mentions claude. Removing A triggers reconciliation that - renders B's SKILL.md into claude's directory (an agent B never - wrote to before) via ``extra_skills_dirs``, but if that write - isn't merged back into B's own ``registered_skills``, a later - ``remove('b')`` only cleans up codex, leaving claude's SKILL.md — - rendered there entirely by side effect of removing A — untracked - by any preset (#2948). - """ - self._write_init_options(project_dir, ai="claude", ai_skills=True) - claude_skills_dir = project_dir / ".claude" / "skills" - # Pre-create the skill so _register_commands/_register_skills find - # an existing skill to overwrite (mirrors every other skill test - # in this class — native skill agents only overwrite already - # existing skill directories, they don't materialize brand-new - # ones outside of active-agent creation). - self._create_skill(claude_skills_dir, "speckit-specify") - - core_cmds = project_dir / ".specify" / "templates" / "commands" - core_cmds.mkdir(parents=True, exist_ok=True) - (core_cmds / "specify.md").write_text( - "---\ndescription: Core specify command\n---\n\nCore specify body\n", - encoding="utf-8", - ) - - preset_a_dir = self._create_command_preset( - temp_dir, "orphan-skill-preset-a", "speckit.specify", - "Preset A", "preset A body", - ) - manager = PresetManager(project_dir) - manager.install_from_directory(preset_a_dir, "0.1.5", priority=1) - - self._write_init_options(project_dir, ai="codex", ai_skills=True) - codex_skills_dir = project_dir / ".agents" / "skills" - self._create_skill(codex_skills_dir, "speckit-specify") - manager.register_enabled_presets_for_agent("codex") - - metadata_a = manager.registry.get("orphan-skill-preset-a") - assert set(metadata_a.get("registered_skills", {})) == {"claude", "codex"}, ( - "sanity: preset A must be tracked under both agents" - ) - - # Preset B is installed only now, while codex is the sole active - # agent — it never writes to or tracks claude. - preset_b_dir = self._create_command_preset( - temp_dir, "orphan-skill-preset-b", "speckit.specify", - "Preset B", "preset B body", - ) - manager.install_from_directory(preset_b_dir, "0.1.5", priority=10) - - metadata_b = manager.registry.get("orphan-skill-preset-b") - assert set(metadata_b.get("registered_skills", {})) == {"codex"}, ( - "sanity: preset B must only be tracked for codex before " - "preset A is removed" - ) - - assert manager.remove("orphan-skill-preset-a") is True - - claude_skill_file = claude_skills_dir / "speckit-specify" / "SKILL.md" - assert claude_skill_file.exists(), ( - "sanity: claude's skill file must have been restored by " - "reconciliation" - ) - assert "preset:orphan-skill-preset-b" in claude_skill_file.read_text(), ( - "sanity: claude's SKILL.md must reflect preset B's content " - "after preset A is removed" - ) - - metadata_b = manager.registry.get("orphan-skill-preset-b") - assert set(metadata_b.get("registered_skills", {})) == {"claude", "codex"}, ( - "preset B's own registered_skills must be updated to include " - "claude once reconciliation actually renders content there " - "on its behalf — otherwise B's registry entry silently lies " - "about which directories it owns (#2948)" - ) - - assert manager.remove("orphan-skill-preset-b") is True - - # No preset is installed any more, so claude's SKILL.md must have - # been reconciled down to the core bundled template (or removed - # entirely) — but it must NOT still contain B's stale content, - # which would mean B's write there was never tracked for cleanup. - if claude_skill_file.exists(): - assert "preset:orphan-skill-preset-b" not in claude_skill_file.read_text(), ( - "removing preset B must clean up claude's directory too, " - "since B's registered_skills was updated to include it — " - "otherwise B's stale content is orphaned there forever " - "with no preset left to track or clean it up (#2948)" - ) - - def test_symlinked_skill_subdir_rejected_on_restore(self, project_dir, temp_dir): - """Restore must validate each per-skill subdirectory, not just its parent. - - ``_safe_skills_dir_for_agent`` only validates the parent skills - directory (e.g. ``.claude/skills``); a symlink planted one level - deeper at the individual skill's own subdirectory (e.g. - ``.claude/skills/speckit-specify``) has a perfectly safe parent and - would otherwise slip past that check, since ``is_dir()`` follows - symlinks. Restoration must refuse to write/rmtree through it (#2948). - """ - self._write_init_options(project_dir, ai="claude", ai_skills=True) - claude_skills_dir = project_dir / ".claude" / "skills" - claude_skills_dir.mkdir(parents=True) - - outside_target = temp_dir / "outside-skill-subdir" - outside_target.mkdir() - sentinel = outside_target / "SKILL.md" - sentinel.write_text("do-not-touch") - (claude_skills_dir / "speckit-specify").symlink_to( - outside_target, target_is_directory=True - ) - - manager = PresetManager(project_dir) - manager._unregister_skills_in_dir( - ["speckit-specify"], claude_skills_dir, "claude" - ) - - assert sentinel.read_text() == "do-not-touch", ( - "restoration must not follow a symlinked skill subdirectory " - "to write/delete outside the project (#2948)" - ) - assert (claude_skills_dir / "speckit-specify").is_symlink(), ( - "the symlink itself should be left alone, not rmtree'd through" - ) - - def test_symlinked_skill_subdir_rejected_on_write(self, project_dir, temp_dir): - """Registration must validate each per-skill subdirectory before writing. - - A symlink planted at an individual skill's own subdirectory (safe - parent, unsafe leaf) would otherwise pass the existing - ``skill_subdir.exists() and not skill_subdir.is_dir()`` guard - (``is_dir()`` follows symlinks) and have ``SKILL.md`` written - through it to an arbitrary location (#2948). - """ - self._write_init_options(project_dir, ai="copilot", ai_skills=True) - copilot_commands_dir = project_dir / ".github" / "agents" - copilot_commands_dir.mkdir(parents=True) - skills_dir = project_dir / ".github" / "skills" - skills_dir.mkdir(parents=True) - - outside_target = temp_dir / "outside-skill-write-target" - outside_target.mkdir() - (skills_dir / "speckit-specify").symlink_to( - outside_target, target_is_directory=True - ) - - preset_dir = self._create_command_preset( - temp_dir, "symlink-write-preset", "speckit.specify", - "Symlink write test", "preset body", - ) - - manager = PresetManager(project_dir) - manager.install_from_directory(preset_dir, "0.1.5") - - assert not (outside_target / "SKILL.md").exists(), ( - "registration must not follow a symlinked skill subdirectory " - "to write outside the project (#2948)" - ) - assert (skills_dir / "speckit-specify").is_symlink(), ( - "the symlink itself should be left alone" - ) - - def test_symlinked_skill_file_rejected_on_write(self, project_dir, temp_dir): - """Registration must not follow a symlinked SKILL.md destination.""" - self._write_init_options(project_dir, ai="copilot", ai_skills=True) - (project_dir / ".github" / "agents").mkdir(parents=True) - skill_dir = ( - project_dir / ".github" / "skills" / "speckit-specify" - ) - skill_dir.mkdir(parents=True) - outside_file = temp_dir / "outside-registration.md" - outside_file.write_text("do-not-touch", encoding="utf-8") - (skill_dir / "SKILL.md").symlink_to(outside_file) - - preset_dir = self._create_command_preset( - temp_dir, - "symlink-file-write-preset", - "speckit.specify", - "Symlink file write", - "preset body", - ) - manager = PresetManager(project_dir) - with pytest.raises(ValueError): - manager.install_from_directory(preset_dir, "0.1.5") - - assert outside_file.read_text(encoding="utf-8") == "do-not-touch" - assert (skill_dir / "SKILL.md").is_symlink() - - def test_symlinked_skill_file_rejected_on_restore( - self, project_dir, temp_dir - ): - """Restoration must not follow a symlinked SKILL.md destination.""" - self._write_init_options(project_dir, ai="claude", ai_skills=True) - core_commands = project_dir / ".specify" / "templates" / "commands" - (core_commands / "specify.md").write_text( - "---\ndescription: Core specify\n---\n\nCore body\n", - encoding="utf-8", - ) - skill_dir = ( - project_dir / ".claude" / "skills" / "speckit-specify" - ) - skill_dir.mkdir(parents=True) - outside_file = temp_dir / "outside-restoration.md" - outside_file.write_text("do-not-touch", encoding="utf-8") - (skill_dir / "SKILL.md").symlink_to(outside_file) - - manager = PresetManager(project_dir) - with pytest.raises(ValueError): - manager._unregister_skills_in_dir( - ["speckit-specify"], skill_dir.parent, "claude" - ) - - assert outside_file.read_text(encoding="utf-8") == "do-not-touch" - assert (skill_dir / "SKILL.md").is_symlink() - - def test_symlinked_skill_file_rejected_on_override_reconcile( - self, project_dir, temp_dir - ): - """Project-override reconciliation must not follow SKILL.md symlinks.""" - self._write_init_options(project_dir, ai="copilot", ai_skills=True) - skill_dir = ( - project_dir / ".github" / "skills" / "speckit-specify" - ) - skill_dir.mkdir(parents=True) - outside_file = temp_dir / "outside-reconciliation.md" - outside_file.write_text("do-not-touch", encoding="utf-8") - (skill_dir / "SKILL.md").symlink_to(outside_file) - - preset_dir = self._create_command_preset( - temp_dir, - "symlink-override-preset", - "speckit.specify", - "Preset", - "Preset body", - ) - manager = PresetManager(project_dir) - manager.registry.add( - "symlink-override-preset", - { - "version": "1.0.0", - "source": "local", - "enabled": True, - "priority": 10, - "registered_commands": {}, - "registered_skills": { - "copilot": ["speckit-specify"] - }, - }, - ) - installed_dir = ( - manager.presets_dir / "symlink-override-preset" - ) - shutil.copytree(preset_dir, installed_dir) - overrides_dir = ( - project_dir / ".specify" / "templates" / "overrides" - ) - overrides_dir.mkdir(parents=True) - (overrides_dir / "speckit.specify.md").write_text( - "---\ndescription: Override\n---\n\nOverride body\n", - encoding="utf-8", - ) - - manager._reconcile_skills(["speckit.specify"]) - - assert outside_file.read_text(encoding="utf-8") == "do-not-touch" - assert (skill_dir / "SKILL.md").is_symlink() - - def test_is_safe_registry_skill_name_rejects_unsafe_values(self, project_dir): - """Unit-test the centralized registry skill-name boundary guard. - - ``registered_skills`` entries are persisted registry data, not - manifest-derived, so every preset cleanup/provenance loop that - joins one onto a directory must first reject: non-strings, empty - strings, absolute paths, multi-component paths (containing ``/``), - and the literal traversal components ``"."``/``".."`` — the last - of which is *not* caught by a naive ``is_absolute() or - len(parts) != 1`` check alone, since ``Path("..").parts`` is a - single-element tuple (#2948). - """ - manager = PresetManager(project_dir) - is_safe = manager._is_safe_registry_skill_name - - assert is_safe("speckit-specify") is True - assert is_safe("") is False - assert is_safe(None) is False - assert is_safe(123) is False - assert is_safe(["speckit-specify"]) is False - assert is_safe(".") is False - assert is_safe("..") is False - assert is_safe("/etc/passwd") is False - assert is_safe(str(project_dir / "important-data")) is False - assert is_safe("foo/bar") is False - assert is_safe("foo/..") is False - assert is_safe("../foo") is False - - def test_unregister_skills_rejects_unknown_agent_provenance( - self, project_dir - ): - """Unknown registry agent keys must not fall back to shared skills.""" - shared_skills_dir = project_dir / ".agents" / "skills" - skill_dir = self._create_skill( - shared_skills_dir, "speckit-specify", "user-owned content" - ) - core_commands = project_dir / ".specify" / "templates" / "commands" - (core_commands / "specify.md").write_text( - "---\ndescription: Core specify\n---\n\nCore body\n", - encoding="utf-8", - ) - - manager = PresetManager(project_dir) - manager._unregister_skills( - {"unknown": ["speckit-specify"]}, project_dir - ) - - assert (skill_dir / "SKILL.md").read_text(encoding="utf-8") == ( - "---\nname: speckit-specify\n---\n\nuser-owned content\n" - ) - - def test_unregister_legacy_fallback_skips_non_owned_skill( - self, project_dir - ): - """Legacy fallback provenance must not overwrite a user-owned skill.""" - self._write_init_options(project_dir, ai="claude", ai_skills=True) - skills_dir = project_dir / ".claude" / "skills" - skill_dir = self._create_skill( - skills_dir, "speckit-specify", "user-owned content" - ) - core_commands = project_dir / ".specify" / "templates" / "commands" - (core_commands / "specify.md").write_text( - "---\ndescription: Core specify\n---\n\nCore body\n", - encoding="utf-8", - ) - - manager = PresetManager(project_dir) - manager._unregister_skills( - ["speckit-specify"], "removed-preset" - ) - - assert (skill_dir / "SKILL.md").read_text(encoding="utf-8") == ( - "---\nname: speckit-specify\n---\n\nuser-owned content\n" - ) - - def test_unregister_skills_in_dir_unreadable_core_template_skips( - self, project_dir - ): - """An undecodable core template must not crash `preset remove`. - - Every other failure in the restore loop — an unsafe registry name, - a missing skill subdirectory, a foreign owner — skips the skill - with ``continue``. The core-template read was outside that - boundary, so one non-UTF-8 project-owned override in - ``.specify/templates/commands/`` raised a raw ``UnicodeDecodeError`` - straight out of ``PresetManager.remove()``, which has no handler - for it. Sibling reads of the very same directory are already - guarded (``_substitute_core_template``, the provenance reads in - ``_infer_legacy_skill_provenance``). - """ - self._write_init_options(project_dir, ai="claude", ai_skills=True) - skills_dir = project_dir / ".claude" / "skills" - skill_dir = self._create_skill( - skills_dir, "speckit-specify", "installed content" - ) - core_commands = project_dir / ".specify" / "templates" / "commands" - core_commands.mkdir(parents=True, exist_ok=True) - (core_commands / "specify.md").write_bytes( - b"---\ndescription: \xff\xfe not utf-8\n---\n\nCore body\n" - ) - - manager = PresetManager(project_dir) - with pytest.warns(UserWarning, match="speckit-specify"): - mutated = manager._unregister_skills_in_dir( - ["speckit-specify"], skills_dir, "claude" - ) - - assert mutated == [], ( - "a skill whose restore source could not be read was not " - "restored, so it must not be reported as mutated" - ) - assert (skill_dir / "SKILL.md").read_text(encoding="utf-8") == ( - "---\nname: speckit-specify\n---\n\ninstalled content\n" - ), ( - "an unreadable core template must leave the skill untouched — " - "falling through to the rmtree branch would delete it exactly " - "when its replacement cannot be generated" - ) - - def test_unregister_skills_in_dir_unreadable_core_template_oserror_skips( - self, project_dir, monkeypatch - ): - """The same boundary must cover ``OSError`` (e.g. permission denied). - - Mocked rather than chmod-based so the case also holds under - privileged CI, where permission bits are not enforced. - """ - self._write_init_options(project_dir, ai="claude", ai_skills=True) - skills_dir = project_dir / ".claude" / "skills" - skill_dir = self._create_skill( - skills_dir, "speckit-specify", "installed content" - ) - core_commands = project_dir / ".specify" / "templates" / "commands" - core_commands.mkdir(parents=True, exist_ok=True) - core_template = core_commands / "specify.md" - core_template.write_text( - "---\ndescription: Core specify\n---\n\nCore body\n", - encoding="utf-8", - ) - - original_read_text = Path.read_text - - def failing_read_text(self_path, *args, **kwargs): - if self_path == core_template: - raise PermissionError(13, "Permission denied") - return original_read_text(self_path, *args, **kwargs) - - monkeypatch.setattr(Path, "read_text", failing_read_text) - - manager = PresetManager(project_dir) - with pytest.warns(UserWarning, match="speckit-specify"): - mutated = manager._unregister_skills_in_dir( - ["speckit-specify"], skills_dir, "claude" - ) - - monkeypatch.undo() - - assert mutated == [] - assert (skill_dir / "SKILL.md").read_text(encoding="utf-8") == ( - "---\nname: speckit-specify\n---\n\ninstalled content\n" - ) - - def test_unregister_skills_in_dir_unreadable_extension_source_skips( - self, project_dir - ): - """The extension-restore arm needs the same boundary as the core arm. - - The two restore reads are independent branches — a skill backed by an - installed extension never reaches the core-template read — so this - half of the guard can regress on its own. An undecodable extension - command file must warn, leave the skill byte-for-byte intact, and stay - out of ``mutated_names``. - """ - self._write_init_options(project_dir, ai="claude", ai_skills=True) - skills_dir = project_dir / ".claude" / "skills" - skill_dir = self._create_skill( - skills_dir, "speckit-fakeext-cmd", "installed content" - ) - - extension_dir = project_dir / ".specify" / "extensions" / "fakeext" - (extension_dir / "commands").mkdir(parents=True, exist_ok=True) - (extension_dir / "commands" / "cmd.md").write_bytes( - b"---\ndescription: \xff\xfe not utf-8\n---\n\nExtension body\n" - ) - extension_manifest = { - "schema_version": "1.0", - "extension": { - "id": "fakeext", - "name": "Fake Extension", - "version": "1.0.0", - "description": "Test", - }, - "requires": {"speckit_version": ">=0.1.0"}, - "provides": { - "commands": [ - { - "name": "speckit.fakeext.cmd", - "file": "commands/cmd.md", - "description": "Fake extension command", - } - ] - }, - } - with open(extension_dir / "extension.yml", "w") as f: - yaml.dump(extension_manifest, f) - - manager = PresetManager(project_dir) - with pytest.warns(UserWarning, match="speckit-fakeext-cmd"): - mutated = manager._unregister_skills_in_dir( - ["speckit-fakeext-cmd"], skills_dir, "claude" - ) - - assert mutated == [], ( - "a skill whose extension restore source could not be read was " - "not restored, so it must not be reported as mutated" - ) - assert (skill_dir / "SKILL.md").read_text(encoding="utf-8") == ( - "---\nname: speckit-fakeext-cmd\n---\n\ninstalled content\n" - ), ( - "an unreadable extension source must leave the skill untouched — " - "falling through to the rmtree branch would delete it exactly " - "when its replacement cannot be generated" - ) - - def test_unregister_skills_in_dir_rejects_absolute_registry_name( - self, project_dir - ): - """A corrupted ``registered_skills`` entry with an absolute path must not escape. - - ``Path`` join with an absolute right-hand operand discards the - left side entirely (``skills_dir / "/abs/path"`` == ``"/abs/path"``), - so an absolute in-project path stored in the registry would bypass - ``skills_dir`` altogether if not rejected before the join (#2948). - """ - self._write_init_options(project_dir, ai="claude", ai_skills=True) - claude_skills_dir = project_dir / ".claude" / "skills" - claude_skills_dir.mkdir(parents=True) - - precious_dir = project_dir / "important-data" - precious_dir.mkdir() - precious_file = precious_dir / "SKILL.md" - precious_file.write_text("precious-absolute-target-marker") - - manager = PresetManager(project_dir) - manager._unregister_skills_in_dir( - [str(precious_dir)], claude_skills_dir, "claude" - ) - - assert precious_dir.is_dir(), ( - "an absolute registry entry must not let cleanup escape " - "skills_dir to an unrelated project directory (#2948)" - ) - assert precious_file.read_text() == "precious-absolute-target-marker" - - def test_infer_legacy_skill_provenance_rejects_absolute_registry_name( - self, project_dir - ): - """Legacy provenance inference must reject an absolute registry name. - - ``_infer_legacy_skill_provenance`` receives its ``skill_names`` - directly from a legacy flat-list ``registered_skills`` value — - registry data, not manifest-derived — and joins each name onto a - candidate agent's resolved skills directory the same way - ``_unregister_skills_in_dir`` does. An absolute in-project name - discards the candidate directory entirely (Python's ``/`` operator - drops the left side for an absolute right side), so it can read - an unrelated project directory's ``SKILL.md`` and, if its - frontmatter happens to carry a matching preset source marker, - falsely attribute an unrelated directory as this preset's own - skill override under whichever agent is being probed (#2948). - """ - self._write_init_options(project_dir, ai="claude", ai_skills=True) - claude_skills_dir = project_dir / ".claude" / "skills" - claude_skills_dir.mkdir(parents=True) - - precious_dir = project_dir / "important-data" - precious_dir.mkdir() - (precious_dir / "SKILL.md").write_text( - "---\n" - "metadata:\n" - " source: preset:some-pack\n" - "---\n\n" - "# Unrelated directory, not a real preset skill\n" - ) - - manager = PresetManager(project_dir) - inferred = manager._infer_legacy_skill_provenance( - [str(precious_dir)], "some-pack", "claude" - ) - - for names in inferred.values(): - assert str(precious_dir) not in names, ( - "an absolute registry entry must not be falsely attributed " - "as preset-owned provenance by probing outside the " - "intended skills subtree (#2948)" - ) - - def test_copilot_skills_registration_restored_after_process_restart( - self, project_dir, temp_dir - ): - """Copilot skills-mode registrations must restore even when the - transient ``_skills_mode`` integration attribute has been reset, - simulating a fresh CLI process. - - ``_skills_mode`` is set during ``setup()`` and is never persisted; - after switching the active agent and running ``preset remove`` in - a brand-new process, a naive "is this integration currently in - skills mode" check would be False even though Copilot's - ``.github/skills`` directory holds a live override this preset - wrote. Restoration must rely on the persisted per-agent provenance - recorded at write time, not on runtime integration state (#2948). - """ - self._write_init_options(project_dir, ai="copilot", ai_skills=True) - core_cmds = project_dir / ".specify" / "templates" / "commands" - core_cmds.mkdir(parents=True, exist_ok=True) - (core_cmds / "specify.md").write_text( - "---\ndescription: Core specify command\n---\n\nCore specify body\n", - encoding="utf-8", - ) - copilot_skills_dir = project_dir / ".github" / "skills" - self._create_skill(copilot_skills_dir, "speckit-specify") - - preset_dir = self._create_command_preset( - temp_dir, "copilot-fresh-process-preset", "speckit.specify", - "Copilot fresh process test", "preset body", - ) - - manager = PresetManager(project_dir) - manager.install_from_directory(preset_dir, "0.1.5") - - skill_file = copilot_skills_dir / "speckit-specify" / "SKILL.md" - assert "preset:copilot-fresh-process-preset" in skill_file.read_text() - - metadata = manager.registry.get("copilot-fresh-process-preset") - assert "copilot" in metadata.get("registered_skills", {}) - - # Switch the active agent away from copilot, then simulate a fresh - # CLI process (a brand-new PresetManager, so any transient - # `_skills_mode` state set during a prior setup() call is gone) - # removing the preset. - self._write_init_options(project_dir, ai="claude", ai_skills=True) - fresh_manager = PresetManager(project_dir) - - assert fresh_manager.remove("copilot-fresh-process-preset") is True - - assert "preset:copilot-fresh-process-preset" not in skill_file.read_text(), ( - "removal must restore copilot's .github/skills override even " - "when copilot's transient skills-mode state isn't set in this " - "process (#2948)" - ) - assert "Core specify body" in skill_file.read_text() - - def test_unregister_agent_artifacts_scoped_to_target_agent_only( - self, project_dir, temp_dir - ): - """``unregister_agent_artifacts`` must remove only the target - agent's own tracked command/skill artifacts. - - Used by ``integration switch`` when deactivating the previous - integration for a not-yet-installed target (#2948): without this, - a preset's command override -- including a custom preset command -- - and skill mirror rendered for the old agent remain orphaned once a - different integration becomes active. Another agent's own - registrations (files and registry tracking) must survive - untouched, and no priority-stack reconciliation should run as a - side effect. - """ - self._write_init_options(project_dir, ai="auggie", ai_skills=False) - # Registration only writes to an agent's directory once it's - # "detected" on disk (mirroring a real `integration install` - # having already created it), so pre-create both agents' - # directories before installing the preset. - (project_dir / ".augment" / "commands").mkdir(parents=True) - (project_dir / ".opencode" / "commands").mkdir(parents=True) - preset_dir = self._create_command_preset( - temp_dir, "switch-cleanup-preset", "speckit.specify", - "Custom preset command", "preset body", - ) - manager = PresetManager(project_dir) - manager.install_from_directory(preset_dir, "0.1.5") - - auggie_cmd = project_dir / ".augment" / "commands" / "speckit.specify.md" - assert auggie_cmd.exists(), "sanity: preset command registered for auggie" - - # Simulate a later `integration use opencode` rescaffold that also - # registered the preset for opencode, while auggie's own - # registration (from before the switch) is still present in the - # registry. - self._write_init_options(project_dir, ai="opencode", ai_skills=False) - manager.register_enabled_presets_for_agent("opencode") - - opencode_cmd = project_dir / ".opencode" / "commands" / "speckit.specify.md" - assert opencode_cmd.exists(), "sanity: preset command registered for opencode" - - metadata = manager.registry.get("switch-cleanup-preset") - registered_commands = metadata.get("registered_commands", {}) - assert "auggie" in registered_commands and "opencode" in registered_commands - - manager.unregister_agent_artifacts("auggie") - - assert not auggie_cmd.exists(), ( - "auggie's own preset command must be removed when switching " - "away from auggie to a not-yet-installed integration (#2948)" - ) - assert opencode_cmd.exists(), ( - "opencode's preset command must survive unregistering auggie's " - "artifacts -- cleanup must stay scoped to the target agent" - ) - - metadata = manager.registry.get("switch-cleanup-preset") - registered_commands = metadata.get("registered_commands", {}) - assert "auggie" not in registered_commands, ( - "auggie's tracking must be dropped after unregistering its artifacts" - ) - assert "opencode" in registered_commands, ( - "opencode's tracking must be preserved untouched" - ) - - def test_unregister_agent_artifacts_deletes_marker_owned_skill( - self, project_dir, temp_dir - ): - self._write_init_options(project_dir, ai="copilot", ai_skills=True) - core_cmds = project_dir / ".specify" / "templates" / "commands" - core_cmds.mkdir(parents=True, exist_ok=True) - (core_cmds / "specify.md").write_text( - "---\ndescription: Core specify\n---\n\nCore body\n", - encoding="utf-8", - ) - skills_dir = project_dir / ".github" / "skills" - self._create_skill(skills_dir, "speckit-specify") - preset_dir = self._create_command_preset( - temp_dir, - "deactivated-skill-preset", - "speckit.specify", - "Deactivation cleanup", - "preset body", - ) - manager = PresetManager(project_dir) - manager.install_from_directory(preset_dir, "0.1.5") - - skill_dir = skills_dir / "speckit-specify" - assert "preset:deactivated-skill-preset" in ( - skill_dir / "SKILL.md" - ).read_text() - - manager.unregister_agent_artifacts("copilot") - - assert not skill_dir.exists() - - def test_unregister_agent_artifacts_deletes_reconciled_override_skill( - self, project_dir, temp_dir - ): - self._write_init_options(project_dir, ai="copilot", ai_skills=True) - skills_dir = project_dir / ".github" / "skills" - self._create_skill(skills_dir, "speckit-specify") - preset_dir = self._create_command_preset( - temp_dir, - "deactivated-override-preset", - "speckit.specify", - "Deactivation override cleanup", - "preset body", - ) - manager = PresetManager(project_dir) - manager.install_from_directory(preset_dir, "0.1.5") - - overrides_dir = ( - project_dir / ".specify" / "templates" / "overrides" - ) - overrides_dir.mkdir(parents=True) - (overrides_dir / "speckit.specify.md").write_text( - "---\ndescription: Project override\n---\n\nOverride body\n", - encoding="utf-8", - ) - ( - manager.presets_dir - / "deactivated-override-preset" - / "commands" - / "speckit.specify.md" - ).unlink() - manager.register_enabled_presets_for_agent("copilot") - - skill_dir = skills_dir / "speckit-specify" - assert "override:speckit.specify" in ( - skill_dir / "SKILL.md" - ).read_text(encoding="utf-8") - - manager.unregister_agent_artifacts("copilot") - - assert not skill_dir.exists() - metadata = manager.registry.get("deactivated-override-preset") - assert "copilot" not in metadata.get("registered_skills", {}) - - def test_unregister_native_agent_persists_skills_metadata_pop( - self, project_dir, temp_dir - ): - self._write_init_options(project_dir, ai="agy", ai_skills=True) - (project_dir / ".agents" / "skills").mkdir(parents=True) - preset_dir = self._create_command_preset( - temp_dir, - "native-metadata-cleanup-preset", - "speckit.shared-cleanup", - "Shared cleanup", - "preset body", - ) - manager = PresetManager(project_dir) - manager.install_from_directory(preset_dir, "0.1.5") - - metadata = manager.registry.get("native-metadata-cleanup-preset") - assert "agy" in metadata.get("registered_commands", {}) - manager.registry.update( - "native-metadata-cleanup-preset", - {"registered_skills": {"agy": ["speckit-shared-cleanup"]}}, - ) - - manager.unregister_agent_artifacts("agy") - - metadata = manager.registry.get("native-metadata-cleanup-preset") - assert "agy" not in metadata.get("registered_commands", {}) - assert "agy" not in metadata.get("registered_skills", {}) - - def test_unregister_native_agent_preserves_shared_output_owner( - self, project_dir, temp_dir - ): - self._write_init_options(project_dir, ai="agy", ai_skills=True) - (project_dir / ".agents" / "skills").mkdir(parents=True) - preset_dir = self._create_command_preset( - temp_dir, - "shared-native-output-preset", - "speckit.shared-owner", - "Shared owner", - "preset body", - ) - manager = PresetManager(project_dir) - manager.install_from_directory(preset_dir, "0.1.5") - - self._write_init_options(project_dir, ai="codex", ai_skills=True) - manager.register_enabled_presets_for_agent("codex") - skill_file = ( - project_dir - / ".agents" - / "skills" - / "speckit-shared-owner" - / "SKILL.md" - ) - assert skill_file.exists() - metadata = manager.registry.get("shared-native-output-preset") - registered_commands = metadata.get("registered_commands", {}) - assert "agy" in registered_commands and "codex" in registered_commands - - manager.unregister_agent_artifacts("agy") - - assert skill_file.exists(), ( - "shared SKILL.md must survive while codex still owns the same " - "physical output" - ) - metadata = manager.registry.get("shared-native-output-preset") - registered_commands = metadata.get("registered_commands", {}) - assert "agy" not in registered_commands - assert "codex" in registered_commands - - def test_unregister_agent_artifacts_migrates_legacy_skill_list_scoped( - self, project_dir, temp_dir - ): - """Unregistering an agent's artifacts from a legacy flat-list - ``registered_skills`` entry must infer real per-agent ownership - before removing anything, so only the target agent's own share is - cleaned up and any other agent's still-live mirror survives, - rather than either guessing every name belongs to the target agent - or dropping all tracking wholesale (#2948). - - Uses Copilot (command-backed, rendering skills via ``ai_skills``) - as the agent being switched away from, and Claude (a native - SKILL.md agent) as the separate, still-live owner — mirroring the - established legacy-provenance test pattern used elsewhere for - this exact registry shape. - """ - self._write_init_options(project_dir, ai="copilot", ai_skills=True) - core_cmds = project_dir / ".specify" / "templates" / "commands" - core_cmds.mkdir(parents=True, exist_ok=True) - (core_cmds / "specify.md").write_text( - "---\ndescription: Core specify command\n---\n\nCore specify body\n", - encoding="utf-8", - ) - - copilot_skills_dir = project_dir / ".github" / "skills" - claude_skills_dir = project_dir / ".claude" / "skills" - self._create_skill(copilot_skills_dir, "speckit-specify") - self._create_skill(claude_skills_dir, "speckit-specify") - - preset_dir = self._create_command_preset( - temp_dir, "switch-legacy-skill-preset", "speckit.specify", - "Legacy skill switch test", "preset body", - ) - manager = PresetManager(project_dir) - manager.install_from_directory(preset_dir, "0.1.5") - - copilot_skill = copilot_skills_dir / "speckit-specify" / "SKILL.md" - assert "preset:switch-legacy-skill-preset" in copilot_skill.read_text(), ( - "sanity: install wrote the override under copilot" - ) - - # A separate activation under claude (before provenance tracking - # existed) also left a live, marker-verified mirror there. - claude_skill = claude_skills_dir / "speckit-specify" / "SKILL.md" - claude_skill.write_text( - "---\nname: speckit-specify\nmetadata:\n source: preset:switch-legacy-skill-preset\n" - "---\n\npreset body\n", - encoding="utf-8", - ) - - # Simulate a pre-#2948 registry: a flat list with no per-agent - # provenance for either writer. - manager.registry.update( - "switch-legacy-skill-preset", - {"registered_skills": ["speckit-specify"]}, - ) - - manager.unregister_agent_artifacts("copilot") - - assert not copilot_skill.parent.exists(), ( - "copilot's marker-owned preset skill must be deleted when " - "switching away from copilot" - ) - - assert "preset:switch-legacy-skill-preset" in claude_skill.read_text(), ( - "claude's own, separately-written mirror must survive " - "unregistering copilot's artifacts -- legacy-list inference " - "must not misattribute or drop claude's real ownership (#2948)" - ) - - metadata = manager.registry.get("switch-legacy-skill-preset") - registered_skills = metadata.get("registered_skills") - assert isinstance(registered_skills, dict), ( - "legacy flat-list value must migrate to per-agent form" - ) - assert "copilot" not in registered_skills - assert "claude" in registered_skills and "speckit-specify" in registered_skills["claude"], ( - "claude's real ownership must be preserved in the migrated tracking" - ) - - def test_short_and_namespaced_commands_scaffold_consistently( - self, project_dir, temp_dir - ): - """A preset's ``speckit.`` and ``speckit..`` commands must - scaffold identically in command mode, with no installed extension. - - Regression: the 3-part (``speckit..``) form was silently - dropped by a name-shape guard whenever ``.specify/extensions//`` - was absent, even though the preset ships the command body itself. The - 2-part form always scaffolded. Both are self-contained and must behave - the same (#4076). - """ - self._write_init_options(project_dir, ai="gemini", ai_skills=False) - gemini_commands_dir = project_dir / ".gemini" / "commands" - gemini_commands_dir.mkdir(parents=True) - - short_preset = self._create_command_preset( - temp_dir, "short-cmd", "speckit.newcmd", "Short", "short body", - ) - ns_preset = self._create_command_preset( - temp_dir, "ns-cmd", "speckit.fakeext.newcmd", "Namespaced", "ns body", - ) - - manager = PresetManager(project_dir) - manager.install_from_directory(short_preset, "0.1.5") - manager.install_from_directory(ns_preset, "0.1.5") - - short_file = gemini_commands_dir / "speckit.newcmd.toml" - ns_file = gemini_commands_dir / "speckit.fakeext.newcmd.toml" - assert short_file.exists(), "2-part command should scaffold" - assert ns_file.exists(), ( - "3-part namespaced command must scaffold too, even without the " - "matching extension installed" - ) - assert manager.registry.get("short-cmd")["registered_commands"] != {} - assert manager.registry.get("ns-cmd")["registered_commands"] != {} - - - - -class TestPresetPriorityBackwardsCompatibility: - """Test backwards compatibility for presets installed before priority feature.""" - - def test_legacy_preset_without_priority_field(self, temp_dir): - """Presets installed before priority feature should default to 10.""" - presets_dir = temp_dir / ".specify" / "presets" - presets_dir.mkdir(parents=True) - - # Simulate legacy registry entry without priority field - registry = PresetRegistry(presets_dir) - registry.data["presets"]["legacy-pack"] = { - "version": "1.0.0", - "source": "local", - "enabled": True, - "installed_at": "2025-01-01T00:00:00Z", - # No "priority" field - simulates pre-feature preset - } - registry._save() - - # Reload registry - registry2 = PresetRegistry(presets_dir) - - # list_by_priority should use default of 10 - result = registry2.list_by_priority() - assert len(result) == 1 - assert result[0][0] == "legacy-pack" - # Priority defaults to 10 and is normalized in returned metadata - assert result[0][1]["priority"] == 10 - - def test_legacy_preset_in_list_installed(self, project_dir, pack_dir): - """list_installed returns priority=10 for legacy presets without priority field.""" - manager = PresetManager(project_dir) - - # Install preset normally - manager.install_from_directory(pack_dir, "0.1.5") - - # Manually remove priority to simulate legacy preset - pack_data = manager.registry.data["presets"]["test-pack"] - del pack_data["priority"] - manager.registry._save() - - # list_installed should still return priority=10 - installed = manager.list_installed() - assert len(installed) == 1 - assert installed[0]["priority"] == 10 - - def test_mixed_legacy_and_new_presets_ordering(self, temp_dir): - """Legacy presets (no priority) sort with default=10 among prioritized presets.""" - presets_dir = temp_dir / ".specify" / "presets" - presets_dir.mkdir(parents=True) - - registry = PresetRegistry(presets_dir) - - # Add preset with explicit priority=5 - registry.add("pack-with-priority", {"version": "1.0.0", "priority": 5}) - - # Add legacy preset without priority (manually) - registry.data["presets"]["legacy-pack"] = { - "version": "1.0.0", - "source": "local", - "enabled": True, - # No priority field - } - - # Add another preset with priority=15 - registry.add("low-priority-pack", {"version": "1.0.0", "priority": 15}) - registry._save() - - # Reload and check ordering - registry2 = PresetRegistry(presets_dir) - sorted_presets = registry2.list_by_priority() - - # Should be: pack-with-priority (5), legacy-pack (default 10), low-priority-pack (15) - assert [p[0] for p in sorted_presets] == [ - "pack-with-priority", - "legacy-pack", - "low-priority-pack", - ] - - -class TestPresetEnableDisable: - """Test preset enable/disable CLI commands.""" - - - - - - - - - - def test_disabled_preset_excluded_from_resolution(self, project_dir, pack_dir): - """Test that disabled presets are excluded from template resolution.""" - # Install preset with a template - manager = PresetManager(project_dir) - manager.install_from_directory(pack_dir, "0.1.5") - - # Create a template in the preset directory - preset_template = project_dir / ".specify" / "presets" / "test-pack" / "templates" / "test-template.md" - preset_template.parent.mkdir(parents=True, exist_ok=True) - preset_template.write_text("# Template from test-pack") - - resolver = PresetResolver(project_dir) - - # Template should be found when enabled - result = resolver.resolve("test-template", "template") - assert result is not None - assert "test-pack" in str(result) - - # Disable the preset - manager.registry.update("test-pack", {"enabled": False}) - - # Template should NOT be found when disabled - resolver2 = PresetResolver(project_dir) - result2 = resolver2.resolve("test-template", "template") - assert result2 is None - - - - -# ===== Lean Preset Tests ===== - - -LEAN_PRESET_DIR = Path(__file__).parent.parent / "presets" / "lean" -CORE_CONSTITUTION_COMMAND = ( - Path(__file__).parent.parent / "templates" / "commands" / "constitution.md" -) - -LEAN_COMMAND_NAMES = [ - "speckit.specify", - "speckit.plan", - "speckit.tasks", - "speckit.implement", - "speckit.constitution", -] - - -@pytest.mark.parametrize( - "command_path", - [ - CORE_CONSTITUTION_COMMAND, - LEAN_PRESET_DIR / "commands" / "speckit.constitution.md", - ], - ids=["core", "lean"], -) -def test_constitution_commands_guard_against_non_governance_work(command_path): - """Constitution commands defer non-governance work instead of executing it.""" - content = command_path.read_text() - lower_content = content.lower() - normalized_content = " ".join(lower_content.split()) - - assert "## Scope Guard" in content - assert "**MUST NOT**" in content - assert "Classify every part" in content - assert "application source files" in content - assert "non-governance intent" in content - assert "`Next Actions`" in content - assert "__SPECKIT_COMMAND_SPECIFY__" in content - assert "omit" in lower_content - assert "do not invoke it" in normalized_content or "without invoking it" in normalized_content - - -def test_core_constitution_command_resolves_template_at_runtime(): - """The core command must consume the composed scaffold on every invocation.""" - content = CORE_CONSTITUTION_COMMAND.read_text() - - assert "resolve-template.sh constitution-template --json" in content - assert "resolve-template.ps1 constitution-template -Json" in content - assert "resolve_template.py constitution-template --json" in content - assert "parse `TEMPLATE_CONTENT` as the active template" in content - assert "do not continue with only one contributing" in content - assert "Do not write back to any versioned template layer" in content - - -def test_core_checklist_command_resolves_template_at_runtime(): - """The checklist command must consume the composed scaffold.""" - content = (CORE_CONSTITUTION_COMMAND.parent / "checklist.md").read_text( - encoding="utf-8" - ) - - assert "--template checklist-template" in content - assert "TEMPLATE_CONTENT" in content - assert "Use TEMPLATE_CONTENT as the structural template" in content - - -class TestLeanPreset: - """Tests for the lean preset that ships with the repo.""" - - def test_lean_preset_exists(self): - """Verify the lean preset directory and manifest exist.""" - assert LEAN_PRESET_DIR.exists() - assert (LEAN_PRESET_DIR / "preset.yml").exists() - - def test_lean_manifest_valid(self): - """Verify the lean preset manifest is valid.""" - manifest = PresetManifest(LEAN_PRESET_DIR / "preset.yml") - assert manifest.id == "lean" - assert manifest.name == "Lean Workflow" - assert manifest.version == "1.0.0" - assert len(manifest.templates) == 5 # 5 commands - - def test_lean_provides_core_workflow_commands(self): - """Verify the lean preset provides overrides for core workflow commands.""" - manifest = PresetManifest(LEAN_PRESET_DIR / "preset.yml") - provided_names = {t["name"] for t in manifest.templates} - for name in LEAN_COMMAND_NAMES: - assert name in provided_names, f"Lean preset missing command: {name}" - - def test_lean_command_files_exist(self): - """Verify that all declared command files actually exist on disk.""" - manifest = PresetManifest(LEAN_PRESET_DIR / "preset.yml") - for tmpl in manifest.templates: - tmpl_path = LEAN_PRESET_DIR / tmpl["file"] - assert tmpl_path.exists(), f"Missing command file: {tmpl['file']}" - - def test_lean_commands_have_no_scripts(self): - """Verify lean commands have no scripts in frontmatter.""" - from specify_cli.agents import CommandRegistrar - - for name in LEAN_COMMAND_NAMES: - cmd_path = LEAN_PRESET_DIR / "commands" / f"speckit.{name.split('.')[-1]}.md" - content = cmd_path.read_text() - frontmatter, _ = CommandRegistrar.parse_frontmatter(content) - assert "scripts" not in frontmatter, f"{name} should not have scripts in frontmatter" - - def test_lean_commands_have_no_hooks(self): - """Verify lean commands do not contain extension hook boilerplate.""" - for name in LEAN_COMMAND_NAMES: - cmd_path = LEAN_PRESET_DIR / "commands" / f"speckit.{name.split('.')[-1]}.md" - content = cmd_path.read_text() - assert "hooks." not in content, f"{name} should not reference extension hooks" - assert "extensions.yml" not in content, f"{name} should not reference extensions.yml" - - def test_install_lean_preset(self, project_dir): - """Test installing the lean preset from its directory.""" - manager = PresetManager(project_dir) - manifest = manager.install_from_directory(LEAN_PRESET_DIR, "0.6.0") - assert manifest.id == "lean" - assert manager.registry.is_installed("lean") - - def test_lean_overrides_commands(self, project_dir): - """Test that lean preset overrides are resolved correctly.""" - manager = PresetManager(project_dir) - manager.install_from_directory(LEAN_PRESET_DIR, "0.6.0") - - resolver = PresetResolver(project_dir) - for name in LEAN_COMMAND_NAMES: - result = resolver.resolve(name, template_type="command") - assert result is not None, f"Lean override for {name} not resolved" - - -# ===== Bundled Preset Locator Tests ===== - - -class TestBundledPresetLocator: - """Tests for _locate_bundled_preset discovery function.""" - - def test_locate_bundled_lean_preset(self): - """_locate_bundled_preset finds the lean preset.""" - from specify_cli import _locate_bundled_preset - - path = _locate_bundled_preset("lean") - assert path is not None - assert (path / "preset.yml").is_file() - - def test_locate_bundled_preset_not_found(self): - """_locate_bundled_preset returns None for nonexistent preset.""" - from specify_cli import _locate_bundled_preset - - path = _locate_bundled_preset("nonexistent-preset") - assert path is None - - def test_locate_bundled_preset_rejects_invalid_id(self): - """_locate_bundled_preset rejects IDs with invalid characters.""" - from specify_cli import _locate_bundled_preset - - assert _locate_bundled_preset("../escape") is None - assert _locate_bundled_preset("UPPERCASE") is None - assert _locate_bundled_preset("has spaces") is None - - - - - - - - - - - - - - def test_bundled_preset_in_catalog(self): - """Verify the lean preset is listed in catalog.json with bundled marker.""" - catalog_path = Path(__file__).parent.parent / "presets" / "catalog.json" - catalog = json.loads(catalog_path.read_text()) - assert "lean" in catalog["presets"] - assert catalog["presets"]["lean"]["bundled"] is True - assert "download_url" not in catalog["presets"]["lean"] - - def test_bundled_preset_download_raises_error(self, project_dir): - """download_pack raises PresetError for bundled presets without download_url.""" - catalog = PresetCatalog(project_dir) - - catalog_data = { - "test-bundled": { - "name": "Test Bundled", - "version": "1.0.0", - "bundled": True, - } - } - from unittest.mock import patch - with patch.object(catalog, "_get_merged_packs", return_value=catalog_data): - with pytest.raises(PresetError, match="bundled with spec-kit"): - catalog.download_pack("test-bundled") - - - - - -class TestWrapStrategy: - """Tests for strategy: wrap preset command substitution.""" - - def test_substitute_core_template_replaces_placeholder(self, project_dir): - """Core template body replaces {CORE_TEMPLATE} in preset command body.""" - from specify_cli.presets import _substitute_core_template - from specify_cli.agents import CommandRegistrar - - # Set up a core command template - core_dir = project_dir / ".specify" / "templates" / "commands" - core_dir.mkdir(parents=True, exist_ok=True) - (core_dir / "specify.md").write_text( - "---\ndescription: core\n---\n\n# Core Specify\n\nDo the thing.\n" - ) - - registrar = CommandRegistrar() - body = "## Pre-Logic\n\nBefore stuff.\n\n{CORE_TEMPLATE}\n\n## Post-Logic\n\nAfter stuff.\n" - result, core_fm = _substitute_core_template(body, "specify", project_dir, registrar) - - assert "{CORE_TEMPLATE}" not in result - assert "# Core Specify" in result - assert "## Pre-Logic" in result - assert "## Post-Logic" in result - assert core_fm.get("description") == "core" - - def test_substitute_core_template_no_op_when_placeholder_absent(self, project_dir): - """Returns body unchanged when {CORE_TEMPLATE} is not present.""" - from specify_cli.presets import _substitute_core_template - from specify_cli.agents import CommandRegistrar - - core_dir = project_dir / ".specify" / "templates" / "commands" - core_dir.mkdir(parents=True, exist_ok=True) - (core_dir / "specify.md").write_text("---\ndescription: core\n---\n\nCore body.\n") - - registrar = CommandRegistrar() - body = "## No placeholder here.\n" - result, core_fm = _substitute_core_template(body, "specify", project_dir, registrar) - assert result == body - assert core_fm == {} + @pytest.mark.parametrize( + "value", + ["名前-プロジェクト", "café-résumé", "Ωmega-Δelta", "🚀-launch"], + ) + def test_save_load_round_trip_preserves_non_ascii(self, project_dir, value): + """Non-ASCII values round-trip via explicit UTF-8 encoding. - def test_substitute_core_template_no_op_when_core_missing(self, project_dir): - """Returns body unchanged when core template file does not exist.""" - from specify_cli.presets import _substitute_core_template - from specify_cli.agents import CommandRegistrar + ``Path.write_text`` / ``Path.read_text`` default to the system + locale codec on Windows (cp1252 / gb2312 / cp932). Without + ``encoding="utf-8"`` pinned on both ends, a project name like + ``café`` written on a UTF-8 host becomes garbled or unreadable on + a cp1252 host (and vice versa). Pin UTF-8 explicitly so init + options round-trip across machines and CI. - registrar = CommandRegistrar() - body = "Pre.\n\n{CORE_TEMPLATE}\n\nPost.\n" - result, core_fm = _substitute_core_template(body, "nonexistent", project_dir, registrar) - assert result == body - assert "{CORE_TEMPLATE}" in result - assert core_fm == {} - - def test_substitute_core_template_unreadable_core_treated_as_missing( - self, project_dir - ): - """An undecodable core template must not crash substitution. - - The wrap-strategy callers (``CommandRegistrar.register_pack`` and - ``_register_commands``) skip an unreadable preset source with a - warning, but the core template read inside - ``_substitute_core_template`` had no boundary, so one corrupted - project-owned override in ``.specify/templates/commands/`` crashed - the whole registration with a raw ``UnicodeDecodeError``. An - unreadable core is treated like a missing one. + Note: this test only meaningfully exercises the encoding pin + because ``save_init_options`` now writes JSON with + ``ensure_ascii=False`` — otherwise ``json.dumps`` would output + ASCII-only ``\\uXXXX`` escapes and the encoding pin would be a + no-op for any value here. ``test_save_writes_real_utf8_bytes`` + below asserts that contract directly. """ - from specify_cli.presets import _substitute_core_template - from specify_cli.agents import CommandRegistrar - - core_dir = project_dir / ".specify" / "templates" / "commands" - core_dir.mkdir(parents=True, exist_ok=True) - (core_dir / "specify.md").write_bytes(b"\xff\xfe not utf-8") - - registrar = CommandRegistrar() - body = "Pre.\n\n{CORE_TEMPLATE}\n\nPost.\n" - with pytest.warns(UserWarning, match="Ignoring core template"): - result, core_fm = _substitute_core_template( - body, "specify", project_dir, registrar - ) - assert result == body - assert core_fm == {} - - def test_register_commands_substitutes_core_template_for_wrap_strategy(self, project_dir): - """register_commands substitutes {CORE_TEMPLATE} when strategy: wrap.""" - from specify_cli.agents import CommandRegistrar - - # Set up core command template - core_dir = project_dir / ".specify" / "templates" / "commands" - core_dir.mkdir(parents=True, exist_ok=True) - (core_dir / "specify.md").write_text( - "---\ndescription: core\n---\n\n# Core Specify\n\nCore body here.\n" - ) - - # Create a preset command dir with a wrap-strategy command - cmd_dir = project_dir / "preset" / "commands" - cmd_dir.mkdir(parents=True, exist_ok=True) - (cmd_dir / "speckit.specify.md").write_text( - "---\ndescription: wrap test\nstrategy: wrap\n---\n\n" - "## Pre\n\n{CORE_TEMPLATE}\n\n## Post\n" - ) - - commands = [{"name": "speckit.specify", "file": "commands/speckit.specify.md"}] - registrar = CommandRegistrar() - - # Use a generic agent that writes markdown to commands/ - agent_dir = project_dir / ".claude" / "commands" - agent_dir.mkdir(parents=True, exist_ok=True) - - # Patch AGENT_CONFIGS to use a simple markdown agent pointing at our dir - import copy - original = copy.deepcopy(registrar.AGENT_CONFIGS) - registrar.AGENT_CONFIGS["test-agent"] = { - "dir": str(agent_dir.relative_to(project_dir)), - "format": "markdown", - "args": "$ARGUMENTS", - "extension": ".md", - "strip_frontmatter_keys": [], - } - try: - registrar.register_commands( - "test-agent", commands, "test-preset", - project_dir / "preset", project_dir - ) - finally: - CommandRegistrar.AGENT_CONFIGS.clear() - CommandRegistrar.AGENT_CONFIGS.update(original) - - written = (agent_dir / "speckit.specify.md").read_text() - assert "{CORE_TEMPLATE}" not in written - assert "# Core Specify" in written - assert "## Pre" in written - assert "## Post" in written - - def test_end_to_end_wrap_via_self_test_preset(self, project_dir): - """Installing self-test preset with a wrap command substitutes {CORE_TEMPLATE}.""" - from specify_cli.presets import PresetManager - - # Install a core template that wrap-test will wrap around - core_dir = project_dir / ".specify" / "templates" / "commands" - core_dir.mkdir(parents=True, exist_ok=True) - (core_dir / "wrap-test.md").write_text( - "---\ndescription: core wrap-test\n---\n\n# Core Wrap-Test Body\n" - ) - - # Set up skills dir (simulating --integration claude) - skills_dir = project_dir / ".claude" / "skills" - skills_dir.mkdir(parents=True, exist_ok=True) - skill_subdir = skills_dir / "speckit-wrap-test" - skill_subdir.mkdir() - (skill_subdir / "SKILL.md").write_text("---\nname: speckit-wrap-test\n---\n\nold content\n") - - # Write init-options so _register_skills finds the claude skills dir - import json - (project_dir / ".specify" / "init-options.json").write_text( - json.dumps({"ai": "claude", "ai_skills": True}) - ) - - manager = PresetManager(project_dir) - install_self_test_preset(manager) - - written = (skill_subdir / "SKILL.md").read_text() - assert "{CORE_TEMPLATE}" not in written - assert "# Core Wrap-Test Body" in written - assert "preset:self-test wrap-pre" in written - assert "preset:self-test wrap-post" in written - - def test_substitute_core_template_returns_core_scripts(self, project_dir): - """core_frontmatter in the returned tuple includes scripts/agent_scripts.""" - from specify_cli.presets import _substitute_core_template - from specify_cli.agents import CommandRegistrar - - core_dir = project_dir / ".specify" / "templates" / "commands" - core_dir.mkdir(parents=True, exist_ok=True) - (core_dir / "specify.md").write_text( - "---\ndescription: core\nscripts:\n sh: run.sh\nagent_scripts:\n sh: agent-run.sh\n---\n\n# Body\n" - ) - - registrar = CommandRegistrar() - body = "## Wrapper\n\n{CORE_TEMPLATE}\n" - result, core_fm = _substitute_core_template(body, "specify", project_dir, registrar) - - assert "# Body" in result - assert core_fm.get("scripts") == {"sh": "run.sh"} - assert core_fm.get("agent_scripts") == {"sh": "agent-run.sh"} - - def test_register_skills_inherits_scripts_from_core_when_preset_omits_them(self, project_dir): - """_register_skills merges scripts/agent_scripts from core when preset lacks them.""" - from specify_cli.presets import PresetManager - import json - - # Core template with scripts - core_dir = project_dir / ".specify" / "templates" / "commands" - core_dir.mkdir(parents=True, exist_ok=True) - (core_dir / "wrap-test.md").write_text( - "---\ndescription: core\nscripts:\n sh: .specify/scripts/run.sh\n---\n\n" - "Run: {SCRIPT}\n" - ) - - # Skills dir for claude - skills_dir = project_dir / ".claude" / "skills" - skills_dir.mkdir(parents=True, exist_ok=True) - skill_subdir = skills_dir / "speckit-wrap-test" - skill_subdir.mkdir() - (skill_subdir / "SKILL.md").write_text("---\nname: speckit-wrap-test\n---\n\nold\n") - - (project_dir / ".specify" / "init-options.json").write_text( - json.dumps({"ai": "claude", "ai_skills": True}) - ) - - manager = PresetManager(project_dir) - install_self_test_preset(manager) - - written = (skill_subdir / "SKILL.md").read_text() - # {SCRIPT} should have been resolved (not left as a literal placeholder) - assert "{SCRIPT}" not in written - - def test_register_skills_preset_scripts_take_precedence_over_core(self, project_dir): - """preset-defined scripts/agent_scripts are not overwritten by core frontmatter.""" - from specify_cli.presets import _substitute_core_template - from specify_cli.agents import CommandRegistrar - - core_dir = project_dir / ".specify" / "templates" / "commands" - core_dir.mkdir(parents=True, exist_ok=True) - (core_dir / "specify.md").write_text( - "---\ndescription: core\nscripts:\n sh: core-run.sh\n---\n\nCore body.\n" - ) - - registrar = CommandRegistrar() - body = "{CORE_TEMPLATE}" - _, core_fm = _substitute_core_template(body, "specify", project_dir, registrar) - - # Simulate preset frontmatter that already defines scripts - preset_fm = {"description": "preset", "strategy": "wrap", "scripts": {"sh": "preset-run.sh"}} - for key in ("scripts", "agent_scripts"): - if key not in preset_fm and key in core_fm: - preset_fm[key] = core_fm[key] - - # Preset's scripts must not be overwritten by core - assert preset_fm["scripts"] == {"sh": "preset-run.sh"} - - def test_register_commands_inherits_scripts_from_core(self, project_dir): - """register_commands merges scripts/agent_scripts from core and normalizes paths.""" - from specify_cli.agents import CommandRegistrar - import copy - - core_dir = project_dir / ".specify" / "templates" / "commands" - core_dir.mkdir(parents=True, exist_ok=True) - (core_dir / "specify.md").write_text( - "---\ndescription: core\nscripts:\n sh: .specify/scripts/run.sh {ARGS}\n---\n\n" - "Run: {SCRIPT}\n" - ) - - cmd_dir = project_dir / "preset" / "commands" - cmd_dir.mkdir(parents=True, exist_ok=True) - # Preset has strategy: wrap but no scripts of its own - (cmd_dir / "speckit.specify.md").write_text( - "---\ndescription: wrap no scripts\nstrategy: wrap\n---\n\n" - "## Pre\n\n{CORE_TEMPLATE}\n\n## Post\n" - ) - - agent_dir = project_dir / ".claude" / "commands" - agent_dir.mkdir(parents=True, exist_ok=True) - - registrar = CommandRegistrar() - original = copy.deepcopy(registrar.AGENT_CONFIGS) - registrar.AGENT_CONFIGS["test-agent"] = { - "dir": str(agent_dir.relative_to(project_dir)), - "format": "markdown", - "args": "$ARGUMENTS", - "extension": ".md", - "strip_frontmatter_keys": [], - } - try: - registrar.register_commands( - "test-agent", - [{"name": "speckit.specify", "file": "commands/speckit.specify.md"}], - "test-preset", - project_dir / "preset", - project_dir, - ) - finally: - CommandRegistrar.AGENT_CONFIGS.clear() - CommandRegistrar.AGENT_CONFIGS.update(original) - - written = (agent_dir / "speckit.specify.md").read_text() - assert "{CORE_TEMPLATE}" not in written - assert "Run:" in written - assert "scripts:" in written - assert "run.sh" in written - - def test_register_commands_toml_resolves_inherited_scripts(self, project_dir): - """TOML agents resolve {SCRIPT} from inherited core scripts when preset omits them.""" - from specify_cli.agents import CommandRegistrar - import copy - - core_dir = project_dir / ".specify" / "templates" / "commands" - core_dir.mkdir(parents=True, exist_ok=True) - (core_dir / "specify.md").write_text( - "---\ndescription: core\nscripts:\n sh: .specify/scripts/run.sh {ARGS}\n---\n\n" - "Run: {SCRIPT}\n" - ) - - cmd_dir = project_dir / "preset" / "commands" - cmd_dir.mkdir(parents=True, exist_ok=True) - (cmd_dir / "speckit.specify.md").write_text( - "---\ndescription: toml wrap\nstrategy: wrap\n---\n\n" - "## Pre\n\n{CORE_TEMPLATE}\n\n## Post\n" - ) - - toml_dir = project_dir / ".gemini" / "commands" - toml_dir.mkdir(parents=True, exist_ok=True) - - registrar = CommandRegistrar() - original = copy.deepcopy(registrar.AGENT_CONFIGS) - registrar.AGENT_CONFIGS["test-toml-agent"] = { - "dir": str(toml_dir.relative_to(project_dir)), - "format": "toml", - "args": "{{args}}", - "extension": ".toml", - "strip_frontmatter_keys": [], - } - try: - registrar.register_commands( - "test-toml-agent", - [{"name": "speckit.specify", "file": "commands/speckit.specify.md"}], - "test-preset", - project_dir / "preset", - project_dir, - ) - finally: - CommandRegistrar.AGENT_CONFIGS.clear() - CommandRegistrar.AGENT_CONFIGS.update(original) - - written = (toml_dir / "speckit.specify.toml").read_text() - assert "{CORE_TEMPLATE}" not in written - assert "{SCRIPT}" not in written - assert "run.sh" in written - # args token must use TOML format, not the intermediate $ARGUMENTS - assert "$ARGUMENTS" not in written - assert "{{args}}" in written - - def test_register_commands_markdown_resolves_inherited_scripts(self, project_dir): - """Markdown agents resolve {SCRIPT} from inherited core scripts when preset omits them.""" - from specify_cli.agents import CommandRegistrar - import copy - - core_dir = project_dir / ".specify" / "templates" / "commands" - core_dir.mkdir(parents=True, exist_ok=True) - (core_dir / "specify.md").write_text( - "---\ndescription: core\nscripts:\n sh: .specify/scripts/run.sh {ARGS}\n---\n\n" - "Run: {SCRIPT}\n" - ) + from specify_cli import save_init_options, load_init_options - cmd_dir = project_dir / "preset" / "commands" - cmd_dir.mkdir(parents=True, exist_ok=True) - (cmd_dir / "speckit.specify.md").write_text( - "---\ndescription: markdown wrap\nstrategy: wrap\n---\n\n" - "## Pre\n\n{CORE_TEMPLATE}\n\n## Post\n" - ) + save_init_options(project_dir, {"ai": "claude", "project_name": value}) - agent_dir = project_dir / ".claude" / "commands" - agent_dir.mkdir(parents=True, exist_ok=True) - - registrar = CommandRegistrar() - original = copy.deepcopy(registrar.AGENT_CONFIGS) - registrar.AGENT_CONFIGS["test-md-agent"] = { - "dir": str(agent_dir.relative_to(project_dir)), - "format": "markdown", - "args": "$ARGUMENTS", - "extension": ".md", - "strip_frontmatter_keys": [], - } - try: - registrar.register_commands( - "test-md-agent", - [{"name": "speckit.specify", "file": "commands/speckit.specify.md"}], - "test-preset", - project_dir / "preset", - project_dir, - ) - finally: - CommandRegistrar.AGENT_CONFIGS.clear() - CommandRegistrar.AGENT_CONFIGS.update(original) + loaded = load_init_options(project_dir) + assert loaded["project_name"] == value - written = (agent_dir / "speckit.specify.md").read_text() - assert "{CORE_TEMPLATE}" not in written - assert "{SCRIPT}" not in written - assert "run.sh" in written - assert "strategy" not in written + def test_save_writes_real_utf8_bytes(self, project_dir): + """The on-disk file contains real UTF-8 bytes, not ``\\uXXXX`` escapes. - def test_register_commands_markdown_converts_args_after_script_resolution(self, project_dir): - """Markdown agents re-run arg placeholder conversion after resolve_skill_placeholders. + Pinning ``encoding="utf-8"`` on ``write_text`` only makes a + difference when the serialiser actually emits non-ASCII + characters. With ``ensure_ascii=False`` on ``json.dumps`` the + non-ASCII bytes hit the file, so the encoding pin is the thing + that decides between cp1252 garbage and clean UTF-8 on Windows. - resolve_skill_placeholders injects $ARGUMENTS (via {ARGS} expansion). A second - _convert_argument_placeholder call must convert those to the agent's native format. + This test pins that behaviour: the on-disk bytes are valid UTF-8 + and contain the multi-byte encoding of ``café``, not its + ``\\u00e9`` escape form. Reviewers can verify that removing + ``ensure_ascii=False`` or ``encoding="utf-8"`` from the writer + breaks this test, which is what Copilot's review pointed out the + original round-trip test failed to do. """ - from specify_cli.agents import CommandRegistrar - import copy - - core_dir = project_dir / ".specify" / "templates" / "commands" - core_dir.mkdir(parents=True, exist_ok=True) - (core_dir / "specify.md").write_text( - "---\ndescription: core\nscripts:\n sh: .specify/scripts/run.sh {ARGS}\n---\n\n" - "Run: {SCRIPT}\n" - ) - - cmd_dir = project_dir / "preset" / "commands" - cmd_dir.mkdir(parents=True, exist_ok=True) - (cmd_dir / "speckit.specify.md").write_text( - "---\ndescription: forge wrap\nstrategy: wrap\n---\n\n" - "## Pre\n\n{CORE_TEMPLATE}\n\n## Post\n" - ) + from specify_cli import save_init_options - agent_dir = project_dir / ".forge" / "commands" - agent_dir.mkdir(parents=True, exist_ok=True) - - registrar = CommandRegistrar() - original = copy.deepcopy(registrar.AGENT_CONFIGS) - registrar.AGENT_CONFIGS["test-forge-agent"] = { - "dir": str(agent_dir.relative_to(project_dir)), - "format": "markdown", - "args": "{{parameters}}", - "extension": ".md", - "strip_frontmatter_keys": [], - } - try: - registrar.register_commands( - "test-forge-agent", - [{"name": "speckit.specify", "file": "commands/speckit.specify.md"}], - "test-preset", - project_dir / "preset", - project_dir, - ) - finally: - CommandRegistrar.AGENT_CONFIGS.clear() - CommandRegistrar.AGENT_CONFIGS.update(original) - - written = (agent_dir / "speckit.specify.md").read_text() - assert "{SCRIPT}" not in written - assert "run.sh" in written - # $ARGUMENTS injected by resolve_skill_placeholders must be re-converted - assert "$ARGUMENTS" not in written - assert "{{parameters}}" in written - - def test_extension_command_resolves_via_extension_directory(self, project_dir): - """Extension commands (e.g. speckit.git.feature) resolve from the extension directory. - - Both _register_skills and register_commands pass the full cmd_name to - _substitute_core_template, which tries the full name first via PresetResolver - and finds speckit.git.feature.md in the extension commands directory. - """ - from specify_cli.presets import _substitute_core_template - from specify_cli.agents import CommandRegistrar + save_init_options(project_dir, {"project_name": "café"}) - # Place the template where a real extension would install it - ext_cmd_dir = project_dir / ".specify" / "extensions" / "git" / "commands" - ext_cmd_dir.mkdir(parents=True, exist_ok=True) - (ext_cmd_dir / "speckit.git.feature.md").write_text( - "---\ndescription: git feature core\n---\n\n# Git Feature Core\n" + opts_file = project_dir / ".specify" / "init-options.json" + raw = opts_file.read_bytes() + # 'café' in UTF-8 ends with bytes 0xC3 0xA9 ('é'). The cp1252 + # encoding of 'é' is the single byte 0xE9. The JSON-escape form + # would be the 6-byte literal '\\u00e9'. We assert the UTF-8 form + # is present so the test pins the actual contract. + assert b"caf\xc3\xa9" in raw, ( + "Expected UTF-8 bytes for 'café' in the on-disk file, " + f"got: {raw!r}" ) - # Ensure a hyphenated or dot-separated fallback does NOT exist - assert not (project_dir / ".specify" / "templates" / "commands" / "git.feature.md").exists() - assert not (project_dir / ".specify" / "templates" / "commands" / "git-feature.md").exists() - - registrar = CommandRegistrar() - body = "## Wrapper\n\n{CORE_TEMPLATE}\n" - - # Both call sites now pass the full cmd_name - result, _ = _substitute_core_template(body, "speckit.git.feature", project_dir, registrar) - - assert "# Git Feature Core" in result - assert "{CORE_TEMPLATE}" not in result + # And the whole file decodes cleanly as UTF-8. + raw.decode("utf-8") - def test_extension_command_resolves_via_manifest_when_filename_differs(self, project_dir): - """Extension commands whose filename differs from the command name resolve via extension.yml. + def test_load_returns_empty_on_locale_corrupted_file(self, project_dir): + """A file written in a non-UTF-8 codec falls back to {}, not crash. - The selftest extension maps speckit.selftest.extension → commands/selftest.md. - Name-based lookup would look for commands/speckit.selftest.extension.md and fail; - manifest-based lookup must find the actual file declared in the manifest. + Simulates a file produced by an old client (or by a peer machine + with a different default locale) that contains bytes invalid as + UTF-8. ``load_init_options`` should fall back to ``{}`` per the + existing contract — never propagate a raw ``UnicodeDecodeError`` + to the CLI surface. """ - from specify_cli.presets import _substitute_core_template - from specify_cli.agents import CommandRegistrar - - ext_dir = project_dir / ".specify" / "extensions" / "selftest" - cmd_dir = ext_dir / "commands" - cmd_dir.mkdir(parents=True, exist_ok=True) - - # File is named selftest.md, NOT speckit.selftest.extension.md - (cmd_dir / "selftest.md").write_text( - "---\ndescription: selftest core\n---\n\n# Selftest Core\n" - ) - # Manifest maps the command name to the actual file - (ext_dir / "extension.yml").write_text( - "schema_version: '1.0'\n" - "extension:\n id: selftest\n name: Self-Test\n version: 1.0.0\n" - " description: test\n author: test\n repository: https://example.com\n" - " license: MIT\n" - "requires:\n speckit_version: '>=0.2.0'\n" - "provides:\n" - " commands:\n" - " - name: speckit.selftest.extension\n" - " file: commands/selftest.md\n" - " description: Selftest command\n" - ) - - registrar = CommandRegistrar() - body = "## Wrapper\n\n{CORE_TEMPLATE}\n" - result, _ = _substitute_core_template(body, "speckit.selftest.extension", project_dir, registrar) + from specify_cli import load_init_options - assert "# Selftest Core" in result - assert "{CORE_TEMPLATE}" not in result + opts_file = project_dir / ".specify" / "init-options.json" + opts_file.parent.mkdir(parents=True, exist_ok=True) + # 0xE9 is 'é' in cp1252 but an invalid lead byte in UTF-8. + opts_file.write_bytes(b'{"project_name": "caf\xe9"}') - def test_extension_template_resolves_via_manifest_when_filename_differs(self, project_dir): - """provides.templates entries resolve via extension.yml when the file - doesn't sit at the conventional path. + assert load_init_options(project_dir) == {} - Regression coverage for #4010: manifest-declared templates/scripts - must actually be consulted by the resolver, not just accepted by - manifest validation. - """ - ext_dir = project_dir / ".specify" / "extensions" / "reportext" - tmpl_dir = ext_dir / "templates" / "nested" - tmpl_dir.mkdir(parents=True, exist_ok=True) - - # File lives at a path convention-based lookup (templates/.md) - # would never find. - (tmpl_dir / "actual.md").write_text("# Report Scaffold\n") - (ext_dir / "extension.yml").write_text( - "schema_version: '1.0'\n" - "extension:\n id: reportext\n name: Report Ext\n version: 1.0.0\n" - " description: test\n author: test\n repository: https://example.com\n" - " license: MIT\n" - "requires:\n speckit_version: '>=0.2.0'\n" - "provides:\n" - " templates:\n" - " - name: report-scaffold\n" - " file: templates/nested/actual.md\n" - " description: Report scaffold\n" - ) + @pytest.mark.parametrize("payload", ["[]", '"value"', "42", "true", "null"]) + def test_load_returns_empty_on_non_object_json(self, project_dir, payload): + from specify_cli import load_init_options - resolver = PresetResolver(project_dir) - layers = resolver.collect_all_layers("report-scaffold", "template") - assert layers, "expected the manifest-declared template to resolve" - assert layers[0]["path"] == tmpl_dir / "actual.md" - assert layers[0]["strategy"] == "replace" - - def test_extension_script_resolves_via_manifest_when_filename_differs(self, project_dir): - """provides.scripts entries resolve via extension.yml when the file - doesn't sit at the conventional path.""" - ext_dir = project_dir / ".specify" / "extensions" / "collectext" - script_dir = ext_dir / "scripts" / "bash" - script_dir.mkdir(parents=True, exist_ok=True) - - # File is under scripts/bash/, not directly under scripts/, so - # convention-based lookup (scripts/.sh) would never find it. - (script_dir / "collect.sh").write_text("#!/usr/bin/env bash\necho collect\n") - (ext_dir / "extension.yml").write_text( - "schema_version: '1.0'\n" - "extension:\n id: collectext\n name: Collect Ext\n version: 1.0.0\n" - " description: test\n author: test\n repository: https://example.com\n" - " license: MIT\n" - "requires:\n speckit_version: '>=0.2.0'\n" - "provides:\n" - " scripts:\n" - " - name: myext-collect\n" - " file: scripts/bash/collect.sh\n" - " description: Data-collection helper\n" - " runtimes: [bash]\n" - ) + opts_file = project_dir / ".specify" / "init-options.json" + opts_file.parent.mkdir(parents=True, exist_ok=True) + opts_file.write_text(payload, encoding="utf-8") - resolver = PresetResolver(project_dir) - layers = resolver.collect_all_layers("myext-collect", "script") - assert layers, "expected the manifest-declared script to resolve" - assert layers[0]["path"] == script_dir / "collect.sh" - assert layers[0]["strategy"] == "replace" - - def test_extension_template_convention_lookup_unaffected_when_undeclared(self, project_dir): - """An extension template with no manifest entry still resolves via - the pre-existing filename convention (no regression).""" - ext_dir = project_dir / ".specify" / "extensions" / "conventionext" - tmpl_dir = ext_dir / "templates" - tmpl_dir.mkdir(parents=True, exist_ok=True) - (tmpl_dir / "legacy-template.md").write_text("# Legacy Template\n") - # No extension.yml at all -- purely convention-based, unregistered extension. + assert load_init_options(project_dir) == {} - resolver = PresetResolver(project_dir) - layers = resolver.collect_all_layers("legacy-template", "template") - assert layers, "expected convention-based lookup to still find the template" - assert layers[0]["path"] == tmpl_dir / "legacy-template.md" - - def test_extension_manifest_wins_over_stale_conventional_file(self, project_dir): - """A declared entry is authoritative even when a stale file also sits at - the conventional path (templates/.md) — the manifest must win, - not the convention lookup, per #4010's acceptance criteria.""" - ext_dir = project_dir / ".specify" / "extensions" / "bothpathsext" - (ext_dir / "templates").mkdir(parents=True, exist_ok=True) - (ext_dir / "custom").mkdir(parents=True, exist_ok=True) - - # Stale file at the conventional path -- must NOT win. - (ext_dir / "templates" / "report-scaffold.md").write_text("# Stale\n") - # Declared file at a non-conventional path -- must win. - (ext_dir / "custom" / "bar.md").write_text("# Actual\n") - (ext_dir / "extension.yml").write_text( - "schema_version: '1.0'\n" - "extension:\n id: bothpathsext\n name: Both Paths Ext\n version: 1.0.0\n" - " description: test\n author: test\n repository: https://example.com\n" - " license: MIT\n" - "requires:\n speckit_version: '>=0.2.0'\n" - "provides:\n" - " templates:\n" - " - name: report-scaffold\n" - " file: custom/bar.md\n" - " description: Report scaffold\n" - ) + def test_load_returns_empty_on_unicode_decode_error(self, project_dir, monkeypatch): + from specify_cli import load_init_options - resolver = PresetResolver(project_dir) + opts_file = project_dir / ".specify" / "init-options.json" + opts_file.parent.mkdir(parents=True, exist_ok=True) + opts_file.write_bytes(b"{}") - layers = resolver.collect_all_layers("report-scaffold", "template") - assert layers, "expected the manifest-declared template to resolve" - assert layers[0]["path"] == ext_dir / "custom" / "bar.md" - - resolved = resolver.resolve("report-scaffold", "template") - assert resolved == ext_dir / "custom" / "bar.md" - - with_source = resolver.resolve_with_source("report-scaffold", "template") - assert with_source["path"] == str(ext_dir / "custom" / "bar.md") - - def test_extension_manifest_declared_but_missing_file_does_not_fall_back(self, project_dir): - """A declared entry whose file is missing is authoritative -- the - resolver must not silently mask the typo by falling back to a - conventional file that happens to also exist.""" - ext_dir = project_dir / ".specify" / "extensions" / "missingfileext" - (ext_dir / "scripts").mkdir(parents=True, exist_ok=True) - - # A conventional file exists, but the manifest declares a different, - # non-existent file for the same name. - (ext_dir / "scripts" / "myext-collect.sh").write_text("#!/usr/bin/env bash\necho legacy\n") - (ext_dir / "extension.yml").write_text( - "schema_version: '1.0'\n" - "extension:\n id: missingfileext\n name: Missing File Ext\n version: 1.0.0\n" - " description: test\n author: test\n repository: https://example.com\n" - " license: MIT\n" - "requires:\n speckit_version: '>=0.2.0'\n" - "provides:\n" - " scripts:\n" - " - name: myext-collect\n" - " file: scripts/does-not-exist.sh\n" - " description: Data-collection helper\n" - ) + original_read_text = Path.read_text - resolver = PresetResolver(project_dir) + def raise_decode_error(path, *args, **kwargs): + if path == opts_file: + raise UnicodeDecodeError("utf-8", b"\xff", 0, 1, "invalid start byte") + return original_read_text(path, *args, **kwargs) - assert resolver.collect_all_layers("myext-collect", "script") == [] - assert resolver.resolve("myext-collect", "script") is None - - def test_extension_script_resolve_and_resolve_with_source_parity(self, project_dir): - """resolve() and resolve_with_source() must find a manifest-declared - script at a non-conventional path, matching collect_all_layers().""" - ext_dir = project_dir / ".specify" / "extensions" / "collectext2" - script_dir = ext_dir / "scripts" / "bash" - script_dir.mkdir(parents=True, exist_ok=True) - - (script_dir / "collect.sh").write_text("#!/usr/bin/env bash\necho collect\n") - (ext_dir / "extension.yml").write_text( - "schema_version: '1.0'\n" - "extension:\n id: collectext2\n name: Collect Ext 2\n version: 1.0.0\n" - " description: test\n author: test\n repository: https://example.com\n" - " license: MIT\n" - "requires:\n speckit_version: '>=0.2.0'\n" - "provides:\n" - " scripts:\n" - " - name: myext-collect2\n" - " file: scripts/bash/collect.sh\n" - " description: Data-collection helper\n" - " runtimes: [bash]\n" - ) + monkeypatch.setattr(Path, "read_text", raise_decode_error) - resolver = PresetResolver(project_dir) + assert load_init_options(project_dir) == {} - resolved = resolver.resolve("myext-collect2", "script") - assert resolved == script_dir / "collect.sh" - - with_source = resolver.resolve_with_source("myext-collect2", "script") - assert with_source is not None - assert with_source["path"] == str(script_dir / "collect.sh") - assert with_source["source"] == "extension:collectext2 (unregistered)" - - -# ===== _replay_wraps_for_command Tests ===== - -def _make_wrap_preset_dir( - base: Path, - preset_id: str, - cmd_name: str, - pre: str, - post: str, - aliases: list[str] | None = None, - file_rel: str | None = None, -) -> Path: - """Create a minimal wrap-strategy preset directory for testing.""" - preset_dir = base / preset_id - cmd_dir = preset_dir / "commands" - cmd_dir.mkdir(parents=True) - file_rel = file_rel or f"commands/{cmd_name}.md" - template = { - "type": "command", - "name": cmd_name, - "file": file_rel, - "description": f"{preset_id} wrap", - } - if aliases is not None: - template["aliases"] = aliases - manifest = { - "schema_version": "1.0", - "preset": { - "id": preset_id, - "name": preset_id, - "version": "1.0.0", - "description": f"Preset {preset_id}", - "author": "test", - "repository": "https://example.com", - "license": "MIT", - }, - "requires": {"speckit_version": ">=0.1.0"}, - "provides": { - "templates": [template] - }, - "tags": [], - } - import yaml as _yaml - (preset_dir / "preset.yml").write_text(_yaml.dump(manifest)) - command_path = preset_dir / file_rel - command_path.parent.mkdir(parents=True, exist_ok=True) - command_path.write_text( - f"---\ndescription: {preset_id} wrap\nstrategy: wrap\n---\n\n" - f"[{pre}]\n\n{{CORE_TEMPLATE}}\n\n[{post}]\n" + @pytest.mark.parametrize( + ("value", "expected"), + [ + (True, True), + (False, False), + ("true", False), + ("false", False), + (1, False), + (0, False), + (None, False), + ], ) - return preset_dir - - - -class TestCompositionStrategyValidation: - """Test strategy field validation in PresetManifest.""" - - def test_valid_replace_strategy(self, temp_dir, valid_pack_data): - """Test that replace strategy is accepted.""" - valid_pack_data["provides"]["templates"][0]["strategy"] = "replace" - manifest_path = temp_dir / "preset.yml" - with open(manifest_path, 'w') as f: - yaml.dump(valid_pack_data, f) - (temp_dir / "templates").mkdir(exist_ok=True) - (temp_dir / "templates" / "spec-template.md").write_text("test") - manifest = PresetManifest(manifest_path) - assert manifest.templates[0]["strategy"] == "replace" - - def test_valid_prepend_strategy(self, temp_dir, valid_pack_data): - """Test that prepend strategy is accepted for templates.""" - valid_pack_data["provides"]["templates"][0]["strategy"] = "prepend" - manifest_path = temp_dir / "preset.yml" - with open(manifest_path, 'w') as f: - yaml.dump(valid_pack_data, f) - (temp_dir / "templates").mkdir(exist_ok=True) - (temp_dir / "templates" / "spec-template.md").write_text("test") - manifest = PresetManifest(manifest_path) - assert manifest.templates[0]["strategy"] == "prepend" - - def test_valid_append_strategy(self, temp_dir, valid_pack_data): - """Test that append strategy is accepted for templates.""" - valid_pack_data["provides"]["templates"][0]["strategy"] = "append" - manifest_path = temp_dir / "preset.yml" - with open(manifest_path, 'w') as f: - yaml.dump(valid_pack_data, f) - (temp_dir / "templates").mkdir(exist_ok=True) - (temp_dir / "templates" / "spec-template.md").write_text("test") - manifest = PresetManifest(manifest_path) - assert manifest.templates[0]["strategy"] == "append" - - def test_valid_wrap_strategy(self, temp_dir, valid_pack_data): - """Test that wrap strategy is accepted for templates.""" - valid_pack_data["provides"]["templates"][0]["strategy"] = "wrap" - manifest_path = temp_dir / "preset.yml" - with open(manifest_path, 'w') as f: - yaml.dump(valid_pack_data, f) - (temp_dir / "templates").mkdir(exist_ok=True) - (temp_dir / "templates" / "spec-template.md").write_text("test") - manifest = PresetManifest(manifest_path) - assert manifest.templates[0]["strategy"] == "wrap" - - def test_default_strategy_is_replace(self, pack_dir): - """Test that omitting strategy defaults to replace (key is absent).""" - manifest = PresetManifest(pack_dir / "preset.yml") - # Strategy key should not be present in the manifest data - assert "strategy" not in manifest.templates[0] - # But consumers should treat missing strategy as "replace" - assert manifest.templates[0].get("strategy", "replace") == "replace" - - def test_invalid_strategy_rejected(self, temp_dir, valid_pack_data): - """Test that invalid strategy values are rejected.""" - valid_pack_data["provides"]["templates"][0]["strategy"] = "merge" - manifest_path = temp_dir / "preset.yml" - with open(manifest_path, 'w') as f: - yaml.dump(valid_pack_data, f) - with pytest.raises(PresetValidationError, match="Invalid strategy"): - PresetManifest(manifest_path) - - def test_prepend_rejected_for_scripts(self, temp_dir, valid_pack_data): - """Test that prepend strategy is rejected for scripts.""" - valid_pack_data["provides"]["templates"] = [{ - "type": "script", - "name": "create-new-feature", - "file": "scripts/create-new-feature.sh", - "strategy": "prepend", - }] - manifest_path = temp_dir / "preset.yml" - with open(manifest_path, 'w') as f: - yaml.dump(valid_pack_data, f) - with pytest.raises(PresetValidationError, match="Invalid strategy.*for script"): - PresetManifest(manifest_path) - - def test_append_rejected_for_scripts(self, temp_dir, valid_pack_data): - """Test that append strategy is rejected for scripts.""" - valid_pack_data["provides"]["templates"] = [{ - "type": "script", - "name": "create-new-feature", - "file": "scripts/create-new-feature.sh", - "strategy": "append", - }] - manifest_path = temp_dir / "preset.yml" - with open(manifest_path, 'w') as f: - yaml.dump(valid_pack_data, f) - with pytest.raises(PresetValidationError, match="Invalid strategy.*for script"): - PresetManifest(manifest_path) - - def test_wrap_accepted_for_scripts(self, temp_dir, valid_pack_data): - """Test that wrap strategy is accepted for scripts.""" - valid_pack_data["provides"]["templates"] = [{ - "type": "script", - "name": "create-new-feature", - "file": "scripts/create-new-feature.sh", - "strategy": "wrap", - }] - manifest_path = temp_dir / "preset.yml" - with open(manifest_path, 'w') as f: - yaml.dump(valid_pack_data, f) - manifest = PresetManifest(manifest_path) - assert manifest.templates[0]["strategy"] == "wrap" - - def test_replace_accepted_for_scripts(self, temp_dir, valid_pack_data): - """Test that replace strategy is accepted for scripts.""" - valid_pack_data["provides"]["templates"] = [{ - "type": "script", - "name": "create-new-feature", - "file": "scripts/create-new-feature.sh", - "strategy": "replace", - }] - manifest_path = temp_dir / "preset.yml" - with open(manifest_path, 'w') as f: - yaml.dump(valid_pack_data, f) - manifest = PresetManifest(manifest_path) - assert manifest.templates[0]["strategy"] == "replace" - - def test_prepend_accepted_for_commands(self, temp_dir, valid_pack_data): - """Test that prepend strategy is accepted for commands.""" - valid_pack_data["provides"]["templates"] = [{ - "type": "command", - "name": "speckit.specify", - "file": "commands/speckit.specify.md", - "strategy": "prepend", - }] - manifest_path = temp_dir / "preset.yml" - with open(manifest_path, 'w') as f: - yaml.dump(valid_pack_data, f) - manifest = PresetManifest(manifest_path) - assert manifest.templates[0]["strategy"] == "prepend" - - -class TestResolveContent: - """Test PresetResolver.resolve_content() composition.""" - - def test_resolve_content_core_template(self, project_dir): - """Test resolve_content returns core template when no composition.""" - resolver = PresetResolver(project_dir) - content = resolver.resolve_content("spec-template") - assert content is not None - assert "Core Spec Template" in content - - def test_resolve_content_nonexistent(self, project_dir): - """Test resolve_content returns None for nonexistent template.""" - resolver = PresetResolver(project_dir) - content = resolver.resolve_content("nonexistent") - assert content is None - - def test_resolve_content_unreadable_winning_layer_returns_none(self, project_dir): - """An undecodable winning layer must yield None, not a raw traceback. - - ``collect_all_layers`` deliberately keeps a non-UTF-8 legacy command - layer (with its ``replace`` default) so unrelated commands still - resolve. ``resolve_content`` then read that same file without a - boundary, so the tolerated layer crashed with ``UnicodeDecodeError`` - at composition time — reachable from ``specify preset add`` via - ``_register_commands``. The documented contract is "Composed content - string, or None if not found". - """ - presets_dir = project_dir / ".specify" / "presets" - command_path = ( - presets_dir / "legacy-pack" / "commands" / "speckit.legacy.md" - ) - command_path.parent.mkdir(parents=True) - command_path.write_bytes(b"\xff\xfe") - PresetRegistry(presets_dir).add( - "legacy-pack", {"version": "1.0.0", "priority": 10} - ) - - resolver = PresetResolver(project_dir) - content = resolver.resolve_content("speckit.legacy", "command") - assert content is None - - def test_resolve_content_unreadable_base_under_composing_layer( - self, project_dir, temp_dir, valid_pack_data - ): - """An undecodable base beneath a valid composing layer yields None. - - Covers the base-read guard: the winning layer composes (append), so - resolution reads the base layer beneath it — here the core template, - corrupted to non-UTF-8 — and must return None instead of crashing. - """ - pack_data = {**valid_pack_data} - pack_data["preset"] = {**valid_pack_data["preset"], "id": "append-pack", "name": "Append"} - pack_data["provides"] = { - "templates": [{ - "type": "template", - "name": "spec-template", - "file": "templates/spec-template.md", - "strategy": "append", - }] - } - pack_dir = temp_dir / "append-pack" - pack_dir.mkdir() - with open(pack_dir / "preset.yml", 'w') as f: - yaml.dump(pack_data, f) - (pack_dir / "templates").mkdir() - (pack_dir / "templates" / "spec-template.md").write_text("## Appended Section\n") - - manager = PresetManager(project_dir) - manager.install_from_directory(pack_dir, "0.1.5") - - core_spec = project_dir / ".specify" / "templates" / "spec-template.md" - core_spec.write_bytes(b"\xff\xfe") + def test_is_ai_skills_enabled_requires_boolean_true(self, value, expected): + from specify_cli._init_options import is_ai_skills_enabled - resolver = PresetResolver(project_dir) - assert resolver.resolve_content("spec-template") is None + assert is_ai_skills_enabled({"ai_skills": value}) is expected - def test_resolve_content_unreadable_composing_layer( - self, project_dir, temp_dir, valid_pack_data, monkeypatch - ): - """An unreadable composing layer over a valid base yields None. - Covers the composition-loop read and the ``OSError`` half of the - boundary: the base (core template) reads fine, but the append layer - raises a mocked ``PermissionError`` — mocked so the case also holds - under privileged CI where permission bits are not enforced. - """ - pack_data = {**valid_pack_data} - pack_data["preset"] = {**valid_pack_data["preset"], "id": "append-pack", "name": "Append"} - pack_data["provides"] = { - "templates": [{ - "type": "template", - "name": "spec-template", - "file": "templates/spec-template.md", - "strategy": "append", - }] - } - pack_dir = temp_dir / "append-pack" - pack_dir.mkdir() - with open(pack_dir / "preset.yml", 'w') as f: - yaml.dump(pack_data, f) - (pack_dir / "templates").mkdir() - (pack_dir / "templates" / "spec-template.md").write_text("## Appended Section\n") +class TestResolveActiveAgentForRegistration: + """Tests for the shared #2948 active-agent resolution helper. - manager = PresetManager(project_dir) - manager.install_from_directory(pack_dir, "0.1.5") + ``load_init_options`` collapses "no file", "corrupted file", and + "valid file with no active agent" into the same ``{}``. Extensions and + presets both need to tell those apart: no file means "legacy project, + fall back to all detected agents"; a corrupted or malformed file means + "fail closed, register nothing" so a corrupted init-options.json can't + silently reintroduce all-agent registration. + """ - layer_path = ( - project_dir / ".specify" / "presets" / "append-pack" - / "templates" / "spec-template.md" + def test_missing_file_returns_sentinel(self, project_dir): + from specify_cli._init_options import ( + MISSING_INIT_OPTIONS_FILE, + resolve_active_agent_for_registration, ) - assert layer_path.is_file() - original_read_text = Path.read_text - def failing_read_text(self_path, *args, **kwargs): - if self_path == layer_path: - raise PermissionError(13, "Permission denied") - return original_read_text(self_path, *args, **kwargs) + assert ( + resolve_active_agent_for_registration(project_dir) + is MISSING_INIT_OPTIONS_FILE + ) - monkeypatch.setattr(Path, "read_text", failing_read_text) + def test_valid_active_agent_returns_string(self, project_dir): + from specify_cli import save_init_options + from specify_cli._init_options import resolve_active_agent_for_registration - resolver = PresetResolver(project_dir) - assert resolver.resolve_content("spec-template") is None + save_init_options(project_dir, {"ai": "claude"}) - def test_resolve_content_replace_strategy(self, project_dir, temp_dir, valid_pack_data): - """Test resolve_content with default replace strategy.""" - manager = PresetManager(project_dir) - manager.install_from_directory( - _create_pack(temp_dir, valid_pack_data, "replace-pack", - "# Replaced Content\n"), - "0.1.5" - ) + assert resolve_active_agent_for_registration(project_dir) == "claude" - resolver = PresetResolver(project_dir) - content = resolver.resolve_content("spec-template") - assert content is not None - assert "Replaced Content" in content - assert "Core Spec Template" not in content - - def test_resolve_content_append_strategy(self, project_dir, temp_dir, valid_pack_data): - """Test resolve_content with append strategy.""" - pack_data = {**valid_pack_data} - pack_data["preset"] = {**valid_pack_data["preset"], "id": "append-pack", "name": "Append"} - pack_data["provides"] = { - "templates": [{ - "type": "template", - "name": "spec-template", - "file": "templates/spec-template.md", - "strategy": "append", - }] - } - pack_dir = temp_dir / "append-pack" - pack_dir.mkdir() - with open(pack_dir / "preset.yml", 'w') as f: - yaml.dump(pack_data, f) - (pack_dir / "templates").mkdir() - (pack_dir / "templates" / "spec-template.md").write_text("## Appended Section\n") + def test_corrupted_json_fails_closed(self, project_dir): + """A present-but-unparseable file must not behave like "no file".""" + from specify_cli._init_options import resolve_active_agent_for_registration - manager = PresetManager(project_dir) - manager.install_from_directory(pack_dir, "0.1.5") + opts_file = project_dir / ".specify" / "init-options.json" + opts_file.parent.mkdir(parents=True, exist_ok=True) + opts_file.write_text("{bad json", encoding="utf-8") - resolver = PresetResolver(project_dir) - content = resolver.resolve_content("spec-template") - assert content is not None - assert "Core Spec Template" in content - assert "Appended Section" in content - # Core should come first, appended after - assert content.index("Core Spec Template") < content.index("Appended Section") - - def test_resolve_content_prepend_strategy(self, project_dir, temp_dir, valid_pack_data): - """Test resolve_content with prepend strategy.""" - pack_data = {**valid_pack_data} - pack_data["preset"] = {**valid_pack_data["preset"], "id": "prepend-pack", "name": "Prepend"} - pack_data["provides"] = { - "templates": [{ - "type": "template", - "name": "spec-template", - "file": "templates/spec-template.md", - "strategy": "prepend", - }] - } - pack_dir = temp_dir / "prepend-pack" - pack_dir.mkdir() - with open(pack_dir / "preset.yml", 'w') as f: - yaml.dump(pack_data, f) - (pack_dir / "templates").mkdir() - (pack_dir / "templates" / "spec-template.md").write_text("## Security Header\n") + assert resolve_active_agent_for_registration(project_dir) is None - manager = PresetManager(project_dir) - manager.install_from_directory(pack_dir, "0.1.5") + @pytest.mark.parametrize("value", [[], {}, "", 0, None, ["claude"]]) + def test_malformed_ai_value_fails_closed(self, project_dir, value): + """A recorded but non-string/empty ``ai`` value fails closed too.""" + from specify_cli import save_init_options + from specify_cli._init_options import resolve_active_agent_for_registration - resolver = PresetResolver(project_dir) - content = resolver.resolve_content("spec-template") - assert content is not None - assert "Security Header" in content - assert "Core Spec Template" in content - # Prepended content should come first - assert content.index("Security Header") < content.index("Core Spec Template") - - def test_resolve_content_wrap_strategy(self, project_dir, temp_dir, valid_pack_data): - """Test resolve_content with wrap strategy for templates.""" - pack_data = {**valid_pack_data} - pack_data["preset"] = {**valid_pack_data["preset"], "id": "wrap-pack", "name": "Wrap"} - pack_data["provides"] = { - "templates": [{ - "type": "template", - "name": "spec-template", - "file": "templates/spec-template.md", - "strategy": "wrap", - }] - } - pack_dir = temp_dir / "wrap-pack" - pack_dir.mkdir() - with open(pack_dir / "preset.yml", 'w') as f: - yaml.dump(pack_data, f) - (pack_dir / "templates").mkdir() - (pack_dir / "templates" / "spec-template.md").write_text( - "# Wrapper Start\n\n{CORE_TEMPLATE}\n\n# Wrapper End\n" - ) + save_init_options(project_dir, {"ai": value}) - manager = PresetManager(project_dir) - manager.install_from_directory(pack_dir, "0.1.5") + assert resolve_active_agent_for_registration(project_dir) is None - resolver = PresetResolver(project_dir) - content = resolver.resolve_content("spec-template") - assert content is not None - assert "Wrapper Start" in content - assert "Core Spec Template" in content - assert "Wrapper End" in content - # Wrapper should surround core - assert content.index("Wrapper Start") < content.index("Core Spec Template") - assert content.index("Core Spec Template") < content.index("Wrapper End") - - def test_resolve_content_wrap_strategy_script(self, project_dir, temp_dir, valid_pack_data): - """Test resolve_content with wrap strategy for scripts uses $CORE_SCRIPT.""" - # Create core script - scripts_dir = project_dir / ".specify" / "templates" / "scripts" - scripts_dir.mkdir(parents=True, exist_ok=True) - (scripts_dir / "test-script.sh").write_text("echo 'core script'\n") - - pack_data = {**valid_pack_data} - pack_data["preset"] = {**valid_pack_data["preset"], "id": "script-wrap", "name": "Script Wrap"} - pack_data["provides"] = { - "templates": [{ - "type": "script", - "name": "test-script", - "file": "scripts/test-script.sh", - "strategy": "wrap", - }] - } - pack_dir = temp_dir / "script-wrap" - pack_dir.mkdir() - with open(pack_dir / "preset.yml", 'w') as f: - yaml.dump(pack_data, f) - (pack_dir / "scripts").mkdir() - (pack_dir / "scripts" / "test-script.sh").write_text( - "#!/bin/bash\necho 'before'\n$CORE_SCRIPT\necho 'after'\n" - ) + def test_dangling_symlink_fails_closed(self, project_dir): + """A dangling init-options.json symlink must fail closed, not fall + back to "no file" (#2948). - manager = PresetManager(project_dir) - manager.install_from_directory(pack_dir, "0.1.5") + ``Path.exists()`` follows symlinks and returns False for a broken + symlink whose target is missing, so a naive presence check treats a + dangling symlink the same as "no file at all" and falls back to + legacy all-agent registration. The path is present (just broken), + so it must be treated as a corrupted file and fail closed instead. + """ + from specify_cli._init_options import resolve_active_agent_for_registration - resolver = PresetResolver(project_dir) - content = resolver.resolve_content("test-script", "script") - assert content is not None - assert "echo 'before'" in content - assert "echo 'core script'" in content - assert "echo 'after'" in content - - def test_resolve_content_multi_preset_chain(self, project_dir, temp_dir, valid_pack_data): - """Test multi-preset composition chain: prepend + append stacking.""" - # Create preset A (priority 1): prepend security header - pack_a_data = {**valid_pack_data} - pack_a_data["preset"] = {**valid_pack_data["preset"], "id": "preset-a", "name": "A"} - pack_a_data["provides"] = { - "templates": [{ - "type": "template", - "name": "spec-template", - "file": "templates/spec-template.md", - "strategy": "prepend", - }] - } - pack_a_dir = temp_dir / "preset-a" - pack_a_dir.mkdir() - with open(pack_a_dir / "preset.yml", 'w') as f: - yaml.dump(pack_a_data, f) - (pack_a_dir / "templates").mkdir() - (pack_a_dir / "templates" / "spec-template.md").write_text("## Security Header\n") - - # Create preset B (priority 2): append compliance footer - pack_b_data = {**valid_pack_data} - pack_b_data["preset"] = {**valid_pack_data["preset"], "id": "preset-b", "name": "B"} - pack_b_data["provides"] = { - "templates": [{ - "type": "template", - "name": "spec-template", - "file": "templates/spec-template.md", - "strategy": "append", - }] - } - pack_b_dir = temp_dir / "preset-b" - pack_b_dir.mkdir() - with open(pack_b_dir / "preset.yml", 'w') as f: - yaml.dump(pack_b_data, f) - (pack_b_dir / "templates").mkdir() - (pack_b_dir / "templates" / "spec-template.md").write_text("## Compliance Footer\n") + opts_file = project_dir / ".specify" / "init-options.json" + opts_file.parent.mkdir(parents=True, exist_ok=True) + opts_file.symlink_to(project_dir / ".specify" / "does-not-exist.json") - manager = PresetManager(project_dir) - manager.install_from_directory(pack_a_dir, "0.1.5", priority=1) - manager.install_from_directory(pack_b_dir, "0.1.5", priority=2) + assert not opts_file.exists() # sanity: this is what makes it dangling + assert opts_file.is_symlink() + assert resolve_active_agent_for_registration(project_dir) is None - resolver = PresetResolver(project_dir) - content = resolver.resolve_content("spec-template") - assert content is not None - # Result: + + - assert "Security Header" in content - assert "Core Spec Template" in content - assert "Compliance Footer" in content - assert content.index("Security Header") < content.index("Core Spec Template") - assert content.index("Core Spec Template") < content.index("Compliance Footer") - - def test_resolve_content_override_trumps_composition(self, project_dir, temp_dir, valid_pack_data): - """Test that project overrides trump composition (replace at top priority).""" - # Install a composing preset - pack_data = {**valid_pack_data} - pack_data["preset"] = {**valid_pack_data["preset"], "id": "append-pack", "name": "Append"} - pack_data["provides"] = { - "templates": [{ - "type": "template", - "name": "spec-template", - "file": "templates/spec-template.md", - "strategy": "append", - }] - } - pack_dir = temp_dir / "append-pack" - pack_dir.mkdir() - with open(pack_dir / "preset.yml", 'w') as f: - yaml.dump(pack_data, f) - (pack_dir / "templates").mkdir() - (pack_dir / "templates" / "spec-template.md").write_text("## Appended\n") - manager = PresetManager(project_dir) - manager.install_from_directory(pack_dir, "0.1.5") +LEAN_PRESET_DIR = Path(__file__).parent.parent / "presets" / "lean" - # Create project override (replaces everything) - overrides_dir = project_dir / ".specify" / "templates" / "overrides" - overrides_dir.mkdir(parents=True) - (overrides_dir / "spec-template.md").write_text("# Override Only\n") - resolver = PresetResolver(project_dir) - content = resolver.resolve_content("spec-template") - assert content is not None - assert "Override Only" in content - # Override replaces, so appended content should not be visible - assert "Core Spec Template" not in content - - def test_resolve_content_command_type(self, project_dir, temp_dir, valid_pack_data): - """Test resolve_content with command template type.""" - # Create core command using stem naming (matches real layout: plan.md, not speckit.plan.md) - commands_dir = project_dir / ".specify" / "templates" / "commands" - commands_dir.mkdir(parents=True, exist_ok=True) - (commands_dir / "plan.md").write_text("# Core Plan Command\n") - - pack_data = {**valid_pack_data} - pack_data["preset"] = {**valid_pack_data["preset"], "id": "cmd-append", "name": "CmdAppend"} - pack_data["provides"] = { - "templates": [{ - "type": "command", - "name": "speckit.plan", - "file": "commands/speckit.plan.md", - "strategy": "append", - }] - } - pack_dir = temp_dir / "cmd-append" - pack_dir.mkdir() - with open(pack_dir / "preset.yml", 'w') as f: - yaml.dump(pack_data, f) - (pack_dir / "commands").mkdir() - (pack_dir / "commands" / "speckit.plan.md").write_text("## Additional Instructions\n") +CORE_CONSTITUTION_COMMAND = ( + Path(__file__).parent.parent / "templates" / "commands" / "constitution.md" +) - manager = PresetManager(project_dir) - manager.install_from_directory(pack_dir, "0.1.5") - resolver = PresetResolver(project_dir) - content = resolver.resolve_content("speckit.plan", "command") - assert content is not None - assert "Core Plan Command" in content - assert "Additional Instructions" in content - - def test_resolve_content_command_frontmatter_stripping(self, project_dir, temp_dir, valid_pack_data): - """Test that command composition strips frontmatter from lower layers - and reattaches only the highest-priority frontmatter.""" - # Create core command with frontmatter - commands_dir = project_dir / ".specify" / "templates" / "commands" - commands_dir.mkdir(parents=True, exist_ok=True) - (commands_dir / "check.md").write_text( - "---\ndescription: Core check command\n---\nCore body content\n" - ) +LEAN_COMMAND_NAMES = [ + "speckit.specify", + "speckit.plan", + "speckit.tasks", + "speckit.implement", + "speckit.constitution", +] - pack_data = {**valid_pack_data} - pack_data["preset"] = {**valid_pack_data["preset"], "id": "fm-test", "name": "FmTest"} - pack_data["provides"] = { - "templates": [{ - "type": "command", - "name": "speckit.check", - "file": "commands/speckit.check.md", - "strategy": "append", - }] - } - pack_dir = temp_dir / "fm-test" - pack_dir.mkdir() - with open(pack_dir / "preset.yml", 'w') as f: - yaml.dump(pack_data, f) - (pack_dir / "commands").mkdir() - (pack_dir / "commands" / "speckit.check.md").write_text( - "---\ndescription: Preset check override\n---\nPreset body content\n" - ) - manager = PresetManager(project_dir) - manager.install_from_directory(pack_dir, "0.1.5") +@pytest.mark.parametrize( + "command_path", + [ + CORE_CONSTITUTION_COMMAND, + LEAN_PRESET_DIR / "commands" / "speckit.constitution.md", + ], + ids=["core", "lean"], +) +def test_constitution_commands_guard_against_non_governance_work(command_path): + """Constitution commands defer non-governance work instead of executing it.""" + content = command_path.read_text() + lower_content = content.lower() + normalized_content = " ".join(lower_content.split()) - resolver = PresetResolver(project_dir) - content = resolver.resolve_content("speckit.check", "command") - assert content is not None - # Should have the preset (highest-priority) frontmatter - assert "Preset check override" in content - # Should have both bodies - assert "Core body content" in content - assert "Preset body content" in content - # Core frontmatter should NOT appear in the body - assert content.count("---") == 2 # only one frontmatter block (opening + closing) - - def test_resolve_content_blank_line_separator(self, project_dir, temp_dir, valid_pack_data): - """Test that prepend/append use blank line separator.""" - pack_data = {**valid_pack_data} - pack_data["preset"] = {**valid_pack_data["preset"], "id": "sep-test", "name": "SepTest"} - pack_data["provides"] = { - "templates": [{ - "type": "template", - "name": "spec-template", - "file": "templates/spec-template.md", - "strategy": "append", - }] - } - pack_dir = temp_dir / "sep-test" - pack_dir.mkdir() - with open(pack_dir / "preset.yml", 'w') as f: - yaml.dump(pack_data, f) - (pack_dir / "templates").mkdir() - (pack_dir / "templates" / "spec-template.md").write_text("appended") + assert "## Scope Guard" in content + assert "**MUST NOT**" in content + assert "Classify every part" in content + assert "application source files" in content + assert "non-governance intent" in content + assert "`Next Actions`" in content + assert "__SPECKIT_COMMAND_SPECIFY__" in content + assert "omit" in lower_content + assert "do not invoke it" in normalized_content or "without invoking it" in normalized_content - manager = PresetManager(project_dir) - manager.install_from_directory(pack_dir, "0.1.5") - resolver = PresetResolver(project_dir) - content = resolver.resolve_content("spec-template") - # Should have blank line separator - assert "\n\n" in content - - def test_resolve_content_replace_over_wrap(self, project_dir, temp_dir, valid_pack_data): - """Top-priority replace layer should win even if a lower layer uses wrap.""" - # Install a low-priority wrap preset (with no placeholder — would fail if evaluated) - wrap_data = {**valid_pack_data} - wrap_data["preset"] = {**valid_pack_data["preset"], "id": "wrap-lo", "name": "WrapLo"} - wrap_data["provides"] = { - "templates": [{ - "type": "template", - "name": "spec-template", - "file": "templates/spec-template.md", - "strategy": "wrap", - }] - } - wrap_dir = temp_dir / "wrap-lo" - wrap_dir.mkdir() - with open(wrap_dir / "preset.yml", "w") as f: - yaml.dump(wrap_data, f) - (wrap_dir / "templates").mkdir() - # Intentionally missing {CORE_TEMPLATE} — would error if composition ran - (wrap_dir / "templates" / "spec-template.md").write_text("wrapper without placeholder") +def test_core_constitution_command_resolves_template_at_runtime(): + """The core command must consume the composed scaffold on every invocation.""" + content = CORE_CONSTITUTION_COMMAND.read_text() - manager = PresetManager(project_dir) - manager.install_from_directory(wrap_dir, "0.1.5", priority=10) - - # Install a high-priority replace preset - rep_data = {**valid_pack_data} - rep_data["preset"] = {**valid_pack_data["preset"], "id": "rep-hi", "name": "RepHi"} - rep_data["provides"] = { - "templates": [{ - "type": "template", - "name": "spec-template", - "file": "templates/spec-template.md", - }] - } - rep_dir = temp_dir / "rep-hi" - rep_dir.mkdir() - with open(rep_dir / "preset.yml", "w") as f: - yaml.dump(rep_data, f) - (rep_dir / "templates").mkdir() - (rep_dir / "templates" / "spec-template.md").write_text("# Replaced content\n") - - manager.install_from_directory(rep_dir, "0.1.5", priority=1) + assert "resolve-template.sh constitution-template --json" in content + assert "resolve-template.ps1 constitution-template -Json" in content + assert "resolve_template.py constitution-template --json" in content + assert "parse `TEMPLATE_CONTENT` as the active template" in content + assert "do not continue with only one contributing" in content + assert "Do not write back to any versioned template layer" in content - resolver = PresetResolver(project_dir) - content = resolver.resolve_content("spec-template") - assert content == "# Replaced content\n" - - @pytest.mark.parametrize("strategy", ["append", "prepend", "wrap"]) - def test_resolve_content_rewrites_extension_base_subdir_paths( - self, project_dir, temp_dir, strategy - ): - """Composing over an extension-provided base command must resolve the - extension's own subdir references (agents/, knowledge-base/) to their - installed location (#2101), not just when the extension wins outright. - """ - extension_dir = project_dir / ".specify" / "extensions" / "fakeext" - (extension_dir / "commands").mkdir(parents=True, exist_ok=True) - (extension_dir / "agents" / "control").mkdir(parents=True, exist_ok=True) - (extension_dir / "agents" / "control" / "commander.md").write_text("# Commander\n") - (extension_dir / "commands" / "cmd.md").write_text( - "---\ndescription: Extension fakeext cmd\n---\n\n" - "Read agents/control/commander.md for context.\n" - ) - extension_manifest = { - "schema_version": "1.0", - "extension": { - "id": "fakeext", - "name": "Fake Extension", - "version": "1.0.0", - "description": "Test", - }, - "requires": {"speckit_version": ">=0.1.0"}, - "provides": { - "commands": [ - { - "name": "speckit.fakeext.cmd", - "file": "commands/cmd.md", - "description": "Fake extension command", - } - ] - }, - } - with open(extension_dir / "extension.yml", "w") as f: - yaml.dump(extension_manifest, f) - - preset_dir = temp_dir / f"ext-base-{strategy}" - preset_dir.mkdir() - (preset_dir / "commands").mkdir() - overlay_body = ( - "{CORE_TEMPLATE}\n## Extra\n" if strategy == "wrap" else "## Extra\n" - ) - (preset_dir / "commands" / "speckit.fakeext.cmd.md").write_text( - f"---\ndescription: Preset overlay\n---\n\n{overlay_body}" - ) - preset_manifest = { - "schema_version": "1.0", - "preset": { - "id": f"ext-base-{strategy}", - "name": "Ext Base", - "version": "1.0.0", - "description": "Test", - }, - "requires": {"speckit_version": ">=0.1.0"}, - "provides": { - "templates": [ - { - "type": "command", - "name": "speckit.fakeext.cmd", - "file": "commands/speckit.fakeext.cmd.md", - "strategy": strategy, - } - ] - }, - } - with open(preset_dir / "preset.yml", "w") as f: - yaml.dump(preset_manifest, f) - manager = PresetManager(project_dir) - manager.install_from_directory(preset_dir, "0.1.5") +def test_core_checklist_command_resolves_template_at_runtime(): + """The checklist command must consume the composed scaffold.""" + content = (CORE_CONSTITUTION_COMMAND.parent / "checklist.md").read_text( + encoding="utf-8" + ) - resolver = PresetResolver(project_dir) - content = resolver.resolve_content("speckit.fakeext.cmd", "command") - assert content is not None - assert ".specify/extensions/fakeext/agents/control/commander.md" in content - assert "Read agents/control" not in content - assert "## Extra" in content + assert "--template checklist-template" in content + assert "TEMPLATE_CONTENT" in content + assert "Use TEMPLATE_CONTENT as the structural template" in content -class TestCollectAllLayers: - """Test PresetResolver.collect_all_layers() method.""" +class TestLeanPreset: + """Tests for the lean preset that ships with the repo.""" - def test_non_utf8_legacy_command_keeps_replace_strategy(self, project_dir): - presets_dir = project_dir / ".specify" / "presets" - command_path = ( - presets_dir / "legacy-pack" / "commands" / "speckit.legacy.md" - ) - command_path.parent.mkdir(parents=True) - command_path.write_bytes(b"\xff\xfe") - PresetRegistry(presets_dir).add( - "legacy-pack", {"version": "1.0.0", "priority": 10} - ) + def test_lean_preset_exists(self): + """Verify the lean preset directory and manifest exist.""" + assert LEAN_PRESET_DIR.exists() + assert (LEAN_PRESET_DIR / "preset.yml").exists() - layers = PresetResolver(project_dir).collect_all_layers( - "speckit.legacy", "command" - ) + def test_lean_manifest_valid(self): + """Verify the lean preset manifest is valid.""" + manifest = PresetManifest(LEAN_PRESET_DIR / "preset.yml") + assert manifest.id == "lean" + assert manifest.name == "Lean Workflow" + assert manifest.version == "1.0.0" + assert len(manifest.templates) == 5 # 5 commands - assert layers[0]["path"] == command_path - assert layers[0]["strategy"] == "replace" + def test_lean_provides_core_workflow_commands(self): + """Verify the lean preset provides overrides for core workflow commands.""" + manifest = PresetManifest(LEAN_PRESET_DIR / "preset.yml") + provided_names = {t["name"] for t in manifest.templates} + for name in LEAN_COMMAND_NAMES: + assert name in provided_names, f"Lean preset missing command: {name}" - def test_single_core_layer(self, project_dir): - """Test collecting layers with only core template.""" - resolver = PresetResolver(project_dir) - layers = resolver.collect_all_layers("spec-template") - assert len(layers) == 1 - assert layers[0]["source"] == "core" - assert layers[0]["strategy"] == "replace" + def test_lean_command_files_exist(self): + """Verify that all declared command files actually exist on disk.""" + manifest = PresetManifest(LEAN_PRESET_DIR / "preset.yml") + for tmpl in manifest.templates: + tmpl_path = LEAN_PRESET_DIR / tmpl["file"] + assert tmpl_path.exists(), f"Missing command file: {tmpl['file']}" - def test_layers_include_presets(self, project_dir, temp_dir, valid_pack_data): - """Test that layers include installed preset.""" - manager = PresetManager(project_dir) - pack_dir = _create_pack(temp_dir, valid_pack_data, "test-pack", - "# From Pack\n") - manager.install_from_directory(pack_dir, "0.1.5") + def test_lean_commands_have_no_scripts(self): + """Verify lean commands have no scripts in frontmatter.""" + from specify_cli.agents import CommandRegistrar - resolver = PresetResolver(project_dir) - layers = resolver.collect_all_layers("spec-template") - assert len(layers) == 2 - # Highest priority first - assert "test-pack" in layers[0]["source"] - assert layers[1]["source"] == "core" - - def test_layers_order_matches_priority(self, project_dir, temp_dir, valid_pack_data): - """Test that layers are ordered by priority (highest first).""" - manager = PresetManager(project_dir) - for pid, prio in [("pack-lo", 10), ("pack-hi", 1)]: - d = {**valid_pack_data} - d["preset"] = {**valid_pack_data["preset"], "id": pid, "name": pid} - p = temp_dir / pid - p.mkdir() - with open(p / "preset.yml", 'w') as f: - yaml.dump(d, f) - (p / "templates").mkdir() - (p / "templates" / "spec-template.md").write_text(f"# {pid}\n") - manager.install_from_directory(p, "0.1.5", priority=prio) + for name in LEAN_COMMAND_NAMES: + cmd_path = LEAN_PRESET_DIR / "commands" / f"speckit.{name.split('.')[-1]}.md" + content = cmd_path.read_text() + frontmatter, _ = CommandRegistrar.parse_frontmatter(content) + assert "scripts" not in frontmatter, f"{name} should not have scripts in frontmatter" - resolver = PresetResolver(project_dir) - layers = resolver.collect_all_layers("spec-template") - assert len(layers) == 3 # pack-hi, pack-lo, core - assert "pack-hi" in layers[0]["source"] - assert "pack-lo" in layers[1]["source"] - assert layers[2]["source"] == "core" - - def test_layers_read_strategy_from_manifest(self, project_dir, temp_dir, valid_pack_data): - """Test that layers read strategy from preset manifest.""" - pack_data = {**valid_pack_data} - pack_data["preset"] = {**valid_pack_data["preset"], "id": "strat-pack", "name": "Strat"} - pack_data["provides"] = { - "templates": [{ - "type": "template", - "name": "spec-template", - "file": "templates/spec-template.md", - "strategy": "append", - }] - } - pack_dir = temp_dir / "strat-pack" - pack_dir.mkdir() - with open(pack_dir / "preset.yml", 'w') as f: - yaml.dump(pack_data, f) - (pack_dir / "templates").mkdir() - (pack_dir / "templates" / "spec-template.md").write_text("## Footer\n") + def test_lean_commands_have_no_hooks(self): + """Verify lean commands do not contain extension hook boilerplate.""" + for name in LEAN_COMMAND_NAMES: + cmd_path = LEAN_PRESET_DIR / "commands" / f"speckit.{name.split('.')[-1]}.md" + content = cmd_path.read_text() + assert "hooks." not in content, f"{name} should not reference extension hooks" + assert "extensions.yml" not in content, f"{name} should not reference extensions.yml" + def test_install_lean_preset(self, project_dir): + """Test installing the lean preset from its directory.""" manager = PresetManager(project_dir) - manager.install_from_directory(pack_dir, "0.1.5") + manifest = manager.install_from_directory(LEAN_PRESET_DIR, "0.6.0") + assert manifest.id == "lean" + assert manager.registry.is_installed("lean") + + def test_lean_overrides_commands(self, project_dir): + """Test that lean preset overrides are resolved correctly.""" + manager = PresetManager(project_dir) + manager.install_from_directory(LEAN_PRESET_DIR, "0.6.0") resolver = PresetResolver(project_dir) - layers = resolver.collect_all_layers("spec-template") - # Preset layer should have strategy=append - assert layers[0]["strategy"] == "append" - # Core layer should be replace - assert layers[1]["strategy"] == "replace" - - -class TestRemoveReconciliation: - """Test that removing a preset re-registers the next layer's command.""" - - def test_remove_restores_extension_command_subdir_paths_for_non_skill_agent( - self, project_dir, temp_dir - ): - """When a preset override of an extension command is removed, the - reconciled non-skill-agent command file should have the extension's - own subdir references rewritten to their installed location (#2101), - not left as bare, unresolvable paths.""" - gemini_dir = project_dir / ".gemini" / "commands" - gemini_dir.mkdir(parents=True) - - extension_dir = project_dir / ".specify" / "extensions" / "fakeext" - (extension_dir / "commands").mkdir(parents=True, exist_ok=True) - (extension_dir / "agents" / "control").mkdir(parents=True, exist_ok=True) - (extension_dir / "agents" / "control" / "commander.md").write_text("# Commander\n") - (extension_dir / "commands" / "cmd.md").write_text( - "---\ndescription: Extension fakeext cmd\n---\n\n" - "Read agents/control/commander.md for context.\n" - ) - extension_manifest = { - "schema_version": "1.0", - "extension": { - "id": "fakeext", - "name": "Fake Extension", - "version": "1.0.0", - "description": "Test", - }, - "requires": {"speckit_version": ">=0.1.0"}, - "provides": { - "commands": [ - { - "name": "speckit.fakeext.cmd", - "file": "commands/cmd.md", - "description": "Fake extension command", - } - ] - }, - } - with open(extension_dir / "extension.yml", "w") as f: - yaml.dump(extension_manifest, f) + for name in LEAN_COMMAND_NAMES: + result = resolver.resolve(name, template_type="command") + assert result is not None, f"Lean override for {name} not resolved" - manager = PresetManager(project_dir) - preset_dir = temp_dir / "ext-cmd-override" - preset_dir.mkdir() - (preset_dir / "commands").mkdir() - (preset_dir / "commands" / "speckit.fakeext.cmd.md").write_text( - "---\ndescription: Override fakeext cmd\n---\n\npreset override content\n" - ) - preset_manifest = { - "schema_version": "1.0", - "preset": { - "id": "ext-cmd-override", - "name": "Ext Cmd Override", - "version": "1.0.0", - "description": "Test", - }, - "requires": {"speckit_version": ">=0.1.0"}, - "provides": { - "templates": [ - { - "type": "command", - "name": "speckit.fakeext.cmd", - "file": "commands/speckit.fakeext.cmd.md", - } - ] - }, - } - with open(preset_dir / "preset.yml", "w") as f: - yaml.dump(preset_manifest, f) - - manager.install_from_directory(preset_dir, "0.1.5") - - cmd_files = list(gemini_dir.glob("*fakeext*")) - assert cmd_files, "Command file should exist in gemini dir" - assert "preset override content" in cmd_files[0].read_text() - - manager.remove("ext-cmd-override") - - cmd_files = list(gemini_dir.glob("*fakeext*")) - assert cmd_files, "Command file should still exist after removal" - content = cmd_files[0].read_text() - assert "preset override content" not in content - assert ".specify/extensions/fakeext/agents/control/commander.md" in content - assert "Read agents/control" not in content - - def test_install_composes_extension_command_and_rewrites_subdir_paths_for_non_skill_agent( - self, project_dir, temp_dir - ): - """When a preset overlays (append) an extension-provided base command, - the initial composed non-skill-agent command file must have the - extension's own subdir references rewritten to their installed - location (#2101), matching the live repro: extension body - 'Read agents/control/commander.md', preset appends to - speckit.fakeext.cmd, generated Gemini content retains the bare path.""" - gemini_dir = project_dir / ".gemini" / "commands" - gemini_dir.mkdir(parents=True) - - extension_dir = project_dir / ".specify" / "extensions" / "fakeext" - (extension_dir / "commands").mkdir(parents=True, exist_ok=True) - (extension_dir / "agents" / "control").mkdir(parents=True, exist_ok=True) - (extension_dir / "agents" / "control" / "commander.md").write_text("# Commander\n") - (extension_dir / "commands" / "cmd.md").write_text( - "---\ndescription: Extension fakeext cmd\n---\n\n" - "Read agents/control/commander.md for context.\n" - ) - extension_manifest = { - "schema_version": "1.0", - "extension": { - "id": "fakeext", - "name": "Fake Extension", - "version": "1.0.0", - "description": "Test", - }, - "requires": {"speckit_version": ">=0.1.0"}, - "provides": { - "commands": [ - { - "name": "speckit.fakeext.cmd", - "file": "commands/cmd.md", - "description": "Fake extension command", - } - ] - }, - } - with open(extension_dir / "extension.yml", "w") as f: - yaml.dump(extension_manifest, f) - - preset_dir = temp_dir / "ext-cmd-append" - preset_dir.mkdir() - (preset_dir / "commands").mkdir() - (preset_dir / "commands" / "speckit.fakeext.cmd.md").write_text( - "---\ndescription: Append fakeext cmd\n---\n\n## Extra\n" - ) - preset_manifest = { - "schema_version": "1.0", - "preset": { - "id": "ext-cmd-append", - "name": "Ext Cmd Append", - "version": "1.0.0", - "description": "Test", - }, - "requires": {"speckit_version": ">=0.1.0"}, - "provides": { - "templates": [ - { - "type": "command", - "name": "speckit.fakeext.cmd", - "file": "commands/speckit.fakeext.cmd.md", - "strategy": "append", - } - ] - }, - } - with open(preset_dir / "preset.yml", "w") as f: - yaml.dump(preset_manifest, f) +class TestBundledPresetLocator: + """Tests for _locate_bundled_preset discovery function.""" - manager = PresetManager(project_dir) - manager.install_from_directory(preset_dir, "0.1.5") - - cmd_files = list(gemini_dir.glob("*fakeext*")) - assert cmd_files, "Command file should exist in gemini dir" - content = cmd_files[0].read_text() - assert ".specify/extensions/fakeext/agents/control/commander.md" in content - assert "Read agents/control" not in content - assert "## Extra" in content - - def test_remove_restores_lower_priority_command( - self, project_dir, temp_dir, valid_pack_data - ): - """After removing the top-priority preset, the next preset's command - should be re-registered in agent directories.""" - manager = PresetManager(project_dir) + def test_locate_bundled_lean_preset(self): + """_locate_bundled_preset finds the lean preset.""" + from specify_cli import _locate_bundled_preset - # Create a gemini commands dir so reconciliation writes there - gemini_dir = project_dir / ".gemini" / "commands" - gemini_dir.mkdir(parents=True) - - # Install a low-priority preset with a command - lo_data = {**valid_pack_data} - lo_data["preset"] = { - **valid_pack_data["preset"], - "id": "lo-preset", - "name": "Lo", - } - lo_data["provides"] = { - "templates": [{ - "type": "command", - "name": "speckit.specify", - "file": "commands/speckit.specify.md", - }] - } - lo_dir = temp_dir / "lo-preset" - lo_dir.mkdir() - with open(lo_dir / "preset.yml", "w") as f: - yaml.dump(lo_data, f) - (lo_dir / "commands").mkdir() - (lo_dir / "commands" / "speckit.specify.md").write_text( - "---\ndescription: lo\n---\nLo content\n" - ) - manager.install_from_directory(lo_dir, "0.1.5", priority=10) - - # Install a high-priority preset overriding the same command - hi_data = {**valid_pack_data} - hi_data["preset"] = { - **valid_pack_data["preset"], - "id": "hi-preset", - "name": "Hi", - } - hi_data["provides"] = { - "templates": [{ - "type": "command", - "name": "speckit.specify", - "file": "commands/speckit.specify.md", - }] - } - hi_dir = temp_dir / "hi-preset" - hi_dir.mkdir() - with open(hi_dir / "preset.yml", "w") as f: - yaml.dump(hi_data, f) - (hi_dir / "commands").mkdir() - (hi_dir / "commands" / "speckit.specify.md").write_text( - "---\ndescription: hi\n---\nHi content\n" - ) - manager.install_from_directory(hi_dir, "0.1.5", priority=1) + path = _locate_bundled_preset("lean") + assert path is not None + assert (path / "preset.yml").is_file() - # Verify the hi-preset's content is active in agent dir - cmd_files = list(gemini_dir.glob("*specify*")) - assert cmd_files, "Command file should exist in gemini dir" - assert "Hi content" in cmd_files[0].read_text() + def test_locate_bundled_preset_not_found(self): + """_locate_bundled_preset returns None for nonexistent preset.""" + from specify_cli import _locate_bundled_preset - # Remove the high-priority preset - manager.remove("hi-preset") + path = _locate_bundled_preset("nonexistent-preset") + assert path is None - # The low-priority preset's command should now be in the resolution stack - resolver = PresetResolver(project_dir) - layers = resolver.collect_all_layers("speckit.specify", "command") - assert len(layers) >= 1 - assert "lo-preset" in layers[0]["source"] - - # Verify on-disk agent command file switched to lo-preset content - cmd_files = list(gemini_dir.glob("*specify*")) - assert cmd_files, "Command file should still exist after removal" - assert "Lo content" in cmd_files[0].read_text() - - -def _create_pack(temp_dir, valid_pack_data, pack_id, content, - strategy="replace", template_type="template", - template_name="spec-template"): - """Helper to create a preset pack directory.""" - pack_data = {**valid_pack_data} - pack_data["preset"] = {**valid_pack_data["preset"], "id": pack_id, "name": pack_id} - - tmpl_entry = { - "type": template_type, - "name": template_name, - } - if template_type == "script": - tmpl_entry["file"] = f"scripts/{template_name}.sh" - elif template_type == "command": - tmpl_entry["file"] = f"commands/{template_name}.md" - else: - tmpl_entry["file"] = f"templates/{template_name}.md" - if strategy != "replace": - tmpl_entry["strategy"] = strategy - pack_data["provides"] = {"templates": [tmpl_entry]} - - pack_dir = temp_dir / pack_id - pack_dir.mkdir(exist_ok=True) - with open(pack_dir / "preset.yml", 'w') as f: - yaml.dump(pack_data, f) - - if template_type == "script": - subdir = pack_dir / "scripts" - subdir.mkdir(exist_ok=True) - (subdir / f"{template_name}.sh").write_text(content) - elif template_type == "command": - subdir = pack_dir / "commands" - subdir.mkdir(exist_ok=True) - (subdir / f"{template_name}.md").write_text(content) - else: - subdir = pack_dir / "templates" - subdir.mkdir(exist_ok=True) - (subdir / f"{template_name}.md").write_text(content) - - return pack_dir - - -def test_preset_wrapper_resolves_ghes_asset_when_host_configured(tmp_path, monkeypatch): - """End-to-end wiring for presets: auth.json github host → GHES asset resolution.""" - from specify_cli.authentication import http as _auth_http - from specify_cli.authentication.config import AuthConfigEntry - from specify_cli.presets import PresetCatalog - - monkeypatch.setattr(_auth_http, "_config_override", [ - AuthConfigEntry(hosts=("ghes.example",), provider="github", - auth="bearer", token="t"), - ]) - catalog = PresetCatalog(tmp_path) - - captured = [] - - @contextmanager - def fake_open(url, timeout=None, extra_headers=None): - captured.append(url) - resp = MagicMock() - resp.read.side_effect = io.BytesIO(json.dumps({ - "assets": [{"name": "pack.zip", - "url": "https://ghes.example/api/v3/repos/o/r/releases/assets/9"}] - }).encode()).read - yield resp - - monkeypatch.setattr(catalog, "_open_url", fake_open) - - resolved = catalog._resolve_github_release_asset_api_url( - "https://ghes.example/o/r/releases/download/v2/pack.zip" - ) - assert resolved == "https://ghes.example/api/v3/repos/o/r/releases/assets/9" - assert captured == ["https://ghes.example/api/v3/repos/o/r/releases/tags/v2"] + def test_locate_bundled_preset_rejects_invalid_id(self): + """_locate_bundled_preset rejects IDs with invalid characters.""" + from specify_cli import _locate_bundled_preset + assert _locate_bundled_preset("../escape") is None + assert _locate_bundled_preset("UPPERCASE") is None + assert _locate_bundled_preset("has spaces") is None -# ===== ensure_constitution_from_template resolver-awareness ===== + def test_bundled_preset_in_catalog(self): + """Verify the lean preset is listed in catalog.json with bundled marker.""" + catalog_path = Path(__file__).parent.parent / "presets" / "catalog.json" + catalog = json.loads(catalog_path.read_text()) + assert "lean" in catalog["presets"] + assert catalog["presets"]["lean"]["bundled"] is True + assert "download_url" not in catalog["presets"]["lean"] class TestEnsureConstitutionResolverAware: @@ -12793,14 +725,6 @@ def test_composes_wrap_strategy_when_ensuring(self, project_dir, temp_dir): assert "[PROJECT_NAME]" in content - - - - - - - - class TestConstitutionSyncPreset: """The bundled opt-in ``constitution-sync`` preset re-adds materialization.