Files
spec-kit/src/specify_cli/presets/__init__.py
T
1a44a6aa08 fix(presets): skip an unreadable restore source in preset remove (#4020)
* fix(presets): skip an unreadable restore source in `preset remove`

`_unregister_skills_in_dir` restores each preset-owned SKILL.md from a core
command template or an extension source. Both of those reads were bare
`read_text(encoding="utf-8")` calls, so a project-owned override in
`.specify/templates/commands/` that exists but cannot be read or decoded
raised a raw `UnicodeDecodeError`/`OSError` straight out of
`PresetManager.remove()`, which has no handler for it — `specify preset
remove` dies with a traceback.

Every other failure in this loop degrades with `continue`: an unsafe
registry name, a missing skill subdirectory, a foreign owner. Sibling reads
of the very same directory are already guarded — `_infer_legacy_skill_
provenance` and `_delete_agent_preset_skills` both wrap their SKILL.md read
in `except (OSError, UnicodeDecodeError): continue`, and the read inside
`_substitute_core_template` was just given the same boundary in #3961. The
two restore reads were the remaining gap.

`continue` is the right recovery here rather than falling through: the
`else` branch below removes the skill outright, so treating an unreadable
source as "no source" would delete a user's skill at exactly the moment its
replacement cannot be generated. Skipping leaves the skill in place and
keeps it out of the returned `mutated_names`, so callers don't record a
restore that never happened.

Two regression tests, one per exception arm: a non-UTF-8 core template, and
a mocked `PermissionError` so the `OSError` half is also covered under
privileged CI where permission bits aren't enforced. Both assert the skill
survives untouched and is not reported as mutated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(presets): warn when a skill keeps preset content after a failed restore

Review follow-up on two points.

Surface the skipped restore. Skipping is still the correct recovery — the
alternative branch deletes the skill — but it was silent, and it is a
partial removal: `remove()` goes on to delete the preset directory and the
registry entry, while this `SKILL.md` keeps the removed preset's content,
and leaving the name out of `mutated_names` also keeps it out of
reconciliation, so nothing retries it. Both arms now emit a warning naming
the skill, the unreadable source, and the exception, and pointing at the
re-run that refreshes it once the file is fixed. `warnings.warn` matches
how the surrounding code reports non-fatal degradation (the reconciliation
failures in `remove()`/`install_from_directory`, the unreadable core
template in `_substitute_core_template` from #3961).

Cover the extension arm. A skill backed by an installed extension never
reaches the core-template read, so the two branches can regress
independently and both prior tests exercised only the core one.
`test_unregister_skills_in_dir_unreadable_extension_source_skips` installs
an extension whose command file is non-UTF-8 and asserts the skill survives
byte-for-byte and is absent from `mutated_names`. Verified it raises the
raw `UnicodeDecodeError` against unpatched source. The two existing tests
now assert the warning via `pytest.warns` so dropping it fails the suite.

pytest tests/test_presets.py -> 583 passed, 2 skipped, 7 failed; the 7 are
the pre-existing Windows symlink tests that need elevation, unchanged from
main. ruff check passes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-10 12:26:07 -05:00

5905 lines
257 KiB
Python

"""
Preset Manager for Spec Kit
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.
"""
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,
)
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 .._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,
)
_CONSTITUTION_PROVENANCE_FILE = ".constitution-template.json"
_CONSTITUTION_SYNC_PRESET_ID = "constitution-sync"
def _content_sha256(content: bytes) -> str:
return hashlib.sha256(content).hexdigest()
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 (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 <cmd_name>.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 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"
)
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)}"
)
# 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", "")
@property
def requires_speckit_version(self) -> str:
"""Get required spec-kit version range."""
return self.data["requires"]["speckit_version"]
@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."""
with open(self.path, 'rb') as f:
return f"sha256:{hashlib.sha256(f.read()).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 _extension_installed_for_command(self, command_name: str) -> bool:
"""Whether *command_name* may be materialized in this project.
Extension command overrides follow ``speckit.<ext-id>.<cmd-name>``;
they must be skipped everywhere preset artifacts are written —
registration *and* reconciliation — when the extension isn't
installed, or reconciliation would materialize files that
registration refused to track. Core commands (single-dot names,
e.g. ``speckit.specify``) always pass.
"""
parts = command_name.split(".")
if len(parts) >= 3 and parts[0] == "speckit":
ext_id = parts[1]
if not (
self.project_root / ".specify" / "extensions" / ext_id
).is_dir():
return False
return True
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 {}
# Filter out extension command overrides if the extension isn't installed.
filtered = [
cmd
for cmd in command_templates
if self._extension_installed_for_command(cmd["name"])
]
if not filtered:
return {}
# Handle composition strategies: resolve composed content for non-replace commands
resolver = PresetResolver(self.project_root)
composed_dir = None
commands_to_register = []
for cmd in filtered:
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:
raise PresetValidationError(
f"Command '{cmd['name']}' uses '{strategy}' strategy "
f"but no base command layer exists to compose onto. "
f"Ensure a lower-priority preset, extension, or core "
f"command provides this command before using "
f"composition strategies."
)
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()
# Never materialize extension-scoped commands whose extension isn't
# installed. Registration (_register_commands / _register_skills)
# already refuses them, so a reconciliation pass writing them would
# create files no registry entry tracks. Filtering here — the single
# chokepoint every install/remove/rescaffold reconciliation funnels
# through — keeps all callers consistent without each one re-applying
# the filter when seeding names from manifest templates.
command_names = [
name
for name in command_names
if self._extension_installed_for_command(name)
]
if not command_names:
return set()
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<!-- Extension: {extension_id} -->\n<!-- Config: .specify/extensions/{extension_id}/ -->\n",
extension_id=extension_id,
only_agent=only_agent,
extra_agents=extra_agents,
)
record_written(written)
registered = True
except Exception:
# 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()
command_names = [
name
for name in command_names
if self._extension_installed_for_command(name)
]
if not command_names:
return set()
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_<NAME>__`` placeholder into the matching
agent-native invocation -- ``/speckit-<cmd>`` or ``$speckit-<cmd>`` for
a ``-`` separator, ``/speckit.<cmd>`` for ``.``, or
``/skill:speckit-<cmd>`` 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}",
"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 {}
# Filter out extension command overrides if the extension isn't installed,
# matching the same logic used by _register_commands().
filtered = [
cmd
for cmd in command_templates
if self._extension_installed_for_command(cmd["name"])
]
if not filtered:
return {}
skills_dir = target_dir if target_dir is not None else self._get_skills_dir()
if not skills_dir:
return {}
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 filtered:
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)
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:<pack_id>"``, 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"],
)
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,
) -> 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.
self.registry.add(manifest.id, {
"version": manifest.version,
"source": "local",
"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,
) -> 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)
def install_from_zip(
self,
zip_path: Path,
speckit_version: str,
priority: int = 10,
force: bool = False,
) -> PresetManifest:
"""Backward-compatible wrapper for archive installation."""
return self.install_from_archive(
zip_path,
speckit_version,
priority,
force=force,
)
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)
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")),
})
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")),
})
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
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._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")) or {}
except (yaml.YAMLError, OSError, UnicodeError) as e:
raise PresetValidationError(
f"Failed to read catalog config {config_path}: {e}"
)
if not isinstance(data, dict):
raise PresetValidationError(
f"Invalid catalog config {config_path}: expected a mapping at root, got {type(data).__name__}"
)
catalogs_data = data.get("catalogs", [])
if not catalogs_data:
return None
if not isinstance(catalogs_data, list):
raise PresetValidationError(
f"Invalid catalog config: 'catalogs' must be a list, got {type(catalogs_data).__name__}"
)
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)
entries.append(PresetCatalogEntry(
url=url,
name=str(item.get("name", f"catalog-{idx + 1}")),
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/<preset-id>/ - Installed presets
3. .specify/extensions/<ext-id>/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.<type>`` 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.<stem>`` 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.<stem> → <stem>.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.<stem> → <stem>.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