diff --git a/docs/reference/presets.md b/docs/reference/presets.md index 1098abfb42..44513c0d1c 100644 --- a/docs/reference/presets.md +++ b/docs/reference/presets.md @@ -39,6 +39,77 @@ specify preset remove Removes an installed preset and cleans up its registered commands. +## Update a Preset + +```bash +specify preset update [] +``` + +Updates one installed preset, or all installed presets when no ID is given. +Catalogue updates are resolved by preset ID alone. Spec Kit searches the active +catalogues for a matching entry and only uses entries from catalogues that +allow installation. It does not retain or replay the original URL or local +directory used to install a preset. If the installed preset ID cannot be +resolved through an installation-enabled catalogue, provide an explicit +`--dev ` or `--from ` source. + +This means a preset installed from a catalogue can later be updated from the +catalogue entry currently associated with its ID, while a development or +one-off archive installation remains updateable only when an explicit source +is supplied or when that ID is otherwise available from an installation-enabled +catalogue. The incoming manifest must still use the installed preset ID and +pass normal compatibility and file validation. + +| Option | Description | +| ---------------- | ------------------------------------------------ | +| `--from ` | Update from a `.zip`, `.tar.gz`, or `.tgz` URL | +| `--dev ` | Update from a local directory | +| `--priority ` | Set a new resolution priority for a single update | +| `--all` | Update all installed presets | +| `--dry-run` | Show the manifest diff without changing anything | + +Single-preset updates do not prompt for confirmation and may use `--priority` +to reprioritize the preset. Bulk updates ask once, report each preset +independently, preserve priority and enabled state, and skip compatible +presets that are already current. If `--priority` is supplied to a bulk +update, it is ignored and a note is printed after pre-flight checks to prevent +unintended priority collisions. +Updates stage and validate the new preset before atomically replacing the +installed directory. + +### Recovering from an interrupted update + +An interrupted update can leave `..update-*.bak` (the pre-update +directory) or `..update-*.staging` (the validated candidate) under +`.specify/presets/`. These are crash-recovery artefacts, not additional +installed presets. Do not edit the registry to point at either directory. + +Inspect both manifests before taking action: + +```bash +for path in \ + .specify/presets/ \ + .specify/presets/.*.update-*.bak \ + .specify/presets/.*.update-*.staging +do + [ -e "$path" ] && printf '%s\n' "$path" +done +sed -n '1,120p' .specify/presets//preset.yml +sed -n '1,120p' .specify/presets/..update-*.bak/preset.yml +sed -n '1,120p' .specify/presets/..update-*.staging/preset.yml +``` + +If the live `` directory is missing and the backup manifest is the +version recorded in `.specify/presets/.registry`, restore the backup by +renaming it to ``. Leave a staging directory untouched until its +contents have been inspected, then remove it only after confirming that the +live directory and registry agree. If the state is ambiguous, copy the +affected directories aside for inspection and use the supported update flow +with `--from ` or `--dev `, or remove and re-add the preset. There +is deliberately no repair command. Recovery uses the normal update, remove, and +add flows, and catalogue re-resolution is deliberately based on preset ID +rather than stored source provenance. + ## List Installed Presets ```bash diff --git a/src/specify_cli/presets/__init__.py b/src/specify_cli/presets/__init__.py index abc63299c2..4f19b51e05 100644 --- a/src/specify_cli/presets/__init__.py +++ b/src/specify_cli/presets/__init__.py @@ -45,7 +45,11 @@ ) from .._invocation_style import get_invocation_prefix from ..integrations.base import IntegrationBase -from .._utils import dump_frontmatter, version_satisfies +from .._utils import ( + dump_frontmatter, + relative_extension_path_violation, + version_satisfies, +) from ..shared_infra import ( _ensure_safe_shared_destination, _ensure_safe_shared_directory, @@ -77,6 +81,14 @@ def _is_comparable_version(value: str) -> bool: return True +def _versions_equal(left: str, right: str) -> bool: + """Compare versions using PEP 440 semantics where possible.""" + try: + return pkg_version.Version(left) == pkg_version.Version(right) + except pkg_version.InvalidVersion: + return left == right + + def _constitution_is_generated( project_root: Path, memory_constitution: Path, @@ -141,24 +153,20 @@ def _materialize_constitution_template( via ``resolve_content``; ``None`` when no constitution template resolves. """ resolver = PresetResolver(project_root) - layers = resolver.collect_all_layers("constitution-template", "template") - if not layers: + resolved = _resolve_constitution_template_bytes(resolver) + if resolved is None: return None + content, top_layer, result = resolved - 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" + provenance = memory_constitution.parent / _CONSTITUTION_PROVENANCE_FILE + if memory_constitution.exists() and memory_constitution.read_bytes() == content: + # An unchanged constitution must produce zero file changes, so no + # provenance sidecar is written here either. Provenance is recorded + # only when constitution content is actually materialized below. + return "unchanged" _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, @@ -174,6 +182,27 @@ def _materialize_constitution_template( return result +def _resolve_constitution_template_bytes( + resolver: "PresetResolver", +) -> tuple[bytes, Dict[str, Any], str] | None: + """Resolve constitution bytes using the same rules as materialization.""" + 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" + return content, top_layer, result + + def _substitute_core_template( body: str, cmd_name: str, @@ -454,6 +483,22 @@ def _validate(self): f"Invalid template {field}: expected a string, " f"got {type(tmpl[field]).__name__}" ) + aliases = tmpl.get("aliases", []) + if aliases is None: + aliases = [] + tmpl["aliases"] = aliases + if not isinstance(aliases, list) or not all( + isinstance(alias, str) for alias in aliases + ): + raise PresetValidationError( + "Invalid template aliases: expected a list of strings" + ) + for alias in aliases: + reason = relative_extension_path_violation(alias) + if reason: + raise PresetValidationError( + f"Invalid template alias '{alias}': {reason}" + ) if tmpl["type"] not in VALID_PRESET_TEMPLATE_TYPES: raise PresetValidationError( @@ -467,13 +512,14 @@ def _validate(self): # counted by PresetManifest.templates. Reject at validation time # instead, mirroring the sibling fix for ExtensionManifest's # provides.templates/scripts (#4016). - name_type = (tmpl["name"], tmpl["type"]) - if name_type in seen_name_types: - raise PresetValidationError( - f"Duplicate template name '{tmpl['name']}' of type " - f"'{tmpl['type']}' in 'provides.templates'" - ) - seen_name_types.add(name_type) + for declared_name in (tmpl["name"], *aliases): + name_type = (declared_name, tmpl["type"]) + if name_type in seen_name_types: + raise PresetValidationError( + f"Duplicate template name or alias '{declared_name}' " + f"of type '{tmpl['type']}' in 'provides.templates'" + ) + seen_name_types.add(name_type) # Validate file path safety: must be relative, no parent traversal file_path = tmpl["file"] @@ -680,6 +726,85 @@ def get_hash(self) -> str: return f"sha256:{h.hexdigest()}" +def diff_preset_manifests( + old_manifest: PresetManifest, new_manifest: PresetManifest +) -> Dict[str, List[Dict[str, Any]]]: + """Diff preset templates by their stable ``(name, type)`` identity.""" + old_entries = { + (item["name"], item["type"]): item for item in old_manifest.templates + } + new_entries = { + (item["name"], item["type"]): item for item in new_manifest.templates + } + added = [] + removed = [] + changed = [] + unchanged = [] + for identity in sorted(new_entries.keys() - old_entries.keys()): + added.append({"identity": identity, "new": new_entries[identity]}) + for identity in sorted(old_entries.keys() - new_entries.keys()): + removed.append({"identity": identity, "old": old_entries[identity]}) + for identity in sorted(old_entries.keys() & new_entries.keys()): + old_item = old_entries[identity] + new_item = new_entries[identity] + if old_item == new_item: + unchanged.append( + {"identity": identity, "old": old_item, "new": new_item} + ) + else: + changed.append( + {"identity": identity, "old": old_item, "new": new_item} + ) + return { + "added": added, + "removed": removed, + "changed": changed, + "unchanged": unchanged, + } + + +def _diff_preset_template_files( + diff: Dict[str, List[Dict[str, Any]]], + old_manifest: PresetManifest, + new_manifest: PresetManifest, + old_dir: Path, + new_dir: Path, +) -> Dict[str, List[Dict[str, Any]]]: + """Include referenced-file content changes in the manifest diff.""" + old_by_identity = { + (item["name"], item["type"]): item for item in old_manifest.templates + } + new_by_identity = { + (item["name"], item["type"]): item for item in new_manifest.templates + } + unchanged = [] + changed = list(diff["changed"]) + for entry in diff["unchanged"]: + identity = entry["identity"] + old_path = old_dir / old_by_identity[identity]["file"] + new_path = new_dir / new_by_identity[identity]["file"] + if ( + not old_path.is_file() + or not new_path.is_file() + or old_path.read_bytes() != new_path.read_bytes() + ): + changed.append( + { + "identity": identity, + "old": old_by_identity[identity], + "new": new_by_identity[identity], + } + ) + else: + unchanged.append(entry) + return { + "added": diff["added"], + "removed": diff["removed"], + "changed": changed, + "unchanged": unchanged, + } + + class PresetRegistry: """Manages the registry of installed presets.""" @@ -1123,7 +1248,9 @@ def find_unmet_extension_dependencies( def _register_commands( self, manifest: PresetManifest, - preset_dir: Path + preset_dir: Path, + *, + command_names: Optional[set[str]] = None, ) -> Dict[str, List[str]]: """Register preset command overrides with all detected AI agents. @@ -1143,7 +1270,13 @@ def _register_commands( Dictionary mapping agent names to lists of registered command names """ command_templates = [ - t for t in manifest.templates if t.get("type") == "command" + t for t in manifest.templates + if t.get("type") == "command" + and ( + command_names is None + or t["name"] in command_names + or any(alias in command_names for alias in t.get("aliases", [])) + ) ] if not command_templates: return {} @@ -1262,7 +1395,9 @@ def _register_commands( only_agent=active_agent, ) - def register_enabled_presets_for_agent(self, agent_name: str) -> None: + def register_enabled_presets_for_agent( + self, agent_name: str, *, strict_pack_id: Optional[str] = None + ) -> None: """Re-register enabled presets' command overrides and skills for ``agent_name``. Mirrors ``ExtensionManager.register_enabled_extensions_for_agent`` for @@ -1280,6 +1415,12 @@ def register_enabled_presets_for_agent(self, agent_name: str) -> None: 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()``. + + ``strict_pack_id`` is used by ``preset enable`` when a disabled + preset's deferred artifacts must be refreshed before the operation + can report success. Failures for that preset, or in the final + active-agent reconciliation, are raised after best-effort repair. + Other callers retain the existing warning-only behaviour. """ if not agent_name: return @@ -1355,6 +1496,7 @@ def register_enabled_presets_for_agent(self, agent_name: str) -> None: ] ] = [] successful_command_replacements: set[tuple[str, str]] = set() + strict_error: Optional[tuple[str, Exception]] = None for pack_id, metadata in reversed(presets_by_priority): pack_dir = self.presets_dir / pack_id manifest = resolver._get_manifest(pack_dir) @@ -1560,6 +1702,14 @@ def register_enabled_presets_for_agent(self, agent_name: str) -> None: ) ) except Exception as pack_err: + if pack_id == strict_pack_id: + strict_error = ( + f"Failed to register preset artifacts for '{pack_id}': " + f"{pack_err}", + pack_err, + ) + continue + from .. import _print_cli_warning _print_cli_warning( @@ -1586,15 +1736,22 @@ def register_enabled_presets_for_agent(self, agent_name: str) -> None: list(affected_cmd_names), target_agent=agent_name ) except Exception as exc: - import warnings + if strict_pack_id is not None: + strict_error = ( + f"Post-rescaffold reconciliation failed for " + f"'{agent_name}': {exc}", + exc, + ) + else: + 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, - ) + warnings.warn( + f"Post-rescaffold reconciliation failed for " + f"'{agent_name}': {exc}. Agent command files may be " + f"stale; re-run 'specify integration use {agent_name}' " + f"or reinstall affected presets to refresh.", + stacklevel=2, + ) successfully_replaced_winners = { command_name @@ -1702,6 +1859,10 @@ def register_enabled_presets_for_agent(self, agent_name: str) -> None: pack_id, {"registered_skills": merged_skills} ) + if strict_error is not None: + message, cause = strict_error + raise PresetError(message) from cause + def unregister_agent_artifacts(self, agent_name: str) -> None: """Remove ``agent_name``'s tracked preset command/skill artifacts. @@ -2029,7 +2190,10 @@ def record_written(written: Dict[str, List[str]]) -> None: # 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()) + presets_by_priority = [ + (pack_id, metadata) + for pack_id, metadata in self.registry.list_by_priority() + ] for cmd_name in command_names: layers = resolver.collect_all_layers(cmd_name, "command") @@ -2431,7 +2595,10 @@ def _reconcile_skills( active_ai = None # Cache registry once to avoid repeated filesystem reads - presets_by_priority = list(self.registry.list_by_priority()) + presets_by_priority = [ + (pack_id, metadata) + for pack_id, metadata in 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 @@ -2911,6 +3078,7 @@ def _register_skills( *, target_dir: Optional[Path] = None, target_agent: Optional[str] = None, + command_names: Optional[set[str]] = None, ) -> Dict[str, List[str]]: """Generate SKILL.md files for preset command overrides. @@ -2944,7 +3112,13 @@ def _register_skills( two can be tracked/restored consistently (#2948). """ command_templates = [ - t for t in manifest.templates if t.get("type") == "command" + t for t in manifest.templates + if t.get("type") == "command" + and ( + command_names is None + or t["name"] in command_names + or any(alias in command_names for alias in t.get("aliases", [])) + ) ] if not command_templates: return {} @@ -3927,9 +4101,17 @@ def install_from_directory( # Reconcile all affected commands from the full priority stack so that # install order doesn't determine the winning command file. cmd_names = [ - t["name"] + name for t in manifest.templates if t.get("type") == "command" + for name in ( + [t["name"]] + + [ + alias + for alias in t.get("aliases", []) + if isinstance(alias, str) + ] + ) ] if cmd_names: try: @@ -4010,6 +4192,673 @@ def _reconcile_constitution(self, *, create_if_missing: bool = False) -> None: return _materialize_constitution_template(self.project_root, memory_constitution) + def _validate_update_source( + self, + source_dir: Path, + pack_id: str, + speckit_version: str, + ) -> tuple[PresetManifest, PresetManifest, Dict[str, List[Dict[str, Any]]]]: + """Validate an update without touching the installed preset.""" + current_dir = self.presets_dir / pack_id + try: + old_manifest = PresetManifest(current_dir / "preset.yml") + except PresetValidationError as exc: + raise PresetValidationError( + f"Installed preset '{pack_id}' has a corrupt manifest; " + "remove and add the preset again" + ) from exc + + try: + new_manifest = PresetManifest(source_dir / "preset.yml") + except PresetValidationError as exc: + raise PresetValidationError( + f"Incoming preset '{pack_id}' has an unparseable or corrupt " + "manifest; remove and add the preset again" + ) from exc + if new_manifest.id != pack_id: + raise PresetValidationError( + f"Preset ID mismatch: installed '{pack_id}', " + f"incoming '{new_manifest.id}'. Remove and add the preset again." + ) + self.check_compatibility(new_manifest, speckit_version) + for template in new_manifest.templates: + referenced = source_dir / template["file"] + if not referenced.is_file(): + raise PresetValidationError( + f"Preset '{pack_id}' is missing referenced file " + f"'{template['file']}'. Remove and add the preset again." + ) + diff = diff_preset_manifests(old_manifest, new_manifest) + diff = _diff_preset_template_files( + diff, old_manifest, new_manifest, current_dir, source_dir + ) + + def convention_constitution(base_dir: Path) -> Optional[bytes]: + for relative in ( + "templates/constitution-template.md", + "constitution-template.md", + ): + candidate = base_dir / relative + if candidate.is_file(): + return candidate.read_bytes() + return None + + diff["_constitution_changed"] = ( + convention_constitution(current_dir) + != convention_constitution(source_dir) + ) + diff["_constitution_layer"] = ( + any( + item.get("type") == "template" + and item.get("name") == "constitution-template" + for item in old_manifest.templates + new_manifest.templates + ) + or convention_constitution(current_dir) is not None + or convention_constitution(source_dir) is not None + ) + return old_manifest, new_manifest, diff + + def update_from_directory( + self, + source_dir: Path, + speckit_version: str, + *, + pack_id: Optional[str] = None, + priority: Optional[int] = None, + dry_run: bool = False, + expected_version: Optional[str] = None, + ) -> tuple[PresetManifest, Dict[str, List[Dict[str, Any]]]]: + """Update an installed preset while serializing its transaction.""" + from ..workflows._commands import _workflow_install_transaction + + if dry_run: + return self._update_from_directory_locked( + source_dir, + speckit_version, + pack_id=pack_id, + priority=priority, + dry_run=True, + expected_version=expected_version, + ) + + with _workflow_install_transaction(self.project_root): + # Refresh after waiting for the lock so concurrent updates cannot + # overwrite newer registry metadata with this manager's snapshot. + self.registry.data = PresetRegistry(self.presets_dir).data + if expected_version is not None: + metadata = self.registry.get(pack_id or "") + installed_version = ( + metadata.get("version") if isinstance(metadata, dict) else None + ) + if isinstance(installed_version, str): + try: + installed = pkg_version.Version(installed_version) + catalogue = pkg_version.Version(expected_version) + except pkg_version.InvalidVersion: + pass + else: + if installed > catalogue: + raise PresetCompatibilityError( + f"installed preset version {installed_version} is " + f"newer than catalogue version {expected_version}" + ) + return self._update_from_directory_locked( + source_dir, + speckit_version, + pack_id=pack_id, + priority=priority, + dry_run=dry_run, + expected_version=expected_version, + ) + + def _update_from_directory_locked( + self, + source_dir: Path, + speckit_version: str, + *, + pack_id: Optional[str] = None, + priority: Optional[int] = None, + dry_run: bool = False, + expected_version: Optional[str] = None, + ) -> tuple[PresetManifest, Dict[str, List[Dict[str, Any]]]]: + """Update an installed preset from a validated directory.""" + if priority is not None and priority < 1: + raise PresetValidationError( + "Priority must be a positive integer (1 or higher)" + ) + try: + incoming = PresetManifest(source_dir / "preset.yml") + except PresetValidationError as exc: + raise PresetValidationError( + "Incoming preset has an unparseable or corrupt manifest; " + "remove and add the preset again" + ) from exc + target_id = pack_id or incoming.id + if not self.registry.is_installed(target_id): + raise PresetError(f"Preset '{target_id}' is not installed") + old_manifest, new_manifest, diff = self._validate_update_source( + source_dir, target_id, speckit_version + ) + if expected_version is not None and not _versions_equal( + new_manifest.version, expected_version + ): + raise PresetValidationError( + f"Preset '{target_id}' manifest version {new_manifest.version} " + f"does not match catalogue version {expected_version}" + ) + metadata = self.registry.get(target_id) + if metadata is None: + raise PresetError(f"Preset '{target_id}' has no valid registry entry") + if dry_run: + if diff.get("_constitution_layer"): + prospective_metadata = { + **metadata, + "version": new_manifest.version, + "priority": normalize_priority( + priority + if priority is not None + else metadata.get("priority", 10) + ), + } + prospective_resolver = PresetResolver( + self.project_root, + preset_overrides={ + target_id: (source_dir, prospective_metadata), + }, + ) + resolved_constitution = _resolve_constitution_template_bytes( + prospective_resolver + ) + if resolved_constitution is not None: + diff["_constitution_content_after"] = resolved_constitution[0] + return new_manifest, diff + + preserved_priority = normalize_priority( + priority if priority is not None else metadata.get("priority", 10) + ) + enabled = metadata.get("enabled", True) + swap_token = tempfile.mkdtemp( + prefix=f".{target_id}.update-", dir=self.presets_dir + ) + os.rmdir(swap_token) + staging_dir = Path(f"{swap_token}.staging") + backup_dir = Path(f"{swap_token}.bak") + + def remove_swap_path(path: Path) -> None: + if path.is_symlink(): + path.unlink() + elif path.exists(): + shutil.rmtree(path) + + def remove_committed_swap_path(path: Path, artifact_kind: str) -> None: + try: + remove_swap_path(path) + except OSError as exc: + import warnings + + warnings.warn( + f"Preset '{target_id}' was updated successfully, but its " + f"temporary {artifact_kind} path could not be removed: " + f"{path} ({exc}). Inspect and remove this recovery " + "artifact manually once it is safe to do so.", + stacklevel=2, + ) + + def ignore_staging_state(directory: str, names: List[str]) -> Set[str]: + directory_path = Path(directory) + return { + name + for name in names + if directory_path / name + in ( + self.project_root / ".specify", + self.presets_dir, + staging_dir, + backup_dir, + ) + } + + try: + shutil.copytree(source_dir, staging_dir, ignore=ignore_staging_state) + generated_composition = staging_dir / ".composed" + if generated_composition.is_symlink(): + generated_composition.unlink() + elif generated_composition.is_dir(): + shutil.rmtree(generated_composition) + elif generated_composition.exists(): + generated_composition.unlink() + _, staged_manifest, staged_diff = self._validate_update_source( + staging_dir, target_id, speckit_version + ) + if expected_version is not None and not _versions_equal( + staged_manifest.version, expected_version + ): + raise PresetValidationError( + f"Preset '{target_id}' manifest version " + f"{staged_manifest.version} does not match catalogue version " + f"{expected_version}" + ) + # The source directory may be edited while it is being copied + # (notably during --dev updates). Once staged, the copied tree is + # the immutable input for this update, so its validation result + # must drive both the registry metadata and reconciliation. + new_manifest = staged_manifest + diff = staged_diff + staged_manifest_hash = new_manifest.get_hash() + except Exception: + remove_swap_path(staging_dir) + raise + current_dir = self.presets_dir / target_id + registry_before = copy.deepcopy(metadata) + try: + try: + os.replace(current_dir, backup_dir) + except FileNotFoundError as exc: + raise PresetError( + f"Preset '{target_id}' changed on disk during the update " + "(another update may be running); no changes were made" + ) from exc + try: + os.replace(staging_dir, current_dir) + new_manifest.path = current_dir / "preset.yml" + self.registry.update( + target_id, + { + "version": new_manifest.version, + "manifest_hash": staged_manifest_hash, + "priority": preserved_priority, + "enabled": enabled, + }, + ) + except Exception: + remove_swap_path(current_dir) + if backup_dir.exists(): + os.replace(backup_dir, current_dir) + self.registry.restore(target_id, registry_before) + raise + except Exception: + remove_swap_path(staging_dir) + raise + + if not enabled: + # Disabled presets keep their existing generated artifacts and + # provenance until removal. Updating only replaces the source and + # metadata while retaining enough ownership data for a later + # removal to clean up artifacts left on disk. + remove_committed_swap_path(backup_dir, "backup") + remove_committed_swap_path(staging_dir, "staging") + return new_manifest, diff + + primary_command_names = { + item["identity"][0] + for item in diff["added"] + diff["removed"] + diff["changed"] + if item["identity"][1] == "command" + } + command_names = set(primary_command_names) + if priority is not None: + original_priority = normalize_priority(metadata.get("priority", 10)) + else: + original_priority = preserved_priority + if preserved_priority != original_priority: + primary_command_names.update( + item["name"] + for item in new_manifest.templates + if item.get("type") == "command" and isinstance(item.get("name"), str) + ) + primary_command_names.update( + item["name"] + for item in old_manifest.templates + if item.get("type") == "command" and isinstance(item.get("name"), str) + ) + for manifest in (old_manifest, new_manifest): + for item in manifest.templates: + if item.get("type") == "command": + command_names.update( + alias + for alias in item.get("aliases", []) + if isinstance(alias, str) + ) + for item in diff["added"] + diff["removed"] + diff["changed"]: + for template in (item.get("old"), item.get("new")): + if template and template.get("type") == "command": + command_names.update( + alias + for alias in template.get("aliases", []) + if isinstance(alias, str) + ) + reconcile_command_names = set(primary_command_names) + reconcile_command_names.update(command_names) + try: + old_command_names = { + item["name"] + for item in old_manifest.templates + if item.get("type") == "command" + } + new_command_names = { + item["name"] + for item in new_manifest.templates + if item.get("type") == "command" + } + old_command_names.update( + alias + for item in old_manifest.templates + if item.get("type") == "command" + for alias in item.get("aliases", []) + if isinstance(alias, str) + ) + new_command_names.update( + alias + for item in new_manifest.templates + if item.get("type") == "command" + for alias in item.get("aliases", []) + if isinstance(alias, str) + ) + removed_command_names = old_command_names - new_command_names + reconcile_command_names.update(removed_command_names) + registered_commands_before = metadata.get("registered_commands", {}) + if removed_command_names and isinstance( + registered_commands_before, dict + ): + stale_commands = { + agent: [ + name + for name in names + if name in removed_command_names + ] + for agent, names in registered_commands_before.items() + if isinstance(names, list) + } + stale_commands = { + agent: names + for agent, names in stale_commands.items() + if names + } + if stale_commands: + self._unregister_commands(stale_commands) + registered_commands = self._register_commands( + new_manifest, current_dir, command_names=primary_command_names + ) + active_agent = resolve_active_agent_for_registration(self.project_root) + fallback_agent = active_agent if isinstance(active_agent, str) else "" + merged_commands: Dict[str, List[str]] = {} + for agent, names in registered_commands_before.items(): + if isinstance(names, list): + names_to_remove = removed_command_names | ( + command_names if agent == active_agent else set() + ) + retained = [name for name in names if name not in names_to_remove] + if retained: + merged_commands[agent] = retained + for agent, names in registered_commands.items(): + merged_commands.setdefault(agent, []).extend( + name for name in names if name not in merged_commands.get(agent, []) + ) + registered_commands = merged_commands + self.registry.update( + target_id, {"registered_commands": registered_commands} + ) + affected_skill_names = set() + for name in reconcile_command_names: + modern, legacy = self._skill_names_for_command(name) + affected_skill_names.update((modern, legacy)) + raw_registered_skills_before = metadata.get("registered_skills", {}) + if isinstance(raw_registered_skills_before, list): + registered_skills_before = self._infer_legacy_skill_provenance( + [name for name in raw_registered_skills_before if isinstance(name, str)], + target_id, + fallback_agent, + ) + elif isinstance(raw_registered_skills_before, dict): + registered_skills_before = copy.deepcopy(raw_registered_skills_before) + else: + registered_skills_before = {} + if isinstance(registered_skills_before, dict): + removed_skill_names = { + skill_name + for command_name in removed_command_names + for skill_name in self._skill_names_for_command(command_name) + } + stale_skills = { + agent: [ + name + for name in names + if name in removed_skill_names + ] + for agent, names in registered_skills_before.items() + if isinstance(names, list) + } + stale_skills = { + agent: names + for agent, names in stale_skills.items() + if names + } + if stale_skills: + self._unregister_skills( + stale_skills, + current_dir, + restore_from_bundled_core=True, + ) + registered_skills = self._register_skills( + new_manifest, current_dir, command_names=reconcile_command_names + ) + if isinstance(registered_skills_before, dict): + merged_skills: Dict[str, List[str]] = {} + for agent, names in registered_skills_before.items(): + if isinstance(names, list): + names_to_remove = removed_skill_names | ( + affected_skill_names if agent == active_agent else set() + ) + retained = [name for name in names if name not in names_to_remove] + if retained: + merged_skills[agent] = retained + for agent, names in registered_skills.items(): + merged_skills.setdefault(agent, []).extend( + name for name in names + if name not in merged_skills.get(agent, []) + ) + registered_skills = merged_skills + self.registry.update(target_id, {"registered_skills": registered_skills}) + if reconcile_command_names: + historical_agents = { + agent + for agent in registered_commands_before + if agent != active_agent + } + # A priority change can promote this preset above a provider + # whose ownership lives only on another preset's registry + # entry, so inactive agents must be gathered stack-wide rather + # than from this preset's own history alone. + for other_id, other_meta in self.registry.list_by_priority( + include_disabled=True + ): + if other_id == target_id or not isinstance(other_meta, dict): + continue + other_commands = other_meta.get("registered_commands", {}) + if not isinstance(other_commands, dict): + continue + for agent, names in other_commands.items(): + if agent == active_agent or not isinstance(names, list): + continue + if reconcile_command_names.intersection(names): + historical_agents.add(agent) + self._reconcile_composed_commands( + sorted(reconcile_command_names), + extra_agents=historical_agents, + ) + extra_skill_dirs: Dict[ + Path, tuple[Optional[str], Set[str]] + ] = {} + + def add_extra_skill_dir( + agent: str, names: Set[str] + ) -> None: + if not names: + return + skill_dir = self._safe_skills_dir_for_agent(agent) + if skill_dir is None: + return + existing_agent, existing_names = extra_skill_dirs.get( + skill_dir, (agent, set()) + ) + extra_skill_dirs[skill_dir] = ( + existing_agent, + existing_names | names, + ) + + if isinstance(registered_skills_before, dict): + for agent, names in registered_skills_before.items(): + if agent == active_agent: + continue + if not isinstance(names, list): + continue + add_extra_skill_dir( + agent, + { + name + for name in names + if self._is_safe_registry_skill_name(name) + }, + ) + + for other_id, other_meta in self.registry.list_by_priority( + include_disabled=True + ): + if other_id == target_id or not isinstance(other_meta, dict): + continue + raw_other_skills = other_meta.get("registered_skills", {}) + if isinstance(raw_other_skills, list): + other_skills = self._infer_legacy_skill_provenance( + [ + name + for name in raw_other_skills + if isinstance(name, str) + ], + other_id, + fallback_agent, + ) + elif isinstance(raw_other_skills, dict): + other_skills = raw_other_skills + else: + continue + for agent, names in other_skills.items(): + if not isinstance(agent, str) or not isinstance(names, list): + continue + add_extra_skill_dir( + agent, + { + name + for name in names + if self._is_safe_registry_skill_name(name) + and name in affected_skill_names + }, + ) + + if extra_skill_dirs: + active_skill_dir = self._get_skills_dir() + if active_skill_dir is not None: + active_tracked_names = { + name + for skill_map in ( + registered_skills_before, + registered_skills, + ) + if isinstance(skill_map, dict) + and isinstance(active_agent, str) + for name in skill_map.get(active_agent, []) + if self._is_safe_registry_skill_name(name) + and name in affected_skill_names + } + active_dir_agent, active_dir_names = ( + extra_skill_dirs.get( + active_skill_dir, + (active_agent, set()), + ) + ) + extra_skill_dirs[active_skill_dir] = ( + active_dir_agent, + active_dir_names | active_tracked_names, + ) + + reconciled_skill_dirs = { + skill_dir: (agent, sorted(names)) + for skill_dir, (agent, names) in extra_skill_dirs.items() + } + self._reconcile_skills( + sorted(reconcile_command_names), + extra_skills_dirs=reconciled_skill_dirs or None, + ) + def _preset_has_constitution_layer( + manifest: PresetManifest, preset_dir: Path + ) -> bool: + return ( + any( + item.get("type") == "template" + and item.get("name") == "constitution-template" + for item in manifest.templates + ) + or any( + (preset_dir / relative_path).is_file() + for relative_path in ( + "templates/constitution-template.md", + "constitution-template.md", + ) + ) + ) + + has_constitution_layer = _preset_has_constitution_layer( + old_manifest, backup_dir + ) or _preset_has_constitution_layer(new_manifest, current_dir) + if has_constitution_layer: + self.reconcile_constitution( + f"Failed to reconcile constitution after updating {target_id}", + create_if_missing=True, + ) + except Exception as exc: + import warnings + + warnings.warn( + f"Preset '{target_id}' was swapped, but post-update " + f"reconciliation failed: {exc}. Generated command, skill, or " + "constitution files may be stale. Re-run the update with " + f"--from or --dev , or remove and re-add " + f"preset '{target_id}' to refresh them.", + stacklevel=2, + ) + finally: + remove_committed_swap_path(backup_dir, "backup") + remove_committed_swap_path(staging_dir, "staging") + return new_manifest, diff + + def update_from_archive( + self, + archive_path: Path, + speckit_version: str, + *, + pack_id: str, + priority: Optional[int] = None, + dry_run: bool = False, + expected_version: Optional[str] = None, + ) -> tuple[PresetManifest, Dict[str, List[Dict[str, Any]]]]: + """Update an installed preset from a supported archive.""" + with tempfile.TemporaryDirectory() as tmpdir: + extracted = Path(tmpdir) + safe_extract_archive( + archive_path, extracted, error_type=PresetValidationError + ) + roots = [extracted] + if not (extracted / "preset.yml").exists(): + roots = [item for item in extracted.iterdir() if item.is_dir()] + if len(roots) != 1 or not (roots[0] / "preset.yml").is_file(): + raise PresetValidationError("No preset.yml found in archive") + return self.update_from_directory( + roots[0], + speckit_version, + pack_id=pack_id, + priority=priority, + dry_run=dry_run, + expected_version=expected_version, + ) + def install_from_archive( self, archive_path: Path, @@ -4418,7 +5267,7 @@ class PresetCatalog: 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): + def __init__(self, project_root: Path, cache_dir: Optional[Path] = None): """Initialize preset catalog manager. Args: @@ -4426,7 +5275,7 @@ def __init__(self, project_root: Path): """ self.project_root = project_root self.presets_dir = project_root / ".specify" / "presets" - self.cache_dir = self.presets_dir / ".cache" + self.cache_dir = cache_dir or self.presets_dir / ".cache" self.cache_file = self.cache_dir / "catalog.json" self.cache_metadata_file = self.cache_dir / "catalog-metadata.json" @@ -5298,7 +6147,12 @@ class PresetResolver: 4. .specify/templates/ - Core templates (shipped with Spec Kit) """ - def __init__(self, project_root: Path): + def __init__( + self, + project_root: Path, + *, + preset_overrides: Optional[Dict[str, tuple[Path, Dict[str, Any]]]] = None, + ): """Initialize preset resolver. Args: @@ -5309,8 +6163,13 @@ def __init__(self, project_root: Path): self.presets_dir = project_root / ".specify" / "presets" self.overrides_dir = self.templates_dir / "overrides" self.extensions_dir = project_root / ".specify" / "extensions" + self.preset_overrides = preset_overrides or {} self._manifest_cache: Dict[str, Optional["PresetManifest"]] = {} + def _preset_dir(self, pack_id: str) -> Path: + override = self.preset_overrides.get(pack_id) + return override[0] if override is not None else self.presets_dir / pack_id + def _get_manifest(self, pack_dir: Path) -> Optional["PresetManifest"]: """Get a cached preset manifest, parsing it on first access.""" key = str(pack_dir) @@ -5331,6 +6190,27 @@ def _is_safe_registry_id(value: object) -> bool: def _get_all_presets_by_priority(self) -> List[tuple[str, dict]]: registry = PresetRegistry(self.presets_dir) + if self.preset_overrides: + presets = { + pack_id: metadata + for pack_id, metadata in registry.list_by_priority( + include_disabled=True + ) + } + for pack_id, (_pack_dir, metadata) in self.preset_overrides.items(): + presets[pack_id] = copy.deepcopy(metadata) + return sorted( + ( + (pack_id, metadata) + for pack_id, metadata in presets.items() + if metadata.get("enabled", True) + and self._is_safe_registry_id(pack_id) + ), + key=lambda item: ( + normalize_priority(item[1].get("priority", 10)), + item[0], + ), + ) return [ (pack_id, metadata) for pack_id, metadata in registry.list_by_priority() @@ -5360,7 +6240,17 @@ def _manifest_declared_template( if not manifest: return None, None for tmpl in manifest.templates: - if tmpl.get("name") == template_name and tmpl.get("type") == template_type: + aliases = tmpl.get("aliases", []) + if ( + tmpl.get("type") == template_type + and ( + tmpl.get("name") == template_name + or ( + isinstance(aliases, list) + and template_name in aliases + ) + ) + ): file_path = tmpl.get("file") if file_path: manifest_candidate = pack_dir / file_path @@ -5540,7 +6430,7 @@ def resolve( # 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 + pack_dir = self._preset_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 @@ -5741,9 +6631,9 @@ def resolve_with_source( 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(): + if self.presets_dir.exists() or self.preset_overrides: for pack_id, metadata in self._get_all_presets_by_priority(): - pack_dir = self.presets_dir / pack_id + pack_dir = self._preset_dir(pack_id) try: resolved.relative_to(pack_dir) version = metadata.get("version", "?") @@ -5833,7 +6723,7 @@ def _find_in_subdirs(base_dir: Path) -> Optional[Path]: # 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 + pack_dir = self._preset_dir(pack_id) # Read strategy and manifest file path from preset manifest strategy = "replace" manifest_has_strategy = False diff --git a/src/specify_cli/presets/_commands.py b/src/specify_cli/presets/_commands.py index ab74a8e029..fed49b730e 100644 --- a/src/specify_cli/presets/_commands.py +++ b/src/specify_cli/presets/_commands.py @@ -10,6 +10,8 @@ import os import re +import shutil +import tempfile from pathlib import Path import typer @@ -18,7 +20,6 @@ from .._console import console from .._download_security import ( - archive_format_from_name, archive_suffix, detect_archive_format, is_https_or_localhost_http, @@ -40,6 +41,127 @@ preset_app.add_typer(preset_catalog_app, name="catalog") +def _fetch_preset_archive_data( + url: str, + error_type: type[Exception], +) -> tuple[bytes, str | None, str]: + """Fetch bounded archive bytes after validating URL and redirects.""" + import urllib.error + from urllib.parse import urlparse + + from specify_cli._github_http import resolve_github_release_asset_api_url + from specify_cli.authentication.http import github_provider_hosts, open_url + + try: + parsed = urlparse(url) + _ = parsed.port + except ValueError as exc: + raise error_type(f"Invalid URL: {url}") from exc + if not is_https_or_localhost_http(url): + raise error_type("URL must use HTTPS (HTTP is only allowed for localhost)") + + def validate_redirect(old_url, new_url): + if not is_safe_download_redirect(old_url, new_url): + raise error_type( + "redirect target must use HTTPS or remain on localhost" + ) + + try: + resolved_url = resolve_github_release_asset_api_url( + url, open_url, github_hosts=github_provider_hosts() + ) + download_url = resolved_url or url + extra_headers = {"Accept": "application/octet-stream"} if resolved_url else None + with open_url( + download_url, + timeout=60, + extra_headers=extra_headers, + redirect_validator=validate_redirect, + ) as response: + final_url = ( + response.geturl() if hasattr(response, "geturl") else download_url + ) + if not is_https_or_localhost_http(final_url): + raise error_type( + "Preset URL redirected to a disallowed URL: " + f"{final_url}. Redirect targets must use HTTPS with a hostname, " + "or HTTP for localhost (127.0.0.1, ::1)." + ) + data = read_response_limited( + response, error_type=error_type, label=f"preset {url}" + ) + content_type = ( + response.getheader("Content-Type") + if hasattr(response, "getheader") + else None + ) + except urllib.error.URLError as exc: + raise error_type(f"Failed to download preset: {exc}") from exc + return data, content_type, final_url + + +def _write_preset_archive( + data: bytes, + source_url: str, + content_type: str | None, + error_type: type[Exception], +) -> Path: + """Write downloaded bytes to a temporary archive with a detected suffix.""" + fd, name = tempfile.mkstemp(prefix="speckit-preset-update-", suffix=".archive") + path = Path(name) + try: + os.close(fd) + path.write_bytes(data) + detected = detect_archive_format( + path, + source_name=source_url, + content_type=content_type, + error_type=error_type, + ) + detected_path = path.with_suffix(archive_suffix(detected)) + os.replace(path, detected_path) + path = detected_path + return path + except Exception: + path.unlink(missing_ok=True) + raise + + +def _download_preset_archive(url: str, error_type: type[Exception]) -> Path: + """Download and classify a preset archive into a temporary file.""" + data, content_type, final_url = _fetch_preset_archive_data(url, error_type) + return _write_preset_archive(data, final_url, content_type, error_type) + + +def _bundled_update_source(preset_id: str): + """Locate a bundled preset and return its parsed local version.""" + from packaging import version as pkg_version + + from .. import _locate_bundled_preset + from . import PresetManifest, PresetValidationError + + bundled_dir = _locate_bundled_preset(preset_id) + if bundled_dir is None: + return None, None + try: + manifest = PresetManifest(bundled_dir / "preset.yml") + return bundled_dir, pkg_version.Version(manifest.version) + except (PresetValidationError, pkg_version.InvalidVersion, OSError): + return None, None + + +def _cleanup_archive(path: Path | None) -> None: + if path is None: + return + try: + path.unlink(missing_ok=True) + except OSError as exc: + console.print( + f"[yellow]Warning:[/yellow] Could not remove temporary archive " + f"'{path}': {exc}" + ) + + def _warn_unmet_extension_dependencies(manager, manifest) -> None: """Warn when a preset's declared extension dependencies are unsatisfied. @@ -206,11 +328,11 @@ def preset_add( """Install a preset.""" from .. import _locate_bundled_preset, _require_specify_project, get_speckit_version from . import ( - PresetManager, PresetCatalog, + PresetCompatibilityError, PresetError, + PresetManager, PresetValidationError, - PresetCompatibilityError, ) project_root = _require_specify_project() @@ -239,20 +361,11 @@ def preset_add( try: _parsed = _urlparse(from_url) - _parsed.port + _ = _parsed.port except ValueError: console.print(f"[red]Error:[/red] Invalid URL: {_escape_markup(from_url)}") raise typer.Exit(1) - def _validate_download_redirect(old_url, new_url): - if not is_safe_download_redirect(old_url, new_url): - import urllib.error - - raise urllib.error.URLError( - "redirect target must use HTTPS without entering a local " - "target, or stay within loopback over HTTP" - ) - if not is_https_or_localhost_http(from_url): console.print( "[red]Error:[/red] URL must use HTTPS with a hostname and be " @@ -262,78 +375,24 @@ def _validate_download_redirect(old_url, new_url): raise typer.Exit(1) console.print(f"Installing preset from [cyan]{_escape_markup(from_url)}[/cyan]...") - import urllib.error - import tempfile - - with tempfile.TemporaryDirectory() as tmpdir: - archive_path = Path(tmpdir) / "preset.archive" - try: - from specify_cli.authentication.http import open_url as _open_url - from specify_cli.authentication.http import github_provider_hosts - from specify_cli._github_http import resolve_github_release_asset_api_url - - _preset_extra_headers = None - _resolved_from_url = resolve_github_release_asset_api_url( - from_url, _open_url, github_hosts=github_provider_hosts() - ) - if _resolved_from_url: - from_url = _resolved_from_url - _preset_extra_headers = {"Accept": "application/octet-stream"} - - with _open_url( - from_url, - timeout=60, - extra_headers=_preset_extra_headers, - redirect_validator=_validate_download_redirect, - ) as response: - final_url = response.geturl() if hasattr(response, "geturl") else from_url - if not is_https_or_localhost_http(final_url): - console.print( - "[red]Error:[/red] Preset URL redirected to a disallowed URL: " - f"{final_url}. Redirect targets must use HTTPS with a hostname, " - "or HTTP for localhost (127.0.0.1, ::1)." - ) - raise typer.Exit(1) - archive_data = read_response_limited( - response, - error_type=PresetError, - label=f"preset {from_url}", - ) - content_type = ( - response.getheader("Content-Type") - if hasattr(response, "getheader") - else None - ) - archive_path.write_bytes(archive_data) - format_source = ( - final_url - if archive_format_from_name(final_url) is not None - else from_url - ) - archive_format = detect_archive_format( - archive_path, - source_name=format_source, - content_type=content_type, - error_type=PresetError, - ) - detected_path = archive_path.with_suffix( - archive_suffix(archive_format) - ) - os.replace(archive_path, detected_path) - archive_path = detected_path - except (urllib.error.URLError, PresetError) as e: - console.print( - f"[red]Error:[/red] Failed to download: " - f"{_escape_markup(str(e))}" - ) - raise typer.Exit(1) - + archive_path = None + try: + archive_path = _download_preset_archive(from_url, PresetError) + except PresetError as e: + console.print( + f"[red]Error:[/red] Failed to download: " + f"{_escape_markup(str(e))}" + ) + raise typer.Exit(1) + try: manifest = manager.install_from_zip( archive_path, speckit_version, priority, ) - + finally: + if archive_path is not None and archive_path.exists(): + _cleanup_archive(archive_path) console.print(f"[green]✓[/green] Preset '{manifest.name}' v{manifest.version} installed (priority {priority})") elif preset_id: @@ -384,7 +443,7 @@ def _validate_download_redirect(old_url, new_url): console.print(f"[green]✓[/green] Preset '{manifest.name}' v{manifest.version} installed (priority {priority})") finally: if 'archive_path' in locals() and archive_path.exists(): - archive_path.unlink(missing_ok=True) + _cleanup_archive(archive_path) else: console.print("[red]Error:[/red] Specify a preset ID, --from URL, or --dev path") raise typer.Exit(1) @@ -428,6 +487,426 @@ def preset_remove( raise typer.Exit(1) +@preset_app.command("update") +def preset_update( + preset_id: str = typer.Argument(None, help="Preset ID to update (or all)"), + from_url: str = typer.Option(None, "--from", help="Update from an archive URL"), + dev: str = typer.Option(None, "--dev", help="Update from a local directory"), + priority: int = typer.Option(None, "--priority", help="New priority"), + dry_run: bool = typer.Option(False, "--dry-run", help="Show changes without writing"), + all_presets: bool = typer.Option(False, "--all", help="Update all installed presets"), +): + """Update one preset, or all installed presets.""" + from packaging import version as pkg_version + + from .. import ( + _require_specify_project, + get_speckit_version, + ) + from . import ( + PresetCatalog, + PresetCompatibilityError, + PresetError, + PresetManager, + PresetResolver, + PresetValidationError, + _constitution_is_generated, + ) + + project_root = _require_specify_project() + manager = PresetManager(project_root) + if priority is not None and not (all_presets or not preset_id) and priority < 1: + console.print("[red]Error:[/red] Priority must be a positive integer (1 or higher)") + raise typer.Exit(1) + if from_url and dev: + console.print("[red]Error:[/red] Use only one of --from or --dev") + raise typer.Exit(1) + if preset_id and all_presets: + console.print("[red]Error:[/red] Use either a preset ID or --all, not both") + raise typer.Exit(1) + if (from_url or dev) and (not preset_id or all_presets): + console.print("[red]Error:[/red] --from and --dev require a preset ID") + raise typer.Exit(1) + + installed = manager.list_installed() + bulk = all_presets or not preset_id + effective_priority = None if bulk else priority + + def resolve_bundled_candidate(item_id, catalog_version, installed_version): + source_path, bundled_version = _bundled_update_source(item_id) + if source_path is not None and bundled_version >= catalog_version: + return source_path, bundled_version + if catalog_version < installed_version and effective_priority is not None: + raise PresetError( + f"installed preset version {installed_version} is newer than " + f"catalogue version {catalog_version}; use 'specify preset " + "set-priority' to reprioritize without downgrading" + ) + if catalog_version <= installed_version and effective_priority is None: + return None, installed_version + local_desc = ( + f"only ships v{bundled_version}" + if source_path is not None + else "does not ship a local copy" + ) + raise PresetError( + f"preset v{catalog_version} is available, but this spec-kit release " + f"{local_desc}; upgrade spec-kit, then rerun 'specify preset update'" + ) + + ids = [preset_id] if not bulk else [item["id"] for item in installed] + if not ids: + console.print("[yellow]No presets installed.[/yellow]") + return + + dry_run_cache = ( + Path(tempfile.mkdtemp(prefix="speckit-preset-dry-run-")) + if dry_run + else None + ) + catalog = PresetCatalog(project_root, cache_dir=dry_run_cache) + speckit_version = get_speckit_version() + outcomes = [] + catalog_candidates = {} + catalog_sources = {} + + if bulk: + actionable_ids = [] + catalog_archives = {} + for item_id in ids: + safe_id = _escape_markup(str(item_id)) + metadata = manager.registry.get(item_id) + archive_path = None + try: + if not manager.registry.is_installed(item_id): + raise PresetError( + f"Preset '{item_id}' is not installed; use 'preset add' instead" + ) + if metadata is None or "version" not in metadata: + raise PresetError("registry entry is missing or corrupt") + installed_version = pkg_version.Version(str(metadata["version"])) + pack_info = catalog.get_pack_info(item_id) + if not pack_info: + raise PresetError( + "source not re-resolvable — supply --from/--dev explicitly" + ) + if not pack_info.get("_install_allowed", True): + raise PresetError( + f"updates are not allowed from " + f"'{pack_info.get('_catalog_name', 'catalog')}'" + ) + catalog_version = pkg_version.Version(str(pack_info["version"])) + source_path = None + if pack_info.get("bundled") and not pack_info.get("download_url"): + source_path, catalog_version = resolve_bundled_candidate( + item_id, catalog_version, installed_version + ) + if source_path is not None: + pack_info = {**pack_info, "version": str(catalog_version)} + if catalog_version <= installed_version and effective_priority is None: + console.print( + f"[dim]• {safe_id}: Up to date, skipped " + f"(v{installed_version})[/dim]" + ) + outcomes.append("skipped") + continue + if source_path is not None: + manager.update_from_directory( + source_path, + speckit_version, + pack_id=item_id, + priority=effective_priority, + dry_run=True, + expected_version=str(pack_info["version"]), + ) + catalog_sources[item_id] = source_path + else: + archive_path = catalog.download_pack(item_id) + manager.update_from_archive( + archive_path, + speckit_version, + pack_id=item_id, + priority=effective_priority, + dry_run=True, + expected_version=str(pack_info["version"]), + ) + catalog_candidates[item_id] = pack_info + catalog_archives[item_id] = archive_path + actionable_ids.append(item_id) + except PresetCompatibilityError as exc: + detail = _escape_markup(str(exc).replace("\n", " ")) + console.print( + f"[yellow]•[/yellow] {safe_id}: skipped — {detail}" + ) + outcomes.append("skipped") + if archive_path is not None: + _cleanup_archive(archive_path) + except (KeyError, TypeError, ValueError): + detail = _escape_markup( + f"invalid version metadata for preset '{item_id}'" + ) + console.print(f"[yellow]•[/yellow] {safe_id}: failed — {detail}") + outcomes.append("failed") + if archive_path is not None: + _cleanup_archive(archive_path) + except (OSError, PresetValidationError, PresetError) as exc: + detail = _escape_markup(str(exc).replace("\n", " ")) + console.print(f"[yellow]•[/yellow] {safe_id}: failed — {detail}") + outcomes.append("failed") + if archive_path is not None: + _cleanup_archive(archive_path) + + ids = actionable_ids + if not ids: + if any(outcome == "failed" for outcome in outcomes): + if dry_run_cache is not None: + shutil.rmtree(dry_run_cache, ignore_errors=True) + raise typer.Exit(1) + if dry_run_cache is not None: + shutil.rmtree(dry_run_cache, ignore_errors=True) + return + if priority is not None: + console.print( + "[yellow]Note:[/yellow] --priority is ignored for bulk updates; " + "existing preset priorities will be preserved." + ) + if not typer.confirm("Update all installed presets?"): + for archive_path in catalog_archives.values(): + if archive_path is not None: + _cleanup_archive(archive_path) + console.print("Cancelled") + if dry_run_cache is not None: + shutil.rmtree(dry_run_cache, ignore_errors=True) + return + + for item_id in ids: + safe_id = _escape_markup(str(item_id)) + source_kind = "catalog" + archive_path = catalog_archives.get(item_id) if bulk else None + try: + metadata = manager.registry.get(item_id) + if not manager.registry.is_installed(item_id): + raise PresetError( + f"Preset '{item_id}' is not installed; use 'preset add' instead" + ) + if metadata is None or "version" not in metadata: + raise PresetError("registry entry is missing or corrupt") + try: + installed_version = pkg_version.Version(str(metadata["version"])) + except (KeyError, TypeError, ValueError) as exc: + raise PresetError( + f"invalid installed version for preset '{item_id}'" + ) from exc + source_path = None + pack_info = None + if dev: + source_kind = "dev" + source_path = Path(dev).resolve() + if not source_path.is_dir(): + raise PresetError(f"Directory not found: {dev}") + elif from_url: + source_kind = "url" + archive_path = _download_preset_archive(from_url, PresetError) + else: + pack_info = catalog_candidates.get(item_id) or catalog.get_pack_info(item_id) + if not pack_info: + raise PresetError( + "source not re-resolvable — supply --from/--dev explicitly" + ) + if not pack_info.get("_install_allowed", True): + raise PresetError( + f"updates are not allowed from " + f"'{pack_info.get('_catalog_name', 'catalog')}'" + ) + try: + catalog_version = pkg_version.Version(str(pack_info["version"])) + except (KeyError, TypeError, ValueError) as exc: + raise PresetError( + f"catalog entry for preset '{item_id}' has an invalid version" + ) from exc + if pack_info.get("bundled") and not pack_info.get("download_url"): + bundled_source = catalog_sources.get(item_id) + if bundled_source is not None: + source_path = bundled_source + catalog_version = pkg_version.Version(str(pack_info["version"])) + else: + source_path, catalog_version = resolve_bundled_candidate( + item_id, catalog_version, installed_version + ) + if source_path is not None: + pack_info = { + **pack_info, + "version": str(catalog_version), + } + if catalog_version < installed_version: + if effective_priority is not None: + raise PresetError( + f"installed preset version {installed_version} is " + f"newer than catalogue version {catalog_version}; " + "use 'specify preset set-priority' to reprioritize " + "without downgrading" + ) + console.print( + f"[dim]• {safe_id}: Up to date, skipped " + f"(v{installed_version})[/dim]" + ) + outcomes.append("skipped") + continue + if catalog_version == installed_version and effective_priority is None: + console.print( + f"[dim]• {safe_id}: Up to date, skipped " + f"(v{installed_version})[/dim]" + ) + outcomes.append("skipped") + continue + if pack_info.get("bundled") and not pack_info.get("download_url"): + if source_path is None: + raise PresetError( + f"Preset '{item_id}' is bundled with spec-kit but " + "could not be found in the installed package" + ) + elif archive_path is None: + archive_path = catalog.download_pack(item_id) + + constitution_path = ( + project_root / ".specify" / "memory" / "constitution.md" + ) + constitution_before = ( + constitution_path.read_bytes() if constitution_path.exists() else None + ) + if source_kind == "dev" or source_path is not None: + manifest, diff = manager.update_from_directory( + source_path, + speckit_version, + pack_id=item_id, + priority=effective_priority, + dry_run=dry_run, + expected_version=( + str(pack_info["version"]) if pack_info is not None else None + ), + ) + else: + manifest, diff = manager.update_from_archive( + archive_path, + speckit_version, + pack_id=item_id, + priority=effective_priority, + dry_run=dry_run, + expected_version=( + str(pack_info["version"]) if pack_info is not None else None + ), + ) + action = "would update" if dry_run else "updated" + added_commands = sum( + entry["identity"][1] == "command" for entry in diff["added"] + ) + removed_commands = sum( + entry["identity"][1] == "command" for entry in diff["removed"] + ) + changed_commands = sum( + entry["identity"][1] == "command" for entry in diff["changed"] + ) + constitution_after = ( + constitution_path.read_bytes() if constitution_path.exists() else None + ) + if dry_run: + sync_metadata = manager.registry.get("constitution-sync") + target_enabled = metadata.get("enabled", True) + prospective_constitution = diff.get( + "_constitution_content_after" + ) + prospective_bytes = ( + prospective_constitution + if isinstance(prospective_constitution, bytes) + else None + ) + constitution_can_reconcile = ( + bool(diff.get("_constitution_layer")) + and target_enabled + and isinstance(sync_metadata, dict) + and sync_metadata.get("enabled", True) + and ( + not constitution_path.exists() + or _constitution_is_generated( + project_root, + constitution_path, + PresetResolver(project_root), + ) + ) + ) + constitution_status = ( + "constitution change planned" + if constitution_can_reconcile + and prospective_bytes is not None + and ( + not constitution_path.exists() + or constitution_before != prospective_bytes + ) + else "constitution unchanged" + ) + else: + constitution_status = ( + "constitution unchanged" + if constitution_before == constitution_after + else "constitution reconciled" + ) + generated_status = ( + ", generated files unchanged while disabled" + if not metadata.get("enabled", True) + else "" + ) + console.print( + f"[green]✓[/green] {safe_id}: {action} to v{manifest.version} " + f"(+{added_commands} commands, -{removed_commands} commands, " + f"~{changed_commands} commands, " + f"{constitution_status}, " + f"priority {'kept at ' + str(metadata.get('priority', 10)) if effective_priority is None else 'set to ' + str(effective_priority)}" + f"{generated_status})" + ) + if dry_run: + for category in ("added", "removed", "changed", "unchanged"): + identities = [ + f"{name} ({template_type})" + for (name, template_type) in ( + entry["identity"] for entry in diff[category] + ) + ] + if identities: + console.print( + f" {category}: {', '.join(identities)}" + ) + planned_actions = "stage, validate, atomically swap" + if metadata.get("enabled", True): + planned_actions += ", reconcile" + console.print(f" planned actions: {planned_actions}") + else: + _warn_unmet_extension_dependencies(manager, manifest) + outcomes.append("updated") + except OSError as exc: + detail = _escape_markup(str(exc).replace("\n", " ")) + console.print(f"[yellow]•[/yellow] {safe_id}: failed — {detail}") + outcomes.append("failed") + except (PresetCompatibilityError, PresetValidationError, PresetError) as exc: + detail = _escape_markup(str(exc).replace("\n", " ")) + prefix = ( + "skipped" + if bulk and isinstance(exc, PresetCompatibilityError) + else "failed" + ) + console.print(f"[yellow]•[/yellow] {safe_id}: {prefix} — {detail}") + outcomes.append(prefix) + finally: + if archive_path is not None: + _cleanup_archive(archive_path) + + if any(outcome == "failed" for outcome in outcomes): + if dry_run_cache is not None: + shutil.rmtree(dry_run_cache, ignore_errors=True) + raise typer.Exit(1) + if dry_run_cache is not None: + shutil.rmtree(dry_run_cache, ignore_errors=True) + + @preset_app.command("search") def preset_search( query: str = typer.Argument(None, help="Search query"), @@ -577,7 +1056,7 @@ def preset_info( """Show detailed information about a preset.""" from .. import _require_specify_project from ..extensions import normalize_priority - from . import PresetCatalog, PresetManager, PresetError + from . import PresetCatalog, PresetError, PresetManager project_root = _require_specify_project() safe_preset_id = _escape_markup(str(preset_id)) @@ -721,8 +1200,14 @@ def preset_enable( preset_id: str = typer.Argument(help="Preset ID to enable"), ): """Enable a disabled preset.""" + import copy + from .. import _require_specify_project - from . import PresetManager + from .._init_options import ( + MISSING_INIT_OPTIONS_FILE, + resolve_active_agent_for_registration, + ) + from . import PresetManager, PresetManifest, PresetValidationError project_root = _require_specify_project() manager = PresetManager(project_root) @@ -742,11 +1227,369 @@ def preset_enable( console.print(f"[yellow]Preset '{preset_id}' is already enabled[/yellow]") raise typer.Exit(0) - # Enable the preset + pack_dir = manager.presets_dir / preset_id + # Validate the installed manifest *before* flipping `enabled`: a missing + # or corrupt preset.yml must fail closed with the preset still disabled, + # not raise after the registry has already been mutated. + try: + manifest = PresetManifest(pack_dir / "preset.yml") + except PresetValidationError as e: + console.print( + f"[red]Error:[/red] Cannot enable '{preset_id}': installed " + f"manifest is invalid ({_escape_markup(str(e))})" + ) + raise typer.Exit(1) + + # Enable the preset. Snapshot the pre-enable registry entry first: if + # anything below raises, the registry is restored to this snapshot so a + # failed enable never leaves the preset marked enabled with only + # partially refreshed artifacts (fail-closed, matching the manifest + # validation above). Filesystem writes already performed before the + # failure are not unwound — the registry itself is what CLI/tests treat + # as the source of truth for enabled/disabled state. + registry_before = copy.deepcopy(metadata) manager.registry.update(preset_id, {"enabled": True}) - manager.reconcile_constitution( - f"Failed to reconcile constitution after enabling preset {preset_id}" - ) + try: + resolved_agent = resolve_active_agent_for_registration(project_root) + fallback_agent = resolved_agent if isinstance(resolved_agent, str) else "" + current_command_names = { + name + for template in manifest.templates + if template.get("type") == "command" + for name in ( + [template.get("name")] + + [ + alias + for alias in template.get("aliases", []) + if isinstance(alias, str) + ] + ) + if isinstance(name, str) + } + current_skill_names = { + skill_name + for command_name in current_command_names + for skill_name in manager._skill_names_for_command(command_name) + } + + # Compute stale skills *before* stale commands (mirrors + # PresetManager.remove()): for a native skill-only agent (extension + # == "/SKILL.md", e.g. claude/codex), `_register_commands` writes + # the exact same SKILL.md file `_register_skills` does, so + # `registered_commands` and `registered_skills` both track that + # agent for the same on-disk file. Restoring/reconciling via + # `_unregister_skills` first, then excluding that coverage from the + # commands pass below, avoids `_unregister_commands` deleting the + # file outright (no core-fallback there) before `_unregister_skills` + # ever gets a chance to restore or reconcile it. + raw_registered_skills = metadata.get("registered_skills", {}) + if isinstance(raw_registered_skills, list): + # Legacy flat-list value: infer real per-agent ownership from + # on-disk provenance rather than dropping it, otherwise a removed + # command's skill can never be identified as stale here. + registered_skills = manager._infer_legacy_skill_provenance( + [name for name in raw_registered_skills if isinstance(name, str)], + preset_id, + fallback_agent, + ) + else: + registered_skills = manager._normalize_registered_skills( + raw_registered_skills, fallback_agent=fallback_agent + ) + stale_skills = {} + for agent, names in registered_skills.items(): + if not isinstance(agent, str) or not isinstance(names, list): + continue + stale_names = [ + name + for name in names + if isinstance(name, str) and name not in current_skill_names + ] + if stale_names: + stale_skills[agent] = stale_names + # Populated by _unregister_skills below with the directories it + # restored to extension/core content, so a surviving lower-priority + # preset that should actually win those commands can be reconciled + # back in afterwards instead of being left showing core content. + affected_skill_dirs: dict[Path, tuple] = {} + if stale_skills: + affected_skill_dirs = manager._unregister_skills( + stale_skills, + pack_dir, + restore_from_bundled_core=True, + ) + updated_skills = {} + for agent, names in registered_skills.items(): + retained = [ + name for name in names if name not in stale_skills.get(agent, []) + ] + if retained: + updated_skills[agent] = retained + manager.registry.update(preset_id, {"registered_skills": updated_skills}) + + stale_commands = {} + registered_commands = metadata.get("registered_commands", {}) + if isinstance(registered_commands, dict): + for agent, names in registered_commands.items(): + if not isinstance(agent, str) or not isinstance(names, list): + continue + stale_names = [ + name + for name in names + if isinstance(name, str) and name not in current_command_names + ] + if stale_names: + stale_commands[agent] = stale_names + if stale_commands: + # Exclude native skill-only agents (extension == "/SKILL.md") + # whose stale command names are already covered by the + # stale-skills pass above: for those agents the "command" + # and "skill" registrations are the same physical SKILL.md + # file, which _unregister_skills already restored/reconciled + # with core-fallback support. _unregister_commands has no + # such fallback — it always deletes — so re-running it here + # would blow away what was just restored (mirrors + # PresetManager.remove()'s identical filtering). + from ..agents import CommandRegistrar as _CommandRegistrarForFilter + + commands_to_unregister: dict[str, list[str]] = {} + for agent, names in stale_commands.items(): + is_native_skill_agent = ( + _CommandRegistrarForFilter.AGENT_CONFIGS.get(agent, {}).get( + "extension" + ) + == "/SKILL.md" + ) + if not is_native_skill_agent: + commands_to_unregister[agent] = names + continue + covered_skill_names = { + skill_name + for skill_name in stale_skills.get(agent, []) + } + uncovered = [ + name + for name in names + if covered_skill_names.isdisjoint( + manager._skill_names_for_command(name) + ) + ] + if uncovered: + commands_to_unregister[agent] = uncovered + if commands_to_unregister: + manager._unregister_commands(commands_to_unregister) + updated_commands = {} + for agent, names in registered_commands.items(): + if not isinstance(agent, str) or not isinstance(names, list): + continue + retained = [ + name + for name in names + if isinstance(name, str) + and name not in stale_commands.get(agent, []) + ] + if retained: + updated_commands[agent] = retained + manager.registry.update(preset_id, {"registered_commands": updated_commands}) + + if resolved_agent is MISSING_INIT_OPTIONS_FILE: + # Legacy pre-init-options project: there is no single "active + # agent" to target, so mirror install-time behaviour and + # register/re-register this preset's commands and skills for + # every agent directory actually present on disk, merging the + # fresh result into the stored registry state. + fresh_commands = manager._register_commands(manifest, pack_dir) + if fresh_commands: + current_metadata = manager.registry.get(preset_id) or {} + merged = copy.deepcopy(current_metadata.get("registered_commands") or {}) + for agent, names in fresh_commands.items(): + existing = merged.get(agent, []) + merged[agent] = existing + [ + name for name in names if name not in existing + ] + manager.registry.update(preset_id, {"registered_commands": merged}) + + fresh_skills = manager._register_skills(manifest, pack_dir) + if fresh_skills: + current_metadata = manager.registry.get(preset_id) or {} + merged_skills = copy.deepcopy(current_metadata.get("registered_skills") or {}) + for agent, names in fresh_skills.items(): + existing = merged_skills.get(agent, []) + merged_skills[agent] = existing + [ + name for name in names if name not in existing + ] + manager.registry.update(preset_id, {"registered_skills": merged_skills}) + elif isinstance(resolved_agent, str): + manager.register_enabled_presets_for_agent( + resolved_agent, strict_pack_id=preset_id + ) + + # Group identical tracked-name sets so historical agents stay scoped + # without repeating the helper's active-agent reconciliation per key. + historical_command_groups: dict[tuple[str, ...], set[str]] = {} + if isinstance(registered_commands, dict): + for agent, names in registered_commands.items(): + if agent == resolved_agent or not isinstance(agent, str): + continue + if not isinstance(names, list): + continue + tracked_names = tuple( + sorted( + current_command_names.intersection( + name for name in names if isinstance(name, str) + ) + ) + ) + if tracked_names: + historical_command_groups.setdefault(tracked_names, set()).add( + agent + ) + for tracked_names, agents in historical_command_groups.items(): + manager._reconcile_composed_commands( + list(tracked_names), + extra_agents=agents, + ) + + historical_skill_dirs: dict[Path, tuple[str, list[str]]] = {} + historical_skill_agents: dict[Path, set[str]] = {} + active_skill_dir = manager._get_skills_dir() + if isinstance(registered_skills, dict): + for agent, names in registered_skills.items(): + if agent == resolved_agent or not isinstance(agent, str): + continue + if not isinstance(names, list): + continue + tracked_names = { + name + for name in names + if isinstance(name, str) and name in current_skill_names + } + if not tracked_names: + continue + skill_dir = manager._safe_skills_dir_for_agent(agent) + if skill_dir is None or skill_dir == active_skill_dir: + continue + historical_skill_agents.setdefault(skill_dir, set()).add(agent) + _existing_agent, existing_names = historical_skill_dirs.get( + skill_dir, (agent, []) + ) + historical_skill_dirs[skill_dir] = ( + min(historical_skill_agents[skill_dir]), + sorted(set(existing_names).union(tracked_names)), + ) + if historical_skill_dirs: + manager._reconcile_skills( + sorted(current_command_names), + extra_skills_dirs=historical_skill_dirs, + ) + + reconcile_command_names = sorted( + {name for names in stale_commands.values() for name in names} + ) + if stale_commands: + manager._reconcile_composed_commands( + reconcile_command_names, + extra_agents=set(stale_commands), + ) + # A skill can go stale without ever being registered as a command + # (skills-only agents have no `registered_commands` entry), so + # `reconcile_command_names` above (derived only from + # `stale_commands`) can be empty even though `affected_skill_dirs` + # is not — and `_reconcile_skills` no-ops on an empty command-name + # list. Recover the command name for each stale skill by matching + # forward via `_skill_names_for_command`, rather than reversing the + # skill-name encoding: that reverse mapping is lossy for namespaced + # commands (e.g. "speckit.git.feature" -> "speckit-git-feature" + # collides with "speckit.git-feature" on the way back), so the + # surviving-lower-priority-preset lookup below could target the + # wrong command entirely. + # + # The candidate set can't be limited to *this* preset's own current + # manifest plus its own `registered_commands`: a command-backed + # integration running in explicit skills-opt-in mode (e.g. Copilot + # with `ai_skills` on) never gets an entry in `registered_commands` + # at all (see `_register_commands`'s ai_skills guard), and the + # command itself is, by definition, no longer present in this + # preset's *current* (post-update) manifest — it's the very thing + # that went stale. In that case this preset alone can never supply + # the name. The preset that's actually meant to win the skill back + # (a surviving lower-priority provider) still lists the command in + # its own current manifest, so the candidate set is gathered from + # every installed preset's manifest and registered_commands, not + # just this one's. + all_known_command_names = set(current_command_names) + if isinstance(registered_commands, dict): + for names in registered_commands.values(): + if isinstance(names, list): + all_known_command_names.update( + name for name in names if isinstance(name, str) + ) + if stale_skills: + for other_pack_id, other_metadata in manager.registry.list().items(): + if other_pack_id == preset_id or not isinstance(other_metadata, dict): + continue + other_registered_commands = other_metadata.get( + "registered_commands", {} + ) + if isinstance(other_registered_commands, dict): + for names in other_registered_commands.values(): + if isinstance(names, list): + all_known_command_names.update( + name for name in names if isinstance(name, str) + ) + other_pack_dir = manager.presets_dir / other_pack_id + other_manifest_path = other_pack_dir / "preset.yml" + if other_manifest_path.exists(): + try: + other_manifest = PresetManifest(other_manifest_path) + except PresetValidationError: + continue + for template in other_manifest.templates: + if template.get("type") != "command": + continue + for name in ( + [template.get("name")] + + [ + alias + for alias in template.get("aliases", []) + if isinstance(alias, str) + ] + ): + if isinstance(name, str): + all_known_command_names.add(name) + stale_skill_command_names = set() + for names in stale_skills.values(): + for skill_name in names: + for command_name in all_known_command_names: + modern, legacy = manager._skill_names_for_command(command_name) + if skill_name in (modern, legacy): + stale_skill_command_names.add(command_name) + break + skill_reconcile_command_names = sorted( + set(reconcile_command_names) | stale_skill_command_names + ) + if skill_reconcile_command_names or affected_skill_dirs: + # Mirrors PresetManager.remove(): a stale skill directory + # restored to extension/core content above may actually be + # owned by a surviving lower-priority preset, which must be + # reconciled back in rather than left showing core content. + manager._reconcile_skills( + skill_reconcile_command_names, extra_skills_dirs=affected_skill_dirs + ) + + manager.reconcile_constitution( + f"Failed to reconcile constitution after enabling preset {preset_id}" + ) + except Exception as e: + manager.registry.restore(preset_id, registry_before) + console.print( + f"[red]Error:[/red] Failed to enable '{preset_id}': " + f"{_escape_markup(str(e))}. The registry entry has been restored " + f"to disabled. Generated command and skill files may reflect the " + f"new version; resolve the underlying issue and re-run " + f"'specify preset enable {preset_id}' to complete the refresh." + ) + raise typer.Exit(1) from e console.print(f"[green]✓[/green] Preset '{preset_id}' enabled") console.print("\nTemplates from this preset will now be included in resolution.") diff --git a/tests/test_presets.py b/tests/test_presets.py index 57a70b4192..3559c0ba67 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -41,7 +41,11 @@ ) from specify_cli.extensions import ExtensionRegistry from specify_cli._console import console -from specify_cli.presets._commands import _warn_unmet_extension_dependencies +from specify_cli.presets._commands import ( + _download_preset_archive, + _warn_unmet_extension_dependencies, + _write_preset_archive, +) # ===== Fixtures ===== @@ -526,6 +530,30 @@ def test_duplicate_template_name_and_type_raises_validation_error( with pytest.raises(PresetValidationError, match="Duplicate template name"): PresetManifest(manifest_path) + def test_duplicate_template_alias_and_type_raises_validation_error( + self, temp_dir, valid_pack_data + ): + """Aliases must not shadow another template's primary identity.""" + valid_pack_data["provides"]["templates"] = [ + { + "type": "command", + "name": "specify", + "file": "commands/specify.md", + "aliases": ["spec"], + }, + { + "type": "command", + "name": "other", + "file": "commands/other.md", + "aliases": ["spec"], + }, + ] + manifest_path = temp_dir / "preset.yml" + with open(manifest_path, "w") as f: + yaml.dump(valid_pack_data, f) + with pytest.raises(PresetValidationError, match="Duplicate template name or alias"): + PresetManifest(manifest_path) + def test_same_name_different_type_templates_allowed( self, temp_dir, valid_pack_data ): @@ -5481,6 +5509,24 @@ def _create_multi_command_preset_with_aliases(self, temp_dir, preset_id, command yaml.dump(manifest_data, f) return preset_dir + def test_resolver_matches_manifest_aliases(self, project_dir, temp_dir): + preset_dir = self._create_multi_command_preset_with_aliases( + temp_dir, "alias-resolver-preset", [("speckit.primary", ["speckit.alias"])] + ) + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "0.1.0") + + resolver = PresetResolver(project_dir) + entry, candidate = resolver._manifest_declared_template( + manager.presets_dir / "alias-resolver-preset", + "speckit.alias", + "command", + ) + + assert entry is not None + assert candidate is not None + assert candidate.name == "speckit.primary.md" + def test_skill_overridden_on_preset_install(self, project_dir, temp_dir): """When skills mode was used, a preset command override should update the skill.""" # Simulate skills mode having been used: write init-options + create skill @@ -11283,1101 +11329,1603 @@ def test_enable_corrupted_registry_entry(self, project_dir, pack_dir): assert result.exit_code == 1 assert "corrupted state" in result.output.lower() - def test_disable_corrupted_registry_entry(self, project_dir, pack_dir): - """Test disable fails gracefully for corrupted registry entry.""" + def test_enable_fails_closed_on_corrupt_installed_manifest( + self, project_dir, pack_dir + ): + """Enable must not flip `enabled` when the installed manifest is invalid. + + A missing or corrupt preset.yml must be validated before the + registry is mutated, otherwise the command fails after the preset + has already been marked enabled with unreconciled artifacts (#4441). + """ from typer.testing import CliRunner from unittest.mock import patch from specify_cli import app - runner = CliRunner() - - # Install preset then corrupt the registry entry manager = PresetManager(project_dir) manager.install_from_directory(pack_dir, "0.1.5") - manager.registry.data["presets"]["test-pack"] = "corrupted-string" - manager.registry._save() - - with patch.object(Path, "cwd", return_value=project_dir): - result = runner.invoke(app, ["preset", "disable", "test-pack"]) - - assert result.exit_code == 1 - assert "corrupted state" in result.output.lower() - + manager.registry.update("test-pack", {"enabled": False}) -# ===== Lean Preset Tests ===== + installed_manifest = ( + project_dir / ".specify" / "presets" / "test-pack" / "preset.yml" + ) + installed_manifest.write_text("not: [valid") + with patch.object(Path, "cwd", return_value=project_dir): + result = CliRunner().invoke(app, ["preset", "enable", "test-pack"]) -LEAN_PRESET_DIR = Path(__file__).parent.parent / "presets" / "lean" -CORE_CONSTITUTION_COMMAND = ( - Path(__file__).parent.parent / "templates" / "commands" / "constitution.md" -) + assert result.exit_code != 0 + assert "invalid" in result.output.lower() + manager2 = PresetManager(project_dir) + assert manager2.registry.get("test-pack")["enabled"] is False -LEAN_COMMAND_NAMES = [ - "speckit.specify", - "speckit.plan", - "speckit.tasks", - "speckit.implement", - "speckit.constitution", -] + def test_enable_reconciles_legacy_flat_list_registered_skills( + self, project_dir, temp_dir + ): + """A legacy flat-list `registered_skills` value must still let enable + detect and clean up a skill whose command was removed while disabled. + `_normalize_registered_skills()` drops a legacy flat list when no + fallback agent is supplied, which would otherwise skip cleanup of + the removed command's stale skill entirely (#4441). + """ + from typer.testing import CliRunner + from unittest.mock import patch + from specify_cli import app, save_init_options -@pytest.mark.parametrize( - "command_path", - [ - CORE_CONSTITUTION_COMMAND, - LEAN_PRESET_DIR / "commands" / "speckit.constitution.md", - ], - ids=["core", "lean"], -) -def test_constitution_commands_guard_against_non_governance_work(command_path): - """Constitution commands defer non-governance work instead of executing it.""" - content = command_path.read_text() - lower_content = content.lower() - normalized_content = " ".join(lower_content.split()) + save_init_options( + project_dir, {"ai": "claude", "ai_skills": True, "script": "sh"} + ) + skills_dir = project_dir / ".claude" / "skills" + skills_dir.mkdir(parents=True) - assert "## Scope Guard" in content - assert "**MUST NOT**" in content - assert "Classify every part" in content - assert "application source files" in content - assert "non-governance intent" in content - assert "`Next Actions`" in content - assert "__SPECKIT_COMMAND_SPECIFY__" in content - assert "omit" in lower_content - assert "do not invoke it" in normalized_content or "without invoking it" in normalized_content + preset_dir = temp_dir / "legacy-skill-preset" + (preset_dir / "commands").mkdir(parents=True) + for name, body in ( + ("speckit.keep", "Keep body"), + ("speckit.drop", "Drop body"), + ): + (preset_dir / "commands" / f"{name}.md").write_text( + f"---\ndescription: {name}\n---\n\n{body}\n" + ) + manifest_data = { + "schema_version": "1.0", + "preset": { + "id": "legacy-skill-preset", + "name": "legacy-skill-preset", + "version": "1.0.0", + "description": "Test", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "templates": [ + { + "type": "command", + "name": "speckit.keep", + "file": "commands/speckit.keep.md", + }, + { + "type": "command", + "name": "speckit.drop", + "file": "commands/speckit.drop.md", + }, + ] + }, + } + (preset_dir / "preset.yml").write_text(yaml.safe_dump(manifest_data)) + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "0.1.0") + metadata = manager.registry.get("legacy-skill-preset") + assert set(metadata["registered_skills"]["claude"]) == { + "speckit-keep", + "speckit-drop", + } -def test_core_constitution_command_resolves_template_at_runtime(): - """The core command must consume the composed scaffold on every invocation.""" - content = CORE_CONSTITUTION_COMMAND.read_text() + # Simulate a legacy pre-#2948 registry: flatten registered_skills to + # a bare list, matching what old registries stored before per-agent + # provenance existed. + manager.registry.update( + "legacy-skill-preset", + {"registered_skills": ["speckit-keep", "speckit-drop"]}, + ) + manager.registry.update("legacy-skill-preset", {"enabled": False}) + + # Simulate a disabled update that dropped "speckit.drop" from the + # installed manifest. A real `update_from_directory` replaces the + # installed preset directory wholesale, so the dropped command's + # file is gone from disk too — remove it here as well, or the + # resolver's convention-based fallback (an on-disk file with no + # matching manifest entry still resolves as a layer) would treat + # the leftover file as still "provided" by this preset and + # reconciliation would legitimately recreate it. + installed_manifest_path = ( + project_dir + / ".specify" + / "presets" + / "legacy-skill-preset" + / "preset.yml" + ) + installed_manifest = yaml.safe_load(installed_manifest_path.read_text()) + installed_manifest["provides"]["templates"] = [ + t + for t in installed_manifest["provides"]["templates"] + if t["name"] != "speckit.drop" + ] + installed_manifest_path.write_text(yaml.safe_dump(installed_manifest)) + ( + project_dir + / ".specify" + / "presets" + / "legacy-skill-preset" + / "commands" + / "speckit.drop.md" + ).unlink() - assert "resolve-template.sh constitution-template --json" in content - assert "resolve-template.ps1 constitution-template -Json" in content - assert "resolve_template.py constitution-template --json" in content - assert "parse `TEMPLATE_CONTENT` as the active template" in content - assert "do not continue with only one contributing" in content - assert "Do not write back to any versioned template layer" in content + with patch.object(Path, "cwd", return_value=project_dir): + result = CliRunner().invoke( + app, ["preset", "enable", "legacy-skill-preset"] + ) + assert result.exit_code == 0, result.output + assert not (skills_dir / "speckit-drop").exists() + assert (skills_dir / "speckit-keep" / "SKILL.md").exists() + registered = PresetManager(project_dir).registry.get( + "legacy-skill-preset" + )["registered_skills"] + assert "speckit-drop" not in registered.get("claude", []) + assert "speckit-keep" in registered.get("claude", []) + + def test_enable_legacy_project_refreshes_all_detected_agents( + self, project_dir, temp_dir + ): + """A legacy pre-init-options project has no single active agent; + enabling a preset updated while disabled must still refresh every + agent directory actually present on disk, not silently do nothing + (#4441). + """ + from typer.testing import CliRunner + from unittest.mock import patch + from specify_cli import app -def test_core_checklist_command_resolves_template_at_runtime(): - """The checklist command must consume the composed scaffold.""" - content = (CORE_CONSTITUTION_COMMAND.parent / "checklist.md").read_text( - encoding="utf-8" - ) + claude_skills = project_dir / ".claude" / "skills" + claude_skills.mkdir(parents=True) + gemini_commands = project_dir / ".gemini" / "commands" + gemini_commands.mkdir(parents=True) - assert "--template checklist-template" in content - assert "TEMPLATE_CONTENT" in content - assert "Use TEMPLATE_CONTENT as the structural template" in content + def build_preset(preset_id, body, version="1.0.0"): + preset_dir = temp_dir / preset_id + (preset_dir / "commands").mkdir(parents=True) + (preset_dir / "commands" / "speckit.specify.md").write_text( + f"---\ndescription: test\n---\n\n{body}\n" + ) + manifest_data = { + "schema_version": "1.0", + "preset": { + "id": preset_id, + "name": preset_id, + "version": version, + "description": "Test", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "templates": [ + { + "type": "command", + "name": "speckit.specify", + "file": "commands/speckit.specify.md", + }, + ] + }, + } + (preset_dir / "preset.yml").write_text(yaml.safe_dump(manifest_data)) + return preset_dir + source_v1 = build_preset("legacy-refresh-preset", "Specify v1") + manager = PresetManager(project_dir) + manager.install_from_directory(source_v1, "0.1.0") -class TestLeanPreset: - """Tests for the lean preset that ships with the repo.""" + assert "Specify v1" in ( + gemini_commands / "speckit.specify.toml" + ).read_text(encoding="utf-8") + assert "Specify v1" in ( + claude_skills / "speckit-specify" / "SKILL.md" + ).read_text(encoding="utf-8") - def test_lean_preset_exists(self): - """Verify the lean preset directory and manifest exist.""" - assert LEAN_PRESET_DIR.exists() - assert (LEAN_PRESET_DIR / "preset.yml").exists() + manager.registry.update("legacy-refresh-preset", {"enabled": False}) - def test_lean_manifest_valid(self): - """Verify the lean preset manifest is valid.""" - manifest = PresetManifest(LEAN_PRESET_DIR / "preset.yml") - assert manifest.id == "lean" - assert manifest.name == "Lean Workflow" - assert manifest.version == "1.0.0" - assert len(manifest.templates) == 5 # 5 commands + source_v2 = build_preset( + "legacy-refresh-preset-v2", "Specify v2", version="2.0.0" + ) + data = yaml.safe_load((source_v2 / "preset.yml").read_text()) + data["preset"]["id"] = "legacy-refresh-preset" + (source_v2 / "preset.yml").write_text(yaml.safe_dump(data)) + manager.update_from_directory( + source_v2, "0.1.0", pack_id="legacy-refresh-preset" + ) - def test_lean_provides_core_workflow_commands(self): - """Verify the lean preset provides overrides for core workflow commands.""" - manifest = PresetManifest(LEAN_PRESET_DIR / "preset.yml") - provided_names = {t["name"] for t in manifest.templates} - for name in LEAN_COMMAND_NAMES: - assert name in provided_names, f"Lean preset missing command: {name}" + # Confirm the disabled update deliberately left generated artifacts + # untouched while disabled. + assert "Specify v1" in ( + gemini_commands / "speckit.specify.toml" + ).read_text(encoding="utf-8") - def test_lean_command_files_exist(self): - """Verify that all declared command files actually exist on disk.""" - manifest = PresetManifest(LEAN_PRESET_DIR / "preset.yml") - for tmpl in manifest.templates: - tmpl_path = LEAN_PRESET_DIR / tmpl["file"] - assert tmpl_path.exists(), f"Missing command file: {tmpl['file']}" + with patch.object(Path, "cwd", return_value=project_dir): + result = CliRunner().invoke( + app, ["preset", "enable", "legacy-refresh-preset"] + ) - def test_lean_commands_have_no_scripts(self): - """Verify lean commands have no scripts in frontmatter.""" - from specify_cli.agents import CommandRegistrar + assert result.exit_code == 0, result.output + assert "Specify v2" in ( + gemini_commands / "speckit.specify.toml" + ).read_text(encoding="utf-8") + assert "Specify v2" in ( + claude_skills / "speckit-specify" / "SKILL.md" + ).read_text(encoding="utf-8") - for name in LEAN_COMMAND_NAMES: - cmd_path = LEAN_PRESET_DIR / "commands" / f"speckit.{name.split('.')[-1]}.md" - content = cmd_path.read_text() - frontmatter, _ = CommandRegistrar.parse_frontmatter(content) - assert "scripts" not in frontmatter, f"{name} should not have scripts in frontmatter" + def test_enable_restores_disabled_state_on_reconciliation_failure( + self, project_dir, temp_dir + ): + """If reconciliation raises partway through enable, the registry + must be restored to its pre-enable (disabled) snapshot rather than + being left showing ``enabled: True`` with only partially refreshed + artifacts (#4441). + """ + from typer.testing import CliRunner + from unittest.mock import patch + from specify_cli import app - def test_lean_commands_have_no_hooks(self): - """Verify lean commands do not contain extension hook boilerplate.""" - for name in LEAN_COMMAND_NAMES: - cmd_path = LEAN_PRESET_DIR / "commands" / f"speckit.{name.split('.')[-1]}.md" - content = cmd_path.read_text() - assert "hooks." not in content, f"{name} should not reference extension hooks" - assert "extensions.yml" not in content, f"{name} should not reference extensions.yml" + # An agent directory must exist for registration to actually + # record any command names — otherwise registered_commands stays + # empty and there's nothing for enable to consider stale. + gemini_dir = project_dir / ".gemini" / "commands" + gemini_dir.mkdir(parents=True) - def test_install_lean_preset(self, project_dir): - """Test installing the lean preset from its directory.""" - manager = PresetManager(project_dir) - manifest = manager.install_from_directory(LEAN_PRESET_DIR, "0.6.0") - assert manifest.id == "lean" - assert manager.registry.is_installed("lean") + def build_preset(preset_id, command_names, version="1.0.0"): + preset_dir = temp_dir / f"{preset_id}-{version}" + (preset_dir / "commands").mkdir(parents=True) + templates = [] + for name in command_names: + (preset_dir / "commands" / f"{name}.md").write_text( + f"---\ndescription: test\n---\n\n{name} body\n" + ) + templates.append({ + "type": "command", + "name": name, + "file": f"commands/{name}.md", + }) + manifest_data = { + "schema_version": "1.0", + "preset": { + "id": preset_id, + "name": preset_id, + "version": version, + "description": "Test", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": {"templates": templates}, + } + (preset_dir / "preset.yml").write_text(yaml.safe_dump(manifest_data)) + return preset_dir - def test_lean_overrides_commands(self, project_dir): - """Test that lean preset overrides are resolved correctly.""" manager = PresetManager(project_dir) - manager.install_from_directory(LEAN_PRESET_DIR, "0.6.0") + source_v1 = build_preset( + "rollback-preset", ["speckit.specify", "speckit.plan"] + ) + manager.install_from_directory(source_v1, "0.1.0") + manager.registry.update("rollback-preset", {"enabled": False}) - resolver = PresetResolver(project_dir) - for name in LEAN_COMMAND_NAMES: - result = resolver.resolve(name, template_type="command") - assert result is not None, f"Lean override for {name} not resolved" + # Update while disabled, dropping speckit.plan — this makes it a + # stale command that enable must reconcile (and is exactly the code + # path we're about to make fail). + source_v2 = build_preset( + "rollback-preset", ["speckit.specify"], version="2.0.0" + ) + manager.update_from_directory( + source_v2, "0.1.0", pack_id="rollback-preset" + ) + registry_before = manager.registry.get("rollback-preset") + assert registry_before["enabled"] is False -# ===== Bundled Preset Locator Tests ===== + with patch.object( + PresetManager, + "_unregister_commands", + side_effect=RuntimeError("simulated reconciliation failure"), + ), patch.object(Path, "cwd", return_value=project_dir): + result = CliRunner().invoke( + app, ["preset", "enable", "rollback-preset"] + ) + assert result.exit_code != 0 + normalized_output = " ".join(result.output.lower().split()) + assert "registry entry has been restored to disabled" in normalized_output + assert ( + "generated command and skill files may reflect the new version" + in normalized_output + ) -class TestBundledPresetLocator: - """Tests for _locate_bundled_preset discovery function.""" + # Reload registry fresh from disk: it must still show disabled, + # not a half-completed "enabled" state. + manager2 = PresetManager(project_dir) + metadata_after = manager2.registry.get("rollback-preset") + assert metadata_after["enabled"] is False + assert metadata_after["version"] == "2.0.0" - def test_locate_bundled_lean_preset(self): - """_locate_bundled_preset finds the lean preset.""" - from specify_cli import _locate_bundled_preset - - path = _locate_bundled_preset("lean") - assert path is not None - assert (path / "preset.yml").is_file() + @pytest.mark.parametrize( + ("failing_method", "failure_message"), + [ + ("_register_skills", "simulated target registration failure"), + ( + "_reconcile_composed_commands", + "simulated active-agent reconciliation failure", + ), + ], + ) + def test_enable_fails_when_active_agent_refresh_fails( + self, + project_dir, + temp_dir, + failing_method, + failure_message, + ): + """Deferred active-agent refresh failures must reach enable's rollback.""" + from typer.testing import CliRunner + from unittest.mock import patch + from specify_cli import app - def test_locate_bundled_preset_not_found(self): - """_locate_bundled_preset returns None for nonexistent preset.""" - from specify_cli import _locate_bundled_preset + init_options = project_dir / ".specify" / "init-options.json" + init_options.parent.mkdir(parents=True, exist_ok=True) + init_options.write_text( + json.dumps({"ai": "gemini", "ai_skills": False}), + encoding="utf-8", + ) + (project_dir / ".gemini" / "commands").mkdir(parents=True) - path = _locate_bundled_preset("nonexistent-preset") - assert path is None + preset_dir = temp_dir / "strict-refresh-preset" + (preset_dir / "commands").mkdir(parents=True) + (preset_dir / "commands" / "speckit.specify.md").write_text( + "---\ndescription: Strict refresh\n---\n\nStrict refresh body\n", + encoding="utf-8", + ) + (preset_dir / "preset.yml").write_text( + yaml.safe_dump({ + "schema_version": "1.0", + "preset": { + "id": "strict-refresh-preset", + "name": "Strict refresh preset", + "version": "1.0.0", + "description": "Test", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "templates": [{ + "type": "command", + "name": "speckit.specify", + "file": "commands/speckit.specify.md", + }] + }, + }), + encoding="utf-8", + ) + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "0.1.5") + manager.registry.update("strict-refresh-preset", {"enabled": False}) - def test_locate_bundled_preset_rejects_invalid_id(self): - """_locate_bundled_preset rejects IDs with invalid characters.""" - from specify_cli import _locate_bundled_preset + with patch.object( + PresetManager, + failing_method, + side_effect=RuntimeError(failure_message), + ), patch.object(Path, "cwd", return_value=project_dir): + result = CliRunner().invoke( + app, ["preset", "enable", "strict-refresh-preset"] + ) - assert _locate_bundled_preset("../escape") is None - assert _locate_bundled_preset("UPPERCASE") is None - assert _locate_bundled_preset("has spaces") is None + assert result.exit_code != 0 + assert failure_message in result.output + assert "Preset 'strict-refresh-preset' enabled" not in result.output + metadata_after = PresetManager(project_dir).registry.get( + "strict-refresh-preset" + ) + assert metadata_after["enabled"] is False - def test_bundled_preset_add_via_cli(self, project_dir): - """Test that 'specify preset add lean' installs the bundled preset.""" + def test_enable_reconciles_surviving_lower_priority_preset_skill_for_historical_agent( + self, project_dir, temp_dir + ): + """A disabled update that drops a command the preset used to + override must, on enable, hand that command's skill back to a + surviving lower-priority preset for *every* agent directory the + preset's ``registered_skills`` actually spans, including a + historical (currently inactive) agent, not just the one active + agent that ``register_enabled_presets_for_agent`` refreshes. + + Mirrors ``test_remove_reconciles_skill_for_every_historical_agent`` + for the enable-after-disabled-update path (#4441): without + capturing ``_unregister_skills``'s return value and handing it to + ``_reconcile_skills``, the historical (claude) directory is left + showing core/extension content instead of the surviving + lo-priority preset's override. + """ from typer.testing import CliRunner from unittest.mock import patch from specify_cli import app - runner = CliRunner() - with patch.object(Path, "cwd", return_value=project_dir), \ - patch("specify_cli.get_speckit_version", return_value="0.6.0"): - result = runner.invoke(app, ["preset", "add", "lean"]) + self_ = TestPresetSkills() + self_._write_init_options(project_dir, ai="claude", ai_skills=True) + claude_skills = project_dir / ".claude" / "skills" - assert result.exit_code == 0, result.output - assert "Lean Workflow" in result.output - assert "installed" in result.output.lower() + # A core template fallback is required so unregistering the stale + # override restores core content rather than deleting the skill + # directory outright, matching the historical-agent removal test. + core_cmds = project_dir / ".specify" / "templates" / "commands" + core_cmds.mkdir(parents=True, exist_ok=True) + (core_cmds / "specify.md").write_text( + "---\ndescription: Core specify command\n---\n\nCore specify body\n", + encoding="utf-8", + ) - def test_preset_add_from_url_rejects_insecure_redirect(self, project_dir, monkeypatch): - """URL installs reject redirects from HTTPS to non-loopback HTTP.""" - import typer - from specify_cli.presets._commands import preset_add + manager = PresetManager(project_dir) - class FakeResponse(io.BytesIO): - def __enter__(self): - return self + lo_dir = self_._create_command_preset( + temp_dir, "lo-priority-preset", "speckit.specify", "lo", "Lo content" + ) + manager.install_from_directory(lo_dir, "0.1.5", priority=10) - def __exit__(self, exc_type, exc, tb): - return False + hi_dir = self_._create_command_preset( + temp_dir, "hi-priority-preset", "speckit.specify", "hi", "Hi content" + ) + manager.install_from_directory(hi_dir, "0.1.5", priority=1) + assert "Hi content" in ( + claude_skills / "speckit-specify" / "SKILL.md" + ).read_text(encoding="utf-8") - def geturl(self): - return "http://example.com/preset.zip" + # Switch the active integration to codex and rescaffold, mirroring + # `integration use codex` — this records the hi-priority preset's + # registered_skills for codex too, leaving claude as a historical + # (now inactive) agent it still owns a directory in. + self_._write_init_options(project_dir, ai="codex", ai_skills=True) + codex_skills = project_dir / ".agents" / "skills" + manager.register_enabled_presets_for_agent("codex") - monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir) - monkeypatch.setattr("specify_cli.get_speckit_version", lambda: "0.6.0") - def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None): - assert redirect_validator is not None - redirect_validator(url, "http://example.com/preset.zip") - return FakeResponse(b"zip") + metadata_hi = manager.registry.get("hi-priority-preset") + assert set(metadata_hi.get("registered_skills", {})) == {"claude", "codex"}, ( + "sanity: hi-priority-preset's registered_skills must span " + "both the historical (claude) and active (codex) agents" + ) - monkeypatch.setattr("specify_cli.authentication.http.open_url", fake_open_url) + manager.registry.update("hi-priority-preset", {"enabled": False}) - installed = False + # Update the disabled hi-priority preset so it no longer provides + # speckit.specify at all — its skill override becomes stale in + # both the historical and active agent directories. + hi_dir_v2 = self_._create_command_preset( + temp_dir, "hi-priority-preset-v2", "speckit.other", "hi2", "Hi other content" + ) + data = yaml.safe_load((hi_dir_v2 / "preset.yml").read_text()) + data["preset"]["id"] = "hi-priority-preset" + data["preset"]["version"] = "2.0.0" + (hi_dir_v2 / "preset.yml").write_text(yaml.safe_dump(data)) + manager.update_from_directory( + hi_dir_v2, "0.1.5", pack_id="hi-priority-preset" + ) - def fake_install_from_zip(self, zip_path, speckit_version, priority=10): - nonlocal installed - installed = True + # Still disabled: the stale override remains untouched on disk in + # both directories. + assert "Hi content" in ( + claude_skills / "speckit-specify" / "SKILL.md" + ).read_text(encoding="utf-8") + assert "Hi content" in ( + codex_skills / "speckit-specify" / "SKILL.md" + ).read_text(encoding="utf-8") - monkeypatch.setattr(PresetManager, "install_from_zip", fake_install_from_zip) + with patch.object(Path, "cwd", return_value=project_dir): + result = CliRunner().invoke( + app, ["preset", "enable", "hi-priority-preset"] + ) - with pytest.raises(typer.Exit) as exc_info: - preset_add(preset_id=None, from_url="https://example.com/preset.zip", dev=None, priority=10) + assert result.exit_code == 0, result.output + # The surviving lo-priority preset's override must win back both + # directories — codex (currently active, refreshed by + # register_enabled_presets_for_agent) and claude (historical, + # only fixed by the _reconcile_skills(extra_skills_dirs=...) call). + assert "Lo content" in ( + claude_skills / "speckit-specify" / "SKILL.md" + ).read_text(encoding="utf-8"), ( + "the historical (claude) directory must be reconciled back " + "to the surviving lo-priority preset's override, not left " + "showing core/extension content" + ) + assert "Lo content" in ( + codex_skills / "speckit-specify" / "SKILL.md" + ).read_text(encoding="utf-8") - assert exc_info.value.exit_code == 1 - assert installed is False + def test_enable_refreshes_changed_current_artifacts_for_recorded_agents( + self, project_dir, temp_dir + ): + from unittest.mock import patch - def test_preset_add_from_url_rejects_hostless_https_url(self, project_dir): - """URL installs reject HTTPS URLs without a hostname before downloading.""" from typer.testing import CliRunner - from unittest.mock import patch + from specify_cli import app - runner = CliRunner() - with patch.object(Path, "cwd", return_value=project_dir), \ - patch("specify_cli.authentication.http.open_url") as open_url: - result = runner.invoke(app, ["preset", "add", "--from", "https:///preset.zip"]) + skills = TestPresetSkills() + skills._write_init_options(project_dir, ai="claude", ai_skills=True) + manager = PresetManager(project_dir) + source_v1 = skills._create_command_preset( + temp_dir, + "changed-current-preset", + "speckit.specify", + "Changed", + "Changed body v1", + ) + manager.install_from_directory(source_v1, "0.1.0") - assert result.exit_code == 1 - output = strip_ansi(result.output) - assert "URL must use HTTPS with a hostname" in output - assert "got https://" not in output - open_url.assert_not_called() + skills._write_init_options(project_dir, ai="codex", ai_skills=True) + manager.register_enabled_presets_for_agent("codex") + manager.registry.update("changed-current-preset", {"enabled": False}) - def test_preset_add_from_malformed_ipv6_url_exits_cleanly(self, project_dir): - """A malformed IPv6 URL must produce a clean error, not a ValueError traceback.""" - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app + source_v2 = skills._create_command_preset( + temp_dir, + "changed-current-preset-v2", + "speckit.specify", + "Changed", + "Changed body v2", + ) + data = yaml.safe_load((source_v2 / "preset.yml").read_text()) + data["preset"]["id"] = "changed-current-preset" + data["preset"]["version"] = "2.0.0" + (source_v2 / "preset.yml").write_text(yaml.safe_dump(data)) + manager.update_from_directory( + source_v2, "0.1.0", pack_id="changed-current-preset" + ) - runner = CliRunner() - with patch.object(Path, "cwd", return_value=project_dir), \ - patch("specify_cli.authentication.http.open_url") as open_url: - result = runner.invoke( - app, - ["preset", "add", "--from", "https://[::1/preset.zip"], - catch_exceptions=True, + skills._write_init_options(project_dir, ai="amp", ai_skills=True) + with patch.object(Path, "cwd", return_value=project_dir): + result = CliRunner().invoke( + app, ["preset", "enable", "changed-current-preset"] ) - assert result.exit_code == 1 - assert result.exception is None or isinstance(result.exception, SystemExit) - output = strip_ansi(result.output) - assert "Invalid URL" in output - open_url.assert_not_called() + assert result.exit_code == 0, result.output + for skill_dir in ( + project_dir / ".claude" / "skills", + project_dir / ".agents" / "skills", + ): + assert "Changed body v2" in ( + skill_dir / "speckit-specify" / "SKILL.md" + ).read_text(encoding="utf-8") + assert not (project_dir / ".amp" / "skills" / "speckit-specify").exists() - def test_preset_add_from_bracketed_non_ip_url_exits_cleanly(self, project_dir): - """A bracketed-but-invalid IPv6 host in --from must exit cleanly. + def test_enable_refreshes_only_tracked_commands_for_historical_agent( + self, project_dir, temp_dir + ): + from unittest.mock import patch - "https://[not-an-ip]/preset.zip" is a malformed authority that raises - ValueError during URL validation; the try/except guard around parsing - and the .hostname read must turn that into a clean "Invalid URL" message. - """ from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - runner = CliRunner() - with patch.object(Path, "cwd", return_value=project_dir), \ - patch("specify_cli.authentication.http.open_url") as open_url: - result = runner.invoke( - app, - ["preset", "add", "--from", "https://[not-an-ip]/preset.zip"], - catch_exceptions=True, - ) + from specify_cli import app - assert result.exit_code == 1 - assert result.exception is None or isinstance(result.exception, SystemExit) - output = strip_ansi(result.output) - assert "Invalid URL" in output - open_url.assert_not_called() + skills = TestPresetSkills() + skills._write_init_options(project_dir, ai="gemini", ai_skills=False) + (project_dir / ".gemini" / "commands").mkdir(parents=True) + manager = PresetManager(project_dir) + source_v1 = skills._create_command_preset( + temp_dir, + "changed-command-preset", + "speckit.specify", + "Changed", + "Changed body v1", + ) + manager.install_from_directory(source_v1, "0.1.0") - def test_preset_add_from_url_out_of_range_port_exits_cleanly(self, project_dir): - """An out-of-range port raises ValueError lazily on .port access. + skills._write_init_options(project_dir, ai="opencode", ai_skills=False) + (project_dir / ".opencode" / "commands").mkdir(parents=True) + manager.register_enabled_presets_for_agent("opencode") + manager.registry.update("changed-command-preset", {"enabled": False}) - The up-front guard reads ``_parsed.port`` (urllib validates the port - range/syntax there) inside its try/except, so "https://example.com:99999/ - preset.zip" must produce a clean "Invalid URL" message rather than - leaking a raw ValueError traceback past the CLI. - """ - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app + source_v2 = skills._create_command_preset( + temp_dir, + "changed-command-preset-v2", + "speckit.specify", + "Changed", + "Changed body v2", + ) + data = yaml.safe_load((source_v2 / "preset.yml").read_text()) + data["preset"]["id"] = "changed-command-preset" + data["preset"]["version"] = "2.0.0" + data["provides"]["templates"].append( + { + "type": "command", + "name": "speckit.plan", + "file": "commands/speckit.plan.md", + } + ) + (source_v2 / "commands" / "speckit.plan.md").write_text( + "---\ndescription: Plan\n---\n\nNew plan body\n" + ) + (source_v2 / "preset.yml").write_text(yaml.safe_dump(data)) + manager.update_from_directory( + source_v2, "0.1.0", pack_id="changed-command-preset" + ) - runner = CliRunner() - with patch.object(Path, "cwd", return_value=project_dir), \ - patch("specify_cli.authentication.http.open_url") as open_url: - result = runner.invoke( - app, - ["preset", "add", "--from", "https://example.com:99999/preset.zip"], - catch_exceptions=True, + with patch.object(Path, "cwd", return_value=project_dir): + result = CliRunner().invoke( + app, ["preset", "enable", "changed-command-preset"] ) - assert result.exit_code == 1 - assert result.exception is None or isinstance(result.exception, SystemExit) - assert "Invalid URL" in strip_ansi(result.output) - open_url.assert_not_called() - - def test_preset_add_bracketed_host_download_url_exits_cleanly(self, project_dir): - """A catalog download_url with a bracketed non-IP host must render cleanly. + assert result.exit_code == 0, result.output + gemini_commands = project_dir / ".gemini" / "commands" + assert "Changed body v2" in ( + gemini_commands / "speckit.specify.toml" + ).read_text(encoding="utf-8") + assert not (gemini_commands / "speckit.plan.toml").exists() + assert (project_dir / ".opencode" / "commands" / "speckit.plan.md").exists() - ``download_pack`` raises ``PresetError`` whose message embeds the raw URL - (e.g. ``https://[not-an-ip]/x``). The ``preset_add`` handler must escape - that message before printing so Rich does not interpret ``[not-an-ip]`` - as a markup tag and crash while rendering the error. + def test_enable_reconciles_stale_skill_for_namespaced_command( + self, project_dir, temp_dir + ): + """A stale skill left over from a disabled update for a namespaced + command (e.g. ``speckit.git.feature``) must be reconciled back to + the surviving lower-priority preset's content on enable (#4441), + even when the preset whose command went stale never had a + ``registered_commands`` entry for it at all. + + ``speckit.git.feature`` (a 3-segment, extension-style namespaced + command per ``presets/scaffold/preset.yml``) encodes to the skill + directory ``speckit-git-feature``. Recovering the command name from + that directory name by naive reversal (every hyphen after the first + segment treated as literal) collides with ``speckit.git-feature`` + and would target the wrong command; forward-matching avoids that + ambiguity by construction. + + Copilot starts active (skills-opt-in mode), so + ``_register_commands``'s guard never populates + ``registered_commands`` for it there. The active agent is then + switched to ``amp`` -- another command-backed, skills-opt-in + integration -- making copilot *historical*. This matters: + ``register_enabled_presets_for_agent`` only rescaffolds the + newly-active agent's own directory, so if copilot stayed active it + would be fully re-derived (correctly) by that call regardless of + whether the stale-skill candidate search below works at all, + masking the bug. With copilot historical and amp active instead, + neither agent's ``registered_commands`` is ever populated (amp is + also skills-opt-in), and the disabled update also drops the command + from hi-priority-preset's own current manifest. So neither + hi-priority-preset's manifest nor its own ``registered_commands`` + can supply the name for copilot's stale skill -- the only source is + the surviving lo-priority preset's own current manifest, which the + candidate search must reach across the whole installed preset stack + (not just the preset whose skill went stale) to find. """ from typer.testing import CliRunner from unittest.mock import patch from specify_cli import app - bad_url = "https://[not-an-ip]/x" - catalog_data = { - "test-pack": { - "name": "Test Pack", - "version": "1.0.0", - "download_url": bad_url, - } - } + self_ = TestPresetSkills() + self_._write_init_options(project_dir, ai="copilot", ai_skills=True) + copilot_skills = project_dir / ".github" / "skills" - runner = CliRunner() - with patch.object(Path, "cwd", return_value=project_dir), \ - patch.object(PresetCatalog, "_get_merged_packs", return_value=catalog_data): - result = runner.invoke( - app, - ["preset", "add", "test-pack"], - catch_exceptions=True, - ) + manager = PresetManager(project_dir) - assert result.exit_code == 1, result.output - assert result.exception is None or isinstance(result.exception, SystemExit) - output = strip_ansi(result.output) - assert "Error:" in output - # The malformed URL surfaces verbatim rather than crashing the renderer. - assert bad_url in output + lo_dir = self_._create_command_preset( + temp_dir, + "lo-priority-preset", + "speckit.git.feature", + "Lo git feature", + "Lo content", + ) + manager.install_from_directory(lo_dir, "0.1.5", priority=10) - @pytest.mark.parametrize( - ("exc_type", "label"), - [ - (PresetCompatibilityError, "Compatibility Error"), - (PresetValidationError, "Validation Error"), - (PresetError, "Error"), - ], - ) - def test_preset_add_exception_handlers_escape_markup(self, project_dir, exc_type, label): - """Preset install exceptions can include catalog-controlled values. + hi_dir = self_._create_command_preset( + temp_dir, + "hi-priority-preset", + "speckit.git.feature", + "Hi git feature", + "Hi content", + ) + manager.install_from_directory(hi_dir, "0.1.5", priority=1) + assert "Hi content" in ( + copilot_skills / "speckit-git-feature" / "SKILL.md" + ).read_text(encoding="utf-8") - The message must be escaped so Rich does not treat bracketed content as - markup and raise while rendering the error. - """ + hi_metadata = manager.registry.get("hi-priority-preset") + assert not hi_metadata.get("registered_commands", {}).get("copilot"), ( + "sanity: copilot in skills-opt-in mode must not record this " + "override in registered_commands, otherwise the candidate " + "search this test targets can't be reached" + ) + + # Switch the active agent to amp (also command-backed, + # skills-opt-in) so copilot becomes historical -- see the docstring + # for why this is required to actually reach the bug. + self_._write_init_options(project_dir, ai="amp", ai_skills=True) + manager.register_enabled_presets_for_agent("amp") + hi_metadata = manager.registry.get("hi-priority-preset") + assert not hi_metadata.get("registered_commands", {}).get("copilot"), ( + "sanity: switching active agent must not retroactively " + "populate registered_commands for the now-historical copilot" + ) + + manager.registry.update("hi-priority-preset", {"enabled": False}) + + # Update the disabled hi-priority preset so it no longer provides + # speckit.git.feature at all -- its skill override becomes stale, + # and the command is now also absent from its own current manifest. + hi_dir_v2 = self_._create_command_preset( + temp_dir, + "hi-priority-preset-v2", + "speckit.other", + "Unrelated", + "Hi other content", + ) + data = yaml.safe_load((hi_dir_v2 / "preset.yml").read_text()) + data["preset"]["id"] = "hi-priority-preset" + data["preset"]["version"] = "2.0.0" + (hi_dir_v2 / "preset.yml").write_text(yaml.safe_dump(data)) + manager.update_from_directory( + hi_dir_v2, "0.1.5", pack_id="hi-priority-preset" + ) + + # Still disabled: the stale override remains untouched on disk. + assert "Hi content" in ( + copilot_skills / "speckit-git-feature" / "SKILL.md" + ).read_text(encoding="utf-8") + + with patch.object(Path, "cwd", return_value=project_dir): + result = CliRunner().invoke( + app, ["preset", "enable", "hi-priority-preset"] + ) + + assert result.exit_code == 0, result.output + assert "Lo content" in ( + copilot_skills / "speckit-git-feature" / "SKILL.md" + ).read_text(encoding="utf-8"), ( + "the namespaced command's stale skill must be reconciled back " + "to the surviving lo-priority preset's override under its " + "correct command name, not left on stale/core content because " + "of a lossy skill-name-to-command-name reversal" + ) + + def test_disable_corrupted_registry_entry(self, project_dir, pack_dir): + """Test disable fails gracefully for corrupted registry entry.""" from typer.testing import CliRunner from unittest.mock import patch from specify_cli import app - dev_dir = project_dir / "dev-pack" - dev_dir.mkdir() - runner = CliRunner() - with patch.object(Path, "cwd", return_value=project_dir), \ - patch.object( - PresetManager, - "install_from_directory", - side_effect=exc_type("bad [red]preset[/red]"), - ): - result = runner.invoke( - app, - ["preset", "add", "--dev", str(dev_dir)], - catch_exceptions=True, - ) - assert result.exit_code == 1, result.output - assert result.exception is None or isinstance(result.exception, SystemExit) - assert f"{label}:" in result.output - assert "bad [red]preset[/red]" in result.output + # Install preset then corrupt the registry entry + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.5") + manager.registry.data["presets"]["test-pack"] = "corrupted-string" + manager.registry._save() - def test_preset_add_from_url_redirect_error_describes_disallowed_url(self, project_dir, monkeypatch, capsys): - """Redirect rejection message covers hostless HTTPS, not only non-HTTPS URLs.""" - import typer - from specify_cli.presets._commands import preset_add + with patch.object(Path, "cwd", return_value=project_dir): + result = runner.invoke(app, ["preset", "disable", "test-pack"]) - class FakeResponse(io.BytesIO): - def __enter__(self): - return self + assert result.exit_code == 1 + assert "corrupted state" in result.output.lower() - def __exit__(self, exc_type, exc, tb): - return False - def geturl(self): - return "https:///preset.zip" +# ===== Lean Preset Tests ===== - monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir) - monkeypatch.setattr("specify_cli.get_speckit_version", lambda: "0.6.0") - monkeypatch.setattr( - "specify_cli.authentication.http.open_url", - lambda url, timeout=None, extra_headers=None, redirect_validator=None: FakeResponse(b"zip"), - ) - monkeypatch.setattr(PresetManager, "install_from_zip", lambda *args, **kwargs: None) - with pytest.raises(typer.Exit) as exc_info: - preset_add(preset_id=None, from_url="https://example.com/preset.zip", dev=None, priority=10) +LEAN_PRESET_DIR = Path(__file__).parent.parent / "presets" / "lean" +CORE_CONSTITUTION_COMMAND = ( + Path(__file__).parent.parent / "templates" / "commands" / "constitution.md" +) - assert exc_info.value.exit_code == 1 - output = strip_ansi(capsys.readouterr().out) - assert "redirected to a disallowed URL" in output - assert "must use HTTPS with a hostname" in output +LEAN_COMMAND_NAMES = [ + "speckit.specify", + "speckit.plan", + "speckit.tasks", + "speckit.implement", + "speckit.constitution", +] - def test_preset_add_from_url_reads_in_bounded_chunks(self, project_dir, monkeypatch): - """URL installs read the response in bounded chunks.""" - from specify_cli.presets._commands import preset_add - class FakeResponse(io.BytesIO): - def __init__(self, data): - super().__init__(data) - self.read_sizes = [] +@pytest.mark.parametrize( + "command_path", + [ + CORE_CONSTITUTION_COMMAND, + LEAN_PRESET_DIR / "commands" / "speckit.constitution.md", + ], + ids=["core", "lean"], +) +def test_constitution_commands_guard_against_non_governance_work(command_path): + """Constitution commands defer non-governance work instead of executing it.""" + content = command_path.read_text() + lower_content = content.lower() + normalized_content = " ".join(lower_content.split()) - def __enter__(self): - return self + assert "## Scope Guard" in content + assert "**MUST NOT**" in content + assert "Classify every part" in content + assert "application source files" in content + assert "non-governance intent" in content + assert "`Next Actions`" in content + assert "__SPECKIT_COMMAND_SPECIFY__" in content + assert "omit" in lower_content + assert "do not invoke it" in normalized_content or "without invoking it" in normalized_content - def __exit__(self, exc_type, exc, tb): - return False - def geturl(self): - return "https://example.com/preset.zip" +def test_core_constitution_command_resolves_template_at_runtime(): + """The core command must consume the composed scaffold on every invocation.""" + content = CORE_CONSTITUTION_COMMAND.read_text() - def read(self, size=-1): - assert size not in (-1, None) - self.read_sizes.append(size) - return super().read(size) + assert "resolve-template.sh constitution-template --json" in content + assert "resolve-template.ps1 constitution-template -Json" in content + assert "resolve_template.py constitution-template --json" in content + assert "parse `TEMPLATE_CONTENT` as the active template" in content + assert "do not continue with only one contributing" in content + assert "Do not write back to any versioned template layer" in content - response = FakeResponse(b"PK\x05\x06" + b"\x00" * 18) - installed = {} - def fake_install_from_zip(self, zip_path, speckit_version, priority=10): - installed["zip_bytes"] = Path(zip_path).read_bytes() - installed["speckit_version"] = speckit_version - installed["priority"] = priority - return SimpleNamespace(name="Test Preset", version="1.0.0") +def test_core_checklist_command_resolves_template_at_runtime(): + """The checklist command must consume the composed scaffold.""" + content = (CORE_CONSTITUTION_COMMAND.parent / "checklist.md").read_text( + encoding="utf-8" + ) - monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir) - monkeypatch.setattr("specify_cli.get_speckit_version", lambda: "0.6.0") - monkeypatch.setattr( - "specify_cli.authentication.http.open_url", - lambda url, timeout=None, extra_headers=None, redirect_validator=None: response, - ) - monkeypatch.setattr(PresetManager, "install_from_zip", fake_install_from_zip) + assert "--template checklist-template" in content + assert "TEMPLATE_CONTENT" in content + assert "Use TEMPLATE_CONTENT as the structural template" in content - preset_add(preset_id=None, from_url="https://example.com/preset.zip", dev=None, priority=7) - assert response.read_sizes - assert installed == { - "zip_bytes": b"PK\x05\x06" + b"\x00" * 18, - "speckit_version": "0.6.0", - "priority": 7, - } +class TestLeanPreset: + """Tests for the lean preset that ships with the repo.""" - def test_preset_add_from_url_rejects_oversized_download( - self, project_dir, monkeypatch, capsys - ): - """An oversized direct download fails before preset installation.""" - import typer - from specify_cli._download_security import ( - read_response_limited as real_read_response_limited, - ) - from specify_cli.presets import _commands as preset_commands + def test_lean_preset_exists(self): + """Verify the lean preset directory and manifest exist.""" + assert LEAN_PRESET_DIR.exists() + assert (LEAN_PRESET_DIR / "preset.yml").exists() - class FakeResponse(io.BytesIO): - def __enter__(self): - return self + def test_lean_manifest_valid(self): + """Verify the lean preset manifest is valid.""" + manifest = PresetManifest(LEAN_PRESET_DIR / "preset.yml") + assert manifest.id == "lean" + assert manifest.name == "Lean Workflow" + assert manifest.version == "1.0.0" + assert len(manifest.templates) == 5 # 5 commands - def __exit__(self, exc_type, exc, tb): - return False + def test_lean_provides_core_workflow_commands(self): + """Verify the lean preset provides overrides for core workflow commands.""" + manifest = PresetManifest(LEAN_PRESET_DIR / "preset.yml") + provided_names = {t["name"] for t in manifest.templates} + for name in LEAN_COMMAND_NAMES: + assert name in provided_names, f"Lean preset missing command: {name}" - def geturl(self): - return "https://example.com/preset.zip" + def test_lean_command_files_exist(self): + """Verify that all declared command files actually exist on disk.""" + manifest = PresetManifest(LEAN_PRESET_DIR / "preset.yml") + for tmpl in manifest.templates: + tmpl_path = LEAN_PRESET_DIR / tmpl["file"] + assert tmpl_path.exists(), f"Missing command file: {tmpl['file']}" - def read_with_tiny_limit(response, **kwargs): - kwargs.pop("max_bytes", None) - return real_read_response_limited(response, max_bytes=4, **kwargs) + def test_lean_commands_have_no_scripts(self): + """Verify lean commands have no scripts in frontmatter.""" + from specify_cli.agents import CommandRegistrar - installed = False + for name in LEAN_COMMAND_NAMES: + cmd_path = LEAN_PRESET_DIR / "commands" / f"speckit.{name.split('.')[-1]}.md" + content = cmd_path.read_text() + frontmatter, _ = CommandRegistrar.parse_frontmatter(content) + assert "scripts" not in frontmatter, f"{name} should not have scripts in frontmatter" - def fake_install_from_zip(*_args, **_kwargs): - nonlocal installed - installed = True + def test_lean_commands_have_no_hooks(self): + """Verify lean commands do not contain extension hook boilerplate.""" + for name in LEAN_COMMAND_NAMES: + cmd_path = LEAN_PRESET_DIR / "commands" / f"speckit.{name.split('.')[-1]}.md" + content = cmd_path.read_text() + assert "hooks." not in content, f"{name} should not reference extension hooks" + assert "extensions.yml" not in content, f"{name} should not reference extensions.yml" - monkeypatch.setattr( - preset_commands, - "read_response_limited", - read_with_tiny_limit, - ) - monkeypatch.setattr( - "specify_cli._require_specify_project", - lambda: project_dir, - ) - monkeypatch.setattr("specify_cli.get_speckit_version", lambda: "0.6.0") - monkeypatch.setattr( - "specify_cli.authentication.http.open_url", - lambda *_args, **_kwargs: FakeResponse(b"12345"), - ) - monkeypatch.setattr(PresetManager, "install_from_zip", fake_install_from_zip) + def test_install_lean_preset(self, project_dir): + """Test installing the lean preset from its directory.""" + manager = PresetManager(project_dir) + manifest = manager.install_from_directory(LEAN_PRESET_DIR, "0.6.0") + assert manifest.id == "lean" + assert manager.registry.is_installed("lean") - with pytest.raises(typer.Exit) as exc_info: - preset_commands.preset_add( - preset_id=None, - from_url="https://example.com/preset.zip", - dev=None, - priority=10, - ) + def test_lean_overrides_commands(self, project_dir): + """Test that lean preset overrides are resolved correctly.""" + manager = PresetManager(project_dir) + manager.install_from_directory(LEAN_PRESET_DIR, "0.6.0") - assert exc_info.value.exit_code == 1 - output = " ".join(strip_ansi(capsys.readouterr().out).split()) - assert "exceeds maximum size of 4 bytes" in output - assert installed is False + resolver = PresetResolver(project_dir) + for name in LEAN_COMMAND_NAMES: + result = resolver.resolve(name, template_type="command") + assert result is not None, f"Lean override for {name} not resolved" - def test_bundled_preset_in_catalog(self): - """Verify the lean preset is listed in catalog.json with bundled marker.""" - catalog_path = Path(__file__).parent.parent / "presets" / "catalog.json" - catalog = json.loads(catalog_path.read_text()) - assert "lean" in catalog["presets"] - assert catalog["presets"]["lean"]["bundled"] is True - assert "download_url" not in catalog["presets"]["lean"] - def test_bundled_preset_download_raises_error(self, project_dir): - """download_pack raises PresetError for bundled presets without download_url.""" - catalog = PresetCatalog(project_dir) +# ===== Bundled Preset Locator Tests ===== - catalog_data = { - "test-bundled": { - "name": "Test Bundled", - "version": "1.0.0", - "bundled": True, - } - } - from unittest.mock import patch - with patch.object(catalog, "_get_merged_packs", return_value=catalog_data): - with pytest.raises(PresetError, match="bundled with spec-kit"): - catalog.download_pack("test-bundled") - def test_bundled_preset_missing_locally_cli_error(self, project_dir): - """CLI shows clear error when bundled preset cannot be found locally.""" +class TestBundledPresetLocator: + """Tests for _locate_bundled_preset discovery function.""" + + def test_locate_bundled_lean_preset(self): + """_locate_bundled_preset finds the lean preset.""" + from specify_cli import _locate_bundled_preset + + path = _locate_bundled_preset("lean") + assert path is not None + assert (path / "preset.yml").is_file() + + def test_locate_bundled_preset_not_found(self): + """_locate_bundled_preset returns None for nonexistent preset.""" + from specify_cli import _locate_bundled_preset + + path = _locate_bundled_preset("nonexistent-preset") + assert path is None + + def test_locate_bundled_preset_rejects_invalid_id(self): + """_locate_bundled_preset rejects IDs with invalid characters.""" + from specify_cli import _locate_bundled_preset + + assert _locate_bundled_preset("../escape") is None + assert _locate_bundled_preset("UPPERCASE") is None + assert _locate_bundled_preset("has spaces") is None + + def test_bundled_preset_add_via_cli(self, project_dir): + """Test that 'specify preset add lean' installs the bundled preset.""" from typer.testing import CliRunner from unittest.mock import patch from specify_cli import app runner = CliRunner() - # Patch _locate_bundled_preset to return None (simulating missing files) - # and mock the catalog to return a bundled entry for "lean" - fake_pack_info = { - "id": "lean", - "name": "Lean Workflow", - "version": "1.0.0", - "bundled": True, - "_install_allowed": True, - } with patch.object(Path, "cwd", return_value=project_dir), \ - patch("specify_cli._locate_bundled_preset", return_value=None), \ - patch("specify_cli.presets.PresetCatalog") as MockCatalog: - MockCatalog.return_value.get_pack_info.return_value = fake_pack_info + patch("specify_cli.get_speckit_version", return_value="0.6.0"): result = runner.invoke(app, ["preset", "add", "lean"]) - # Should fail with a helpful error explaining this is a bundled preset - # and suggesting how to recover. - assert result.exit_code == 1 - output = strip_ansi(result.output).lower() - assert "bundled" in output, result.output - assert "reinstall" in output, result.output - + assert result.exit_code == 0, result.output + assert "Lean Workflow" in result.output + assert "installed" in result.output.lower() -class TestPresetAddFromUrlResolution: - """CLI-level tests for preset add --from GitHub release resolution.""" + def test_preset_add_from_url_rejects_insecure_redirect(self, project_dir, monkeypatch): + """URL installs reject redirects from HTTPS to non-loopback HTTP.""" + import typer + from specify_cli.presets._commands import preset_add - def test_preset_add_from_github_release_url_resolves_and_downloads(self, project_dir): - """'preset add --from ' resolves to API asset URL.""" - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app + class FakeResponse(io.BytesIO): + def __enter__(self): + return self - manifest_content = yaml.dump({ - "schema_version": "1.0", - "preset": {"id": "my-preset", "name": "My Preset", "version": "1.0.0", "description": "Test preset", "author": "Test", "license": "MIT"}, - "requires": {"speckit_version": ">=0.1.0"}, - "provides": {"templates": [{"type": "template", "name": "t", "file": "templates/t.md", "description": "t"}]}, - }) - zip_buf = __import__("io").BytesIO() - with zipfile.ZipFile(zip_buf, "w") as zf: - zf.writestr("preset.yml", manifest_content) - zip_bytes = zip_buf.getvalue() + def __exit__(self, exc_type, exc, tb): + return False - captured_urls = [] + def geturl(self): + return "http://example.com/preset.zip" + monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir) + monkeypatch.setattr("specify_cli.get_speckit_version", lambda: "0.6.0") def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None): - captured_urls.append((url, extra_headers)) - if "releases/tags/" in url: - return io.BytesIO(json.dumps({ - "assets": [{"name": "preset.zip", "url": "https://api.github.com/repos/org/repo/releases/assets/42"}] - }).encode()) - return io.BytesIO(zip_bytes) + assert redirect_validator is not None + redirect_validator(url, "http://example.com/preset.zip") + return FakeResponse(b"zip") - runner = CliRunner() - with patch.object(Path, "cwd", return_value=project_dir), \ - patch("specify_cli.get_speckit_version", return_value="1.0.0"), \ - patch("specify_cli.authentication.http.open_url", side_effect=fake_open_url): - result = runner.invoke(app, [ - "preset", "add", - "--from", "https://github.com/org/repo/releases/download/v1.0/preset.zip", - ]) + monkeypatch.setattr("specify_cli.authentication.http.open_url", fake_open_url) - assert result.exit_code == 0, result.output - assert "My Preset" in result.output - # First call should resolve the release tag - assert any("releases/tags/v1.0" in url for url, _ in captured_urls) - # Second call should download from the resolved asset URL with octet-stream - asset_calls = [(url, h) for url, h in captured_urls if "releases/assets/" in url] - assert len(asset_calls) >= 1 - assert asset_calls[0][1] == {"Accept": "application/octet-stream"} + installed = False - def test_preset_add_from_direct_api_asset_url_passes_through(self, project_dir): - """'preset add --from ' uses URL directly with octet-stream.""" + def fake_install_from_zip(self, zip_path, speckit_version, priority=10): + nonlocal installed + installed = True + + monkeypatch.setattr(PresetManager, "install_from_zip", fake_install_from_zip) + + with pytest.raises(typer.Exit) as exc_info: + preset_add(preset_id=None, from_url="https://example.com/preset.zip", dev=None, priority=10) + + assert exc_info.value.exit_code == 1 + assert installed is False + + def test_preset_add_from_url_rejects_hostless_https_url(self, project_dir): + """URL installs reject HTTPS URLs without a hostname before downloading.""" from typer.testing import CliRunner from unittest.mock import patch from specify_cli import app - manifest_content = yaml.dump({ - "schema_version": "1.0", - "preset": {"id": "my-preset", "name": "My Preset", "version": "1.0.0", "description": "Test preset", "author": "Test", "license": "MIT"}, - "requires": {"speckit_version": ">=0.1.0"}, - "provides": {"templates": [{"type": "template", "name": "t", "file": "templates/t.md", "description": "t"}]}, - }) - zip_buf = __import__("io").BytesIO() - with zipfile.ZipFile(zip_buf, "w") as zf: - zf.writestr("preset.yml", manifest_content) - zip_bytes = zip_buf.getvalue() + runner = CliRunner() + with patch.object(Path, "cwd", return_value=project_dir), \ + patch("specify_cli.authentication.http.open_url") as open_url: + result = runner.invoke(app, ["preset", "add", "--from", "https:///preset.zip"]) - captured_urls = [] + assert result.exit_code == 1 + output = strip_ansi(result.output) + assert "URL must use HTTPS with a hostname" in output + assert "got https://" not in output + open_url.assert_not_called() - def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None): - captured_urls.append((url, extra_headers)) - return io.BytesIO(zip_bytes) + def test_preset_add_from_malformed_ipv6_url_exits_cleanly(self, project_dir): + """A malformed IPv6 URL must produce a clean error, not a ValueError traceback.""" + from typer.testing import CliRunner + from unittest.mock import patch + from specify_cli import app runner = CliRunner() with patch.object(Path, "cwd", return_value=project_dir), \ - patch("specify_cli.get_speckit_version", return_value="1.0.0"), \ - patch("specify_cli.authentication.http.open_url", side_effect=fake_open_url): - result = runner.invoke(app, [ - "preset", "add", - "--from", "https://api.github.com/repos/org/repo/releases/assets/42", - ]) + patch("specify_cli.authentication.http.open_url") as open_url: + result = runner.invoke( + app, + ["preset", "add", "--from", "https://[::1/preset.zip"], + catch_exceptions=True, + ) - assert result.exit_code == 0, result.output - # Should go directly to the asset URL with Accept header - assert len(captured_urls) == 1 - assert captured_urls[0][0] == "https://api.github.com/repos/org/repo/releases/assets/42" - assert captured_urls[0][1] == {"Accept": "application/octet-stream"} + assert result.exit_code == 1 + assert result.exception is None or isinstance(result.exception, SystemExit) + output = strip_ansi(result.output) + assert "Invalid URL" in output + open_url.assert_not_called() - def test_preset_add_from_ghes_release_url_resolves_via_api_v3(self, project_dir, monkeypatch): - """'preset add --from ' resolves via GHES /api/v3 endpoint.""" + def test_preset_add_from_bracketed_non_ip_url_exits_cleanly(self, project_dir): + """A bracketed-but-invalid IPv6 host in --from must exit cleanly. + + "https://[not-an-ip]/preset.zip" is a malformed authority that raises + ValueError during URL validation; the try/except guard around parsing + and the .hostname read must turn that into a clean "Invalid URL" message. + """ from typer.testing import CliRunner from unittest.mock import patch from specify_cli import app - from specify_cli.authentication import http as _auth_http - from specify_cli.authentication.config import AuthConfigEntry - monkeypatch.setattr(_auth_http, "_config_override", [ - AuthConfigEntry(hosts=("ghes.example",), provider="github", auth="bearer", token="t"), - ]) + runner = CliRunner() + with patch.object(Path, "cwd", return_value=project_dir), \ + patch("specify_cli.authentication.http.open_url") as open_url: + result = runner.invoke( + app, + ["preset", "add", "--from", "https://[not-an-ip]/preset.zip"], + catch_exceptions=True, + ) - manifest_content = yaml.dump({ - "schema_version": "1.0", - "preset": {"id": "my-preset", "name": "My Preset", "version": "1.0.0", "description": "Test preset", "author": "Test", "license": "MIT"}, - "requires": {"speckit_version": ">=0.1.0"}, - "provides": {"templates": [{"type": "template", "name": "t", "file": "templates/t.md", "description": "t"}]}, - }) - zip_buf = io.BytesIO() - with zipfile.ZipFile(zip_buf, "w") as zf: - zf.writestr("preset.yml", manifest_content) - zip_bytes = zip_buf.getvalue() + assert result.exit_code == 1 + assert result.exception is None or isinstance(result.exception, SystemExit) + output = strip_ansi(result.output) + assert "Invalid URL" in output + open_url.assert_not_called() - captured_urls = [] + def test_preset_add_from_url_out_of_range_port_exits_cleanly(self, project_dir): + """An out-of-range port raises ValueError lazily on .port access. - def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None): - captured_urls.append((url, extra_headers)) - if "releases/tags/" in url: - return io.BytesIO(json.dumps({ - "assets": [{"name": "preset.zip", "url": "https://ghes.example/api/v3/repos/org/repo/releases/assets/42"}] - }).encode()) - return io.BytesIO(zip_bytes) + The up-front guard reads ``_parsed.port`` (urllib validates the port + range/syntax there) inside its try/except, so "https://example.com:99999/ + preset.zip" must produce a clean "Invalid URL" message rather than + leaking a raw ValueError traceback past the CLI. + """ + from typer.testing import CliRunner + from unittest.mock import patch + from specify_cli import app runner = CliRunner() with patch.object(Path, "cwd", return_value=project_dir), \ - patch("specify_cli.get_speckit_version", return_value="1.0.0"), \ - patch("specify_cli.authentication.http.open_url", side_effect=fake_open_url): - result = runner.invoke(app, [ - "preset", "add", - "--from", "https://ghes.example/org/repo/releases/download/v1.0/preset.zip", - ]) + patch("specify_cli.authentication.http.open_url") as open_url: + result = runner.invoke( + app, + ["preset", "add", "--from", "https://example.com:99999/preset.zip"], + catch_exceptions=True, + ) - assert result.exit_code == 0, result.output - # The tag-lookup call must use the GHES /api/v3 endpoint - assert any("ghes.example/api/v3/repos/org/repo/releases/tags/v1.0" in url for url, _ in captured_urls) - # The asset download call must carry Accept: application/octet-stream - asset_calls = [(url, h) for url, h in captured_urls if "releases/assets/" in url] - assert len(asset_calls) >= 1 - assert asset_calls[0][1] == {"Accept": "application/octet-stream"} + assert result.exit_code == 1 + assert result.exception is None or isinstance(result.exception, SystemExit) + assert "Invalid URL" in strip_ansi(result.output) + open_url.assert_not_called() + def test_preset_add_bracketed_host_download_url_exits_cleanly(self, project_dir): + """A catalog download_url with a bracketed non-IP host must render cleanly. -class TestWrapStrategy: - """Tests for strategy: wrap preset command substitution.""" + ``download_pack`` raises ``PresetError`` whose message embeds the raw URL + (e.g. ``https://[not-an-ip]/x``). The ``preset_add`` handler must escape + that message before printing so Rich does not interpret ``[not-an-ip]`` + as a markup tag and crash while rendering the error. + """ + from typer.testing import CliRunner + from unittest.mock import patch + from specify_cli import app - def test_substitute_core_template_replaces_placeholder(self, project_dir): - """Core template body replaces {CORE_TEMPLATE} in preset command body.""" - from specify_cli.presets import _substitute_core_template - from specify_cli.agents import CommandRegistrar + bad_url = "https://[not-an-ip]/x" + catalog_data = { + "test-pack": { + "name": "Test Pack", + "version": "1.0.0", + "download_url": bad_url, + } + } - # Set up a core command template - core_dir = project_dir / ".specify" / "templates" / "commands" - core_dir.mkdir(parents=True, exist_ok=True) - (core_dir / "specify.md").write_text( - "---\ndescription: core\n---\n\n# Core Specify\n\nDo the thing.\n" - ) + runner = CliRunner() + with patch.object(Path, "cwd", return_value=project_dir), \ + patch.object(PresetCatalog, "_get_merged_packs", return_value=catalog_data): + result = runner.invoke( + app, + ["preset", "add", "test-pack"], + catch_exceptions=True, + ) - registrar = CommandRegistrar() - body = "## Pre-Logic\n\nBefore stuff.\n\n{CORE_TEMPLATE}\n\n## Post-Logic\n\nAfter stuff.\n" - result, core_fm = _substitute_core_template(body, "specify", project_dir, registrar) + assert result.exit_code == 1, result.output + assert result.exception is None or isinstance(result.exception, SystemExit) + output = strip_ansi(result.output) + assert "Error:" in output + # The malformed URL surfaces verbatim rather than crashing the renderer. + assert bad_url in output - assert "{CORE_TEMPLATE}" not in result - assert "# Core Specify" in result - assert "## Pre-Logic" in result - assert "## Post-Logic" in result - assert core_fm.get("description") == "core" + @pytest.mark.parametrize( + ("exc_type", "label"), + [ + (PresetCompatibilityError, "Compatibility Error"), + (PresetValidationError, "Validation Error"), + (PresetError, "Error"), + ], + ) + def test_preset_add_exception_handlers_escape_markup(self, project_dir, exc_type, label): + """Preset install exceptions can include catalog-controlled values. - def test_substitute_core_template_no_op_when_placeholder_absent(self, project_dir): - """Returns body unchanged when {CORE_TEMPLATE} is not present.""" - from specify_cli.presets import _substitute_core_template - from specify_cli.agents import CommandRegistrar + The message must be escaped so Rich does not treat bracketed content as + markup and raise while rendering the error. + """ + from typer.testing import CliRunner + from unittest.mock import patch + from specify_cli import app - core_dir = project_dir / ".specify" / "templates" / "commands" - core_dir.mkdir(parents=True, exist_ok=True) - (core_dir / "specify.md").write_text("---\ndescription: core\n---\n\nCore body.\n") + dev_dir = project_dir / "dev-pack" + dev_dir.mkdir() - registrar = CommandRegistrar() - body = "## No placeholder here.\n" - result, core_fm = _substitute_core_template(body, "specify", project_dir, registrar) - assert result == body - assert core_fm == {} + runner = CliRunner() + with patch.object(Path, "cwd", return_value=project_dir), \ + patch.object( + PresetManager, + "install_from_directory", + side_effect=exc_type("bad [red]preset[/red]"), + ): + result = runner.invoke( + app, + ["preset", "add", "--dev", str(dev_dir)], + catch_exceptions=True, + ) - def test_substitute_core_template_no_op_when_core_missing(self, project_dir): - """Returns body unchanged when core template file does not exist.""" - from specify_cli.presets import _substitute_core_template - from specify_cli.agents import CommandRegistrar + assert result.exit_code == 1, result.output + assert result.exception is None or isinstance(result.exception, SystemExit) + assert f"{label}:" in result.output + assert "bad [red]preset[/red]" in result.output - registrar = CommandRegistrar() - body = "Pre.\n\n{CORE_TEMPLATE}\n\nPost.\n" - result, core_fm = _substitute_core_template(body, "nonexistent", project_dir, registrar) - assert result == body - assert "{CORE_TEMPLATE}" in result - assert core_fm == {} + def test_preset_add_from_url_redirect_error_describes_disallowed_url(self, project_dir, monkeypatch, capsys): + """Redirect rejection message covers hostless HTTPS, not only non-HTTPS URLs.""" + import typer + from specify_cli.presets._commands import preset_add - def test_substitute_core_template_unreadable_core_treated_as_missing( - self, project_dir - ): - """An undecodable core template must not crash substitution. + class FakeResponse(io.BytesIO): + def __enter__(self): + return self - The wrap-strategy callers (``CommandRegistrar.register_pack`` and - ``_register_commands``) skip an unreadable preset source with a - warning, but the core template read inside - ``_substitute_core_template`` had no boundary, so one corrupted - project-owned override in ``.specify/templates/commands/`` crashed - the whole registration with a raw ``UnicodeDecodeError``. An - unreadable core is treated like a missing one. - """ - from specify_cli.presets import _substitute_core_template - from specify_cli.agents import CommandRegistrar + def __exit__(self, exc_type, exc, tb): + return False - core_dir = project_dir / ".specify" / "templates" / "commands" - core_dir.mkdir(parents=True, exist_ok=True) - (core_dir / "specify.md").write_bytes(b"\xff\xfe not utf-8") + def geturl(self): + return "https:///preset.zip" - registrar = CommandRegistrar() - body = "Pre.\n\n{CORE_TEMPLATE}\n\nPost.\n" - with pytest.warns(UserWarning, match="Ignoring core template"): - result, core_fm = _substitute_core_template( - body, "specify", project_dir, registrar - ) - assert result == body - assert core_fm == {} + monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir) + monkeypatch.setattr("specify_cli.get_speckit_version", lambda: "0.6.0") + monkeypatch.setattr( + "specify_cli.authentication.http.open_url", + lambda url, timeout=None, extra_headers=None, redirect_validator=None: FakeResponse(b"zip"), + ) + monkeypatch.setattr(PresetManager, "install_from_zip", lambda *args, **kwargs: None) - def test_register_commands_substitutes_core_template_for_wrap_strategy(self, project_dir): - """register_commands substitutes {CORE_TEMPLATE} when strategy: wrap.""" - from specify_cli.agents import CommandRegistrar + with pytest.raises(typer.Exit) as exc_info: + preset_add(preset_id=None, from_url="https://example.com/preset.zip", dev=None, priority=10) - # Set up core command template - core_dir = project_dir / ".specify" / "templates" / "commands" - core_dir.mkdir(parents=True, exist_ok=True) - (core_dir / "specify.md").write_text( - "---\ndescription: core\n---\n\n# Core Specify\n\nCore body here.\n" - ) + assert exc_info.value.exit_code == 1 + output = strip_ansi(capsys.readouterr().out) + assert "redirected to a disallowed URL" in output + assert "must use HTTPS with a hostname" in output - # Create a preset command dir with a wrap-strategy command - cmd_dir = project_dir / "preset" / "commands" - cmd_dir.mkdir(parents=True, exist_ok=True) - (cmd_dir / "speckit.specify.md").write_text( - "---\ndescription: wrap test\nstrategy: wrap\n---\n\n" - "## Pre\n\n{CORE_TEMPLATE}\n\n## Post\n" - ) + def test_preset_add_from_url_reads_in_bounded_chunks(self, project_dir, monkeypatch): + """URL installs read the response in bounded chunks.""" + from specify_cli.presets._commands import preset_add - commands = [{"name": "speckit.specify", "file": "commands/speckit.specify.md"}] - registrar = CommandRegistrar() + class FakeResponse(io.BytesIO): + def __init__(self, data): + super().__init__(data) + self.read_sizes = [] - # Use a generic agent that writes markdown to commands/ - agent_dir = project_dir / ".claude" / "commands" - agent_dir.mkdir(parents=True, exist_ok=True) + def __enter__(self): + return self - # Patch AGENT_CONFIGS to use a simple markdown agent pointing at our dir - import copy - original = copy.deepcopy(registrar.AGENT_CONFIGS) - registrar.AGENT_CONFIGS["test-agent"] = { - "dir": str(agent_dir.relative_to(project_dir)), - "format": "markdown", - "args": "$ARGUMENTS", - "extension": ".md", - "strip_frontmatter_keys": [], - } - try: - registrar.register_commands( - "test-agent", commands, "test-preset", - project_dir / "preset", project_dir - ) - finally: - CommandRegistrar.AGENT_CONFIGS.clear() - CommandRegistrar.AGENT_CONFIGS.update(original) + def __exit__(self, exc_type, exc, tb): + return False - written = (agent_dir / "speckit.specify.md").read_text() - assert "{CORE_TEMPLATE}" not in written - assert "# Core Specify" in written - assert "## Pre" in written - assert "## Post" in written + def geturl(self): + return "https://example.com/preset.zip" - def test_end_to_end_wrap_via_self_test_preset(self, project_dir): - """Installing self-test preset with a wrap command substitutes {CORE_TEMPLATE}.""" - from specify_cli.presets import PresetManager + def read(self, size=-1): + assert size not in (-1, None) + self.read_sizes.append(size) + return super().read(size) - # Install a core template that wrap-test will wrap around - core_dir = project_dir / ".specify" / "templates" / "commands" - core_dir.mkdir(parents=True, exist_ok=True) - (core_dir / "wrap-test.md").write_text( - "---\ndescription: core wrap-test\n---\n\n# Core Wrap-Test Body\n" - ) + response = FakeResponse(b"PK\x05\x06" + b"\x00" * 18) + installed = {} - # Set up skills dir (simulating --integration claude) - skills_dir = project_dir / ".claude" / "skills" - skills_dir.mkdir(parents=True, exist_ok=True) - skill_subdir = skills_dir / "speckit-wrap-test" - skill_subdir.mkdir() - (skill_subdir / "SKILL.md").write_text("---\nname: speckit-wrap-test\n---\n\nold content\n") + def fake_install_from_zip(self, zip_path, speckit_version, priority=10): + installed["zip_bytes"] = Path(zip_path).read_bytes() + installed["speckit_version"] = speckit_version + installed["priority"] = priority + return SimpleNamespace(name="Test Preset", version="1.0.0") - # Write init-options so _register_skills finds the claude skills dir - import json - (project_dir / ".specify" / "init-options.json").write_text( - json.dumps({"ai": "claude", "ai_skills": True}) + monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir) + monkeypatch.setattr("specify_cli.get_speckit_version", lambda: "0.6.0") + monkeypatch.setattr( + "specify_cli.authentication.http.open_url", + lambda url, timeout=None, extra_headers=None, redirect_validator=None: response, ) + monkeypatch.setattr(PresetManager, "install_from_zip", fake_install_from_zip) - manager = PresetManager(project_dir) - install_self_test_preset(manager) - - written = (skill_subdir / "SKILL.md").read_text() - assert "{CORE_TEMPLATE}" not in written - assert "# Core Wrap-Test Body" in written - assert "preset:self-test wrap-pre" in written - assert "preset:self-test wrap-post" in written + preset_add(preset_id=None, from_url="https://example.com/preset.zip", dev=None, priority=7) - def test_substitute_core_template_returns_core_scripts(self, project_dir): - """core_frontmatter in the returned tuple includes scripts/agent_scripts.""" - from specify_cli.presets import _substitute_core_template - from specify_cli.agents import CommandRegistrar + assert response.read_sizes + assert installed == { + "zip_bytes": b"PK\x05\x06" + b"\x00" * 18, + "speckit_version": "0.6.0", + "priority": 7, + } - core_dir = project_dir / ".specify" / "templates" / "commands" - core_dir.mkdir(parents=True, exist_ok=True) - (core_dir / "specify.md").write_text( - "---\ndescription: core\nscripts:\n sh: run.sh\nagent_scripts:\n sh: agent-run.sh\n---\n\n# Body\n" + def test_preset_add_from_url_rejects_oversized_download( + self, project_dir, monkeypatch, capsys + ): + """An oversized direct download fails before preset installation.""" + import typer + from specify_cli._download_security import ( + read_response_limited as real_read_response_limited, ) + from specify_cli.presets import _commands as preset_commands - registrar = CommandRegistrar() - body = "## Wrapper\n\n{CORE_TEMPLATE}\n" - result, core_fm = _substitute_core_template(body, "specify", project_dir, registrar) + class FakeResponse(io.BytesIO): + def __enter__(self): + return self - assert "# Body" in result - assert core_fm.get("scripts") == {"sh": "run.sh"} - assert core_fm.get("agent_scripts") == {"sh": "agent-run.sh"} + def __exit__(self, exc_type, exc, tb): + return False - def test_register_skills_inherits_scripts_from_core_when_preset_omits_them(self, project_dir): - """_register_skills merges scripts/agent_scripts from core when preset lacks them.""" - from specify_cli.presets import PresetManager - import json + def geturl(self): + return "https://example.com/preset.zip" - # Core template with scripts - core_dir = project_dir / ".specify" / "templates" / "commands" - core_dir.mkdir(parents=True, exist_ok=True) - (core_dir / "wrap-test.md").write_text( - "---\ndescription: core\nscripts:\n sh: .specify/scripts/run.sh\n---\n\n" - "Run: {SCRIPT}\n" - ) + def read_with_tiny_limit(response, **kwargs): + kwargs.pop("max_bytes", None) + return real_read_response_limited(response, max_bytes=4, **kwargs) - # Skills dir for claude - skills_dir = project_dir / ".claude" / "skills" - skills_dir.mkdir(parents=True, exist_ok=True) - skill_subdir = skills_dir / "speckit-wrap-test" - skill_subdir.mkdir() - (skill_subdir / "SKILL.md").write_text("---\nname: speckit-wrap-test\n---\n\nold\n") + installed = False - (project_dir / ".specify" / "init-options.json").write_text( - json.dumps({"ai": "claude", "ai_skills": True}) + def fake_install_from_zip(*_args, **_kwargs): + nonlocal installed + installed = True + + monkeypatch.setattr( + preset_commands, + "read_response_limited", + read_with_tiny_limit, + ) + monkeypatch.setattr( + "specify_cli._require_specify_project", + lambda: project_dir, + ) + monkeypatch.setattr("specify_cli.get_speckit_version", lambda: "0.6.0") + monkeypatch.setattr( + "specify_cli.authentication.http.open_url", + lambda *_args, **_kwargs: FakeResponse(b"12345"), ) + monkeypatch.setattr(PresetManager, "install_from_zip", fake_install_from_zip) - manager = PresetManager(project_dir) - install_self_test_preset(manager) + with pytest.raises(typer.Exit) as exc_info: + preset_commands.preset_add( + preset_id=None, + from_url="https://example.com/preset.zip", + dev=None, + priority=10, + ) - written = (skill_subdir / "SKILL.md").read_text() - # {SCRIPT} should have been resolved (not left as a literal placeholder) - assert "{SCRIPT}" not in written + assert exc_info.value.exit_code == 1 + output = " ".join(strip_ansi(capsys.readouterr().out).split()) + assert "exceeds maximum size of 4 bytes" in output + assert installed is False - def test_register_skills_preset_scripts_take_precedence_over_core(self, project_dir): - """preset-defined scripts/agent_scripts are not overwritten by core frontmatter.""" - from specify_cli.presets import _substitute_core_template - from specify_cli.agents import CommandRegistrar + def test_bundled_preset_in_catalog(self): + """Verify the lean preset is listed in catalog.json with bundled marker.""" + catalog_path = Path(__file__).parent.parent / "presets" / "catalog.json" + catalog = json.loads(catalog_path.read_text()) + assert "lean" in catalog["presets"] + assert catalog["presets"]["lean"]["bundled"] is True + assert "download_url" not in catalog["presets"]["lean"] - core_dir = project_dir / ".specify" / "templates" / "commands" - core_dir.mkdir(parents=True, exist_ok=True) - (core_dir / "specify.md").write_text( - "---\ndescription: core\nscripts:\n sh: core-run.sh\n---\n\nCore body.\n" - ) + def test_bundled_preset_download_raises_error(self, project_dir): + """download_pack raises PresetError for bundled presets without download_url.""" + catalog = PresetCatalog(project_dir) - registrar = CommandRegistrar() - body = "{CORE_TEMPLATE}" - _, core_fm = _substitute_core_template(body, "specify", project_dir, registrar) + catalog_data = { + "test-bundled": { + "name": "Test Bundled", + "version": "1.0.0", + "bundled": True, + } + } + from unittest.mock import patch + with patch.object(catalog, "_get_merged_packs", return_value=catalog_data): + with pytest.raises(PresetError, match="bundled with spec-kit"): + catalog.download_pack("test-bundled") - # Simulate preset frontmatter that already defines scripts - preset_fm = {"description": "preset", "strategy": "wrap", "scripts": {"sh": "preset-run.sh"}} - for key in ("scripts", "agent_scripts"): - if key not in preset_fm and key in core_fm: - preset_fm[key] = core_fm[key] + def test_bundled_preset_missing_locally_cli_error(self, project_dir): + """CLI shows clear error when bundled preset cannot be found locally.""" + from typer.testing import CliRunner + from unittest.mock import patch + from specify_cli import app - # Preset's scripts must not be overwritten by core - assert preset_fm["scripts"] == {"sh": "preset-run.sh"} + runner = CliRunner() + # Patch _locate_bundled_preset to return None (simulating missing files) + # and mock the catalog to return a bundled entry for "lean" + fake_pack_info = { + "id": "lean", + "name": "Lean Workflow", + "version": "1.0.0", + "bundled": True, + "_install_allowed": True, + } + with patch.object(Path, "cwd", return_value=project_dir), \ + patch("specify_cli._locate_bundled_preset", return_value=None), \ + patch("specify_cli.presets.PresetCatalog") as MockCatalog: + MockCatalog.return_value.get_pack_info.return_value = fake_pack_info + result = runner.invoke(app, ["preset", "add", "lean"]) - def test_register_commands_inherits_scripts_from_core(self, project_dir): - """register_commands merges scripts/agent_scripts from core and normalizes paths.""" - from specify_cli.agents import CommandRegistrar - import copy + # Should fail with a helpful error explaining this is a bundled preset + # and suggesting how to recover. + assert result.exit_code == 1 + output = strip_ansi(result.output).lower() + assert "bundled" in output, result.output + assert "reinstall" in output, result.output - core_dir = project_dir / ".specify" / "templates" / "commands" - core_dir.mkdir(parents=True, exist_ok=True) - (core_dir / "specify.md").write_text( - "---\ndescription: core\nscripts:\n sh: .specify/scripts/run.sh {ARGS}\n---\n\n" - "Run: {SCRIPT}\n" - ) - cmd_dir = project_dir / "preset" / "commands" - cmd_dir.mkdir(parents=True, exist_ok=True) - # Preset has strategy: wrap but no scripts of its own - (cmd_dir / "speckit.specify.md").write_text( - "---\ndescription: wrap no scripts\nstrategy: wrap\n---\n\n" - "## Pre\n\n{CORE_TEMPLATE}\n\n## Post\n" - ) +class TestPresetAddFromUrlResolution: + """CLI-level tests for preset add --from GitHub release resolution.""" - agent_dir = project_dir / ".claude" / "commands" - agent_dir.mkdir(parents=True, exist_ok=True) + def test_preset_add_from_github_release_url_resolves_and_downloads(self, project_dir): + """'preset add --from ' resolves to API asset URL.""" + from typer.testing import CliRunner + from unittest.mock import patch + from specify_cli import app - registrar = CommandRegistrar() - original = copy.deepcopy(registrar.AGENT_CONFIGS) - registrar.AGENT_CONFIGS["test-agent"] = { - "dir": str(agent_dir.relative_to(project_dir)), - "format": "markdown", - "args": "$ARGUMENTS", - "extension": ".md", - "strip_frontmatter_keys": [], - } - try: - registrar.register_commands( - "test-agent", - [{"name": "speckit.specify", "file": "commands/speckit.specify.md"}], - "test-preset", - project_dir / "preset", - project_dir, - ) - finally: - CommandRegistrar.AGENT_CONFIGS.clear() - CommandRegistrar.AGENT_CONFIGS.update(original) + manifest_content = yaml.dump({ + "schema_version": "1.0", + "preset": {"id": "my-preset", "name": "My Preset", "version": "1.0.0", "description": "Test preset", "author": "Test", "license": "MIT"}, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": {"templates": [{"type": "template", "name": "t", "file": "templates/t.md", "description": "t"}]}, + }) + zip_buf = __import__("io").BytesIO() + with zipfile.ZipFile(zip_buf, "w") as zf: + zf.writestr("preset.yml", manifest_content) + zip_bytes = zip_buf.getvalue() - written = (agent_dir / "speckit.specify.md").read_text() - assert "{CORE_TEMPLATE}" not in written - assert "Run:" in written - assert "scripts:" in written - assert "run.sh" in written + captured_urls = [] - def test_register_commands_toml_resolves_inherited_scripts(self, project_dir): - """TOML agents resolve {SCRIPT} from inherited core scripts when preset omits them.""" + def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None): + captured_urls.append((url, extra_headers)) + if "releases/tags/" in url: + return io.BytesIO(json.dumps({ + "assets": [{"name": "preset.zip", "url": "https://api.github.com/repos/org/repo/releases/assets/42"}] + }).encode()) + return io.BytesIO(zip_bytes) + + runner = CliRunner() + with patch.object(Path, "cwd", return_value=project_dir), \ + patch("specify_cli.get_speckit_version", return_value="1.0.0"), \ + patch("specify_cli.authentication.http.open_url", side_effect=fake_open_url): + result = runner.invoke(app, [ + "preset", "add", + "--from", "https://github.com/org/repo/releases/download/v1.0/preset.zip", + ]) + + assert result.exit_code == 0, result.output + assert "My Preset" in result.output + # First call should resolve the release tag + assert any("releases/tags/v1.0" in url for url, _ in captured_urls) + # Second call should download from the resolved asset URL with octet-stream + asset_calls = [(url, h) for url, h in captured_urls if "releases/assets/" in url] + assert len(asset_calls) >= 1 + assert asset_calls[0][1] == {"Accept": "application/octet-stream"} + + def test_preset_add_from_direct_api_asset_url_passes_through(self, project_dir): + """'preset add --from ' uses URL directly with octet-stream.""" + from typer.testing import CliRunner + from unittest.mock import patch + from specify_cli import app + + manifest_content = yaml.dump({ + "schema_version": "1.0", + "preset": {"id": "my-preset", "name": "My Preset", "version": "1.0.0", "description": "Test preset", "author": "Test", "license": "MIT"}, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": {"templates": [{"type": "template", "name": "t", "file": "templates/t.md", "description": "t"}]}, + }) + zip_buf = __import__("io").BytesIO() + with zipfile.ZipFile(zip_buf, "w") as zf: + zf.writestr("preset.yml", manifest_content) + zip_bytes = zip_buf.getvalue() + + captured_urls = [] + + def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None): + captured_urls.append((url, extra_headers)) + return io.BytesIO(zip_bytes) + + runner = CliRunner() + with patch.object(Path, "cwd", return_value=project_dir), \ + patch("specify_cli.get_speckit_version", return_value="1.0.0"), \ + patch("specify_cli.authentication.http.open_url", side_effect=fake_open_url): + result = runner.invoke(app, [ + "preset", "add", + "--from", "https://api.github.com/repos/org/repo/releases/assets/42", + ]) + + assert result.exit_code == 0, result.output + # Should go directly to the asset URL with Accept header + assert len(captured_urls) == 1 + assert captured_urls[0][0] == "https://api.github.com/repos/org/repo/releases/assets/42" + assert captured_urls[0][1] == {"Accept": "application/octet-stream"} + + def test_preset_add_from_ghes_release_url_resolves_via_api_v3(self, project_dir, monkeypatch): + """'preset add --from ' resolves via GHES /api/v3 endpoint.""" + from typer.testing import CliRunner + from unittest.mock import patch + from specify_cli import app + from specify_cli.authentication import http as _auth_http + from specify_cli.authentication.config import AuthConfigEntry + + monkeypatch.setattr(_auth_http, "_config_override", [ + AuthConfigEntry(hosts=("ghes.example",), provider="github", auth="bearer", token="t"), + ]) + + manifest_content = yaml.dump({ + "schema_version": "1.0", + "preset": {"id": "my-preset", "name": "My Preset", "version": "1.0.0", "description": "Test preset", "author": "Test", "license": "MIT"}, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": {"templates": [{"type": "template", "name": "t", "file": "templates/t.md", "description": "t"}]}, + }) + zip_buf = io.BytesIO() + with zipfile.ZipFile(zip_buf, "w") as zf: + zf.writestr("preset.yml", manifest_content) + zip_bytes = zip_buf.getvalue() + + captured_urls = [] + + def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None): + captured_urls.append((url, extra_headers)) + if "releases/tags/" in url: + return io.BytesIO(json.dumps({ + "assets": [{"name": "preset.zip", "url": "https://ghes.example/api/v3/repos/org/repo/releases/assets/42"}] + }).encode()) + return io.BytesIO(zip_bytes) + + runner = CliRunner() + with patch.object(Path, "cwd", return_value=project_dir), \ + patch("specify_cli.get_speckit_version", return_value="1.0.0"), \ + patch("specify_cli.authentication.http.open_url", side_effect=fake_open_url): + result = runner.invoke(app, [ + "preset", "add", + "--from", "https://ghes.example/org/repo/releases/download/v1.0/preset.zip", + ]) + + assert result.exit_code == 0, result.output + # The tag-lookup call must use the GHES /api/v3 endpoint + assert any("ghes.example/api/v3/repos/org/repo/releases/tags/v1.0" in url for url, _ in captured_urls) + # The asset download call must carry Accept: application/octet-stream + asset_calls = [(url, h) for url, h in captured_urls if "releases/assets/" in url] + assert len(asset_calls) >= 1 + assert asset_calls[0][1] == {"Accept": "application/octet-stream"} + + +class TestWrapStrategy: + """Tests for strategy: wrap preset command substitution.""" + + def test_substitute_core_template_replaces_placeholder(self, project_dir): + """Core template body replaces {CORE_TEMPLATE} in preset command body.""" + from specify_cli.presets import _substitute_core_template from specify_cli.agents import CommandRegistrar - import copy + # Set up a core command template core_dir = project_dir / ".specify" / "templates" / "commands" core_dir.mkdir(parents=True, exist_ok=True) (core_dir / "specify.md").write_text( - "---\ndescription: core\nscripts:\n sh: .specify/scripts/run.sh {ARGS}\n---\n\n" - "Run: {SCRIPT}\n" + "---\ndescription: core\n---\n\n# Core Specify\n\nDo the thing.\n" ) - cmd_dir = project_dir / "preset" / "commands" - cmd_dir.mkdir(parents=True, exist_ok=True) - (cmd_dir / "speckit.specify.md").write_text( - "---\ndescription: toml wrap\nstrategy: wrap\n---\n\n" - "## Pre\n\n{CORE_TEMPLATE}\n\n## Post\n" - ) + registrar = CommandRegistrar() + body = "## Pre-Logic\n\nBefore stuff.\n\n{CORE_TEMPLATE}\n\n## Post-Logic\n\nAfter stuff.\n" + result, core_fm = _substitute_core_template(body, "specify", project_dir, registrar) - toml_dir = project_dir / ".gemini" / "commands" - toml_dir.mkdir(parents=True, exist_ok=True) + assert "{CORE_TEMPLATE}" not in result + assert "# Core Specify" in result + assert "## Pre-Logic" in result + assert "## Post-Logic" in result + assert core_fm.get("description") == "core" + + def test_substitute_core_template_no_op_when_placeholder_absent(self, project_dir): + """Returns body unchanged when {CORE_TEMPLATE} is not present.""" + from specify_cli.presets import _substitute_core_template + from specify_cli.agents import CommandRegistrar + + core_dir = project_dir / ".specify" / "templates" / "commands" + core_dir.mkdir(parents=True, exist_ok=True) + (core_dir / "specify.md").write_text("---\ndescription: core\n---\n\nCore body.\n") registrar = CommandRegistrar() - original = copy.deepcopy(registrar.AGENT_CONFIGS) - registrar.AGENT_CONFIGS["test-toml-agent"] = { - "dir": str(toml_dir.relative_to(project_dir)), - "format": "toml", - "args": "{{args}}", - "extension": ".toml", - "strip_frontmatter_keys": [], - } - try: - registrar.register_commands( - "test-toml-agent", - [{"name": "speckit.specify", "file": "commands/speckit.specify.md"}], - "test-preset", - project_dir / "preset", - project_dir, - ) - finally: - CommandRegistrar.AGENT_CONFIGS.clear() - CommandRegistrar.AGENT_CONFIGS.update(original) + body = "## No placeholder here.\n" + result, core_fm = _substitute_core_template(body, "specify", project_dir, registrar) + assert result == body + assert core_fm == {} - written = (toml_dir / "speckit.specify.toml").read_text() - assert "{CORE_TEMPLATE}" not in written - assert "{SCRIPT}" not in written - assert "run.sh" in written - # args token must use TOML format, not the intermediate $ARGUMENTS - assert "$ARGUMENTS" not in written - assert "{{args}}" in written + def test_substitute_core_template_no_op_when_core_missing(self, project_dir): + """Returns body unchanged when core template file does not exist.""" + from specify_cli.presets import _substitute_core_template + from specify_cli.agents import CommandRegistrar - def test_register_commands_markdown_resolves_inherited_scripts(self, project_dir): - """Markdown agents resolve {SCRIPT} from inherited core scripts when preset omits them.""" + registrar = CommandRegistrar() + body = "Pre.\n\n{CORE_TEMPLATE}\n\nPost.\n" + result, core_fm = _substitute_core_template(body, "nonexistent", project_dir, registrar) + assert result == body + assert "{CORE_TEMPLATE}" in result + assert core_fm == {} + + def test_substitute_core_template_unreadable_core_treated_as_missing( + self, project_dir + ): + """An undecodable core template must not crash substitution. + + The wrap-strategy callers (``CommandRegistrar.register_pack`` and + ``_register_commands``) skip an unreadable preset source with a + warning, but the core template read inside + ``_substitute_core_template`` had no boundary, so one corrupted + project-owned override in ``.specify/templates/commands/`` crashed + the whole registration with a raw ``UnicodeDecodeError``. An + unreadable core is treated like a missing one. + """ + from specify_cli.presets import _substitute_core_template + from specify_cli.agents import CommandRegistrar + + core_dir = project_dir / ".specify" / "templates" / "commands" + core_dir.mkdir(parents=True, exist_ok=True) + (core_dir / "specify.md").write_bytes(b"\xff\xfe not utf-8") + + registrar = CommandRegistrar() + body = "Pre.\n\n{CORE_TEMPLATE}\n\nPost.\n" + with pytest.warns(UserWarning, match="Ignoring core template"): + result, core_fm = _substitute_core_template( + body, "specify", project_dir, registrar + ) + assert result == body + assert core_fm == {} + + def test_register_commands_substitutes_core_template_for_wrap_strategy(self, project_dir): + """register_commands substitutes {CORE_TEMPLATE} when strategy: wrap.""" from specify_cli.agents import CommandRegistrar - import copy + # Set up core command template core_dir = project_dir / ".specify" / "templates" / "commands" core_dir.mkdir(parents=True, exist_ok=True) (core_dir / "specify.md").write_text( - "---\ndescription: core\nscripts:\n sh: .specify/scripts/run.sh {ARGS}\n---\n\n" - "Run: {SCRIPT}\n" + "---\ndescription: core\n---\n\n# Core Specify\n\nCore body here.\n" ) + # Create a preset command dir with a wrap-strategy command cmd_dir = project_dir / "preset" / "commands" cmd_dir.mkdir(parents=True, exist_ok=True) (cmd_dir / "speckit.specify.md").write_text( - "---\ndescription: markdown wrap\nstrategy: wrap\n---\n\n" + "---\ndescription: wrap test\nstrategy: wrap\n---\n\n" "## Pre\n\n{CORE_TEMPLATE}\n\n## Post\n" ) + commands = [{"name": "speckit.specify", "file": "commands/speckit.specify.md"}] + registrar = CommandRegistrar() + + # Use a generic agent that writes markdown to commands/ agent_dir = project_dir / ".claude" / "commands" agent_dir.mkdir(parents=True, exist_ok=True) - registrar = CommandRegistrar() + # Patch AGENT_CONFIGS to use a simple markdown agent pointing at our dir + import copy original = copy.deepcopy(registrar.AGENT_CONFIGS) - registrar.AGENT_CONFIGS["test-md-agent"] = { + registrar.AGENT_CONFIGS["test-agent"] = { "dir": str(agent_dir.relative_to(project_dir)), "format": "markdown", "args": "$ARGUMENTS", @@ -12386,11 +12934,8 @@ def test_register_commands_markdown_resolves_inherited_scripts(self, project_dir } try: registrar.register_commands( - "test-md-agent", - [{"name": "speckit.specify", "file": "commands/speckit.specify.md"}], - "test-preset", - project_dir / "preset", - project_dir, + "test-agent", commands, "test-preset", + project_dir / "preset", project_dir ) finally: CommandRegistrar.AGENT_CONFIGS.clear() @@ -12398,16 +12943,119 @@ def test_register_commands_markdown_resolves_inherited_scripts(self, project_dir written = (agent_dir / "speckit.specify.md").read_text() assert "{CORE_TEMPLATE}" not in written + assert "# Core Specify" in written + assert "## Pre" in written + assert "## Post" in written + + def test_end_to_end_wrap_via_self_test_preset(self, project_dir): + """Installing self-test preset with a wrap command substitutes {CORE_TEMPLATE}.""" + from specify_cli.presets import PresetManager + + # Install a core template that wrap-test will wrap around + core_dir = project_dir / ".specify" / "templates" / "commands" + core_dir.mkdir(parents=True, exist_ok=True) + (core_dir / "wrap-test.md").write_text( + "---\ndescription: core wrap-test\n---\n\n# Core Wrap-Test Body\n" + ) + + # Set up skills dir (simulating --integration claude) + skills_dir = project_dir / ".claude" / "skills" + skills_dir.mkdir(parents=True, exist_ok=True) + skill_subdir = skills_dir / "speckit-wrap-test" + skill_subdir.mkdir() + (skill_subdir / "SKILL.md").write_text("---\nname: speckit-wrap-test\n---\n\nold content\n") + + # Write init-options so _register_skills finds the claude skills dir + import json + (project_dir / ".specify" / "init-options.json").write_text( + json.dumps({"ai": "claude", "ai_skills": True}) + ) + + manager = PresetManager(project_dir) + install_self_test_preset(manager) + + written = (skill_subdir / "SKILL.md").read_text() + assert "{CORE_TEMPLATE}" not in written + assert "# Core Wrap-Test Body" in written + assert "preset:self-test wrap-pre" in written + assert "preset:self-test wrap-post" in written + + def test_substitute_core_template_returns_core_scripts(self, project_dir): + """core_frontmatter in the returned tuple includes scripts/agent_scripts.""" + from specify_cli.presets import _substitute_core_template + from specify_cli.agents import CommandRegistrar + + core_dir = project_dir / ".specify" / "templates" / "commands" + core_dir.mkdir(parents=True, exist_ok=True) + (core_dir / "specify.md").write_text( + "---\ndescription: core\nscripts:\n sh: run.sh\nagent_scripts:\n sh: agent-run.sh\n---\n\n# Body\n" + ) + + registrar = CommandRegistrar() + body = "## Wrapper\n\n{CORE_TEMPLATE}\n" + result, core_fm = _substitute_core_template(body, "specify", project_dir, registrar) + + assert "# Body" in result + assert core_fm.get("scripts") == {"sh": "run.sh"} + assert core_fm.get("agent_scripts") == {"sh": "agent-run.sh"} + + def test_register_skills_inherits_scripts_from_core_when_preset_omits_them(self, project_dir): + """_register_skills merges scripts/agent_scripts from core when preset lacks them.""" + from specify_cli.presets import PresetManager + import json + + # Core template with scripts + core_dir = project_dir / ".specify" / "templates" / "commands" + core_dir.mkdir(parents=True, exist_ok=True) + (core_dir / "wrap-test.md").write_text( + "---\ndescription: core\nscripts:\n sh: .specify/scripts/run.sh\n---\n\n" + "Run: {SCRIPT}\n" + ) + + # Skills dir for claude + skills_dir = project_dir / ".claude" / "skills" + skills_dir.mkdir(parents=True, exist_ok=True) + skill_subdir = skills_dir / "speckit-wrap-test" + skill_subdir.mkdir() + (skill_subdir / "SKILL.md").write_text("---\nname: speckit-wrap-test\n---\n\nold\n") + + (project_dir / ".specify" / "init-options.json").write_text( + json.dumps({"ai": "claude", "ai_skills": True}) + ) + + manager = PresetManager(project_dir) + install_self_test_preset(manager) + + written = (skill_subdir / "SKILL.md").read_text() + # {SCRIPT} should have been resolved (not left as a literal placeholder) assert "{SCRIPT}" not in written - assert "run.sh" in written - assert "strategy" not in written - def test_register_commands_markdown_converts_args_after_script_resolution(self, project_dir): - """Markdown agents re-run arg placeholder conversion after resolve_skill_placeholders. + def test_register_skills_preset_scripts_take_precedence_over_core(self, project_dir): + """preset-defined scripts/agent_scripts are not overwritten by core frontmatter.""" + from specify_cli.presets import _substitute_core_template + from specify_cli.agents import CommandRegistrar - resolve_skill_placeholders injects $ARGUMENTS (via {ARGS} expansion). A second - _convert_argument_placeholder call must convert those to the agent's native format. - """ + core_dir = project_dir / ".specify" / "templates" / "commands" + core_dir.mkdir(parents=True, exist_ok=True) + (core_dir / "specify.md").write_text( + "---\ndescription: core\nscripts:\n sh: core-run.sh\n---\n\nCore body.\n" + ) + + registrar = CommandRegistrar() + body = "{CORE_TEMPLATE}" + _, core_fm = _substitute_core_template(body, "specify", project_dir, registrar) + + # Simulate preset frontmatter that already defines scripts + preset_fm = {"description": "preset", "strategy": "wrap", "scripts": {"sh": "preset-run.sh"}} + for key in ("scripts", "agent_scripts"): + if key not in preset_fm and key in core_fm: + preset_fm[key] = core_fm[key] + + # Preset's scripts must not be overwritten by core + assert preset_fm["scripts"] == {"sh": "preset-run.sh"} + + def test_register_commands_inherits_scripts_from_core(self, project_dir): + """register_commands merges scripts/agent_scripts from core and normalizes paths.""" from specify_cli.agents import CommandRegistrar import copy @@ -12420,26 +13068,27 @@ def test_register_commands_markdown_converts_args_after_script_resolution(self, cmd_dir = project_dir / "preset" / "commands" cmd_dir.mkdir(parents=True, exist_ok=True) + # Preset has strategy: wrap but no scripts of its own (cmd_dir / "speckit.specify.md").write_text( - "---\ndescription: forge wrap\nstrategy: wrap\n---\n\n" + "---\ndescription: wrap no scripts\nstrategy: wrap\n---\n\n" "## Pre\n\n{CORE_TEMPLATE}\n\n## Post\n" ) - agent_dir = project_dir / ".forge" / "commands" + agent_dir = project_dir / ".claude" / "commands" agent_dir.mkdir(parents=True, exist_ok=True) registrar = CommandRegistrar() original = copy.deepcopy(registrar.AGENT_CONFIGS) - registrar.AGENT_CONFIGS["test-forge-agent"] = { + registrar.AGENT_CONFIGS["test-agent"] = { "dir": str(agent_dir.relative_to(project_dir)), "format": "markdown", - "args": "{{parameters}}", + "args": "$ARGUMENTS", "extension": ".md", "strip_frontmatter_keys": [], } try: registrar.register_commands( - "test-forge-agent", + "test-agent", [{"name": "speckit.specify", "file": "commands/speckit.specify.md"}], "test-preset", project_dir / "preset", @@ -12450,71 +13099,224 @@ def test_register_commands_markdown_converts_args_after_script_resolution(self, CommandRegistrar.AGENT_CONFIGS.update(original) written = (agent_dir / "speckit.specify.md").read_text() - assert "{SCRIPT}" not in written + assert "{CORE_TEMPLATE}" not in written + assert "Run:" in written + assert "scripts:" in written assert "run.sh" in written - # $ARGUMENTS injected by resolve_skill_placeholders must be re-converted - assert "$ARGUMENTS" not in written - assert "{{parameters}}" in written - - def test_extension_command_resolves_via_extension_directory(self, project_dir): - """Extension commands (e.g. speckit.git.feature) resolve from the extension directory. - Both _register_skills and register_commands pass the full cmd_name to - _substitute_core_template, which tries the full name first via PresetResolver - and finds speckit.git.feature.md in the extension commands directory. - """ - from specify_cli.presets import _substitute_core_template + def test_register_commands_toml_resolves_inherited_scripts(self, project_dir): + """TOML agents resolve {SCRIPT} from inherited core scripts when preset omits them.""" from specify_cli.agents import CommandRegistrar + import copy - # Place the template where a real extension would install it - ext_cmd_dir = project_dir / ".specify" / "extensions" / "git" / "commands" - ext_cmd_dir.mkdir(parents=True, exist_ok=True) - (ext_cmd_dir / "speckit.git.feature.md").write_text( - "---\ndescription: git feature core\n---\n\n# Git Feature Core\n" + core_dir = project_dir / ".specify" / "templates" / "commands" + core_dir.mkdir(parents=True, exist_ok=True) + (core_dir / "specify.md").write_text( + "---\ndescription: core\nscripts:\n sh: .specify/scripts/run.sh {ARGS}\n---\n\n" + "Run: {SCRIPT}\n" ) - # Ensure a hyphenated or dot-separated fallback does NOT exist - assert not (project_dir / ".specify" / "templates" / "commands" / "git.feature.md").exists() - assert not (project_dir / ".specify" / "templates" / "commands" / "git-feature.md").exists() - registrar = CommandRegistrar() - body = "## Wrapper\n\n{CORE_TEMPLATE}\n" + cmd_dir = project_dir / "preset" / "commands" + cmd_dir.mkdir(parents=True, exist_ok=True) + (cmd_dir / "speckit.specify.md").write_text( + "---\ndescription: toml wrap\nstrategy: wrap\n---\n\n" + "## Pre\n\n{CORE_TEMPLATE}\n\n## Post\n" + ) - # Both call sites now pass the full cmd_name - result, _ = _substitute_core_template(body, "speckit.git.feature", project_dir, registrar) + toml_dir = project_dir / ".gemini" / "commands" + toml_dir.mkdir(parents=True, exist_ok=True) - assert "# Git Feature Core" in result - assert "{CORE_TEMPLATE}" not in result + registrar = CommandRegistrar() + original = copy.deepcopy(registrar.AGENT_CONFIGS) + registrar.AGENT_CONFIGS["test-toml-agent"] = { + "dir": str(toml_dir.relative_to(project_dir)), + "format": "toml", + "args": "{{args}}", + "extension": ".toml", + "strip_frontmatter_keys": [], + } + try: + registrar.register_commands( + "test-toml-agent", + [{"name": "speckit.specify", "file": "commands/speckit.specify.md"}], + "test-preset", + project_dir / "preset", + project_dir, + ) + finally: + CommandRegistrar.AGENT_CONFIGS.clear() + CommandRegistrar.AGENT_CONFIGS.update(original) - def test_extension_command_resolves_via_manifest_when_filename_differs(self, project_dir): - """Extension commands whose filename differs from the command name resolve via extension.yml. + written = (toml_dir / "speckit.specify.toml").read_text() + assert "{CORE_TEMPLATE}" not in written + assert "{SCRIPT}" not in written + assert "run.sh" in written + # args token must use TOML format, not the intermediate $ARGUMENTS + assert "$ARGUMENTS" not in written + assert "{{args}}" in written - The selftest extension maps speckit.selftest.extension → commands/selftest.md. - Name-based lookup would look for commands/speckit.selftest.extension.md and fail; - manifest-based lookup must find the actual file declared in the manifest. - """ - from specify_cli.presets import _substitute_core_template + def test_register_commands_markdown_resolves_inherited_scripts(self, project_dir): + """Markdown agents resolve {SCRIPT} from inherited core scripts when preset omits them.""" from specify_cli.agents import CommandRegistrar + import copy - ext_dir = project_dir / ".specify" / "extensions" / "selftest" - cmd_dir = ext_dir / "commands" - cmd_dir.mkdir(parents=True, exist_ok=True) - - # File is named selftest.md, NOT speckit.selftest.extension.md - (cmd_dir / "selftest.md").write_text( - "---\ndescription: selftest core\n---\n\n# Selftest Core\n" + core_dir = project_dir / ".specify" / "templates" / "commands" + core_dir.mkdir(parents=True, exist_ok=True) + (core_dir / "specify.md").write_text( + "---\ndescription: core\nscripts:\n sh: .specify/scripts/run.sh {ARGS}\n---\n\n" + "Run: {SCRIPT}\n" ) - # Manifest maps the command name to the actual file - (ext_dir / "extension.yml").write_text( - "schema_version: '1.0'\n" - "extension:\n id: selftest\n name: Self-Test\n version: 1.0.0\n" - " description: test\n author: test\n repository: https://example.com\n" - " license: MIT\n" - "requires:\n speckit_version: '>=0.2.0'\n" - "provides:\n" - " commands:\n" - " - name: speckit.selftest.extension\n" - " file: commands/selftest.md\n" - " description: Selftest command\n" + + cmd_dir = project_dir / "preset" / "commands" + cmd_dir.mkdir(parents=True, exist_ok=True) + (cmd_dir / "speckit.specify.md").write_text( + "---\ndescription: markdown wrap\nstrategy: wrap\n---\n\n" + "## Pre\n\n{CORE_TEMPLATE}\n\n## Post\n" + ) + + agent_dir = project_dir / ".claude" / "commands" + agent_dir.mkdir(parents=True, exist_ok=True) + + registrar = CommandRegistrar() + original = copy.deepcopy(registrar.AGENT_CONFIGS) + registrar.AGENT_CONFIGS["test-md-agent"] = { + "dir": str(agent_dir.relative_to(project_dir)), + "format": "markdown", + "args": "$ARGUMENTS", + "extension": ".md", + "strip_frontmatter_keys": [], + } + try: + registrar.register_commands( + "test-md-agent", + [{"name": "speckit.specify", "file": "commands/speckit.specify.md"}], + "test-preset", + project_dir / "preset", + project_dir, + ) + finally: + CommandRegistrar.AGENT_CONFIGS.clear() + CommandRegistrar.AGENT_CONFIGS.update(original) + + written = (agent_dir / "speckit.specify.md").read_text() + assert "{CORE_TEMPLATE}" not in written + assert "{SCRIPT}" not in written + assert "run.sh" in written + assert "strategy" not in written + + def test_register_commands_markdown_converts_args_after_script_resolution(self, project_dir): + """Markdown agents re-run arg placeholder conversion after resolve_skill_placeholders. + + resolve_skill_placeholders injects $ARGUMENTS (via {ARGS} expansion). A second + _convert_argument_placeholder call must convert those to the agent's native format. + """ + from specify_cli.agents import CommandRegistrar + import copy + + core_dir = project_dir / ".specify" / "templates" / "commands" + core_dir.mkdir(parents=True, exist_ok=True) + (core_dir / "specify.md").write_text( + "---\ndescription: core\nscripts:\n sh: .specify/scripts/run.sh {ARGS}\n---\n\n" + "Run: {SCRIPT}\n" + ) + + cmd_dir = project_dir / "preset" / "commands" + cmd_dir.mkdir(parents=True, exist_ok=True) + (cmd_dir / "speckit.specify.md").write_text( + "---\ndescription: forge wrap\nstrategy: wrap\n---\n\n" + "## Pre\n\n{CORE_TEMPLATE}\n\n## Post\n" + ) + + agent_dir = project_dir / ".forge" / "commands" + agent_dir.mkdir(parents=True, exist_ok=True) + + registrar = CommandRegistrar() + original = copy.deepcopy(registrar.AGENT_CONFIGS) + registrar.AGENT_CONFIGS["test-forge-agent"] = { + "dir": str(agent_dir.relative_to(project_dir)), + "format": "markdown", + "args": "{{parameters}}", + "extension": ".md", + "strip_frontmatter_keys": [], + } + try: + registrar.register_commands( + "test-forge-agent", + [{"name": "speckit.specify", "file": "commands/speckit.specify.md"}], + "test-preset", + project_dir / "preset", + project_dir, + ) + finally: + CommandRegistrar.AGENT_CONFIGS.clear() + CommandRegistrar.AGENT_CONFIGS.update(original) + + written = (agent_dir / "speckit.specify.md").read_text() + assert "{SCRIPT}" not in written + assert "run.sh" in written + # $ARGUMENTS injected by resolve_skill_placeholders must be re-converted + assert "$ARGUMENTS" not in written + assert "{{parameters}}" in written + + def test_extension_command_resolves_via_extension_directory(self, project_dir): + """Extension commands (e.g. speckit.git.feature) resolve from the extension directory. + + Both _register_skills and register_commands pass the full cmd_name to + _substitute_core_template, which tries the full name first via PresetResolver + and finds speckit.git.feature.md in the extension commands directory. + """ + from specify_cli.presets import _substitute_core_template + from specify_cli.agents import CommandRegistrar + + # Place the template where a real extension would install it + ext_cmd_dir = project_dir / ".specify" / "extensions" / "git" / "commands" + ext_cmd_dir.mkdir(parents=True, exist_ok=True) + (ext_cmd_dir / "speckit.git.feature.md").write_text( + "---\ndescription: git feature core\n---\n\n# Git Feature Core\n" + ) + # Ensure a hyphenated or dot-separated fallback does NOT exist + assert not (project_dir / ".specify" / "templates" / "commands" / "git.feature.md").exists() + assert not (project_dir / ".specify" / "templates" / "commands" / "git-feature.md").exists() + + registrar = CommandRegistrar() + body = "## Wrapper\n\n{CORE_TEMPLATE}\n" + + # Both call sites now pass the full cmd_name + result, _ = _substitute_core_template(body, "speckit.git.feature", project_dir, registrar) + + assert "# Git Feature Core" in result + assert "{CORE_TEMPLATE}" not in result + + def test_extension_command_resolves_via_manifest_when_filename_differs(self, project_dir): + """Extension commands whose filename differs from the command name resolve via extension.yml. + + The selftest extension maps speckit.selftest.extension → commands/selftest.md. + Name-based lookup would look for commands/speckit.selftest.extension.md and fail; + manifest-based lookup must find the actual file declared in the manifest. + """ + from specify_cli.presets import _substitute_core_template + from specify_cli.agents import CommandRegistrar + + ext_dir = project_dir / ".specify" / "extensions" / "selftest" + cmd_dir = ext_dir / "commands" + cmd_dir.mkdir(parents=True, exist_ok=True) + + # File is named selftest.md, NOT speckit.selftest.extension.md + (cmd_dir / "selftest.md").write_text( + "---\ndescription: selftest core\n---\n\n# Selftest Core\n" + ) + # Manifest maps the command name to the actual file + (ext_dir / "extension.yml").write_text( + "schema_version: '1.0'\n" + "extension:\n id: selftest\n name: Self-Test\n version: 1.0.0\n" + " description: test\n author: test\n repository: https://example.com\n" + " license: MIT\n" + "requires:\n speckit_version: '>=0.2.0'\n" + "provides:\n" + " commands:\n" + " - name: speckit.selftest.extension\n" + " file: commands/selftest.md\n" + " description: Selftest command\n" ) registrar = CommandRegistrar() @@ -13107,1547 +13909,3765 @@ def test_resolve_content_wrap_strategy(self, project_dir, temp_dir, valid_pack_d "# Wrapper Start\n\n{CORE_TEMPLATE}\n\n# Wrapper End\n" ) - manager = PresetManager(project_dir) - manager.install_from_directory(pack_dir, "0.1.5") + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.5") + + resolver = PresetResolver(project_dir) + content = resolver.resolve_content("spec-template") + assert content is not None + assert "Wrapper Start" in content + assert "Core Spec Template" in content + assert "Wrapper End" in content + # Wrapper should surround core + assert content.index("Wrapper Start") < content.index("Core Spec Template") + assert content.index("Core Spec Template") < content.index("Wrapper End") + + def test_resolve_content_wrap_strategy_script(self, project_dir, temp_dir, valid_pack_data): + """Test resolve_content with wrap strategy for scripts uses $CORE_SCRIPT.""" + # Create core script + scripts_dir = project_dir / ".specify" / "templates" / "scripts" + scripts_dir.mkdir(parents=True, exist_ok=True) + (scripts_dir / "test-script.sh").write_text("echo 'core script'\n") + + pack_data = {**valid_pack_data} + pack_data["preset"] = {**valid_pack_data["preset"], "id": "script-wrap", "name": "Script Wrap"} + pack_data["provides"] = { + "templates": [{ + "type": "script", + "name": "test-script", + "file": "scripts/test-script.sh", + "strategy": "wrap", + }] + } + pack_dir = temp_dir / "script-wrap" + pack_dir.mkdir() + with open(pack_dir / "preset.yml", 'w') as f: + yaml.dump(pack_data, f) + (pack_dir / "scripts").mkdir() + (pack_dir / "scripts" / "test-script.sh").write_text( + "#!/bin/bash\necho 'before'\n$CORE_SCRIPT\necho 'after'\n" + ) + + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.5") + + resolver = PresetResolver(project_dir) + content = resolver.resolve_content("test-script", "script") + assert content is not None + assert "echo 'before'" in content + assert "echo 'core script'" in content + assert "echo 'after'" in content + + def test_resolve_content_multi_preset_chain(self, project_dir, temp_dir, valid_pack_data): + """Test multi-preset composition chain: prepend + append stacking.""" + # Create preset A (priority 1): prepend security header + pack_a_data = {**valid_pack_data} + pack_a_data["preset"] = {**valid_pack_data["preset"], "id": "preset-a", "name": "A"} + pack_a_data["provides"] = { + "templates": [{ + "type": "template", + "name": "spec-template", + "file": "templates/spec-template.md", + "strategy": "prepend", + }] + } + pack_a_dir = temp_dir / "preset-a" + pack_a_dir.mkdir() + with open(pack_a_dir / "preset.yml", 'w') as f: + yaml.dump(pack_a_data, f) + (pack_a_dir / "templates").mkdir() + (pack_a_dir / "templates" / "spec-template.md").write_text("## Security Header\n") + + # Create preset B (priority 2): append compliance footer + pack_b_data = {**valid_pack_data} + pack_b_data["preset"] = {**valid_pack_data["preset"], "id": "preset-b", "name": "B"} + pack_b_data["provides"] = { + "templates": [{ + "type": "template", + "name": "spec-template", + "file": "templates/spec-template.md", + "strategy": "append", + }] + } + pack_b_dir = temp_dir / "preset-b" + pack_b_dir.mkdir() + with open(pack_b_dir / "preset.yml", 'w') as f: + yaml.dump(pack_b_data, f) + (pack_b_dir / "templates").mkdir() + (pack_b_dir / "templates" / "spec-template.md").write_text("## Compliance Footer\n") + + manager = PresetManager(project_dir) + manager.install_from_directory(pack_a_dir, "0.1.5", priority=1) + manager.install_from_directory(pack_b_dir, "0.1.5", priority=2) + + resolver = PresetResolver(project_dir) + content = resolver.resolve_content("spec-template") + assert content is not None + # Result: + + + assert "Security Header" in content + assert "Core Spec Template" in content + assert "Compliance Footer" in content + assert content.index("Security Header") < content.index("Core Spec Template") + assert content.index("Core Spec Template") < content.index("Compliance Footer") + + def test_resolve_content_override_trumps_composition(self, project_dir, temp_dir, valid_pack_data): + """Test that project overrides trump composition (replace at top priority).""" + # Install a composing preset + pack_data = {**valid_pack_data} + pack_data["preset"] = {**valid_pack_data["preset"], "id": "append-pack", "name": "Append"} + pack_data["provides"] = { + "templates": [{ + "type": "template", + "name": "spec-template", + "file": "templates/spec-template.md", + "strategy": "append", + }] + } + pack_dir = temp_dir / "append-pack" + pack_dir.mkdir() + with open(pack_dir / "preset.yml", 'w') as f: + yaml.dump(pack_data, f) + (pack_dir / "templates").mkdir() + (pack_dir / "templates" / "spec-template.md").write_text("## Appended\n") + + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.5") + + # Create project override (replaces everything) + overrides_dir = project_dir / ".specify" / "templates" / "overrides" + overrides_dir.mkdir(parents=True) + (overrides_dir / "spec-template.md").write_text("# Override Only\n") + + resolver = PresetResolver(project_dir) + content = resolver.resolve_content("spec-template") + assert content is not None + assert "Override Only" in content + # Override replaces, so appended content should not be visible + assert "Core Spec Template" not in content + + def test_resolve_content_command_type(self, project_dir, temp_dir, valid_pack_data): + """Test resolve_content with command template type.""" + # Create core command using stem naming (matches real layout: plan.md, not speckit.plan.md) + commands_dir = project_dir / ".specify" / "templates" / "commands" + commands_dir.mkdir(parents=True, exist_ok=True) + (commands_dir / "plan.md").write_text("# Core Plan Command\n") + + pack_data = {**valid_pack_data} + pack_data["preset"] = {**valid_pack_data["preset"], "id": "cmd-append", "name": "CmdAppend"} + pack_data["provides"] = { + "templates": [{ + "type": "command", + "name": "speckit.plan", + "file": "commands/speckit.plan.md", + "strategy": "append", + }] + } + pack_dir = temp_dir / "cmd-append" + pack_dir.mkdir() + with open(pack_dir / "preset.yml", 'w') as f: + yaml.dump(pack_data, f) + (pack_dir / "commands").mkdir() + (pack_dir / "commands" / "speckit.plan.md").write_text("## Additional Instructions\n") + + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.5") + + resolver = PresetResolver(project_dir) + content = resolver.resolve_content("speckit.plan", "command") + assert content is not None + assert "Core Plan Command" in content + assert "Additional Instructions" in content + + def test_resolve_content_command_frontmatter_stripping(self, project_dir, temp_dir, valid_pack_data): + """Test that command composition strips frontmatter from lower layers + and reattaches only the highest-priority frontmatter.""" + # Create core command with frontmatter + commands_dir = project_dir / ".specify" / "templates" / "commands" + commands_dir.mkdir(parents=True, exist_ok=True) + (commands_dir / "check.md").write_text( + "---\ndescription: Core check command\n---\nCore body content\n" + ) + + pack_data = {**valid_pack_data} + pack_data["preset"] = {**valid_pack_data["preset"], "id": "fm-test", "name": "FmTest"} + pack_data["provides"] = { + "templates": [{ + "type": "command", + "name": "speckit.check", + "file": "commands/speckit.check.md", + "strategy": "append", + }] + } + pack_dir = temp_dir / "fm-test" + pack_dir.mkdir() + with open(pack_dir / "preset.yml", 'w') as f: + yaml.dump(pack_data, f) + (pack_dir / "commands").mkdir() + (pack_dir / "commands" / "speckit.check.md").write_text( + "---\ndescription: Preset check override\n---\nPreset body content\n" + ) + + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.5") + + resolver = PresetResolver(project_dir) + content = resolver.resolve_content("speckit.check", "command") + assert content is not None + # Should have the preset (highest-priority) frontmatter + assert "Preset check override" in content + # Should have both bodies + assert "Core body content" in content + assert "Preset body content" in content + # Core frontmatter should NOT appear in the body + assert content.count("---") == 2 # only one frontmatter block (opening + closing) + + def test_resolve_content_blank_line_separator(self, project_dir, temp_dir, valid_pack_data): + """Test that prepend/append use blank line separator.""" + pack_data = {**valid_pack_data} + pack_data["preset"] = {**valid_pack_data["preset"], "id": "sep-test", "name": "SepTest"} + pack_data["provides"] = { + "templates": [{ + "type": "template", + "name": "spec-template", + "file": "templates/spec-template.md", + "strategy": "append", + }] + } + pack_dir = temp_dir / "sep-test" + pack_dir.mkdir() + with open(pack_dir / "preset.yml", 'w') as f: + yaml.dump(pack_data, f) + (pack_dir / "templates").mkdir() + (pack_dir / "templates" / "spec-template.md").write_text("appended") + + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.5") + + resolver = PresetResolver(project_dir) + content = resolver.resolve_content("spec-template") + # Should have blank line separator + assert "\n\n" in content + + def test_resolve_content_replace_over_wrap(self, project_dir, temp_dir, valid_pack_data): + """Top-priority replace layer should win even if a lower layer uses wrap.""" + # Install a low-priority wrap preset (with no placeholder — would fail if evaluated) + wrap_data = {**valid_pack_data} + wrap_data["preset"] = {**valid_pack_data["preset"], "id": "wrap-lo", "name": "WrapLo"} + wrap_data["provides"] = { + "templates": [{ + "type": "template", + "name": "spec-template", + "file": "templates/spec-template.md", + "strategy": "wrap", + }] + } + wrap_dir = temp_dir / "wrap-lo" + wrap_dir.mkdir() + with open(wrap_dir / "preset.yml", "w") as f: + yaml.dump(wrap_data, f) + (wrap_dir / "templates").mkdir() + # Intentionally missing {CORE_TEMPLATE} — would error if composition ran + (wrap_dir / "templates" / "spec-template.md").write_text("wrapper without placeholder") + + manager = PresetManager(project_dir) + manager.install_from_directory(wrap_dir, "0.1.5", priority=10) + + # Install a high-priority replace preset + rep_data = {**valid_pack_data} + rep_data["preset"] = {**valid_pack_data["preset"], "id": "rep-hi", "name": "RepHi"} + rep_data["provides"] = { + "templates": [{ + "type": "template", + "name": "spec-template", + "file": "templates/spec-template.md", + }] + } + rep_dir = temp_dir / "rep-hi" + rep_dir.mkdir() + with open(rep_dir / "preset.yml", "w") as f: + yaml.dump(rep_data, f) + (rep_dir / "templates").mkdir() + (rep_dir / "templates" / "spec-template.md").write_text("# Replaced content\n") + + manager.install_from_directory(rep_dir, "0.1.5", priority=1) + + resolver = PresetResolver(project_dir) + content = resolver.resolve_content("spec-template") + assert content == "# Replaced content\n" + + @pytest.mark.parametrize("strategy", ["append", "prepend", "wrap"]) + def test_resolve_content_rewrites_extension_base_subdir_paths( + self, project_dir, temp_dir, strategy + ): + """Composing over an extension-provided base command must resolve the + extension's own subdir references (agents/, knowledge-base/) to their + installed location (#2101), not just when the extension wins outright. + """ + extension_dir = project_dir / ".specify" / "extensions" / "fakeext" + (extension_dir / "commands").mkdir(parents=True, exist_ok=True) + (extension_dir / "agents" / "control").mkdir(parents=True, exist_ok=True) + (extension_dir / "agents" / "control" / "commander.md").write_text("# Commander\n") + (extension_dir / "commands" / "cmd.md").write_text( + "---\ndescription: Extension fakeext cmd\n---\n\n" + "Read agents/control/commander.md for context.\n" + ) + extension_manifest = { + "schema_version": "1.0", + "extension": { + "id": "fakeext", + "name": "Fake Extension", + "version": "1.0.0", + "description": "Test", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "commands": [ + { + "name": "speckit.fakeext.cmd", + "file": "commands/cmd.md", + "description": "Fake extension command", + } + ] + }, + } + with open(extension_dir / "extension.yml", "w") as f: + yaml.dump(extension_manifest, f) + + preset_dir = temp_dir / f"ext-base-{strategy}" + preset_dir.mkdir() + (preset_dir / "commands").mkdir() + overlay_body = ( + "{CORE_TEMPLATE}\n## Extra\n" if strategy == "wrap" else "## Extra\n" + ) + (preset_dir / "commands" / "speckit.fakeext.cmd.md").write_text( + f"---\ndescription: Preset overlay\n---\n\n{overlay_body}" + ) + preset_manifest = { + "schema_version": "1.0", + "preset": { + "id": f"ext-base-{strategy}", + "name": "Ext Base", + "version": "1.0.0", + "description": "Test", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "templates": [ + { + "type": "command", + "name": "speckit.fakeext.cmd", + "file": "commands/speckit.fakeext.cmd.md", + "strategy": strategy, + } + ] + }, + } + with open(preset_dir / "preset.yml", "w") as f: + yaml.dump(preset_manifest, f) + + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "0.1.5") + + resolver = PresetResolver(project_dir) + content = resolver.resolve_content("speckit.fakeext.cmd", "command") + assert content is not None + assert ".specify/extensions/fakeext/agents/control/commander.md" in content + assert "Read agents/control" not in content + assert "## Extra" in content + + +class TestCollectAllLayers: + """Test PresetResolver.collect_all_layers() method.""" + + def test_non_utf8_legacy_command_keeps_replace_strategy(self, project_dir): + presets_dir = project_dir / ".specify" / "presets" + command_path = ( + presets_dir / "legacy-pack" / "commands" / "speckit.legacy.md" + ) + command_path.parent.mkdir(parents=True) + command_path.write_bytes(b"\xff\xfe") + PresetRegistry(presets_dir).add( + "legacy-pack", {"version": "1.0.0", "priority": 10} + ) + + layers = PresetResolver(project_dir).collect_all_layers( + "speckit.legacy", "command" + ) + + assert layers[0]["path"] == command_path + assert layers[0]["strategy"] == "replace" + + def test_single_core_layer(self, project_dir): + """Test collecting layers with only core template.""" + resolver = PresetResolver(project_dir) + layers = resolver.collect_all_layers("spec-template") + assert len(layers) == 1 + assert layers[0]["source"] == "core" + assert layers[0]["strategy"] == "replace" + + def test_layers_include_presets(self, project_dir, temp_dir, valid_pack_data): + """Test that layers include installed preset.""" + manager = PresetManager(project_dir) + pack_dir = _create_pack(temp_dir, valid_pack_data, "test-pack", + "# From Pack\n") + manager.install_from_directory(pack_dir, "0.1.5") + + resolver = PresetResolver(project_dir) + layers = resolver.collect_all_layers("spec-template") + assert len(layers) == 2 + # Highest priority first + assert "test-pack" in layers[0]["source"] + assert layers[1]["source"] == "core" + + def test_layers_order_matches_priority(self, project_dir, temp_dir, valid_pack_data): + """Test that layers are ordered by priority (highest first).""" + manager = PresetManager(project_dir) + for pid, prio in [("pack-lo", 10), ("pack-hi", 1)]: + d = {**valid_pack_data} + d["preset"] = {**valid_pack_data["preset"], "id": pid, "name": pid} + p = temp_dir / pid + p.mkdir() + with open(p / "preset.yml", 'w') as f: + yaml.dump(d, f) + (p / "templates").mkdir() + (p / "templates" / "spec-template.md").write_text(f"# {pid}\n") + manager.install_from_directory(p, "0.1.5", priority=prio) + + resolver = PresetResolver(project_dir) + layers = resolver.collect_all_layers("spec-template") + assert len(layers) == 3 # pack-hi, pack-lo, core + assert "pack-hi" in layers[0]["source"] + assert "pack-lo" in layers[1]["source"] + assert layers[2]["source"] == "core" + + def test_layers_read_strategy_from_manifest(self, project_dir, temp_dir, valid_pack_data): + """Test that layers read strategy from preset manifest.""" + pack_data = {**valid_pack_data} + pack_data["preset"] = {**valid_pack_data["preset"], "id": "strat-pack", "name": "Strat"} + pack_data["provides"] = { + "templates": [{ + "type": "template", + "name": "spec-template", + "file": "templates/spec-template.md", + "strategy": "append", + }] + } + pack_dir = temp_dir / "strat-pack" + pack_dir.mkdir() + with open(pack_dir / "preset.yml", 'w') as f: + yaml.dump(pack_data, f) + (pack_dir / "templates").mkdir() + (pack_dir / "templates" / "spec-template.md").write_text("## Footer\n") + + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.5") + + resolver = PresetResolver(project_dir) + layers = resolver.collect_all_layers("spec-template") + # Preset layer should have strategy=append + assert layers[0]["strategy"] == "append" + # Core layer should be replace + assert layers[1]["strategy"] == "replace" + + +class TestRemoveReconciliation: + """Test that removing a preset re-registers the next layer's command.""" + + def test_remove_restores_extension_command_subdir_paths_for_non_skill_agent( + self, project_dir, temp_dir + ): + """When a preset override of an extension command is removed, the + reconciled non-skill-agent command file should have the extension's + own subdir references rewritten to their installed location (#2101), + not left as bare, unresolvable paths.""" + gemini_dir = project_dir / ".gemini" / "commands" + gemini_dir.mkdir(parents=True) + + extension_dir = project_dir / ".specify" / "extensions" / "fakeext" + (extension_dir / "commands").mkdir(parents=True, exist_ok=True) + (extension_dir / "agents" / "control").mkdir(parents=True, exist_ok=True) + (extension_dir / "agents" / "control" / "commander.md").write_text("# Commander\n") + (extension_dir / "commands" / "cmd.md").write_text( + "---\ndescription: Extension fakeext cmd\n---\n\n" + "Read agents/control/commander.md for context.\n" + ) + extension_manifest = { + "schema_version": "1.0", + "extension": { + "id": "fakeext", + "name": "Fake Extension", + "version": "1.0.0", + "description": "Test", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "commands": [ + { + "name": "speckit.fakeext.cmd", + "file": "commands/cmd.md", + "description": "Fake extension command", + } + ] + }, + } + with open(extension_dir / "extension.yml", "w") as f: + yaml.dump(extension_manifest, f) + + manager = PresetManager(project_dir) + + preset_dir = temp_dir / "ext-cmd-override" + preset_dir.mkdir() + (preset_dir / "commands").mkdir() + (preset_dir / "commands" / "speckit.fakeext.cmd.md").write_text( + "---\ndescription: Override fakeext cmd\n---\n\npreset override content\n" + ) + preset_manifest = { + "schema_version": "1.0", + "preset": { + "id": "ext-cmd-override", + "name": "Ext Cmd Override", + "version": "1.0.0", + "description": "Test", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "templates": [ + { + "type": "command", + "name": "speckit.fakeext.cmd", + "file": "commands/speckit.fakeext.cmd.md", + } + ] + }, + } + with open(preset_dir / "preset.yml", "w") as f: + yaml.dump(preset_manifest, f) + + manager.install_from_directory(preset_dir, "0.1.5") + + cmd_files = list(gemini_dir.glob("*fakeext*")) + assert cmd_files, "Command file should exist in gemini dir" + assert "preset override content" in cmd_files[0].read_text() + + manager.remove("ext-cmd-override") + + cmd_files = list(gemini_dir.glob("*fakeext*")) + assert cmd_files, "Command file should still exist after removal" + content = cmd_files[0].read_text() + assert "preset override content" not in content + assert ".specify/extensions/fakeext/agents/control/commander.md" in content + assert "Read agents/control" not in content + + def test_install_composes_extension_command_and_rewrites_subdir_paths_for_non_skill_agent( + self, project_dir, temp_dir + ): + """When a preset overlays (append) an extension-provided base command, + the initial composed non-skill-agent command file must have the + extension's own subdir references rewritten to their installed + location (#2101), matching the live repro: extension body + 'Read agents/control/commander.md', preset appends to + speckit.fakeext.cmd, generated Gemini content retains the bare path.""" + gemini_dir = project_dir / ".gemini" / "commands" + gemini_dir.mkdir(parents=True) + + extension_dir = project_dir / ".specify" / "extensions" / "fakeext" + (extension_dir / "commands").mkdir(parents=True, exist_ok=True) + (extension_dir / "agents" / "control").mkdir(parents=True, exist_ok=True) + (extension_dir / "agents" / "control" / "commander.md").write_text("# Commander\n") + (extension_dir / "commands" / "cmd.md").write_text( + "---\ndescription: Extension fakeext cmd\n---\n\n" + "Read agents/control/commander.md for context.\n" + ) + extension_manifest = { + "schema_version": "1.0", + "extension": { + "id": "fakeext", + "name": "Fake Extension", + "version": "1.0.0", + "description": "Test", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "commands": [ + { + "name": "speckit.fakeext.cmd", + "file": "commands/cmd.md", + "description": "Fake extension command", + } + ] + }, + } + with open(extension_dir / "extension.yml", "w") as f: + yaml.dump(extension_manifest, f) + + preset_dir = temp_dir / "ext-cmd-append" + preset_dir.mkdir() + (preset_dir / "commands").mkdir() + (preset_dir / "commands" / "speckit.fakeext.cmd.md").write_text( + "---\ndescription: Append fakeext cmd\n---\n\n## Extra\n" + ) + preset_manifest = { + "schema_version": "1.0", + "preset": { + "id": "ext-cmd-append", + "name": "Ext Cmd Append", + "version": "1.0.0", + "description": "Test", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "templates": [ + { + "type": "command", + "name": "speckit.fakeext.cmd", + "file": "commands/speckit.fakeext.cmd.md", + "strategy": "append", + } + ] + }, + } + with open(preset_dir / "preset.yml", "w") as f: + yaml.dump(preset_manifest, f) + + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "0.1.5") + + cmd_files = list(gemini_dir.glob("*fakeext*")) + assert cmd_files, "Command file should exist in gemini dir" + content = cmd_files[0].read_text() + assert ".specify/extensions/fakeext/agents/control/commander.md" in content + assert "Read agents/control" not in content + assert "## Extra" in content + + def test_remove_restores_lower_priority_command( + self, project_dir, temp_dir, valid_pack_data + ): + """After removing the top-priority preset, the next preset's command + should be re-registered in agent directories.""" + manager = PresetManager(project_dir) + + # Create a gemini commands dir so reconciliation writes there + gemini_dir = project_dir / ".gemini" / "commands" + gemini_dir.mkdir(parents=True) + + # Install a low-priority preset with a command + lo_data = {**valid_pack_data} + lo_data["preset"] = { + **valid_pack_data["preset"], + "id": "lo-preset", + "name": "Lo", + } + lo_data["provides"] = { + "templates": [{ + "type": "command", + "name": "speckit.specify", + "file": "commands/speckit.specify.md", + }] + } + lo_dir = temp_dir / "lo-preset" + lo_dir.mkdir() + with open(lo_dir / "preset.yml", "w") as f: + yaml.dump(lo_data, f) + (lo_dir / "commands").mkdir() + (lo_dir / "commands" / "speckit.specify.md").write_text( + "---\ndescription: lo\n---\nLo content\n" + ) + manager.install_from_directory(lo_dir, "0.1.5", priority=10) + + # Install a high-priority preset overriding the same command + hi_data = {**valid_pack_data} + hi_data["preset"] = { + **valid_pack_data["preset"], + "id": "hi-preset", + "name": "Hi", + } + hi_data["provides"] = { + "templates": [{ + "type": "command", + "name": "speckit.specify", + "file": "commands/speckit.specify.md", + }] + } + hi_dir = temp_dir / "hi-preset" + hi_dir.mkdir() + with open(hi_dir / "preset.yml", "w") as f: + yaml.dump(hi_data, f) + (hi_dir / "commands").mkdir() + (hi_dir / "commands" / "speckit.specify.md").write_text( + "---\ndescription: hi\n---\nHi content\n" + ) + manager.install_from_directory(hi_dir, "0.1.5", priority=1) + + # Verify the hi-preset's content is active in agent dir + cmd_files = list(gemini_dir.glob("*specify*")) + assert cmd_files, "Command file should exist in gemini dir" + assert "Hi content" in cmd_files[0].read_text() + + # Remove the high-priority preset + manager.remove("hi-preset") + + # The low-priority preset's command should now be in the resolution stack + resolver = PresetResolver(project_dir) + layers = resolver.collect_all_layers("speckit.specify", "command") + assert len(layers) >= 1 + assert "lo-preset" in layers[0]["source"] + + # Verify on-disk agent command file switched to lo-preset content + cmd_files = list(gemini_dir.glob("*specify*")) + assert cmd_files, "Command file should still exist after removal" + assert "Lo content" in cmd_files[0].read_text() + + +def _create_pack(temp_dir, valid_pack_data, pack_id, content, + strategy="replace", template_type="template", + template_name="spec-template"): + """Helper to create a preset pack directory.""" + pack_data = {**valid_pack_data} + pack_data["preset"] = {**valid_pack_data["preset"], "id": pack_id, "name": pack_id} + + tmpl_entry = { + "type": template_type, + "name": template_name, + } + if template_type == "script": + tmpl_entry["file"] = f"scripts/{template_name}.sh" + elif template_type == "command": + tmpl_entry["file"] = f"commands/{template_name}.md" + else: + tmpl_entry["file"] = f"templates/{template_name}.md" + if strategy != "replace": + tmpl_entry["strategy"] = strategy + pack_data["provides"] = {"templates": [tmpl_entry]} + + pack_dir = temp_dir / pack_id + pack_dir.mkdir(exist_ok=True) + with open(pack_dir / "preset.yml", 'w') as f: + yaml.dump(pack_data, f) + + if template_type == "script": + subdir = pack_dir / "scripts" + subdir.mkdir(exist_ok=True) + (subdir / f"{template_name}.sh").write_text(content) + elif template_type == "command": + subdir = pack_dir / "commands" + subdir.mkdir(exist_ok=True) + (subdir / f"{template_name}.md").write_text(content) + else: + subdir = pack_dir / "templates" + subdir.mkdir(exist_ok=True) + (subdir / f"{template_name}.md").write_text(content) + + return pack_dir + + +def test_preset_wrapper_resolves_ghes_asset_when_host_configured(tmp_path, monkeypatch): + """End-to-end wiring for presets: auth.json github host → GHES asset resolution.""" + from specify_cli.authentication import http as _auth_http + from specify_cli.authentication.config import AuthConfigEntry + from specify_cli.presets import PresetCatalog + + monkeypatch.setattr(_auth_http, "_config_override", [ + AuthConfigEntry(hosts=("ghes.example",), provider="github", + auth="bearer", token="t"), + ]) + catalog = PresetCatalog(tmp_path) + + captured = [] + + @contextmanager + def fake_open(url, timeout=None, extra_headers=None): + captured.append(url) + resp = MagicMock() + resp.read.side_effect = io.BytesIO(json.dumps({ + "assets": [{"name": "pack.zip", + "url": "https://ghes.example/api/v3/repos/o/r/releases/assets/9"}] + }).encode()).read + yield resp + + monkeypatch.setattr(catalog, "_open_url", fake_open) + + resolved = catalog._resolve_github_release_asset_api_url( + "https://ghes.example/o/r/releases/download/v2/pack.zip" + ) + assert resolved == "https://ghes.example/api/v3/repos/o/r/releases/assets/9" + assert captured == ["https://ghes.example/api/v3/repos/o/r/releases/tags/v2"] + + +# ===== ensure_constitution_from_template resolver-awareness ===== + + +class TestEnsureConstitutionResolverAware: + """`ensure_constitution_from_template` must resolve through PresetResolver. + + Init materializes the live constitution once, while later /constitution + runs resolve on demand. These tests pin the regression from issue #3272: + a preset-provided ``constitution-template`` must win during the init seed, + while the core template is used when no preset overrides it. + """ + + def _core_constitution(self, project_dir): + templates_dir = project_dir / ".specify" / "templates" + templates_dir.mkdir(parents=True, exist_ok=True) + (templates_dir / "constitution-template.md").write_text( + "# [PROJECT_NAME] Constitution\n\n### [PRINCIPLE_1_NAME]\n" + ) + + def _wrap_constitution_preset(self, temp_dir): + preset_dir = temp_dir / "ensure-wrap-preset" + (preset_dir / "templates").mkdir(parents=True) + (preset_dir / "templates" / "constitution-template.md").write_text( + "# Ensure Wrapper\n\n{CORE_TEMPLATE}\n\n## Tail\n" + ) + (preset_dir / "preset.yml").write_text( + yaml.dump( + { + "schema_version": "1.0", + "preset": { + "id": "ensure-wrap", + "name": "Ensure Wrap", + "version": "1.0.0", + "description": "Wrap strategy for ensure() coverage", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "templates": [ + { + "type": "template", + "name": "constitution-template", + "file": "templates/constitution-template.md", + "strategy": "wrap", + "description": "Wrapped constitution", + } + ] + }, + } + ) + ) + return preset_dir + + def test_seeds_from_core_when_no_preset(self, project_dir): + from specify_cli.commands.init import ensure_constitution_from_template + + self._core_constitution(project_dir) + ensure_constitution_from_template(project_dir) + + memory = project_dir / ".specify" / "memory" / "constitution.md" + assert memory.exists() + assert "[PROJECT_NAME]" in memory.read_text() + assert (memory.parent / ".constitution-template.json").exists() + + def test_seeds_from_preset_when_installed(self, project_dir): + from specify_cli.commands.init import ensure_constitution_from_template + + self._core_constitution(project_dir) + manager = PresetManager(project_dir) + install_self_test_preset(manager) + + memory = project_dir / ".specify" / "memory" / "constitution.md" + assert not memory.exists() + + ensure_constitution_from_template(project_dir) + + assert memory.exists() + content = memory.read_text() + assert "preset:self-test" in content + assert "[PROJECT_NAME]" not in content + + def test_preserves_existing_memory(self, project_dir): + from specify_cli.commands.init import ensure_constitution_from_template + + self._core_constitution(project_dir) + memory = project_dir / ".specify" / "memory" / "constitution.md" + memory.parent.mkdir(parents=True, exist_ok=True) + authored = "# Acme Constitution\nAuthored.\n" + memory.write_text(authored) + + ensure_constitution_from_template(project_dir) + + assert memory.read_text() == authored + + def test_preserves_edited_generated_memory(self, project_dir): + from specify_cli.commands.init import ensure_constitution_from_template + + self._core_constitution(project_dir) + ensure_constitution_from_template(project_dir) + memory = project_dir / ".specify" / "memory" / "constitution.md" + authored = memory.read_text() + "\nAuthored amendment.\n" + memory.write_text(authored) + + manager = PresetManager(project_dir) + install_self_test_preset(manager) + + assert memory.read_text() == authored + + def test_composes_wrap_strategy_when_ensuring(self, project_dir, temp_dir): + from specify_cli.commands.init import ensure_constitution_from_template + + self._core_constitution(project_dir) + manager = PresetManager(project_dir) + manager.install_from_directory(self._wrap_constitution_preset(temp_dir), "0.1.5") + + memory = project_dir / ".specify" / "memory" / "constitution.md" + assert not memory.exists() + ensure_constitution_from_template(project_dir) + + content = memory.read_text() + assert "{CORE_TEMPLATE}" not in content + assert "# Ensure Wrapper" in content + assert "[PROJECT_NAME]" in content + + +class TestPresetTagsNonString: + """Non-string catalog tags must not crash preset display commands. + + Catalog payloads are user-editable YAML/JSON, so a `tags:` list can contain + numbers or other non-strings. The display path joins them; a raw + ``", ".join(...)`` blows up with ``TypeError: sequence item 0: expected str``. + Sibling command surfaces (extensions/integrations/workflows) already guard + this with ``str(t) for t in ...`` — presets must match. + """ + + def _seed_catalog(self, project_dir, tags, extra=None): + catalog = PresetCatalog(project_dir) + catalog.cache_dir.mkdir(parents=True, exist_ok=True) + pack = { + "name": "Numeric Tags", + "description": "Preset with non-string tags", + "version": "1.0.0", + "tags": tags, + } + if extra: + pack.update(extra) + catalog_data = { + "schema_version": "1.0", + "presets": { + "numeric-tags": pack, + }, + } + catalog.cache_file.write_text(json.dumps(catalog_data)) + catalog.cache_metadata_file.write_text(json.dumps({ + "cached_at": datetime.now(timezone.utc).isoformat(), + })) + return catalog + + def test_search_renders_non_string_tags(self, project_dir): + from typer.testing import CliRunner + from unittest.mock import patch + from specify_cli import app + + catalog = self._seed_catalog(project_dir, [1, 2]) + default_only = [PresetCatalogEntry( + url=catalog.DEFAULT_CATALOG_URL, name="default", priority=1, install_allowed=True + )] + + with patch.object(Path, "cwd", return_value=project_dir), \ + patch.object(PresetCatalog, "get_active_catalogs", return_value=default_only): + result = CliRunner().invoke(app, ["preset", "search", "Numeric"]) + + assert result.exit_code == 0, result.output + plain = strip_ansi(result.output) + assert "Tags: 1, 2" in plain + + def test_info_renders_non_string_tags(self, project_dir): + from typer.testing import CliRunner + from unittest.mock import patch + from specify_cli import app + + catalog = self._seed_catalog(project_dir, [1, 2]) + default_only = [PresetCatalogEntry( + url=catalog.DEFAULT_CATALOG_URL, name="default", priority=1, install_allowed=True + )] + + with patch.object(Path, "cwd", return_value=project_dir), \ + patch.object(PresetCatalog, "get_active_catalogs", return_value=default_only): + result = CliRunner().invoke(app, ["preset", "info", "numeric-tags"]) + + assert result.exit_code == 0, result.output + plain = strip_ansi(result.output) + assert "Tags: 1, 2" in plain + + def _default_only(self, catalog): + return [PresetCatalogEntry( + url=catalog.DEFAULT_CATALOG_URL, name="default", priority=1, install_allowed=True + )] + + def test_search_by_author_tolerates_non_string_author(self, project_dir): + """``--author`` must not crash on a numeric catalog ``author``. + + ``PresetCatalog.search`` called ``.lower()`` straight on the raw value, + raising ``AttributeError: 'int' object has no attribute 'lower'``. The + sibling extension/integration catalogs coerce with ``str(...)`` first. + """ + from typer.testing import CliRunner + from unittest.mock import patch + from specify_cli import app + + catalog = self._seed_catalog(project_dir, ["ci"], extra={"author": 789}) + + with patch.object(Path, "cwd", return_value=project_dir), \ + patch.object(PresetCatalog, "get_active_catalogs", + return_value=self._default_only(catalog)): + result = CliRunner().invoke(app, ["preset", "search", "--author", "789"]) + + assert result.exit_code == 0, result.output + assert "Numeric Tags" in strip_ansi(result.output) + + def test_search_query_tolerates_non_string_name_and_description(self, project_dir): + """A query search must not crash on numeric ``name``/``description``. + + The searchable-text join passed the raw values through, raising + ``TypeError: sequence item 0: expected str instance, int found``. + """ + from typer.testing import CliRunner + from unittest.mock import patch + from specify_cli import app + + catalog = self._seed_catalog( + project_dir, ["ci"], extra={"name": 123, "description": 456} + ) + + with patch.object(Path, "cwd", return_value=project_dir), \ + patch.object(PresetCatalog, "get_active_catalogs", + return_value=self._default_only(catalog)): + result = CliRunner().invoke(app, ["preset", "search", "123"]) + + assert result.exit_code == 0, result.output + assert "numeric-tags" in strip_ansi(result.output) + + def test_search_tolerates_non_list_tags(self, project_dir): + """A scalar ``tags:`` value must not crash the tag filter or display. + + ``tags: 5`` is truthy but not iterable, so both the ``--tag`` filter and + the result-display join raised ``TypeError: 'int' object is not + iterable``. Siblings guard with ``isinstance(raw_tags, list)``. + """ + from typer.testing import CliRunner + from unittest.mock import patch + from specify_cli import app + + catalog = self._seed_catalog(project_dir, 5) + + with patch.object(Path, "cwd", return_value=project_dir), \ + patch.object(PresetCatalog, "get_active_catalogs", + return_value=self._default_only(catalog)): + filtered = CliRunner().invoke(app, ["preset", "search", "--tag", "ci"]) + displayed = CliRunner().invoke(app, ["preset", "search", "Numeric"]) + + assert filtered.exit_code == 0, filtered.output + assert "No presets found" in strip_ansi(filtered.output) + + assert displayed.exit_code == 0, displayed.output + plain = strip_ansi(displayed.output) + assert "Numeric Tags" in plain + assert "Tags:" not in plain + + def test_info_tolerates_non_list_tags(self, project_dir): + """``preset info`` must not crash rendering a scalar ``tags:`` value.""" + from typer.testing import CliRunner + from unittest.mock import patch + from specify_cli import app + + catalog = self._seed_catalog(project_dir, 5) + + with patch.object(Path, "cwd", return_value=project_dir), \ + patch.object(PresetCatalog, "get_active_catalogs", + return_value=self._default_only(catalog)): + result = CliRunner().invoke(app, ["preset", "info", "numeric-tags"]) + + assert result.exit_code == 0, result.output + plain = strip_ansi(result.output) + assert "numeric-tags" in plain + assert "Tags:" not in plain + + def test_search_escapes_rich_markup_in_tags(self, project_dir): + """Bracketed tag text must survive Rich markup parsing. + + ``preset search`` printed tags unescaped, so a tag like ``[bold]`` was + swallowed as a style tag. ``preset list`` already escaped this. + """ + from typer.testing import CliRunner + from unittest.mock import patch + from specify_cli import app + + catalog = self._seed_catalog(project_dir, ["[bold]ci"]) + + with patch.object(Path, "cwd", return_value=project_dir), \ + patch.object(PresetCatalog, "get_active_catalogs", + return_value=self._default_only(catalog)): + result = CliRunner().invoke(app, ["preset", "search", "Numeric"]) + + assert result.exit_code == 0, result.output + assert "[bold]ci" in strip_ansi(result.output) + + +class TestPresetCatalogRichMarkup: + """Catalog metadata must render as literal text in Rich output.""" + + MARKUP_PRESET = { + "id": "[red]markup-id[/red]", + "name": "[green]Markup Name[/green]", + "version": "[blue]1.0.0[/blue]", + "description": "[yellow]Markup Description[/yellow]", + "author": "[magenta]Markup Author[/magenta]", + "tags": ["[italic]markup-tag[/italic]"], + "repository": "[bold]Markup Repository[/bold]", + "license": "[cyan]Markup License[/cyan]", + } + + def test_search_escapes_catalog_markup(self, project_dir): + from typer.testing import CliRunner + from unittest.mock import patch + from specify_cli import app + + with patch.object(Path, "cwd", return_value=project_dir), patch.object( + PresetCatalog, + "search", + return_value=[self.MARKUP_PRESET], + ): + result = CliRunner().invoke(app, ["preset", "search"]) + + assert result.exit_code == 0, result.output + output = " ".join(strip_ansi(result.output).split()) + for value in ( + self.MARKUP_PRESET["id"], + self.MARKUP_PRESET["name"], + self.MARKUP_PRESET["version"], + self.MARKUP_PRESET["description"], + ): + assert value in output + + def test_info_escapes_catalog_markup(self, project_dir): + from typer.testing import CliRunner + from unittest.mock import patch + from specify_cli import app + + with patch.object(Path, "cwd", return_value=project_dir), patch.object( + PresetCatalog, + "get_pack_info", + return_value=self.MARKUP_PRESET, + ): + result = CliRunner().invoke( + app, + ["preset", "info", self.MARKUP_PRESET["id"]], + ) + + assert result.exit_code == 0, result.output + output = " ".join(strip_ansi(result.output).split()) + for field in ( + "id", + "name", + "version", + "description", + "author", + "repository", + "license", + ): + value = self.MARKUP_PRESET[field] + assert value in output + # Tags are joined into a single line, so assert on the rendered join. + assert ", ".join(self.MARKUP_PRESET["tags"]) in output + + +class TestInstalledPresetRichMarkup: + """Locally installed preset metadata must render as literal text. + + ``preset.yml`` is user-editable, so its fields can contain ``[...]``. + ``TestPresetCatalogRichMarkup`` covers the catalog branch of these + commands; the installed-preset branch of ``preset list``/``preset info`` + and all of ``preset resolve`` were left unescaped, so a field like + ``Does [stuff] nicely`` silently rendered as ``Does nicely`` and an + unbalanced tag such as ``[/red]`` raised ``rich.errors.MarkupError``, + aborting the command with a traceback. + """ + + MARKUP_FIELDS = { + "name": "[green]Markup Name[/green]", + "version": "1.0.0", + "description": "[yellow]Markup Description[/yellow]", + "author": "[magenta]Markup Author[/magenta]", + "repository": "[bold]Markup Repository[/bold]", + "license": "[cyan]Markup License[/cyan]", + } + + def _install(self, temp_dir, project_dir, preset_overrides=None, strategy=None, + pack_id="markup-pack", priority=10, tmpl_description=None): + """Install a preset from a directory built with the given manifest fields.""" + from specify_cli.presets import PresetManager + + src = temp_dir / f"src-{pack_id}" + (src / "templates").mkdir(parents=True) + (src / "templates" / "spec-template.md").write_text("# tmpl\n") + + preset_section = { + "id": pack_id, + "name": pack_id, + "version": "1.0.0", + "description": "plain description", + } + preset_section.update(preset_overrides or {}) + tmpl = { + "type": "template", + "name": "spec-template", + "file": "templates/spec-template.md", + } + if tmpl_description is not None: + tmpl["description"] = tmpl_description + if strategy: + tmpl["strategy"] = strategy + (src / "preset.yml").write_text(yaml.dump({ + "schema_version": "1.0", + "preset": preset_section, + "requires": {"speckit_version": ">=0.0.1"}, + "provides": {"templates": [tmpl]}, + "tags": ["[italic]markup-tag[/italic]"], + })) + + manager = PresetManager(project_dir) + manager.install_from_directory(src, "9.9.9", priority) + return manager + + def _invoke(self, project_dir, args): + from typer.testing import CliRunner + from unittest.mock import patch + from specify_cli import app + + with patch.object(Path, "cwd", return_value=project_dir): + return CliRunner().invoke(app, args) + + def test_list_and_info_escape_installed_markup(self, temp_dir, project_dir): + """Every ``preset.yml`` field must survive verbatim in list/info output.""" + self._install(temp_dir, project_dir, preset_overrides=self.MARKUP_FIELDS) + + for args in (["preset", "list"], ["preset", "info", "markup-pack"]): + result = self._invoke(project_dir, args) + assert result.exit_code == 0, result.output + output = " ".join(strip_ansi(result.output).split()) + # `preset list` does not render repository/license. + fields = ("name", "description") if args[1] == "list" else self.MARKUP_FIELDS + for field in fields: + assert self.MARKUP_FIELDS[field] in output, (field, args, output) + assert "[italic]markup-tag[/italic]" in output, (args, output) + + def test_info_does_not_swallow_template_description(self, temp_dir, project_dir): + """The per-template line in ``preset info`` must escape the template description. + + ``name``/``type`` are format-restricted by manifest validation, but + ``description`` is free-form, so it is the field that can carry markup. + """ + self._install( + temp_dir, + project_dir, + tmpl_description="Template [desc] here", + ) + result = self._invoke(project_dir, ["preset", "info", "markup-pack"]) + assert result.exit_code == 0, result.output + output = " ".join(strip_ansi(result.output).split()) + assert "spec-template (template): Template [desc] here" in output, output + + def test_unbalanced_markup_does_not_crash_list_or_info(self, temp_dir, project_dir): + """An unbalanced tag must not raise MarkupError and abort the command.""" + self._install( + temp_dir, + project_dir, + preset_overrides={"description": "Broken [/red] tag"}, + ) + + for args in (["preset", "list"], ["preset", "info", "markup-pack"]): + result = self._invoke(project_dir, args) + assert result.exit_code == 0, (args, result.output, result.exception) + assert "Broken [/red] tag" in strip_ansi(result.output) + + def test_resolve_rejects_invalid_template_name(self, project_dir): + """``preset resolve`` rejects names before joining them into paths.""" + result = self._invoke(project_dir, ["preset", "resolve", "no[/red]such"]) + assert result.exit_code == 1, (result.output, result.exception) + assert "invalid template name" in strip_ansi(result.output) + + def test_resolve_rejects_path_traversal(self, project_dir): + """The resolver rejects traversal before joining names into paths.""" + result = self._invoke( + project_dir, + ["preset", "resolve", "../../../README"], + ) + + assert result.exit_code == 1 + assert "invalid template name" in strip_ansi(result.output) + + def test_resolve_accepts_dotted_command_name(self, project_dir): + """Documented dotted command identifiers use command resolution.""" + result = self._invoke( + project_dir, + ["preset", "resolve", "speckit.constitution"], + ) + + assert result.exit_code == 0, (result.output, result.exception) + assert "constitution.md" in "".join(strip_ansi(result.output).split()) + + def test_resolve_rejects_empty_command_segments(self, project_dir): + """Dotted command identifiers cannot contain empty path-like segments.""" + result = self._invoke( + project_dir, + ["preset", "resolve", "speckit..constitution"], + ) + + assert result.exit_code == 1 + assert "invalid template name" in strip_ansi(result.output) + + def test_resolve_escapes_layer_path_and_source(self, project_dir): + """The top-layer path/source lines must render markup literally. + + A preset can be installed from any directory, so the resolved path can + contain ``[...]``; the layer source carries the pack id and version. + """ + from unittest.mock import patch + from specify_cli.presets import PresetResolver + + # A closing tag cannot live inside a path segment: `Path` treats its + # `/` as a separator on POSIX and rewrites it to `\` on Windows. The + # opening tag covers the swallowing case for the path; the unbalanced + # closing tag rides on `source`, which is a plain string. + layer = { + "path": Path("/tmp/[red]dir/spec-template.md"), + "source": "pack [/red] v1.0.0", + "strategy": "replace", + } + with patch.object(PresetResolver, "collect_all_layers", return_value=[layer]): + result = self._invoke(project_dir, ["preset", "resolve", "spec-template"]) + + assert result.exit_code == 0, (result.output, result.exception) + output = " ".join(strip_ansi(result.output).split()) + assert "[red]dir" in output, output + assert "pack [/red] v1.0.0" in output, output + + def test_resolve_escapes_fallback_path_and_source(self, project_dir): + """The no-layer fallback branch must escape ``resolve_with_source`` output.""" + from unittest.mock import patch + from specify_cli.presets import PresetResolver + + with patch.object( + PresetResolver, "collect_all_layers", return_value=[] + ), patch.object( + PresetResolver, + "resolve_with_source", + return_value={ + "path": "/tmp/[blue]fallback[/blue]/spec-template.md", + "source": "fallback [/red] source", + }, + ): + result = self._invoke(project_dir, ["preset", "resolve", "spec-template"]) + + assert result.exit_code == 0, (result.output, result.exception) + output = " ".join(strip_ansi(result.output).split()) + assert "[blue]fallback[/blue]" in output, output + assert "fallback [/red] source" in output, output + + def test_resolve_escapes_composition_error(self, project_dir): + """A composition exception message must not be parsed as markup.""" + from unittest.mock import patch + from specify_cli.presets import PresetResolver + + layers = [ + { + "path": Path("/tmp/top/spec-template.md"), + "source": "top-pack v1.0.0", + "strategy": "append", + }, + { + "path": Path("/tmp/base/spec-template.md"), + "source": "base-pack v1.0.0", + "strategy": "append", + }, + ] + with patch.object( + PresetResolver, "collect_all_layers", return_value=layers + ), patch.object( + PresetResolver, + "resolve_content", + side_effect=RuntimeError("compose failed: [/red] bad layer"), + ): + result = self._invoke(project_dir, ["preset", "resolve", "spec-template"]) + + assert result.exit_code == 0, (result.output, result.exception) + output = " ".join(strip_ansi(result.output).split()) + assert "compose failed: [/red] bad layer" in output, output + + def test_resolve_renders_composition_strategy_labels(self, temp_dir, project_dir): + """The composition chain's ``[]`` label must not be eaten as a tag.""" + self._install(temp_dir, project_dir, strategy="replace", + pack_id="base-pack", priority=20) + self._install(temp_dir, project_dir, strategy="append", + pack_id="app-pack", priority=5) + + result = self._invoke(project_dir, ["preset", "resolve", "spec-template"]) + assert result.exit_code == 0, (result.output, result.exception) + output = strip_ansi(result.output) + assert "Composition chain" in output, output + assert "[base]" in output, output + assert "[append]" in output, output + + +class TestPresetListOrdering: + """``preset list`` must print presets in actual resolution/precedence order. + + Regression coverage for #4086: the printed order was registry/insertion + order, so a preset with a *higher* priority number (lower precedence) could + appear before one with a lower number, misleading users about which preset + wins. Output must be sorted by (priority, id) to match + ``PresetRegistry.list_by_priority()``. + """ + + def _install(self, temp_dir, project_dir, pack_id, priority): + from specify_cli.presets import PresetManager + + src = temp_dir / f"src-{pack_id}" + (src / "templates").mkdir(parents=True) + (src / "templates" / "spec-template.md").write_text("# tmpl\n") + (src / "preset.yml").write_text(yaml.dump({ + "schema_version": "1.0", + "preset": { + "id": pack_id, + "name": pack_id, + "version": "1.0.0", + "description": "plain description", + }, + "requires": {"speckit_version": ">=0.0.1"}, + "provides": {"templates": [{ + "type": "template", + "name": "spec-template", + "file": "templates/spec-template.md", + }]}, + })) + PresetManager(project_dir).install_from_directory(src, "9.9.9", priority) + + def _invoke(self, project_dir, args): + from typer.testing import CliRunner + from unittest.mock import patch + from specify_cli import app + + with patch.object(Path, "cwd", return_value=project_dir): + return CliRunner().invoke(app, args) + + def test_list_sorted_by_priority(self, temp_dir, project_dir): + """Lower priority number is listed first regardless of install order.""" + # Install in an order that does NOT match precedence. + self._install(temp_dir, project_dir, "copilot-sub-agents", priority=100) + self._install(temp_dir, project_dir, "lean", priority=10) + + result = self._invoke(project_dir, ["preset", "list"]) + assert result.exit_code == 0, result.output + output = strip_ansi(result.output) + # `lean` (priority 10) must appear before `copilot-sub-agents` (100). + assert output.index("(lean)") < output.index("(copilot-sub-agents)"), output + assert "resolution order" in output, output + assert "Ties are broken by preset id" in output, output + + def test_list_ties_broken_by_id(self, temp_dir, project_dir): + """Equal priority ties are broken alphabetically by preset id.""" + self._install(temp_dir, project_dir, "zebra", priority=10) + self._install(temp_dir, project_dir, "alpha", priority=10) + + result = self._invoke(project_dir, ["preset", "list"]) + assert result.exit_code == 0, result.output + output = strip_ansi(result.output) + assert output.index("(alpha)") < output.index("(zebra)"), output + + +class TestConstitutionSyncPreset: + """The bundled opt-in ``constitution-sync`` preset re-adds materialization. + + Follow-up to #3790: core ``/constitution`` no longer propagates guidance + into templates. Issue #3950 also gates install-time constitution seeding on + this preset. Its command override remains a ``wrap`` of core so it stays + forward-compatible with core changes. + """ + + PRESET_DIR = Path(__file__).parent.parent / "presets" / "constitution-sync" + + def test_manifest_provides_wrap_of_constitution(self): + manifest = yaml.safe_load((self.PRESET_DIR / "preset.yml").read_text()) + assert manifest["preset"]["id"] == "constitution-sync" + entries = manifest["provides"]["templates"] + assert len(entries) == 1 + entry = entries[0] + assert entry["type"] == "command" + assert entry["name"] == "speckit.constitution" + assert entry["strategy"] == "wrap" + # Must target the post-#3790 baseline so propagation is not double-applied. + assert manifest["requires"]["speckit_version"] == ">=0.14.4" + + def test_wrapper_uses_core_template_and_propagates(self): + text = (self.PRESET_DIR / "commands" / "speckit.constitution.md").read_text() + + # Parse the Markdown frontmatter as YAML rather than substring-matching, + # so `strategy: wrap` is asserted structurally (not as text that could + # appear in the body) and {CORE_TEMPLATE} is asserted in the body only. + assert text.startswith("---\n") + _, frontmatter_block, body = text.split("---", 2) + frontmatter = yaml.safe_load(frontmatter_block) + assert frontmatter["strategy"] == "wrap" + + assert "{CORE_TEMPLATE}" in body + assert "strategy: wrap" not in body # only in frontmatter + # The three governed scaffolds the old checklist propagated into. + assert "plan-template.md" in body + assert "spec-template.md" in body + assert "tasks-template.md" in body + # Must not mutate versioned preset/extension artifacts. + assert "Do not edit versioned preset- or extension-provided template or command files" in body + + def test_catalog_lists_bundled_preset(self): + manifest = yaml.safe_load((self.PRESET_DIR / "preset.yml").read_text()) + catalog = json.loads((self.PRESET_DIR.parent / "catalog.json").read_text()) + entry = catalog["presets"]["constitution-sync"] + assert entry["bundled"] is True + assert entry["version"] == manifest["preset"]["version"] + assert entry["provides"]["commands"] == 1 + assert entry["provides"]["templates"] == 0 + + def test_wrap_composes_over_core_constitution(self, project_dir): + """Installing the preset yields a wrap layer atop the bundled core.""" + manager = PresetManager(project_dir) + manager.install_from_directory(self.PRESET_DIR, "0.15.0") + + resolver = PresetResolver(project_dir) + layers = resolver.collect_all_layers("speckit.constitution", "command") + assert len(layers) >= 2, "expected preset wrap layer plus a core base" + assert layers[0]["strategy"] == "wrap" + assert any("constitution-sync" in str(layer["path"]) for layer in layers) + assert layers[-1]["source"] == "core (bundled)" + + def test_resolved_content_embeds_core_and_sync_pass(self, project_dir): + """resolve_content substitutes {CORE_TEMPLATE} so the effective command + contains both the bundled core body and the propagation pass.""" + manager = PresetManager(project_dir) + manager.install_from_directory(self.PRESET_DIR, "0.15.0") + + resolver = PresetResolver(project_dir) + content = resolver.resolve_content("speckit.constitution", "command") + assert content is not None + # {CORE_TEMPLATE} must be replaced, not left literal. + assert "{CORE_TEMPLATE}" not in content + # Core body is present (distinctive core-only heading). + assert "## Scope Guard" in content + # The wrapper's propagation pass is present and supersedes the guard. + assert "## Constitution Template Sync" in content + assert "supersedes the \"Scope Guard\" above" in content + assert "plan-template.md" in content + + +class TestPresetUpdate: + def _updated_source(self, pack_dir, version="2.0.0"): + source = pack_dir.parent / "updated-pack" + shutil.copytree(pack_dir, source) + manifest_path = source / "preset.yml" + data = yaml.safe_load(manifest_path.read_text()) + data["preset"]["version"] = version + data["provides"]["templates"].append( + { + "type": "command", + "name": "new-command", + "file": "commands/new-command.md", + } + ) + (source / "commands").mkdir() + (source / "commands" / "new-command.md").write_text("# New\n") + manifest_path.write_text(yaml.safe_dump(data)) + return source + + def _command_preset(self, temp_dir, preset_id, body): + preset_dir = temp_dir / preset_id + (preset_dir / "commands").mkdir(parents=True) + (preset_dir / "commands" / "speckit.specify.md").write_text( + "---\ndescription: Test command\n---\n\n" + f"{body}\n" + ) + (preset_dir / "preset.yml").write_text( + yaml.safe_dump( + { + "schema_version": "1.0", + "preset": { + "id": preset_id, + "name": preset_id, + "version": "1.0.0", + "description": "Test", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "templates": [ + { + "type": "command", + "name": "speckit.specify", + "file": "commands/speckit.specify.md", + } + ] + }, + } + ) + ) + return preset_dir + + def _multi_command_preset(self, temp_dir, preset_id, commands, version="1.0.0"): + preset_dir = temp_dir / preset_id + (preset_dir / "commands").mkdir(parents=True) + templates = [] + for command_name, body in commands.items(): + file_name = f"{command_name}.md" + (preset_dir / "commands" / file_name).write_text( + "---\ndescription: Test command\n---\n\n" + f"{body}\n" + ) + templates.append( + { + "type": "command", + "name": command_name, + "file": f"commands/{file_name}", + } + ) + (preset_dir / "preset.yml").write_text( + yaml.safe_dump( + { + "schema_version": "1.0", + "preset": { + "id": preset_id, + "name": preset_id, + "version": version, + "description": "Test", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": {"templates": templates}, + } + ) + ) + return preset_dir + + def _updated_command_source(self, preset_dir, version, body): + source = preset_dir.parent / f"updated-{preset_dir.name}" + shutil.copytree(preset_dir, source) + manifest_path = source / "preset.yml" + data = yaml.safe_load(manifest_path.read_text()) + data["preset"]["version"] = version + manifest_path.write_text(yaml.safe_dump(data)) + (source / "commands" / "speckit.specify.md").write_text( + "---\ndescription: Test command\n---\n\n" + f"{body}\n" + ) + return source + + def _constitution_preset( + self, temp_dir, preset_id, body, *, strategy="replace", version="1.0.0" + ): + preset_dir = temp_dir / preset_id + (preset_dir / "templates").mkdir(parents=True) + (preset_dir / "templates" / "constitution-template.md").write_text(body) + (preset_dir / "preset.yml").write_text( + yaml.safe_dump( + { + "schema_version": "1.0", + "preset": { + "id": preset_id, + "name": preset_id, + "version": version, + "description": "Test", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "templates": [ + { + "type": "template", + "name": "constitution-template", + "file": "templates/constitution-template.md", + "strategy": strategy, + } + ] + }, + } + ) + ) + return preset_dir + + def test_manifest_diff_uses_name_and_type_identity(self, pack_dir): + source = self._updated_source(pack_dir) + old = PresetManifest(pack_dir / "preset.yml") + new = PresetManifest(source / "preset.yml") + from specify_cli.presets import diff_preset_manifests - resolver = PresetResolver(project_dir) - content = resolver.resolve_content("spec-template") - assert content is not None - assert "Wrapper Start" in content - assert "Core Spec Template" in content - assert "Wrapper End" in content - # Wrapper should surround core - assert content.index("Wrapper Start") < content.index("Core Spec Template") - assert content.index("Core Spec Template") < content.index("Wrapper End") + diff = diff_preset_manifests(old, new) + assert [item["identity"] for item in diff["added"]] == [ + ("new-command", "command") + ] + assert diff["changed"] == [] + assert diff["unchanged"][0]["identity"] == ("spec-template", "template") - def test_resolve_content_wrap_strategy_script(self, project_dir, temp_dir, valid_pack_data): - """Test resolve_content with wrap strategy for scripts uses $CORE_SCRIPT.""" - # Create core script - scripts_dir = project_dir / ".specify" / "templates" / "scripts" - scripts_dir.mkdir(parents=True, exist_ok=True) - (scripts_dir / "test-script.sh").write_text("echo 'core script'\n") + def test_update_preserves_priority_and_enabled_state( + self, project_dir, pack_dir + ): + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.0", priority=3) + manager.registry.update("test-pack", {"enabled": False}) + source = self._updated_source(pack_dir) - pack_data = {**valid_pack_data} - pack_data["preset"] = {**valid_pack_data["preset"], "id": "script-wrap", "name": "Script Wrap"} - pack_data["provides"] = { - "templates": [{ - "type": "script", - "name": "test-script", - "file": "scripts/test-script.sh", - "strategy": "wrap", - }] - } - pack_dir = temp_dir / "script-wrap" - pack_dir.mkdir() - with open(pack_dir / "preset.yml", 'w') as f: - yaml.dump(pack_data, f) - (pack_dir / "scripts").mkdir() - (pack_dir / "scripts" / "test-script.sh").write_text( - "#!/bin/bash\necho 'before'\n$CORE_SCRIPT\necho 'after'\n" + manifest, diff = manager.update_from_directory( + source, "0.1.0", pack_id="test-pack" ) - manager = PresetManager(project_dir) - manager.install_from_directory(pack_dir, "0.1.5") - - resolver = PresetResolver(project_dir) - content = resolver.resolve_content("test-script", "script") - assert content is not None - assert "echo 'before'" in content - assert "echo 'core script'" in content - assert "echo 'after'" in content + assert manifest.version == "2.0.0" + assert diff["added"] + metadata = manager.registry.get("test-pack") + assert metadata["priority"] == 3 + assert metadata["enabled"] is False + assert (project_dir / ".specify/presets/test-pack/preset.yml").exists() - def test_resolve_content_multi_preset_chain(self, project_dir, temp_dir, valid_pack_data): - """Test multi-preset composition chain: prepend + append stacking.""" - # Create preset A (priority 1): prepend security header - pack_a_data = {**valid_pack_data} - pack_a_data["preset"] = {**valid_pack_data["preset"], "id": "preset-a", "name": "A"} - pack_a_data["provides"] = { - "templates": [{ - "type": "template", - "name": "spec-template", - "file": "templates/spec-template.md", - "strategy": "prepend", - }] - } - pack_a_dir = temp_dir / "preset-a" - pack_a_dir.mkdir() - with open(pack_a_dir / "preset.yml", 'w') as f: - yaml.dump(pack_a_data, f) - (pack_a_dir / "templates").mkdir() - (pack_a_dir / "templates" / "spec-template.md").write_text("## Security Header\n") + @pytest.mark.parametrize("enabled", [False, True]) + def test_post_commit_backup_cleanup_failure_warns_without_failing_update( + self, project_dir, pack_dir, monkeypatch, enabled + ): + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.0") + if not enabled: + manager.registry.update("test-pack", {"enabled": False}) + source = self._updated_source(pack_dir) + original_rmtree = shutil.rmtree + + def fail_committed_backup(path, *args, **kwargs): + if Path(path).name.startswith(".test-pack.update-") and Path( + path + ).name.endswith(".bak"): + raise PermissionError("simulated committed backup cleanup failure") + return original_rmtree(path, *args, **kwargs) + + monkeypatch.setattr(shutil, "rmtree", fail_committed_backup) + + with pytest.warns(UserWarning, match="updated successfully") as caught: + manifest, diff = manager.update_from_directory( + source, "0.1.0", pack_id="test-pack" + ) - # Create preset B (priority 2): append compliance footer - pack_b_data = {**valid_pack_data} - pack_b_data["preset"] = {**valid_pack_data["preset"], "id": "preset-b", "name": "B"} - pack_b_data["provides"] = { - "templates": [{ - "type": "template", - "name": "spec-template", - "file": "templates/spec-template.md", - "strategy": "append", - }] - } - pack_b_dir = temp_dir / "preset-b" - pack_b_dir.mkdir() - with open(pack_b_dir / "preset.yml", 'w') as f: - yaml.dump(pack_b_data, f) - (pack_b_dir / "templates").mkdir() - (pack_b_dir / "templates" / "spec-template.md").write_text("## Compliance Footer\n") + assert manifest.version == "2.0.0" + assert diff["added"] + metadata = manager.registry.get("test-pack") + assert metadata["version"] == "2.0.0" + assert metadata["enabled"] is enabled + assert "2.0.0" in ( + project_dir / ".specify/presets/test-pack/preset.yml" + ).read_text(encoding="utf-8") + backups = list( + (project_dir / ".specify/presets").glob( + ".test-pack.update-*.bak" + ) + ) + assert len(backups) == 1 + warning = str(caught[0].message) + assert "temporary backup path" in warning + assert str(backups[0]) in warning + assert "Inspect and remove this recovery artifact" in warning + original_rmtree(backups[0]) + def test_dry_run_does_not_modify_installation(self, project_dir, pack_dir): manager = PresetManager(project_dir) - manager.install_from_directory(pack_a_dir, "0.1.5", priority=1) - manager.install_from_directory(pack_b_dir, "0.1.5", priority=2) + manager.install_from_directory(pack_dir, "0.1.0", priority=4) + before = (project_dir / ".specify/presets/test-pack/preset.yml").read_bytes() + source = self._updated_source(pack_dir) - resolver = PresetResolver(project_dir) - content = resolver.resolve_content("spec-template") - assert content is not None - # Result: + + - assert "Security Header" in content - assert "Core Spec Template" in content - assert "Compliance Footer" in content - assert content.index("Security Header") < content.index("Core Spec Template") - assert content.index("Core Spec Template") < content.index("Compliance Footer") + manifest, diff = manager.update_from_directory( + source, "0.1.0", pack_id="test-pack", dry_run=True + ) - def test_resolve_content_override_trumps_composition(self, project_dir, temp_dir, valid_pack_data): - """Test that project overrides trump composition (replace at top priority).""" - # Install a composing preset - pack_data = {**valid_pack_data} - pack_data["preset"] = {**valid_pack_data["preset"], "id": "append-pack", "name": "Append"} - pack_data["provides"] = { - "templates": [{ - "type": "template", - "name": "spec-template", - "file": "templates/spec-template.md", - "strategy": "append", - }] - } - pack_dir = temp_dir / "append-pack" - pack_dir.mkdir() - with open(pack_dir / "preset.yml", 'w') as f: - yaml.dump(pack_data, f) - (pack_dir / "templates").mkdir() - (pack_dir / "templates" / "spec-template.md").write_text("## Appended\n") + assert manifest.version == "2.0.0" + assert diff["added"] + assert ( + project_dir / ".specify/presets/test-pack/preset.yml" + ).read_bytes() == before + assert manager.registry.get("test-pack")["version"] == "1.0.0" + def test_dry_run_validates_catalogue_expected_version( + self, project_dir, pack_dir + ): manager = PresetManager(project_dir) - manager.install_from_directory(pack_dir, "0.1.5") - - # Create project override (replaces everything) - overrides_dir = project_dir / ".specify" / "templates" / "overrides" - overrides_dir.mkdir(parents=True) - (overrides_dir / "spec-template.md").write_text("# Override Only\n") - - resolver = PresetResolver(project_dir) - content = resolver.resolve_content("spec-template") - assert content is not None - assert "Override Only" in content - # Override replaces, so appended content should not be visible - assert "Core Spec Template" not in content + manager.install_from_directory(pack_dir, "0.1.0") + source = self._updated_source(pack_dir) + + with pytest.raises(PresetValidationError, match="does not match catalogue"): + manager.update_from_directory( + source, + "0.1.0", + pack_id="test-pack", + dry_run=True, + expected_version="9.9.9", + ) - def test_resolve_content_command_type(self, project_dir, temp_dir, valid_pack_data): - """Test resolve_content with command template type.""" - # Create core command using stem naming (matches real layout: plan.md, not speckit.plan.md) - commands_dir = project_dir / ".specify" / "templates" / "commands" - commands_dir.mkdir(parents=True, exist_ok=True) - (commands_dir / "plan.md").write_text("# Core Plan Command\n") + assert manager.registry.get("test-pack")["version"] == "1.0.0" - pack_data = {**valid_pack_data} - pack_data["preset"] = {**valid_pack_data["preset"], "id": "cmd-append", "name": "CmdAppend"} - pack_data["provides"] = { - "templates": [{ - "type": "command", - "name": "speckit.plan", - "file": "commands/speckit.plan.md", - "strategy": "append", - }] - } - pack_dir = temp_dir / "cmd-append" - pack_dir.mkdir() - with open(pack_dir / "preset.yml", 'w') as f: - yaml.dump(pack_data, f) - (pack_dir / "commands").mkdir() - (pack_dir / "commands" / "speckit.plan.md").write_text("## Additional Instructions\n") + def test_disabled_preset_update_preserves_existing_artifacts( + self, project_dir, temp_dir + ): + from specify_cli import save_init_options + save_init_options( + project_dir, {"ai": "qwen", "ai_skills": False, "script": "sh"} + ) + (project_dir / ".qwen" / "commands").mkdir(parents=True) + preset_dir = self._command_preset( + temp_dir, "disabled-update-preset", "Original disabled body" + ) manager = PresetManager(project_dir) - manager.install_from_directory(pack_dir, "0.1.5") + manager.install_from_directory(preset_dir, "0.1.0") + manager.registry.update("disabled-update-preset", {"enabled": False}) - resolver = PresetResolver(project_dir) - content = resolver.resolve_content("speckit.plan", "command") - assert content is not None - assert "Core Plan Command" in content - assert "Additional Instructions" in content + command_file = project_dir / ".qwen" / "commands" / "speckit.specify.md" + command_before = command_file.read_bytes() + metadata_before = manager.registry.get("disabled-update-preset") - def test_resolve_content_command_frontmatter_stripping(self, project_dir, temp_dir, valid_pack_data): - """Test that command composition strips frontmatter from lower layers - and reattaches only the highest-priority frontmatter.""" - # Create core command with frontmatter - commands_dir = project_dir / ".specify" / "templates" / "commands" - commands_dir.mkdir(parents=True, exist_ok=True) - (commands_dir / "check.md").write_text( - "---\ndescription: Core check command\n---\nCore body content\n" + save_init_options( + project_dir, {"ai": "claude", "ai_skills": True, "script": "sh"} + ) + source = self._updated_command_source( + preset_dir, "2.0.0", "Updated disabled body" ) - pack_data = {**valid_pack_data} - pack_data["preset"] = {**valid_pack_data["preset"], "id": "fm-test", "name": "FmTest"} - pack_data["provides"] = { - "templates": [{ - "type": "command", - "name": "speckit.check", - "file": "commands/speckit.check.md", - "strategy": "append", - }] - } - pack_dir = temp_dir / "fm-test" - pack_dir.mkdir() - with open(pack_dir / "preset.yml", 'w') as f: - yaml.dump(pack_data, f) - (pack_dir / "commands").mkdir() - (pack_dir / "commands" / "speckit.check.md").write_text( - "---\ndescription: Preset check override\n---\nPreset body content\n" + manager.update_from_directory( + source, "0.1.0", pack_id="disabled-update-preset" + ) + + metadata_after = manager.registry.get("disabled-update-preset") + assert command_file.read_bytes() == command_before + assert not (project_dir / ".claude" / "skills").exists() + assert "Updated disabled body" in ( + project_dir + / ".specify" + / "presets" + / "disabled-update-preset" + / "commands" + / "speckit.specify.md" + ).read_text(encoding="utf-8") + assert metadata_after["version"] == "2.0.0" + assert metadata_after["enabled"] is False + assert ( + metadata_after["registered_commands"] + == metadata_before["registered_commands"] + ) + assert metadata_after["registered_skills"] == metadata_before["registered_skills"] + assert not list( + (project_dir / ".specify" / "presets").glob( + ".disabled-update-preset.update-*" + ) ) + def test_disabled_preset_update_does_not_replace_enabled_winner( + self, project_dir, temp_dir + ): + from specify_cli import save_init_options + + save_init_options( + project_dir, {"ai": "qwen", "ai_skills": False, "script": "sh"} + ) + (project_dir / ".qwen" / "commands").mkdir(parents=True) + disabled_dir = self._command_preset( + temp_dir, "disabled-winner-preset", "Disabled body v1" + ) + enabled_dir = self._command_preset( + temp_dir, "enabled-winner-preset", "Enabled body" + ) manager = PresetManager(project_dir) - manager.install_from_directory(pack_dir, "0.1.5") + manager.install_from_directory(disabled_dir, "0.1.0", priority=1) + manager.registry.update("disabled-winner-preset", {"enabled": False}) + manager.install_from_directory(enabled_dir, "0.1.0", priority=5) - resolver = PresetResolver(project_dir) - content = resolver.resolve_content("speckit.check", "command") - assert content is not None - # Should have the preset (highest-priority) frontmatter - assert "Preset check override" in content - # Should have both bodies - assert "Core body content" in content - assert "Preset body content" in content - # Core frontmatter should NOT appear in the body - assert content.count("---") == 2 # only one frontmatter block (opening + closing) + command_file = project_dir / ".qwen" / "commands" / "speckit.specify.md" + assert "Enabled body" in command_file.read_text(encoding="utf-8") + source = self._updated_command_source( + disabled_dir, "2.0.0", "Disabled body v2" + ) - def test_resolve_content_blank_line_separator(self, project_dir, temp_dir, valid_pack_data): - """Test that prepend/append use blank line separator.""" - pack_data = {**valid_pack_data} - pack_data["preset"] = {**valid_pack_data["preset"], "id": "sep-test", "name": "SepTest"} - pack_data["provides"] = { - "templates": [{ - "type": "template", - "name": "spec-template", - "file": "templates/spec-template.md", - "strategy": "append", - }] - } - pack_dir = temp_dir / "sep-test" - pack_dir.mkdir() - with open(pack_dir / "preset.yml", 'w') as f: - yaml.dump(pack_data, f) - (pack_dir / "templates").mkdir() - (pack_dir / "templates" / "spec-template.md").write_text("appended") + manager.update_from_directory( + source, "0.1.0", pack_id="disabled-winner-preset" + ) - manager = PresetManager(project_dir) - manager.install_from_directory(pack_dir, "0.1.5") + assert "Enabled body" in command_file.read_text(encoding="utf-8") + assert "Disabled body v2" not in command_file.read_text( + encoding="utf-8" + ) - resolver = PresetResolver(project_dir) - content = resolver.resolve_content("spec-template") - # Should have blank line separator - assert "\n\n" in content + def test_disabled_preset_update_retains_removed_command_for_cleanup( + self, project_dir, temp_dir + ): + from specify_cli import save_init_options - def test_resolve_content_replace_over_wrap(self, project_dir, temp_dir, valid_pack_data): - """Top-priority replace layer should win even if a lower layer uses wrap.""" - # Install a low-priority wrap preset (with no placeholder — would fail if evaluated) - wrap_data = {**valid_pack_data} - wrap_data["preset"] = {**valid_pack_data["preset"], "id": "wrap-lo", "name": "WrapLo"} - wrap_data["provides"] = { - "templates": [{ + save_init_options( + project_dir, {"ai": "qwen", "ai_skills": False, "script": "sh"} + ) + (project_dir / ".qwen" / "commands").mkdir(parents=True) + preset_dir = self._command_preset( + temp_dir, "disabled-removed-command", "Removed command body" + ) + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "0.1.0") + manager.registry.update("disabled-removed-command", {"enabled": False}) + command_file = project_dir / ".qwen" / "commands" / "speckit.specify.md" + + source = preset_dir.parent / "updated-disabled-removed-command" + shutil.copytree(preset_dir, source) + manifest_path = source / "preset.yml" + data = yaml.safe_load(manifest_path.read_text()) + data["preset"]["version"] = "2.0.0" + data["provides"]["templates"] = [ + { "type": "template", "name": "spec-template", "file": "templates/spec-template.md", - "strategy": "wrap", - }] - } - wrap_dir = temp_dir / "wrap-lo" - wrap_dir.mkdir() - with open(wrap_dir / "preset.yml", "w") as f: - yaml.dump(wrap_data, f) - (wrap_dir / "templates").mkdir() - # Intentionally missing {CORE_TEMPLATE} — would error if composition ran - (wrap_dir / "templates" / "spec-template.md").write_text("wrapper without placeholder") + } + ] + manifest_path.write_text(yaml.safe_dump(data)) + (source / "commands" / "speckit.specify.md").unlink() + (source / "templates").mkdir() + (source / "templates" / "spec-template.md").write_text("# Spec\n") - manager = PresetManager(project_dir) - manager.install_from_directory(wrap_dir, "0.1.5", priority=10) + manager.update_from_directory( + source, "0.1.0", pack_id="disabled-removed-command" + ) - # Install a high-priority replace preset - rep_data = {**valid_pack_data} - rep_data["preset"] = {**valid_pack_data["preset"], "id": "rep-hi", "name": "RepHi"} - rep_data["provides"] = { - "templates": [{ - "type": "template", - "name": "spec-template", - "file": "templates/spec-template.md", - }] - } - rep_dir = temp_dir / "rep-hi" - rep_dir.mkdir() - with open(rep_dir / "preset.yml", "w") as f: - yaml.dump(rep_data, f) - (rep_dir / "templates").mkdir() - (rep_dir / "templates" / "spec-template.md").write_text("# Replaced content\n") + metadata = manager.registry.get("disabled-removed-command") + assert command_file.exists() + assert metadata["registered_commands"]["qwen"] == ["speckit.specify"] - manager.install_from_directory(rep_dir, "0.1.5", priority=1) + manager.remove("disabled-removed-command") - resolver = PresetResolver(project_dir) - content = resolver.resolve_content("spec-template") - assert content == "# Replaced content\n" + assert "Removed command body" not in command_file.read_text( + encoding="utf-8" + ) - @pytest.mark.parametrize("strategy", ["append", "prepend", "wrap"]) - def test_resolve_content_rewrites_extension_base_subdir_paths( - self, project_dir, temp_dir, strategy + def test_enable_after_disabled_update_refreshes_generated_commands( + self, project_dir, temp_dir, monkeypatch ): - """Composing over an extension-provided base command must resolve the - extension's own subdir references (agents/, knowledge-base/) to their - installed location (#2101), not just when the extension wins outright. - """ - extension_dir = project_dir / ".specify" / "extensions" / "fakeext" - (extension_dir / "commands").mkdir(parents=True, exist_ok=True) - (extension_dir / "agents" / "control").mkdir(parents=True, exist_ok=True) - (extension_dir / "agents" / "control" / "commander.md").write_text("# Commander\n") - (extension_dir / "commands" / "cmd.md").write_text( - "---\ndescription: Extension fakeext cmd\n---\n\n" - "Read agents/control/commander.md for context.\n" + from typer.testing import CliRunner + from specify_cli import app, save_init_options + + save_init_options( + project_dir, {"ai": "qwen", "ai_skills": False, "script": "sh"} ) - extension_manifest = { - "schema_version": "1.0", - "extension": { - "id": "fakeext", - "name": "Fake Extension", - "version": "1.0.0", - "description": "Test", + (project_dir / ".qwen" / "commands").mkdir(parents=True) + source_v1 = self._multi_command_preset( + temp_dir, + "enable-refresh-preset", + { + "speckit.specify": "Specify v1", + "speckit.removed": "Removed v1", }, - "requires": {"speckit_version": ">=0.1.0"}, - "provides": { - "commands": [ - { - "name": "speckit.fakeext.cmd", - "file": "commands/cmd.md", - "description": "Fake extension command", - } - ] + ) + manager = PresetManager(project_dir) + manager.install_from_directory(source_v1, "0.1.0") + manager.registry.update("enable-refresh-preset", {"enabled": False}) + + source_v2 = self._multi_command_preset( + temp_dir, + "enable-refresh-preset-v2", + { + "speckit.specify": "Specify v2", + "speckit.added": "Added v2", }, - } - with open(extension_dir / "extension.yml", "w") as f: - yaml.dump(extension_manifest, f) + version="2.0.0", + ) + data = yaml.safe_load((source_v2 / "preset.yml").read_text()) + data["preset"]["id"] = "enable-refresh-preset" + (source_v2 / "preset.yml").write_text(yaml.safe_dump(data)) - preset_dir = temp_dir / f"ext-base-{strategy}" - preset_dir.mkdir() - (preset_dir / "commands").mkdir() - overlay_body = ( - "{CORE_TEMPLATE}\n## Extra\n" if strategy == "wrap" else "## Extra\n" + manager.update_from_directory( + source_v2, "0.1.0", pack_id="enable-refresh-preset" ) - (preset_dir / "commands" / "speckit.fakeext.cmd.md").write_text( - f"---\ndescription: Preset overlay\n---\n\n{overlay_body}" + + commands_dir = project_dir / ".qwen" / "commands" + assert "Specify v1" in ( + commands_dir / "speckit.specify.md" + ).read_text(encoding="utf-8") + assert (commands_dir / "speckit.removed.md").exists() + assert not (commands_dir / "speckit.added.md").exists() + + monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir) + result = CliRunner().invoke(app, ["preset", "enable", "enable-refresh-preset"]) + + assert result.exit_code == 0, result.output + assert "Specify v2" in ( + commands_dir / "speckit.specify.md" + ).read_text(encoding="utf-8") + assert "Added v2" in ( + commands_dir / "speckit.added.md" + ).read_text(encoding="utf-8") + assert not (commands_dir / "speckit.removed.md").exists() + registered = PresetManager(project_dir).registry.get( + "enable-refresh-preset" + )["registered_commands"]["qwen"] + assert "speckit.added" in registered + assert "speckit.removed" not in registered + + def test_enable_after_disabled_update_refreshes_generated_skills( + self, project_dir, temp_dir, monkeypatch + ): + from typer.testing import CliRunner + from specify_cli import app, save_init_options + + save_init_options( + project_dir, {"ai": "claude", "ai_skills": True, "script": "sh"} ) - preset_manifest = { - "schema_version": "1.0", - "preset": { - "id": f"ext-base-{strategy}", - "name": "Ext Base", - "version": "1.0.0", - "description": "Test", + skills_dir = project_dir / ".claude" / "skills" + skills_dir.mkdir(parents=True) + source_v1 = self._multi_command_preset( + temp_dir, + "enable-skill-refresh-preset", + { + "speckit.specify": "Specify skill v1", + "speckit.removed": "Removed skill v1", }, - "requires": {"speckit_version": ">=0.1.0"}, - "provides": { - "templates": [ - { - "type": "command", - "name": "speckit.fakeext.cmd", - "file": "commands/speckit.fakeext.cmd.md", - "strategy": strategy, - } - ] + ) + manager = PresetManager(project_dir) + manager.install_from_directory(source_v1, "0.1.0") + manager.registry.update("enable-skill-refresh-preset", {"enabled": False}) + + source_v2 = self._multi_command_preset( + temp_dir, + "enable-skill-refresh-preset-v2", + { + "speckit.specify": "Specify skill v2", + "speckit.added": "Added skill v2", }, - } - with open(preset_dir / "preset.yml", "w") as f: - yaml.dump(preset_manifest, f) + version="2.0.0", + ) + data = yaml.safe_load((source_v2 / "preset.yml").read_text()) + data["preset"]["id"] = "enable-skill-refresh-preset" + (source_v2 / "preset.yml").write_text(yaml.safe_dump(data)) - manager = PresetManager(project_dir) - manager.install_from_directory(preset_dir, "0.1.5") + manager.update_from_directory( + source_v2, "0.1.0", pack_id="enable-skill-refresh-preset" + ) - resolver = PresetResolver(project_dir) - content = resolver.resolve_content("speckit.fakeext.cmd", "command") - assert content is not None - assert ".specify/extensions/fakeext/agents/control/commander.md" in content - assert "Read agents/control" not in content - assert "## Extra" in content + assert "Specify skill v1" in ( + skills_dir / "speckit-specify" / "SKILL.md" + ).read_text(encoding="utf-8") + assert (skills_dir / "speckit-removed" / "SKILL.md").exists() + assert not (skills_dir / "speckit-added" / "SKILL.md").exists() + monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir) + result = CliRunner().invoke( + app, ["preset", "enable", "enable-skill-refresh-preset"] + ) -class TestCollectAllLayers: - """Test PresetResolver.collect_all_layers() method.""" + assert result.exit_code == 0, result.output + assert "Specify skill v2" in ( + skills_dir / "speckit-specify" / "SKILL.md" + ).read_text(encoding="utf-8") + assert "Added skill v2" in ( + skills_dir / "speckit-added" / "SKILL.md" + ).read_text(encoding="utf-8") + assert not (skills_dir / "speckit-removed").exists() + registered = PresetManager(project_dir).registry.get( + "enable-skill-refresh-preset" + )["registered_skills"]["claude"] + assert "speckit-added" in registered + assert "speckit-removed" not in registered + + def test_dev_update_from_project_root_does_not_copy_staging_into_itself( + self, project_dir, temp_dir + ): + preset_dir = self._command_preset( + temp_dir, "project-root-update", "Original body" + ) + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "0.1.0") - def test_non_utf8_legacy_command_keeps_replace_strategy(self, project_dir): - presets_dir = project_dir / ".specify" / "presets" - command_path = ( - presets_dir / "legacy-pack" / "commands" / "speckit.legacy.md" + shutil.copytree(preset_dir / "commands", project_dir / "commands") + (project_dir / "preset.yml").write_text( + (preset_dir / "preset.yml").read_text(encoding="utf-8"), + encoding="utf-8", ) - command_path.parent.mkdir(parents=True) - command_path.write_bytes(b"\xff\xfe") - PresetRegistry(presets_dir).add( - "legacy-pack", {"version": "1.0.0", "priority": 10} + data = yaml.safe_load((project_dir / "preset.yml").read_text()) + data["preset"]["version"] = "2.0.0" + (project_dir / "preset.yml").write_text(yaml.safe_dump(data)) + (project_dir / "commands" / "speckit.specify.md").write_text( + "---\ndescription: Test command\n---\n\nUpdated root body\n", + encoding="utf-8", ) - layers = PresetResolver(project_dir).collect_all_layers( - "speckit.legacy", "command" + manager.update_from_directory( + project_dir, "0.1.0", pack_id="project-root-update" ) - assert layers[0]["path"] == command_path - assert layers[0]["strategy"] == "replace" + installed = ( + project_dir + / ".specify" + / "presets" + / "project-root-update" + ) + assert "Updated root body" in ( + installed / "commands" / "speckit.specify.md" + ).read_text(encoding="utf-8") + assert not (installed / ".specify").exists() + assert not (installed / ".registry").exists() + assert not list(installed.glob("**/.project-root-update.update-*")) - def test_single_core_layer(self, project_dir): - """Test collecting layers with only core template.""" - resolver = PresetResolver(project_dir) - layers = resolver.collect_all_layers("spec-template") - assert len(layers) == 1 - assert layers[0]["source"] == "core" - assert layers[0]["strategy"] == "replace" + def test_missing_referenced_file_leaves_installation_untouched( + self, project_dir, pack_dir + ): + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.0") + source = self._updated_source(pack_dir) + (source / "commands" / "new-command.md").unlink() + installed = ( + project_dir / ".specify/presets/test-pack/preset.yml" + ).read_bytes() - def test_layers_include_presets(self, project_dir, temp_dir, valid_pack_data): - """Test that layers include installed preset.""" + with pytest.raises(PresetValidationError, match="[Rr]emove and add"): + manager.update_from_directory(source, "0.1.0", pack_id="test-pack") + + assert ( + project_dir / ".specify/presets/test-pack/preset.yml" + ).read_bytes() == installed + + def test_template_file_content_change_is_reconciled( + self, project_dir, pack_dir + ): manager = PresetManager(project_dir) - pack_dir = _create_pack(temp_dir, valid_pack_data, "test-pack", - "# From Pack\n") - manager.install_from_directory(pack_dir, "0.1.5") + manager.install_from_directory(pack_dir, "0.1.0") + source = pack_dir.parent / "content-updated-pack" + shutil.copytree(pack_dir, source) + source_file = source / "templates" / "spec-template.md" + source_file.write_text(source_file.read_text() + "\nUpdated.\n") - resolver = PresetResolver(project_dir) - layers = resolver.collect_all_layers("spec-template") - assert len(layers) == 2 - # Highest priority first - assert "test-pack" in layers[0]["source"] - assert layers[1]["source"] == "core" + _, _, diff = manager._validate_update_source( + source, "test-pack", "0.1.0" + ) - def test_layers_order_matches_priority(self, project_dir, temp_dir, valid_pack_data): - """Test that layers are ordered by priority (highest first).""" + assert diff["changed"][0]["identity"] == ("spec-template", "template") + assert diff["unchanged"] == [] + + def test_id_mismatch_is_rejected_before_swap(self, project_dir, pack_dir): manager = PresetManager(project_dir) - for pid, prio in [("pack-lo", 10), ("pack-hi", 1)]: - d = {**valid_pack_data} - d["preset"] = {**valid_pack_data["preset"], "id": pid, "name": pid} - p = temp_dir / pid - p.mkdir() - with open(p / "preset.yml", 'w') as f: - yaml.dump(d, f) - (p / "templates").mkdir() - (p / "templates" / "spec-template.md").write_text(f"# {pid}\n") - manager.install_from_directory(p, "0.1.5", priority=prio) + manager.install_from_directory(pack_dir, "0.1.0") + source = self._updated_source(pack_dir) + data = yaml.safe_load((source / "preset.yml").read_text()) + data["preset"]["id"] = "renamed-pack" + (source / "preset.yml").write_text(yaml.safe_dump(data)) - resolver = PresetResolver(project_dir) - layers = resolver.collect_all_layers("spec-template") - assert len(layers) == 3 # pack-hi, pack-lo, core - assert "pack-hi" in layers[0]["source"] - assert "pack-lo" in layers[1]["source"] - assert layers[2]["source"] == "core" + with pytest.raises(PresetValidationError, match="Remove and add"): + manager.update_from_directory(source, "0.1.0", pack_id="test-pack") - def test_layers_read_strategy_from_manifest(self, project_dir, temp_dir, valid_pack_data): - """Test that layers read strategy from preset manifest.""" - pack_data = {**valid_pack_data} - pack_data["preset"] = {**valid_pack_data["preset"], "id": "strat-pack", "name": "Strat"} - pack_data["provides"] = { - "templates": [{ - "type": "template", - "name": "spec-template", - "file": "templates/spec-template.md", - "strategy": "append", - }] - } - pack_dir = temp_dir / "strat-pack" - pack_dir.mkdir() - with open(pack_dir / "preset.yml", 'w') as f: - yaml.dump(pack_data, f) - (pack_dir / "templates").mkdir() - (pack_dir / "templates" / "spec-template.md").write_text("## Footer\n") + assert manager.registry.get("test-pack")["version"] == "1.0.0" + + def test_corrupt_manifest_is_rejected_before_swap(self, project_dir, pack_dir): + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.0") + source = self._updated_source(pack_dir) + (source / "preset.yml").write_text("not: [valid") + + with pytest.raises(PresetValidationError, match="corrupt"): + manager.update_from_directory(source, "0.1.0", pack_id="test-pack") - manager = PresetManager(project_dir) - manager.install_from_directory(pack_dir, "0.1.5") + assert manager.registry.get("test-pack")["version"] == "1.0.0" - resolver = PresetResolver(project_dir) - layers = resolver.collect_all_layers("spec-template") - # Preset layer should have strategy=append - assert layers[0]["strategy"] == "append" - # Core layer should be replace - assert layers[1]["strategy"] == "replace" + def test_type_change_remains_reconcilable(self, project_dir, pack_dir): + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.0") + source = self._updated_source(pack_dir) + data = yaml.safe_load((source / "preset.yml").read_text()) + data["provides"]["templates"][0]["type"] = "script" + data["provides"]["templates"][0]["name"] = "spec-template-script" + (source / "preset.yml").write_text(yaml.safe_dump(data)) + (source / "templates" / "spec-template.md").write_text("# Script\n") + _, diff = manager.update_from_directory( + source, "0.1.0", pack_id="test-pack" + ) -class TestRemoveReconciliation: - """Test that removing a preset re-registers the next layer's command.""" + assert ("spec-template", "template") in [ + entry["identity"] for entry in diff["removed"] + ] + assert ("spec-template-script", "script") in [ + entry["identity"] for entry in diff["added"] + ] - def test_remove_restores_extension_command_subdir_paths_for_non_skill_agent( - self, project_dir, temp_dir + def test_swap_failure_restores_directory_and_registry( + self, project_dir, pack_dir, monkeypatch ): - """When a preset override of an extension command is removed, the - reconciled non-skill-agent command file should have the extension's - own subdir references rewritten to their installed location (#2101), - not left as bare, unresolvable paths.""" - gemini_dir = project_dir / ".gemini" / "commands" - gemini_dir.mkdir(parents=True) + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.0") + source = self._updated_source(pack_dir) + installed_manifest = ( + project_dir / ".specify" / "presets" / "test-pack" / "preset.yml" + ).read_bytes() + original_update = manager.registry.update - extension_dir = project_dir / ".specify" / "extensions" / "fakeext" - (extension_dir / "commands").mkdir(parents=True, exist_ok=True) - (extension_dir / "agents" / "control").mkdir(parents=True, exist_ok=True) - (extension_dir / "agents" / "control" / "commander.md").write_text("# Commander\n") - (extension_dir / "commands" / "cmd.md").write_text( - "---\ndescription: Extension fakeext cmd\n---\n\n" - "Read agents/control/commander.md for context.\n" - ) - extension_manifest = { - "schema_version": "1.0", - "extension": { - "id": "fakeext", - "name": "Fake Extension", - "version": "1.0.0", - "description": "Test", - }, - "requires": {"speckit_version": ">=0.1.0"}, - "provides": { - "commands": [ - { - "name": "speckit.fakeext.cmd", - "file": "commands/cmd.md", - "description": "Fake extension command", - } - ] - }, - } - with open(extension_dir / "extension.yml", "w") as f: - yaml.dump(extension_manifest, f) + def fail_update(pack_id, updates): + if updates.get("version") == "2.0.0": + raise OSError("simulated registry failure") + return original_update(pack_id, updates) + + monkeypatch.setattr(manager.registry, "update", fail_update) + with pytest.raises(OSError, match="simulated registry failure"): + manager.update_from_directory(source, "0.1.0", pack_id="test-pack") + + assert ( + project_dir / ".specify" / "presets" / "test-pack" / "preset.yml" + ).read_bytes() == installed_manifest + assert manager.registry.get("test-pack")["version"] == "1.0.0" + def test_staged_validation_failure_leaves_live_install_untouched( + self, project_dir, pack_dir, monkeypatch + ): manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.0") + source = self._updated_source(pack_dir) + installed_manifest = ( + project_dir / ".specify" / "presets" / "test-pack" / "preset.yml" + ).read_bytes() + original_validate = manager._validate_update_source + calls = 0 + + def fail_staged(source_dir, pack_id, speckit_version): + nonlocal calls + calls += 1 + if calls == 2: + raise PresetValidationError("staged validation failed") + return original_validate(source_dir, pack_id, speckit_version) + + monkeypatch.setattr(manager, "_validate_update_source", fail_staged) + with pytest.raises(PresetValidationError, match="staged validation"): + manager.update_from_directory(source, "0.1.0", pack_id="test-pack") - preset_dir = temp_dir / "ext-cmd-override" - preset_dir.mkdir() - (preset_dir / "commands").mkdir() - (preset_dir / "commands" / "speckit.fakeext.cmd.md").write_text( - "---\ndescription: Override fakeext cmd\n---\n\npreset override content\n" + assert ( + project_dir / ".specify" / "presets" / "test-pack" / "preset.yml" + ).read_bytes() == installed_manifest + assert not list( + (project_dir / ".specify" / "presets").glob(".test-pack.update-*") ) - preset_manifest = { - "schema_version": "1.0", - "preset": { - "id": "ext-cmd-override", - "name": "Ext Cmd Override", - "version": "1.0.0", - "description": "Test", - }, - "requires": {"speckit_version": ">=0.1.0"}, - "provides": { - "templates": [ - { - "type": "command", - "name": "speckit.fakeext.cmd", - "file": "commands/speckit.fakeext.cmd.md", - } - ] - }, - } - with open(preset_dir / "preset.yml", "w") as f: - yaml.dump(preset_manifest, f) - manager.install_from_directory(preset_dir, "0.1.5") + def test_staged_validation_result_is_authoritative( + self, project_dir, pack_dir, monkeypatch + ): + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.0") + source = self._updated_source(pack_dir) + original_copytree = shutil.copytree - cmd_files = list(gemini_dir.glob("*fakeext*")) - assert cmd_files, "Command file should exist in gemini dir" - assert "preset override content" in cmd_files[0].read_text() + def copy_then_mutate(source_dir, destination_dir, *args, **kwargs): + result = original_copytree(source_dir, destination_dir, *args, **kwargs) + if isinstance(source_dir, Path) and source_dir == source: + data = yaml.safe_load((source_dir / "preset.yml").read_text()) + data["preset"]["version"] = "3.0.0" + (source_dir / "preset.yml").write_text(yaml.safe_dump(data)) + return result - manager.remove("ext-cmd-override") + monkeypatch.setattr(shutil, "copytree", copy_then_mutate) - cmd_files = list(gemini_dir.glob("*fakeext*")) - assert cmd_files, "Command file should still exist after removal" - content = cmd_files[0].read_text() - assert "preset override content" not in content - assert ".specify/extensions/fakeext/agents/control/commander.md" in content - assert "Read agents/control" not in content + manifest, _ = manager.update_from_directory( + source, "0.1.0", pack_id="test-pack" + ) - def test_install_composes_extension_command_and_rewrites_subdir_paths_for_non_skill_agent( + assert manifest.version == "2.0.0" + assert manager.registry.get("test-pack")["version"] == "2.0.0" + + def test_convention_only_constitution_update_reconciles( self, project_dir, temp_dir ): - """When a preset overlays (append) an extension-provided base command, - the initial composed non-skill-agent command file must have the - extension's own subdir references rewritten to their installed - location (#2101), matching the live repro: extension body - 'Read agents/control/commander.md', preset appends to - speckit.fakeext.cmd, generated Gemini content retains the bare path.""" - gemini_dir = project_dir / ".gemini" / "commands" - gemini_dir.mkdir(parents=True) + manager = PresetManager(project_dir) + manager.install_from_directory(CONSTITUTION_SYNC_PRESET_DIR, "0.15.0") + source = _make_convention_constitution_preset(temp_dir) + manager.install_from_directory(source, "0.1.5") + updated = temp_dir / "updated-convention-constitution" + shutil.copytree(source, updated) + data = yaml.safe_load((updated / "preset.yml").read_text()) + data["preset"]["version"] = "2.0.0" + (updated / "preset.yml").write_text(yaml.safe_dump(data)) + (updated / "templates" / "constitution-template.md").write_text( + "# Updated Convention Constitution\n" + ) - extension_dir = project_dir / ".specify" / "extensions" / "fakeext" - (extension_dir / "commands").mkdir(parents=True, exist_ok=True) - (extension_dir / "agents" / "control").mkdir(parents=True, exist_ok=True) - (extension_dir / "agents" / "control" / "commander.md").write_text("# Commander\n") - (extension_dir / "commands" / "cmd.md").write_text( - "---\ndescription: Extension fakeext cmd\n---\n\n" - "Read agents/control/commander.md for context.\n" + manager.update_from_directory( + updated, "0.1.5", pack_id="convention-constitution" ) - extension_manifest = { - "schema_version": "1.0", - "extension": { - "id": "fakeext", - "name": "Fake Extension", - "version": "1.0.0", - "description": "Test", - }, - "requires": {"speckit_version": ">=0.1.0"}, - "provides": { - "commands": [ - { - "name": "speckit.fakeext.cmd", - "file": "commands/cmd.md", - "description": "Fake extension command", - } - ] - }, - } - with open(extension_dir / "extension.yml", "w") as f: - yaml.dump(extension_manifest, f) - preset_dir = temp_dir / "ext-cmd-append" - preset_dir.mkdir() - (preset_dir / "commands").mkdir() - (preset_dir / "commands" / "speckit.fakeext.cmd.md").write_text( - "---\ndescription: Append fakeext cmd\n---\n\n## Extra\n" + memory = project_dir / ".specify" / "memory" / "constitution.md" + assert memory.read_text() == "# Updated Convention Constitution\n" + + def test_archive_write_cleans_temp_file_on_detection_failure( + self, monkeypatch + ): + created = {} + original_mkstemp = tempfile.mkstemp + + def record_mkstemp(*args, **kwargs): + fd, name = original_mkstemp(*args, **kwargs) + created["path"] = Path(name) + return fd, name + + monkeypatch.setattr(tempfile, "mkstemp", record_mkstemp) + monkeypatch.setattr( + "specify_cli.presets._commands.detect_archive_format", + lambda *args, **kwargs: (_ for _ in ()).throw( + PresetValidationError("invalid archive") + ), ) - preset_manifest = { - "schema_version": "1.0", - "preset": { - "id": "ext-cmd-append", - "name": "Ext Cmd Append", - "version": "1.0.0", - "description": "Test", - }, - "requires": {"speckit_version": ">=0.1.0"}, - "provides": { - "templates": [ - { - "type": "command", - "name": "speckit.fakeext.cmd", - "file": "commands/speckit.fakeext.cmd.md", - "strategy": "append", - } - ] - }, - } - with open(preset_dir / "preset.yml", "w") as f: - yaml.dump(preset_manifest, f) - manager = PresetManager(project_dir) - manager.install_from_directory(preset_dir, "0.1.5") + with pytest.raises(PresetValidationError, match="invalid archive"): + _write_preset_archive( + b"not an archive", + "https://example.com/preset", + None, + PresetValidationError, + ) - cmd_files = list(gemini_dir.glob("*fakeext*")) - assert cmd_files, "Command file should exist in gemini dir" - content = cmd_files[0].read_text() - assert ".specify/extensions/fakeext/agents/control/commander.md" in content - assert "Read agents/control" not in content - assert "## Extra" in content + assert not created["path"].exists() - def test_remove_restores_lower_priority_command( - self, project_dir, temp_dir, valid_pack_data + def test_archive_download_uses_final_redirect_url_and_canonical_suffix( + self, monkeypatch ): - """After removing the top-priority preset, the next preset's command - should be re-registered in agent directories.""" - manager = PresetManager(project_dir) + """A .zip request redirected to a tarball must retain the final format.""" + archive = io.BytesIO() + with tarfile.open(fileobj=archive, mode="w:gz") as tar: + info = tarfile.TarInfo("preset.yml") + payload = b"schema_version: '1.0'\n" + info.size = len(payload) + tar.addfile(info, io.BytesIO(payload)) - # Create a gemini commands dir so reconciliation writes there - gemini_dir = project_dir / ".gemini" / "commands" - gemini_dir.mkdir(parents=True) + monkeypatch.setattr( + "specify_cli.presets._commands._fetch_preset_archive_data", + lambda url, error_type: ( + archive.getvalue(), + "application/gzip", + "https://cdn.example.test/preset.tgz", + ), + ) - # Install a low-priority preset with a command - lo_data = {**valid_pack_data} - lo_data["preset"] = { - **valid_pack_data["preset"], - "id": "lo-preset", - "name": "Lo", - } - lo_data["provides"] = { - "templates": [{ - "type": "command", - "name": "speckit.specify", - "file": "commands/speckit.specify.md", - }] - } - lo_dir = temp_dir / "lo-preset" - lo_dir.mkdir() - with open(lo_dir / "preset.yml", "w") as f: - yaml.dump(lo_data, f) - (lo_dir / "commands").mkdir() - (lo_dir / "commands" / "speckit.specify.md").write_text( - "---\ndescription: lo\n---\nLo content\n" + path = _download_preset_archive( + "https://example.test/preset.zip", PresetValidationError ) - manager.install_from_directory(lo_dir, "0.1.5", priority=10) + try: + assert path.suffixes[-2:] == [".tar", ".gz"] + finally: + path.unlink(missing_ok=True) - # Install a high-priority preset overriding the same command - hi_data = {**valid_pack_data} - hi_data["preset"] = { - **valid_pack_data["preset"], - "id": "hi-preset", - "name": "Hi", - } - hi_data["provides"] = { - "templates": [{ - "type": "command", - "name": "speckit.specify", - "file": "commands/speckit.specify.md", - }] - } - hi_dir = temp_dir / "hi-preset" - hi_dir.mkdir() - with open(hi_dir / "preset.yml", "w") as f: - yaml.dump(hi_data, f) - (hi_dir / "commands").mkdir() - (hi_dir / "commands" / "speckit.specify.md").write_text( - "---\ndescription: hi\n---\nHi content\n" + def test_cli_missing_id_directs_user_to_add(self, project_dir): + from typer.testing import CliRunner + from specify_cli import app + + result = CliRunner().invoke( + app, + ["preset", "update", "missing-pack"], + obj={"project_root": project_dir}, + ) + + assert result.exit_code == 1 + assert "preset" in result.output and "add" in result.output + + def test_cli_single_dry_run_does_not_prompt( + self, project_dir, pack_dir, monkeypatch + ): + from typer.testing import CliRunner + from specify_cli import app + + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.0") + source = self._updated_source(pack_dir) + monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir) + monkeypatch.setattr("specify_cli.get_speckit_version", lambda: "0.1.0") + + result = CliRunner().invoke( + app, + [ + "preset", + "update", + "test-pack", + "--dev", + str(source), + "--dry-run", + ], ) - manager.install_from_directory(hi_dir, "0.1.5", priority=1) - # Verify the hi-preset's content is active in agent dir - cmd_files = list(gemini_dir.glob("*specify*")) - assert cmd_files, "Command file should exist in gemini dir" - assert "Hi content" in cmd_files[0].read_text() + assert result.exit_code == 0, result.output + assert "would update" in result.output + assert "planned actions" in result.output + assert "Update all installed presets?" not in result.output - # Remove the high-priority preset - manager.remove("hi-preset") + def test_cli_dry_run_does_not_plan_constitution_for_command_only_priority_change( + self, project_dir, pack_dir, monkeypatch + ): + from typer.testing import CliRunner + from specify_cli import app - # The low-priority preset's command should now be in the resolution stack - resolver = PresetResolver(project_dir) - layers = resolver.collect_all_layers("speckit.specify", "command") - assert len(layers) >= 1 - assert "lo-preset" in layers[0]["source"] + manager = PresetManager(project_dir) + manager.install_from_directory(CONSTITUTION_SYNC_PRESET_DIR, "0.15.0") + manager.install_from_directory(pack_dir, "0.1.0") + source = self._updated_source(pack_dir) + monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir) + monkeypatch.setattr("specify_cli.get_speckit_version", lambda: "0.1.0") - # Verify on-disk agent command file switched to lo-preset content - cmd_files = list(gemini_dir.glob("*specify*")) - assert cmd_files, "Command file should still exist after removal" - assert "Lo content" in cmd_files[0].read_text() + result = CliRunner().invoke( + app, + [ + "preset", + "update", + "test-pack", + "--dev", + str(source), + "--priority", + "5", + "--dry-run", + ], + ) + assert result.exit_code == 0, result.output + assert "constitution unchanged" in result.output + assert "constitution reconciliation pending" not in result.output -def _create_pack(temp_dir, valid_pack_data, pack_id, content, - strategy="replace", template_type="template", - template_name="spec-template"): - """Helper to create a preset pack directory.""" - pack_data = {**valid_pack_data} - pack_data["preset"] = {**valid_pack_data["preset"], "id": pack_id, "name": pack_id} + def test_cli_dry_run_plans_missing_constitution_without_creating_it( + self, project_dir, temp_dir, monkeypatch + ): + from typer.testing import CliRunner + from specify_cli import app - tmpl_entry = { - "type": template_type, - "name": template_name, - } - if template_type == "script": - tmpl_entry["file"] = f"scripts/{template_name}.sh" - elif template_type == "command": - tmpl_entry["file"] = f"commands/{template_name}.md" - else: - tmpl_entry["file"] = f"templates/{template_name}.md" - if strategy != "replace": - tmpl_entry["strategy"] = strategy - pack_data["provides"] = {"templates": [tmpl_entry]} + manager = PresetManager(project_dir) + install_constitution_sync_preset(manager) + source = _make_convention_constitution_preset(temp_dir) + manager.install_from_directory(source, "0.15.0") + manifest_path = source / "preset.yml" + manifest_data = yaml.safe_load(manifest_path.read_text()) + manifest_data["preset"]["version"] = "2.0.0" + manifest_path.write_text(yaml.safe_dump(manifest_data)) + + constitution_path = ( + project_dir / ".specify" / "memory" / "constitution.md" + ) + constitution_path.unlink() + registry_path = project_dir / ".specify" / "presets" / ".registry" + registry_before = registry_path.read_bytes() + installed_before = ( + project_dir + / ".specify" + / "presets" + / "convention-constitution" + / "preset.yml" + ).read_bytes() + monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir) + monkeypatch.setattr("specify_cli.get_speckit_version", lambda: "0.15.0") - pack_dir = temp_dir / pack_id - pack_dir.mkdir(exist_ok=True) - with open(pack_dir / "preset.yml", 'w') as f: - yaml.dump(pack_data, f) + result = CliRunner().invoke( + app, + [ + "preset", + "update", + "convention-constitution", + "--dev", + str(source), + "--dry-run", + ], + ) - if template_type == "script": - subdir = pack_dir / "scripts" - subdir.mkdir(exist_ok=True) - (subdir / f"{template_name}.sh").write_text(content) - elif template_type == "command": - subdir = pack_dir / "commands" - subdir.mkdir(exist_ok=True) - (subdir / f"{template_name}.md").write_text(content) - else: - subdir = pack_dir / "templates" - subdir.mkdir(exist_ok=True) - (subdir / f"{template_name}.md").write_text(content) + assert result.exit_code == 0, result.output + assert "constitution change planned" in result.output + assert not constitution_path.exists() + assert registry_path.read_bytes() == registry_before + assert ( + project_dir + / ".specify" + / "presets" + / "convention-constitution" + / "preset.yml" + ).read_bytes() == installed_before + assert not ( + project_dir / ".specify" / ".workflow-install.lock" + ).exists() - return pack_dir + def test_cli_bulk_dry_run_ignores_priority_in_constitution_preview( + self, project_dir, temp_dir, monkeypatch + ): + from typer.testing import CliRunner + from specify_cli import app + manager = PresetManager(project_dir) + install_constitution_sync_preset(manager) + source = _make_convention_constitution_preset(temp_dir) + manager.install_from_directory(source, "0.15.0", priority=7) + manifest_path = source / "preset.yml" + manifest_data = yaml.safe_load(manifest_path.read_text()) + manifest_data["preset"]["version"] = "2.0.0" + manifest_path.write_text(yaml.safe_dump(manifest_data)) + registry_path = project_dir / ".specify" / "presets" / ".registry" + registry_before = registry_path.read_bytes() + constitution_path = ( + project_dir / ".specify" / "memory" / "constitution.md" + ) + constitution_before = constitution_path.read_bytes() + self._bulk_cli_env( + project_dir, + monkeypatch, + { + "constitution-sync": { + "version": "1.0.0", + "bundled": True, + }, + "convention-constitution": { + "version": "2.0.0", + "bundled": True, + }, + }, + { + "constitution-sync": CONSTITUTION_SYNC_PRESET_DIR, + "convention-constitution": source, + }, + ) -def test_preset_wrapper_resolves_ghes_asset_when_host_configured(tmp_path, monkeypatch): - """End-to-end wiring for presets: auth.json github host → GHES asset resolution.""" - from specify_cli.authentication import http as _auth_http - from specify_cli.authentication.config import AuthConfigEntry - from specify_cli.presets import PresetCatalog + result = CliRunner().invoke( + app, + ["preset", "update", "--all", "--priority", "3", "--dry-run"], + input="y\n", + ) - monkeypatch.setattr(_auth_http, "_config_override", [ - AuthConfigEntry(hosts=("ghes.example",), provider="github", - auth="bearer", token="t"), - ]) - catalog = PresetCatalog(tmp_path) + assert result.exit_code == 0, result.output + assert "constitution unchanged" in result.output + assert registry_path.read_bytes() == registry_before + assert constitution_path.read_bytes() == constitution_before + assert PresetManager(project_dir).registry.get( + "convention-constitution" + )["priority"] == 7 + + def test_cli_dry_run_ignores_shadowed_constitution_change( + self, project_dir, temp_dir, monkeypatch + ): + from typer.testing import CliRunner + from specify_cli import app - captured = [] + manager = PresetManager(project_dir) + install_constitution_sync_preset(manager) + higher_source = _make_convention_constitution_preset(temp_dir) + higher_manifest = higher_source / "preset.yml" + higher_data = yaml.safe_load(higher_manifest.read_text()) + higher_data["preset"]["id"] = "higher-constitution" + higher_manifest.write_text(yaml.safe_dump(higher_data)) + (higher_source / "templates" / "constitution-template.md").write_text( + "# Higher Constitution\n" + ) + manager.install_from_directory(higher_source, "0.15.0", priority=1) + + lower_source = temp_dir / "lower-constitution" + shutil.copytree(higher_source, lower_source) + lower_manifest = lower_source / "preset.yml" + lower_data = yaml.safe_load(lower_manifest.read_text()) + lower_data["preset"]["id"] = "lower-constitution" + lower_manifest.write_text(yaml.safe_dump(lower_data)) + (lower_source / "templates" / "constitution-template.md").write_text( + "# Lower Constitution\n" + ) + manager.install_from_directory(lower_source, "0.15.0", priority=10) + + lower_data["preset"]["version"] = "2.0.0" + lower_manifest.write_text(yaml.safe_dump(lower_data)) + (lower_source / "templates" / "constitution-template.md").write_text( + "# Updated Lower Constitution\n" + ) + constitution_path = ( + project_dir / ".specify" / "memory" / "constitution.md" + ) + constitution_before = constitution_path.read_bytes() + monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir) + monkeypatch.setattr("specify_cli.get_speckit_version", lambda: "0.15.0") - @contextmanager - def fake_open(url, timeout=None, extra_headers=None): - captured.append(url) - resp = MagicMock() - resp.read.side_effect = io.BytesIO(json.dumps({ - "assets": [{"name": "pack.zip", - "url": "https://ghes.example/api/v3/repos/o/r/releases/assets/9"}] - }).encode()).read - yield resp + result = CliRunner().invoke( + app, + [ + "preset", + "update", + "lower-constitution", + "--dev", + str(lower_source), + "--priority", + "8", + "--dry-run", + ], + ) - monkeypatch.setattr(catalog, "_open_url", fake_open) + assert result.exit_code == 0, result.output + assert "constitution unchanged" in result.output + assert constitution_path.read_bytes() == constitution_before - resolved = catalog._resolve_github_release_asset_api_url( - "https://ghes.example/o/r/releases/download/v2/pack.zip" - ) - assert resolved == "https://ghes.example/api/v3/repos/o/r/releases/assets/9" - assert captured == ["https://ghes.example/api/v3/repos/o/r/releases/tags/v2"] + def test_cli_dry_run_plans_new_winning_constitution_layer( + self, project_dir, temp_dir, monkeypatch + ): + from typer.testing import CliRunner + from specify_cli import app + manager = PresetManager(project_dir) + install_constitution_sync_preset(manager) + lower_source = _make_convention_constitution_preset(temp_dir) + lower_manifest = lower_source / "preset.yml" + lower_data = yaml.safe_load(lower_manifest.read_text()) + lower_data["preset"]["id"] = "lower-constitution" + lower_manifest.write_text(yaml.safe_dump(lower_data)) + manager.install_from_directory(lower_source, "0.15.0", priority=5) + + target_source = self._command_preset( + temp_dir, "higher-new-constitution", "Command body" + ) + manager.install_from_directory(target_source, "0.15.0", priority=1) + (target_source / "templates").mkdir() + (target_source / "templates" / "constitution-template.md").write_text( + "# New Winning Constitution\n" + ) + target_manifest = target_source / "preset.yml" + target_data = yaml.safe_load(target_manifest.read_text()) + target_data["preset"]["version"] = "2.0.0" + target_manifest.write_text(yaml.safe_dump(target_data)) + constitution_path = ( + project_dir / ".specify" / "memory" / "constitution.md" + ) + constitution_before = constitution_path.read_bytes() + monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir) + monkeypatch.setattr("specify_cli.get_speckit_version", lambda: "0.15.0") -# ===== ensure_constitution_from_template resolver-awareness ===== + result = CliRunner().invoke( + app, + [ + "preset", + "update", + "higher-new-constitution", + "--dev", + str(target_source), + "--dry-run", + ], + ) + assert result.exit_code == 0, result.output + assert "constitution change planned" in result.output + assert constitution_path.read_bytes() == constitution_before -class TestEnsureConstitutionResolverAware: - """`ensure_constitution_from_template` must resolve through PresetResolver. + def test_cli_dry_run_plans_change_to_composed_constitution_layer( + self, project_dir, temp_dir, monkeypatch + ): + from typer.testing import CliRunner + from specify_cli import app - Init materializes the live constitution once, while later /constitution - runs resolve on demand. These tests pin the regression from issue #3272: - a preset-provided ``constitution-template`` must win during the init seed, - while the core template is used when no preset overrides it. - """ + manager = PresetManager(project_dir) + install_constitution_sync_preset(manager) + target_source = self._constitution_preset( + temp_dir, "composed-target", "# Target v1\n" + ) + manager.install_from_directory(target_source, "0.15.0", priority=5) + top_source = self._constitution_preset( + temp_dir, "composed-top", "# Top\n", strategy="prepend" + ) + manager.install_from_directory(top_source, "0.15.0", priority=1) - def _core_constitution(self, project_dir): - templates_dir = project_dir / ".specify" / "templates" - templates_dir.mkdir(parents=True, exist_ok=True) - (templates_dir / "constitution-template.md").write_text( - "# [PROJECT_NAME] Constitution\n\n### [PRINCIPLE_1_NAME]\n" + target_manifest = target_source / "preset.yml" + target_data = yaml.safe_load(target_manifest.read_text()) + target_data["preset"]["version"] = "2.0.0" + target_manifest.write_text(yaml.safe_dump(target_data)) + (target_source / "templates" / "constitution-template.md").write_text( + "# Target v2\n" ) + constitution_path = ( + project_dir / ".specify" / "memory" / "constitution.md" + ) + constitution_before = constitution_path.read_bytes() + monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir) + monkeypatch.setattr("specify_cli.get_speckit_version", lambda: "0.15.0") + runner = CliRunner() - def _wrap_constitution_preset(self, temp_dir): - preset_dir = temp_dir / "ensure-wrap-preset" - (preset_dir / "templates").mkdir(parents=True) - (preset_dir / "templates" / "constitution-template.md").write_text( - "# Ensure Wrapper\n\n{CORE_TEMPLATE}\n\n## Tail\n" + dry_run = runner.invoke( + app, + [ + "preset", + "update", + "composed-target", + "--dev", + str(target_source), + "--dry-run", + ], ) - (preset_dir / "preset.yml").write_text( - yaml.dump( - { - "schema_version": "1.0", - "preset": { - "id": "ensure-wrap", - "name": "Ensure Wrap", - "version": "1.0.0", - "description": "Wrap strategy for ensure() coverage", - }, - "requires": {"speckit_version": ">=0.1.0"}, - "provides": { - "templates": [ - { - "type": "template", - "name": "constitution-template", - "file": "templates/constitution-template.md", - "strategy": "wrap", - "description": "Wrapped constitution", - } - ] - }, - } - ) + assert dry_run.exit_code == 0, dry_run.output + assert "constitution change planned" in dry_run.output + assert constitution_path.read_bytes() == constitution_before + + update = runner.invoke( + app, + [ + "preset", + "update", + "composed-target", + "--dev", + str(target_source), + ], ) - return preset_dir + assert update.exit_code == 0, update.output + assert "constitution reconciled" in update.output - def test_seeds_from_core_when_no_preset(self, project_dir): - from specify_cli.commands.init import ensure_constitution_from_template + def test_cli_dry_run_plans_missing_shadowed_constitution( + self, project_dir, temp_dir, monkeypatch + ): + from typer.testing import CliRunner + from specify_cli import app - self._core_constitution(project_dir) - ensure_constitution_from_template(project_dir) + manager = PresetManager(project_dir) + install_constitution_sync_preset(manager) + higher_source = self._constitution_preset( + temp_dir, "missing-higher", "# Higher\n" + ) + manager.install_from_directory(higher_source, "0.15.0", priority=1) + target_source = self._constitution_preset( + temp_dir, "missing-target", "# Target v1\n" + ) + manager.install_from_directory(target_source, "0.15.0", priority=5) - memory = project_dir / ".specify" / "memory" / "constitution.md" - assert memory.exists() - assert "[PROJECT_NAME]" in memory.read_text() - assert (memory.parent / ".constitution-template.json").exists() + target_manifest = target_source / "preset.yml" + target_data = yaml.safe_load(target_manifest.read_text()) + target_data["preset"]["version"] = "2.0.0" + target_manifest.write_text(yaml.safe_dump(target_data)) + (target_source / "templates" / "constitution-template.md").write_text( + "# Target v2\n" + ) + constitution_path = ( + project_dir / ".specify" / "memory" / "constitution.md" + ) + constitution_path.unlink() + monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir) + monkeypatch.setattr("specify_cli.get_speckit_version", lambda: "0.15.0") - def test_seeds_from_preset_when_installed(self, project_dir): - from specify_cli.commands.init import ensure_constitution_from_template + result = CliRunner().invoke( + app, + [ + "preset", + "update", + "missing-target", + "--dev", + str(target_source), + "--dry-run", + ], + ) + + assert result.exit_code == 0, result.output + assert "constitution change planned" in result.output + assert not constitution_path.exists() + + def test_cli_dry_run_ignores_priority_change_with_same_constitution( + self, project_dir, temp_dir, monkeypatch + ): + from typer.testing import CliRunner + from specify_cli import app - self._core_constitution(project_dir) manager = PresetManager(project_dir) - install_self_test_preset(manager) + install_constitution_sync_preset(manager) + source = self._constitution_preset( + temp_dir, "same-constitution", "# Same Constitution\n" + ) + manager.install_from_directory(source, "0.15.0", priority=10) + manifest_path = source / "preset.yml" + data = yaml.safe_load(manifest_path.read_text()) + data["preset"]["version"] = "2.0.0" + manifest_path.write_text(yaml.safe_dump(data)) + constitution_path = ( + project_dir / ".specify" / "memory" / "constitution.md" + ) + constitution_before = constitution_path.read_bytes() + monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir) + monkeypatch.setattr("specify_cli.get_speckit_version", lambda: "0.15.0") - memory = project_dir / ".specify" / "memory" / "constitution.md" - assert not memory.exists() + result = CliRunner().invoke( + app, + [ + "preset", + "update", + "same-constitution", + "--dev", + str(source), + "--priority", + "8", + "--dry-run", + ], + ) - ensure_constitution_from_template(project_dir) + assert result.exit_code == 0, result.output + assert "constitution unchanged" in result.output + assert constitution_path.read_bytes() == constitution_before - assert memory.exists() - content = memory.read_text() - assert "preset:self-test" in content - assert "[PROJECT_NAME]" not in content + def test_cli_dry_run_compares_replace_constitution_as_bytes( + self, project_dir, temp_dir, monkeypatch + ): + from typer.testing import CliRunner + from specify_cli import app - def test_preserves_existing_memory(self, project_dir): - from specify_cli.commands.init import ensure_constitution_from_template + manager = PresetManager(project_dir) + install_constitution_sync_preset(manager) + source = self._constitution_preset( + temp_dir, "crlf-constitution", "# placeholder\n" + ) + crlf_content = b"# CRLF\r\n\r\nBody line\r\n" + (source / "templates" / "constitution-template.md").write_bytes( + crlf_content + ) + manager.install_from_directory(source, "0.15.0") + manifest_path = source / "preset.yml" + data = yaml.safe_load(manifest_path.read_text()) + data["preset"]["version"] = "2.0.0" + manifest_path.write_text(yaml.safe_dump(data)) + constitution_path = ( + project_dir / ".specify" / "memory" / "constitution.md" + ) + assert constitution_path.read_bytes() == crlf_content + monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir) + monkeypatch.setattr("specify_cli.get_speckit_version", lambda: "0.15.0") + runner = CliRunner() - self._core_constitution(project_dir) - memory = project_dir / ".specify" / "memory" / "constitution.md" - memory.parent.mkdir(parents=True, exist_ok=True) - authored = "# Acme Constitution\nAuthored.\n" - memory.write_text(authored) + dry_run = runner.invoke( + app, + [ + "preset", + "update", + "crlf-constitution", + "--dev", + str(source), + "--dry-run", + ], + ) - ensure_constitution_from_template(project_dir) + assert dry_run.exit_code == 0, dry_run.output + assert "constitution unchanged" in dry_run.output + assert constitution_path.read_bytes() == crlf_content - assert memory.read_text() == authored + update = runner.invoke( + app, + [ + "preset", + "update", + "crlf-constitution", + "--dev", + str(source), + ], + ) - def test_preserves_edited_generated_memory(self, project_dir): - from specify_cli.commands.init import ensure_constitution_from_template + assert update.exit_code == 0, update.output + assert "constitution unchanged" in update.output + assert constitution_path.read_bytes() == crlf_content - self._core_constitution(project_dir) - ensure_constitution_from_template(project_dir) - memory = project_dir / ".specify" / "memory" / "constitution.md" - authored = memory.read_text() + "\nAuthored amendment.\n" - memory.write_text(authored) + @pytest.mark.parametrize( + "legacy_registry_owner", [None, "other", "target"] + ) + def test_priority_update_reconciles_other_presets_inactive_skill_agent( + self, project_dir, temp_dir, legacy_registry_owner + ): + from specify_cli import save_init_options + save_init_options( + project_dir, + {"ai": "claude", "ai_skills": True, "script": "sh"}, + ) + higher_source = self._command_preset( + temp_dir, "higher-preset", "higher preset body" + ) + target_source = self._command_preset( + temp_dir, "target-preset", "target preset body" + ) manager = PresetManager(project_dir) - install_self_test_preset(manager) + manager.install_from_directory(higher_source, "0.1.0", priority=5) - assert memory.read_text() == authored + save_init_options( + project_dir, + {"ai": "codex", "ai_skills": True, "script": "sh"}, + ) + manager.install_from_directory(target_source, "0.1.0", priority=10) + if legacy_registry_owner == "other": + manager.registry.update( + "higher-preset", + {"registered_skills": ["speckit-specify"]}, + ) + elif legacy_registry_owner == "target": + manager.registry.update( + "target-preset", + {"registered_skills": ["speckit-specify"]}, + ) - def test_composes_wrap_strategy_when_ensuring(self, project_dir, temp_dir): - from specify_cli.commands.init import ensure_constitution_from_template + claude_skill = ( + project_dir + / ".claude" + / "skills" + / "speckit-specify" + / "SKILL.md" + ) + codex_skill = ( + project_dir + / ".agents" + / "skills" + / "speckit-specify" + / "SKILL.md" + ) + assert "preset:higher-preset" in claude_skill.read_text() + assert "preset:higher-preset" in codex_skill.read_text() - self._core_constitution(project_dir) + manifest_path = target_source / "preset.yml" + manifest_data = yaml.safe_load(manifest_path.read_text()) + manifest_data["preset"]["version"] = "2.0.0" + manifest_path.write_text(yaml.safe_dump(manifest_data)) + (target_source / "commands" / "speckit.specify.md").write_text( + "---\ndescription: Test command\n---\n\n" + "updated target preset body\n" + ) + + manager.update_from_directory( + target_source, + "0.1.0", + pack_id="target-preset", + priority=1, + ) + + assert "preset:target-preset" in claude_skill.read_text() + assert "updated target preset body" in claude_skill.read_text() + assert "preset:target-preset" in codex_skill.read_text() + assert "updated target preset body" in codex_skill.read_text() + assert not (project_dir / ".github" / "skills").exists() + assert not ( + project_dir / ".agents" / "skills" / "speckit.specify" + ).exists() + target_skills = PresetManager(project_dir).registry.get( + "target-preset" + )["registered_skills"] + assert "speckit-specify" in target_skills["claude"] + assert "speckit-specify" in target_skills["codex"] + + def test_dry_run_does_not_create_transaction_lock( + self, project_dir, pack_dir + ): + """Dry-run validation must not leave a project lock behind.""" manager = PresetManager(project_dir) - manager.install_from_directory(self._wrap_constitution_preset(temp_dir), "0.1.5") + manager.install_from_directory(pack_dir, "0.1.0") + source = self._updated_source(pack_dir) + lock_path = project_dir / ".specify" / ".workflow-install.lock" + assert not lock_path.exists() - memory = project_dir / ".specify" / "memory" / "constitution.md" - assert not memory.exists() - ensure_constitution_from_template(project_dir) + manager.update_from_directory( + source, "0.1.0", pack_id="test-pack", dry_run=True + ) - content = memory.read_text() - assert "{CORE_TEMPLATE}" not in content - assert "# Ensure Wrapper" in content - assert "[PROJECT_NAME]" in content + assert not lock_path.exists() + def _second_pack(self, pack_dir, pack_id, version="1.0.0"): + source = pack_dir.parent / pack_id + shutil.copytree(pack_dir, source) + manifest_path = source / "preset.yml" + data = yaml.safe_load(manifest_path.read_text()) + data["preset"]["id"] = pack_id + data["preset"]["name"] = pack_id + data["preset"]["version"] = version + manifest_path.write_text(yaml.safe_dump(data)) + return source -class TestPresetTagsNonString: - """Non-string catalog tags must not crash preset display commands. + def _bulk_cli_env(self, project_dir, monkeypatch, pack_infos, sources): + import specify_cli + from specify_cli.presets import PresetCatalog - Catalog payloads are user-editable YAML/JSON, so a `tags:` list can contain - numbers or other non-strings. The display path joins them; a raw - ``", ".join(...)`` blows up with ``TypeError: sequence item 0: expected str``. - Sibling command surfaces (extensions/integrations/workflows) already guard - this with ``str(t) for t in ...`` — presets must match. - """ + monkeypatch.setattr( + "specify_cli._require_specify_project", lambda: project_dir + ) + monkeypatch.setattr("specify_cli.get_speckit_version", lambda: "0.1.0") + monkeypatch.setattr( + PresetCatalog, + "get_pack_info", + lambda self, pack_id: pack_infos.get(pack_id), + ) + monkeypatch.setattr( + specify_cli, + "_locate_bundled_preset", + lambda pack_id: sources.get(pack_id), + ) - def _seed_catalog(self, project_dir, tags, extra=None): - catalog = PresetCatalog(project_dir) - catalog.cache_dir.mkdir(parents=True, exist_ok=True) - pack = { - "name": "Numeric Tags", - "description": "Preset with non-string tags", - "version": "1.0.0", - "tags": tags, - } - if extra: - pack.update(extra) - catalog_data = { - "schema_version": "1.0", - "presets": { - "numeric-tags": pack, + def test_cli_bulk_isolates_failures_and_preserves_state( + self, project_dir, pack_dir, monkeypatch + ): + from typer.testing import CliRunner + from specify_cli import app + + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.0", priority=7) + second_source = self._second_pack(pack_dir, "second-pack") + manager.install_from_directory(second_source, "0.1.0", priority=5) + manager.registry.update("second-pack", {"enabled": False}) + + broken = pack_dir.parent / "broken-update" + shutil.copytree(pack_dir, broken) + (broken / "preset.yml").write_text("preset: [unbalanced\n") + good = self._second_pack(pack_dir, "second-pack-v2", version="2.0.0") + data = yaml.safe_load((good / "preset.yml").read_text()) + data["preset"]["id"] = "second-pack" + data["preset"]["name"] = "second-pack" + (good / "preset.yml").write_text(yaml.safe_dump(data)) + + self._bulk_cli_env( + project_dir, + monkeypatch, + { + "test-pack": {"version": "2.0.0", "bundled": True}, + "second-pack": {"version": "2.0.0", "bundled": True}, }, - } - catalog.cache_file.write_text(json.dumps(catalog_data)) - catalog.cache_metadata_file.write_text(json.dumps({ - "cached_at": datetime.now(timezone.utc).isoformat(), - })) - return catalog + {"test-pack": broken, "second-pack": good}, + ) - def test_search_renders_non_string_tags(self, project_dir): + result = CliRunner().invoke(app, ["preset", "update"], input="y\n") + + assert result.exit_code == 1, result.output + assert result.output.count("Update all installed presets?") == 1 + assert "test-pack: failed" in result.output + registry = PresetManager(project_dir).registry + second = registry.get("second-pack") + assert second["version"] == "2.0.0" + assert second["enabled"] is False + assert second["priority"] == 5 + assert registry.get("test-pack")["version"] == "1.0.0" + + def test_cli_bulk_skips_already_current_presets( + self, project_dir, pack_dir, monkeypatch + ): from typer.testing import CliRunner - from unittest.mock import patch from specify_cli import app - catalog = self._seed_catalog(project_dir, [1, 2]) - default_only = [PresetCatalogEntry( - url=catalog.DEFAULT_CATALOG_URL, name="default", priority=1, install_allowed=True - )] + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.0") - with patch.object(Path, "cwd", return_value=project_dir), \ - patch.object(PresetCatalog, "get_active_catalogs", return_value=default_only): - result = CliRunner().invoke(app, ["preset", "search", "Numeric"]) + self._bulk_cli_env( + project_dir, + monkeypatch, + {"test-pack": {"version": "1.0.0", "bundled": True}}, + {"test-pack": pack_dir}, + ) + + result = CliRunner().invoke(app, ["preset", "update"], input="y\n") assert result.exit_code == 0, result.output - plain = strip_ansi(result.output) - assert "Tags: 1, 2" in plain + assert "Up to date, skipped" in result.output + assert PresetManager(project_dir).registry.get("test-pack")["version"] == "1.0.0" - def test_info_renders_non_string_tags(self, project_dir): + def test_cli_bulk_cancellation_leaves_installations_untouched( + self, project_dir, pack_dir, monkeypatch + ): from typer.testing import CliRunner - from unittest.mock import patch from specify_cli import app - catalog = self._seed_catalog(project_dir, [1, 2]) - default_only = [PresetCatalogEntry( - url=catalog.DEFAULT_CATALOG_URL, name="default", priority=1, install_allowed=True - )] + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.0") + source = self._updated_source(pack_dir) - with patch.object(Path, "cwd", return_value=project_dir), \ - patch.object(PresetCatalog, "get_active_catalogs", return_value=default_only): - result = CliRunner().invoke(app, ["preset", "info", "numeric-tags"]) + self._bulk_cli_env( + project_dir, + monkeypatch, + {"test-pack": {"version": "2.0.0", "bundled": True}}, + {"test-pack": source}, + ) + + result = CliRunner().invoke(app, ["preset", "update"], input="n\n") assert result.exit_code == 0, result.output - plain = strip_ansi(result.output) - assert "Tags: 1, 2" in plain + assert "Cancelled" in result.output + assert PresetManager(project_dir).registry.get("test-pack")["version"] == "1.0.0" - def _default_only(self, catalog): - return [PresetCatalogEntry( - url=catalog.DEFAULT_CATALOG_URL, name="default", priority=1, install_allowed=True - )] + def _two_actionable_packs(self, project_dir, pack_dir, monkeypatch): + """Install two presets that both clear preflight, so the main update + loop is genuinely exercised rather than short-circuited early.""" + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.0", priority=7) + manager.install_from_directory( + self._second_pack(pack_dir, "second-pack"), "0.1.0", priority=5 + ) - def test_search_by_author_tolerates_non_string_author(self, project_dir): - """``--author`` must not crash on a numeric catalog ``author``. + def _renamed(tmp_id, target_id): + src = self._second_pack(pack_dir, tmp_id, version="2.0.0") + data = yaml.safe_load((src / "preset.yml").read_text()) + data["preset"]["id"] = target_id + data["preset"]["name"] = target_id + (src / "preset.yml").write_text(yaml.safe_dump(data)) + return src - ``PresetCatalog.search`` called ``.lower()`` straight on the raw value, - raising ``AttributeError: 'int' object has no attribute 'lower'``. The - sibling extension/integration catalogs coerce with ``str(...)`` first. - """ + self._bulk_cli_env( + project_dir, + monkeypatch, + { + "test-pack": {"version": "2.0.0", "bundled": True}, + "second-pack": {"version": "2.0.0", "bundled": True}, + }, + { + "test-pack": _renamed("first-new", "test-pack"), + "second-pack": _renamed("second-new", "second-pack"), + }, + ) + + def test_cli_bulk_confirms_once_for_multiple_presets( + self, project_dir, pack_dir, monkeypatch + ): + """Two actionable presets must produce exactly one confirmation.""" from typer.testing import CliRunner - from unittest.mock import patch from specify_cli import app - catalog = self._seed_catalog(project_dir, ["ci"], extra={"author": 789}) + self._two_actionable_packs(project_dir, pack_dir, monkeypatch) - with patch.object(Path, "cwd", return_value=project_dir), \ - patch.object(PresetCatalog, "get_active_catalogs", - return_value=self._default_only(catalog)): - result = CliRunner().invoke(app, ["preset", "search", "--author", "789"]) + result = CliRunner().invoke(app, ["preset", "update"], input="y\n") assert result.exit_code == 0, result.output - assert "Numeric Tags" in strip_ansi(result.output) - - def test_search_query_tolerates_non_string_name_and_description(self, project_dir): - """A query search must not crash on numeric ``name``/``description``. + assert result.output.count("Update all installed presets?") == 1 + registry = PresetManager(project_dir).registry + assert registry.get("test-pack")["version"] == "2.0.0" + assert registry.get("second-pack")["version"] == "2.0.0" - The searchable-text join passed the raw values through, raising - ``TypeError: sequence item 0: expected str instance, int found``. - """ + def test_cli_bulk_cleanup_warning_does_not_report_successful_update_as_failed( + self, project_dir, pack_dir, monkeypatch + ): from typer.testing import CliRunner - from unittest.mock import patch from specify_cli import app - catalog = self._seed_catalog( - project_dir, ["ci"], extra={"name": 123, "description": 456} - ) + self._two_actionable_packs(project_dir, pack_dir, monkeypatch) + original_rmtree = shutil.rmtree - with patch.object(Path, "cwd", return_value=project_dir), \ - patch.object(PresetCatalog, "get_active_catalogs", - return_value=self._default_only(catalog)): - result = CliRunner().invoke(app, ["preset", "search", "123"]) + def fail_first_committed_backup(path, *args, **kwargs): + name = Path(path).name + if name.startswith(".test-pack.update-") and name.endswith(".bak"): + raise PermissionError("simulated committed backup cleanup failure") + return original_rmtree(path, *args, **kwargs) - assert result.exit_code == 0, result.output - assert "numeric-tags" in strip_ansi(result.output) + monkeypatch.setattr(shutil, "rmtree", fail_first_committed_backup) - def test_search_tolerates_non_list_tags(self, project_dir): - """A scalar ``tags:`` value must not crash the tag filter or display. + with pytest.warns(UserWarning, match="updated successfully") as caught: + result = CliRunner().invoke(app, ["preset", "update"], input="y\n") - ``tags: 5`` is truthy but not iterable, so both the ``--tag`` filter and - the result-display join raised ``TypeError: 'int' object is not - iterable``. Siblings guard with ``isinstance(raw_tags, list)``. - """ + assert result.exit_code == 0, result.output + assert "test-pack: updated" in result.output + assert "second-pack: updated" in result.output + assert "failed" not in result.output + registry = PresetManager(project_dir).registry + assert registry.get("test-pack")["version"] == "2.0.0" + assert registry.get("second-pack")["version"] == "2.0.0" + backups = list( + (project_dir / ".specify/presets").glob( + ".test-pack.update-*.bak" + ) + ) + assert len(backups) == 1 + assert str(backups[0]) in str(caught[0].message) + original_rmtree(backups[0]) + + def test_cli_all_flag_matches_bare_bulk_invocation( + self, project_dir, pack_dir, monkeypatch + ): + """The explicit --all spelling drives the same bulk path.""" from typer.testing import CliRunner - from unittest.mock import patch from specify_cli import app - catalog = self._seed_catalog(project_dir, 5) - - with patch.object(Path, "cwd", return_value=project_dir), \ - patch.object(PresetCatalog, "get_active_catalogs", - return_value=self._default_only(catalog)): - filtered = CliRunner().invoke(app, ["preset", "search", "--tag", "ci"]) - displayed = CliRunner().invoke(app, ["preset", "search", "Numeric"]) + self._two_actionable_packs(project_dir, pack_dir, monkeypatch) - assert filtered.exit_code == 0, filtered.output - assert "No presets found" in strip_ansi(filtered.output) + result = CliRunner().invoke(app, ["preset", "update", "--all"], input="y\n") - assert displayed.exit_code == 0, displayed.output - plain = strip_ansi(displayed.output) - assert "Numeric Tags" in plain - assert "Tags:" not in plain + assert result.exit_code == 0, result.output + assert result.output.count("Update all installed presets?") == 1 + registry = PresetManager(project_dir).registry + assert registry.get("test-pack")["version"] == "2.0.0" + assert registry.get("second-pack")["version"] == "2.0.0" - def test_info_tolerates_non_list_tags(self, project_dir): - """``preset info`` must not crash rendering a scalar ``tags:`` value.""" + def test_cli_bulk_execution_failure_does_not_abort_later_presets( + self, project_dir, pack_dir, monkeypatch + ): + """A failure raised inside the main update loop, after preflight and + confirmation both succeeded, must not stop a later preset updating.""" from typer.testing import CliRunner - from unittest.mock import patch from specify_cli import app + from specify_cli.presets import PresetManager as RealManager - catalog = self._seed_catalog(project_dir, 5) + self._two_actionable_packs(project_dir, pack_dir, monkeypatch) + real_update = RealManager.update_from_directory - with patch.object(Path, "cwd", return_value=project_dir), \ - patch.object(PresetCatalog, "get_active_catalogs", - return_value=self._default_only(catalog)): - result = CliRunner().invoke(app, ["preset", "info", "numeric-tags"]) + def failing_update(self, source, speckit_version, **kwargs): + # Only fail the real update, never the preflight dry run, so the + # failure is guaranteed to occur in the main loop. + if kwargs.get("pack_id") == "test-pack" and not kwargs.get("dry_run"): + raise PresetError("simulated execution failure") + return real_update(self, source, speckit_version, **kwargs) - assert result.exit_code == 0, result.output - plain = strip_ansi(result.output) - assert "numeric-tags" in plain - assert "Tags:" not in plain + monkeypatch.setattr(RealManager, "update_from_directory", failing_update) - def test_search_escapes_rich_markup_in_tags(self, project_dir): - """Bracketed tag text must survive Rich markup parsing. + result = CliRunner().invoke(app, ["preset", "update"], input="y\n") - ``preset search`` printed tags unescaped, so a tag like ``[bold]`` was - swallowed as a style tag. ``preset list`` already escaped this. - """ + assert result.exit_code == 1, result.output + assert "test-pack: failed — simulated execution failure" in result.output + registry = PresetManager(project_dir).registry + assert registry.get("test-pack")["version"] == "1.0.0" + assert registry.get("second-pack")["version"] == "2.0.0" + + def test_cli_bulk_reports_incompatible_as_skipped_not_failed( + self, project_dir, pack_dir, monkeypatch + ): + """An incompatible preset surfacing during the real update is a true + no-op: reported as skipped, and it must not force a nonzero exit.""" from typer.testing import CliRunner - from unittest.mock import patch from specify_cli import app + from specify_cli.presets import PresetCompatibilityError + from specify_cli.presets import PresetManager as RealManager + + self._two_actionable_packs(project_dir, pack_dir, monkeypatch) + real_update = RealManager.update_from_directory - catalog = self._seed_catalog(project_dir, ["[bold]ci"]) + def incompatible(self, source, speckit_version, **kwargs): + if kwargs.get("pack_id") == "test-pack" and not kwargs.get("dry_run"): + raise PresetCompatibilityError("requires speckit >=9.0.0") + return real_update(self, source, speckit_version, **kwargs) - with patch.object(Path, "cwd", return_value=project_dir), \ - patch.object(PresetCatalog, "get_active_catalogs", - return_value=self._default_only(catalog)): - result = CliRunner().invoke(app, ["preset", "search", "Numeric"]) + monkeypatch.setattr(RealManager, "update_from_directory", incompatible) + + result = CliRunner().invoke(app, ["preset", "update"], input="y\n") assert result.exit_code == 0, result.output - assert "[bold]ci" in strip_ansi(result.output) + assert "test-pack: skipped" in result.output + assert "failed" not in result.output + registry = PresetManager(project_dir).registry + assert registry.get("test-pack")["version"] == "1.0.0" + assert registry.get("second-pack")["version"] == "2.0.0" + + def test_cli_bulk_rejects_discovery_only_catalogue( + self, project_dir, pack_dir, monkeypatch + ): + """The _install_allowed discovery-only gate applies to updates.""" + from typer.testing import CliRunner + from specify_cli import app + PresetManager(project_dir).install_from_directory(pack_dir, "0.1.0") + self._bulk_cli_env( + project_dir, + monkeypatch, + { + "test-pack": { + "version": "2.0.0", + "bundled": True, + "_install_allowed": False, + "_catalog_name": "community", + } + }, + {"test-pack": self._updated_source(pack_dir)}, + ) -class TestPresetCatalogRichMarkup: - """Catalog metadata must render as literal text in Rich output.""" + result = CliRunner().invoke(app, ["preset", "update"], input="y\n") - MARKUP_PRESET = { - "id": "[red]markup-id[/red]", - "name": "[green]Markup Name[/green]", - "version": "[blue]1.0.0[/blue]", - "description": "[yellow]Markup Description[/yellow]", - "author": "[magenta]Markup Author[/magenta]", - "tags": ["[italic]markup-tag[/italic]"], - "repository": "[bold]Markup Repository[/bold]", - "license": "[cyan]Markup License[/cyan]", - } + assert result.exit_code == 1, result.output + assert "not allowed from" in result.output + assert "community" in result.output + assert PresetManager(project_dir).registry.get("test-pack")["version"] == "1.0.0" - def test_search_escapes_catalog_markup(self, project_dir): + def test_cli_bulk_skips_when_installed_is_newer_than_catalogue( + self, project_dir, pack_dir, monkeypatch + ): + """A catalogue entry older than the installed version is a skip, never + a silent downgrade.""" from typer.testing import CliRunner - from unittest.mock import patch from specify_cli import app - with patch.object(Path, "cwd", return_value=project_dir), patch.object( - PresetCatalog, - "search", - return_value=[self.MARKUP_PRESET], - ): - result = CliRunner().invoke(app, ["preset", "search"]) + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.0") + manager.registry.update("test-pack", {"version": "5.0.0"}) + + self._bulk_cli_env( + project_dir, + monkeypatch, + {"test-pack": {"version": "2.0.0", "bundled": True}}, + {"test-pack": self._updated_source(pack_dir)}, + ) + + result = CliRunner().invoke(app, ["preset", "update"], input="y\n") assert result.exit_code == 0, result.output - output = " ".join(strip_ansi(result.output).split()) - for value in ( - self.MARKUP_PRESET["id"], - self.MARKUP_PRESET["name"], - self.MARKUP_PRESET["version"], - self.MARKUP_PRESET["description"], - ): - assert value in output + assert "Up to date, skipped" in result.output + assert PresetManager(project_dir).registry.get("test-pack")["version"] == "5.0.0" - def test_info_escapes_catalog_markup(self, project_dir): + def test_cli_bulk_bundled_missing_local_copy_skips_newer_installed_version( + self, project_dir, pack_dir, monkeypatch + ): from typer.testing import CliRunner - from unittest.mock import patch + from specify_cli import app - with patch.object(Path, "cwd", return_value=project_dir), patch.object( - PresetCatalog, - "get_pack_info", - return_value=self.MARKUP_PRESET, - ): - result = CliRunner().invoke( - app, - ["preset", "info", self.MARKUP_PRESET["id"]], - ) + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.0") + manager.registry.update("test-pack", {"version": "3.0.0"}) + self._bulk_cli_env( + project_dir, + monkeypatch, + {"test-pack": {"version": "2.0.0", "bundled": True}}, + {}, + ) + + result = CliRunner().invoke(app, ["preset", "update", "--all"]) assert result.exit_code == 0, result.output - output = " ".join(strip_ansi(result.output).split()) - for field in ( - "id", - "name", - "version", - "description", - "author", - "repository", - "license", - ): - value = self.MARKUP_PRESET[field] - assert value in output - # Tags are joined into a single line, so assert on the rendered join. - assert ", ".join(self.MARKUP_PRESET["tags"]) in output + assert "Up to date, skipped" in result.output + assert PresetManager(project_dir).registry.get("test-pack")["version"] == "3.0.0" + def test_cli_bulk_priority_is_ignored_for_equal_catalogue_version( + self, project_dir, pack_dir, monkeypatch + ): + """Bulk priority remains ignored when the catalogue is already current.""" + from typer.testing import CliRunner + from specify_cli import app -class TestInstalledPresetRichMarkup: - """Locally installed preset metadata must render as literal text. + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.0", priority=7) + self._bulk_cli_env( + project_dir, + monkeypatch, + {"test-pack": {"version": "1.0.0", "bundled": True}}, + {"test-pack": pack_dir}, + ) - ``preset.yml`` is user-editable, so its fields can contain ``[...]``. - ``TestPresetCatalogRichMarkup`` covers the catalog branch of these - commands; the installed-preset branch of ``preset list``/``preset info`` - and all of ``preset resolve`` were left unescaped, so a field like - ``Does [stuff] nicely`` silently rendered as ``Does nicely`` and an - unbalanced tag such as ``[/red]`` raised ``rich.errors.MarkupError``, - aborting the command with a traceback. - """ + result = CliRunner().invoke( + app, ["preset", "update", "--all", "--priority", "3"], input="y\n" + ) - MARKUP_FIELDS = { - "name": "[green]Markup Name[/green]", - "version": "1.0.0", - "description": "[yellow]Markup Description[/yellow]", - "author": "[magenta]Markup Author[/magenta]", - "repository": "[bold]Markup Repository[/bold]", - "license": "[cyan]Markup License[/cyan]", - } + assert result.exit_code == 0, result.output + assert "Up to date, skipped" in result.output + assert PresetManager(project_dir).registry.get("test-pack")["priority"] == 7 - def _install(self, temp_dir, project_dir, preset_overrides=None, strategy=None, - pack_id="markup-pack", priority=10, tmpl_description=None): - """Install a preset from a directory built with the given manifest fields.""" - from specify_cli.presets import PresetManager + def test_cli_single_priority_update_runs_at_equal_catalogue_version( + self, project_dir, pack_dir, monkeypatch + ): + """An explicit single-item priority change is actionable at equal version.""" + from typer.testing import CliRunner + from specify_cli import app - src = temp_dir / f"src-{pack_id}" - (src / "templates").mkdir(parents=True) - (src / "templates" / "spec-template.md").write_text("# tmpl\n") + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.0", priority=7) + self._bulk_cli_env( + project_dir, + monkeypatch, + {"test-pack": {"version": "1.0.0", "bundled": True}}, + {"test-pack": pack_dir}, + ) - preset_section = { - "id": pack_id, - "name": pack_id, - "version": "1.0.0", - "description": "plain description", - } - preset_section.update(preset_overrides or {}) - tmpl = { - "type": "template", - "name": "spec-template", - "file": "templates/spec-template.md", - } - if tmpl_description is not None: - tmpl["description"] = tmpl_description - if strategy: - tmpl["strategy"] = strategy - (src / "preset.yml").write_text(yaml.dump({ - "schema_version": "1.0", - "preset": preset_section, - "requires": {"speckit_version": ">=0.0.1"}, - "provides": {"templates": [tmpl]}, - "tags": ["[italic]markup-tag[/italic]"], - })) + result = CliRunner().invoke( + app, ["preset", "update", "test-pack", "--priority", "3"] + ) - manager = PresetManager(project_dir) - manager.install_from_directory(src, "9.9.9", priority) - return manager + assert result.exit_code == 0, result.output + assert "updated" in result.output + assert PresetManager(project_dir).registry.get("test-pack")["priority"] == 3 - def _invoke(self, project_dir, args): + def test_cli_single_priority_update_rejects_newer_installed_catalogue( + self, project_dir, pack_dir, monkeypatch + ): + """A single catalogue update must not silently ignore requested priority + when applying it would require a downgrade.""" from typer.testing import CliRunner - from unittest.mock import patch from specify_cli import app - with patch.object(Path, "cwd", return_value=project_dir): - return CliRunner().invoke(app, args) + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.0", priority=7) + manager.registry.update("test-pack", {"version": "5.0.0"}) + self._bulk_cli_env( + project_dir, + monkeypatch, + {"test-pack": {"version": "2.0.0", "bundled": True}}, + {"test-pack": self._updated_source(pack_dir)}, + ) - def test_list_and_info_escape_installed_markup(self, temp_dir, project_dir): - """Every ``preset.yml`` field must survive verbatim in list/info output.""" - self._install(temp_dir, project_dir, preset_overrides=self.MARKUP_FIELDS) + result = CliRunner().invoke( + app, ["preset", "update", "test-pack", "--priority", "3"] + ) - for args in (["preset", "list"], ["preset", "info", "markup-pack"]): - result = self._invoke(project_dir, args) - assert result.exit_code == 0, result.output - output = " ".join(strip_ansi(result.output).split()) - # `preset list` does not render repository/license. - fields = ("name", "description") if args[1] == "list" else self.MARKUP_FIELDS - for field in fields: - assert self.MARKUP_FIELDS[field] in output, (field, args, output) - assert "[italic]markup-tag[/italic]" in output, (args, output) + assert result.exit_code == 1, result.output + assert "newer than catalogue" in result.output + assert "preset set-priority" in result.output + metadata = PresetManager(project_dir).registry.get("test-pack") + assert metadata["version"] == "5.0.0" + assert metadata["priority"] == 7 + + def test_cli_single_priority_bundled_missing_local_copy_uses_set_priority_error( + self, project_dir, pack_dir, monkeypatch + ): + from typer.testing import CliRunner - def test_info_does_not_swallow_template_description(self, temp_dir, project_dir): - """The per-template line in ``preset info`` must escape the template description. + from specify_cli import app - ``name``/``type`` are format-restricted by manifest validation, but - ``description`` is free-form, so it is the field that can carry markup. - """ - self._install( - temp_dir, + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.0", priority=7) + manager.registry.update("test-pack", {"version": "3.0.0"}) + self._bulk_cli_env( project_dir, - tmpl_description="Template [desc] here", + monkeypatch, + {"test-pack": {"version": "2.0.0", "bundled": True}}, + {}, ) - result = self._invoke(project_dir, ["preset", "info", "markup-pack"]) - assert result.exit_code == 0, result.output - output = " ".join(strip_ansi(result.output).split()) - assert "spec-template (template): Template [desc] here" in output, output - def test_unbalanced_markup_does_not_crash_list_or_info(self, temp_dir, project_dir): - """An unbalanced tag must not raise MarkupError and abort the command.""" - self._install( - temp_dir, + result = CliRunner().invoke( + app, ["preset", "update", "test-pack", "--priority", "3"] + ) + + assert result.exit_code == 1, result.output + assert "newer than catalogue" in result.output + assert "preset set-priority" in result.output + metadata = PresetManager(project_dir).registry.get("test-pack") + assert metadata["version"] == "3.0.0" + assert metadata["priority"] == 7 + + def test_cli_bundled_update_uses_local_bundled_version_when_newer( + self, project_dir, pack_dir, monkeypatch + ): + from typer.testing import CliRunner + from specify_cli import app + + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.0") + local_source = self._updated_source(pack_dir, version="3.0.0") + self._bulk_cli_env( project_dir, - preset_overrides={"description": "Broken [/red] tag"}, + monkeypatch, + {"test-pack": {"version": "2.0.0", "bundled": True}}, + {"test-pack": local_source}, ) - for args in (["preset", "list"], ["preset", "info", "markup-pack"]): - result = self._invoke(project_dir, args) - assert result.exit_code == 0, (args, result.output, result.exception) - assert "Broken [/red] tag" in strip_ansi(result.output) + result = CliRunner().invoke(app, ["preset", "update", "test-pack"]) - def test_resolve_rejects_invalid_template_name(self, project_dir): - """``preset resolve`` rejects names before joining them into paths.""" - result = self._invoke(project_dir, ["preset", "resolve", "no[/red]such"]) - assert result.exit_code == 1, (result.output, result.exception) - assert "invalid template name" in strip_ansi(result.output) + assert result.exit_code == 0, result.output + assert "updated to v3.0.0" in result.output + assert PresetManager(project_dir).registry.get("test-pack")["version"] == "3.0.0" - def test_resolve_rejects_path_traversal(self, project_dir): - """The resolver rejects traversal before joining names into paths.""" - result = self._invoke( + def test_cli_bulk_bundled_update_uses_local_version_when_catalogue_matches_installed( + self, project_dir, pack_dir, monkeypatch + ): + from typer.testing import CliRunner + + from specify_cli import app + + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.0") + manager.registry.update("test-pack", {"version": "2.0.0"}) + local_source = self._updated_source(pack_dir, version="3.0.0") + self._bulk_cli_env( project_dir, - ["preset", "resolve", "../../../README"], + monkeypatch, + {"test-pack": {"version": "2.0.0", "bundled": True}}, + {"test-pack": local_source}, ) - assert result.exit_code == 1 - assert "invalid template name" in strip_ansi(result.output) + result = CliRunner().invoke(app, ["preset", "update", "--all"], input="y\n") - def test_resolve_accepts_dotted_command_name(self, project_dir): - """Documented dotted command identifiers use command resolution.""" - result = self._invoke( + assert result.exit_code == 0, result.output + assert "updated to v3.0.0" in result.output + assert PresetManager(project_dir).registry.get("test-pack")["version"] == "3.0.0" + + def test_cli_single_bundled_update_uses_local_version_when_catalogue_matches_installed( + self, project_dir, pack_dir, monkeypatch + ): + from typer.testing import CliRunner + + from specify_cli import app + + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.0") + manager.registry.update("test-pack", {"version": "2.0.0"}) + local_source = self._updated_source(pack_dir, version="3.0.0") + self._bulk_cli_env( project_dir, - ["preset", "resolve", "speckit.constitution"], + monkeypatch, + {"test-pack": {"version": "2.0.0", "bundled": True}}, + {"test-pack": local_source}, ) - assert result.exit_code == 0, (result.output, result.exception) - assert "constitution.md" in "".join(strip_ansi(result.output).split()) + result = CliRunner().invoke(app, ["preset", "update", "test-pack"]) - def test_resolve_rejects_empty_command_segments(self, project_dir): - """Dotted command identifiers cannot contain empty path-like segments.""" - result = self._invoke( + assert result.exit_code == 0, result.output + assert "updated to v3.0.0" in result.output + assert PresetManager(project_dir).registry.get("test-pack")["version"] == "3.0.0" + + def test_cli_bundled_update_blocks_when_local_bundled_copy_lags( + self, project_dir, pack_dir, monkeypatch + ): + from typer.testing import CliRunner + from specify_cli import app + + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.0") + local_source = self._updated_source(pack_dir, version="1.5.0") + self._bulk_cli_env( project_dir, - ["preset", "resolve", "speckit..constitution"], + monkeypatch, + {"test-pack": {"version": "2.0.0", "bundled": True}}, + {"test-pack": local_source}, ) - assert result.exit_code == 1 - assert "invalid template name" in strip_ansi(result.output) + result = CliRunner().invoke(app, ["preset", "update", "test-pack"]) - def test_resolve_escapes_layer_path_and_source(self, project_dir): - """The top-layer path/source lines must render markup literally. + assert result.exit_code == 1, result.output + assert "upgrade spec-kit" in result.output + assert PresetManager(project_dir).registry.get("test-pack")["version"] == "1.0.0" + + def test_cli_bulk_unresolvable_source_reports_clearly( + self, project_dir, pack_dir, monkeypatch + ): + """A preset with no catalogue entry cannot be re-resolved by id.""" + from typer.testing import CliRunner + from specify_cli import app - A preset can be installed from any directory, so the resolved path can - contain ``[...]``; the layer source carries the pack id and version. - """ - from unittest.mock import patch - from specify_cli.presets import PresetResolver + PresetManager(project_dir).install_from_directory(pack_dir, "0.1.0") + self._bulk_cli_env(project_dir, monkeypatch, {}, {}) - # A closing tag cannot live inside a path segment: `Path` treats its - # `/` as a separator on POSIX and rewrites it to `\` on Windows. The - # opening tag covers the swallowing case for the path; the unbalanced - # closing tag rides on `source`, which is a plain string. - layer = { - "path": Path("/tmp/[red]dir/spec-template.md"), - "source": "pack [/red] v1.0.0", - "strategy": "replace", - } - with patch.object(PresetResolver, "collect_all_layers", return_value=[layer]): - result = self._invoke(project_dir, ["preset", "resolve", "spec-template"]) + result = CliRunner().invoke(app, ["preset", "update"], input="y\n") - assert result.exit_code == 0, (result.output, result.exception) - output = " ".join(strip_ansi(result.output).split()) - assert "[red]dir" in output, output - assert "pack [/red] v1.0.0" in output, output + assert result.exit_code == 1, result.output + assert "not re-resolvable" in result.output + assert "--from/--dev" in result.output - def test_resolve_escapes_fallback_path_and_source(self, project_dir): - """The no-layer fallback branch must escape ``resolve_with_source`` output.""" + def test_update_legacy_skills_normalizes_missing_active_agent_fallback( + self, project_dir, temp_dir + ): from unittest.mock import patch - from specify_cli.presets import PresetResolver - with patch.object( - PresetResolver, "collect_all_layers", return_value=[] - ), patch.object( - PresetResolver, - "resolve_with_source", - return_value={ - "path": "/tmp/[blue]fallback[/blue]/spec-template.md", - "source": "fallback [/red] source", - }, + from specify_cli._init_options import MISSING_INIT_OPTIONS_FILE + + manager = PresetManager(project_dir) + source_v1 = self._multi_command_preset( + temp_dir, + "legacy-update-preset", + {"speckit.keep": "Keep v1"}, + version="1.0.0", + ) + manager.install_from_directory(source_v1, "0.1.0") + manager.registry.update( + "legacy-update-preset", + {"registered_skills": ["unmatched-legacy-skill"]}, + ) + source_v2 = self._updated_command_source( + source_v1, "2.0.0", "Keep v2" + ) + + with patch( + "specify_cli.presets.resolve_active_agent_for_registration", + return_value=MISSING_INIT_OPTIONS_FILE, ): - result = self._invoke(project_dir, ["preset", "resolve", "spec-template"]) + manager.update_from_directory( + source_v2, "0.1.0", pack_id="legacy-update-preset" + ) - assert result.exit_code == 0, (result.output, result.exception) - output = " ".join(strip_ansi(result.output).split()) - assert "[blue]fallback[/blue]" in output, output - assert "fallback [/red] source" in output, output + reloaded = PresetManager(project_dir).registry.get("legacy-update-preset") + assert reloaded["version"] == "2.0.0" + assert isinstance(reloaded.get("registered_skills"), dict) + assert all(isinstance(agent, str) for agent in reloaded["registered_skills"]) - def test_resolve_escapes_composition_error(self, project_dir): - """A composition exception message must not be parsed as markup.""" - from unittest.mock import patch - from specify_cli.presets import PresetResolver + def test_update_removing_command_reconciles_surviving_preset_for_native_skill_agent( + self, project_dir, temp_dir + ): + """A command dropped by an update must reconcile back to a + surviving lower-priority preset's content for native skill-only + agents (e.g. claude), never leaving the SKILL.md directory deleted. + + Guards the exact scenario a review flagged as a possible ordering + bug (stale commands unregistered before stale skills, for agents + where "command" and "skill" track the same physical file). It + already passes on unmodified code because `_reconcile_skills` runs + unconditionally afterwards for every affected command name, + regenerating content regardless of unregister ordering -- this + test locks that guarantee in place. + """ + from specify_cli import save_init_options - layers = [ - { - "path": Path("/tmp/top/spec-template.md"), - "source": "top-pack v1.0.0", - "strategy": "append", - }, + save_init_options( + project_dir, {"ai": "claude", "ai_skills": True, "script": "sh"} + ) + lo_source = self._multi_command_preset( + temp_dir, + "lo-preset", + {"speckit.plan": "lo-preset plan body"}, + version="1.0.0", + ) + hi_source = self._multi_command_preset( + temp_dir, + "hi-preset", { - "path": Path("/tmp/base/spec-template.md"), - "source": "base-pack v1.0.0", - "strategy": "append", + "speckit.specify": "hi-preset specify body", + "speckit.plan": "hi-preset plan body", }, - ] - with patch.object( - PresetResolver, "collect_all_layers", return_value=layers - ), patch.object( - PresetResolver, - "resolve_content", - side_effect=RuntimeError("compose failed: [/red] bad layer"), - ): - result = self._invoke(project_dir, ["preset", "resolve", "spec-template"]) + version="1.0.0", + ) + manager = PresetManager(project_dir) + manager.install_from_directory(lo_source, "0.1.0", priority=20) + manager.install_from_directory(hi_source, "0.1.0", priority=10) - assert result.exit_code == 0, (result.output, result.exception) - output = " ".join(strip_ansi(result.output).split()) - assert "compose failed: [/red] bad layer" in output, output + plan_skill = ( + project_dir / ".claude" / "skills" / "speckit-plan" / "SKILL.md" + ) + specify_skill = ( + project_dir / ".claude" / "skills" / "speckit-specify" / "SKILL.md" + ) + assert "hi-preset plan body" in plan_skill.read_text() + assert "hi-preset specify body" in specify_skill.read_text() - def test_resolve_renders_composition_strategy_labels(self, temp_dir, project_dir): - """The composition chain's ``[]`` label must not be eaten as a tag.""" - self._install(temp_dir, project_dir, strategy="replace", - pack_id="base-pack", priority=20) - self._install(temp_dir, project_dir, strategy="append", - pack_id="app-pack", priority=5) + # Update hi-preset to a version that drops "speckit.plan" entirely. + updated_hi_source = self._multi_command_preset( + temp_dir / "updated", + "hi-preset", + {"speckit.specify": "hi-preset specify body v2"}, + version="2.0.0", + ) + manager.update_from_directory( + updated_hi_source, "0.1.0", pack_id="hi-preset" + ) - result = self._invoke(project_dir, ["preset", "resolve", "spec-template"]) - assert result.exit_code == 0, (result.output, result.exception) - output = strip_ansi(result.output) - assert "Composition chain" in output, output - assert "[base]" in output, output - assert "[append]" in output, output + assert specify_skill.is_file() + assert "hi-preset specify body v2" in specify_skill.read_text() + # The dropped command's skill must still exist and now show the + # surviving lower-priority preset's content, not be deleted. + assert plan_skill.is_file(), ( + "speckit-plan SKILL.md was deleted instead of being reconciled " + "to the surviving lower-priority preset" + ) + assert "lo-preset plan body" in plan_skill.read_text() -class TestPresetListOrdering: - """``preset list`` must print presets in actual resolution/precedence order. + hi_metadata = PresetManager(project_dir).registry.get("hi-preset") + assert "speckit.plan" not in hi_metadata["registered_commands"].get( + "claude", [] + ) + assert "speckit-plan" not in hi_metadata["registered_skills"].get( + "claude", [] + ) - Regression coverage for #4086: the printed order was registry/insertion - order, so a preset with a *higher* priority number (lower precedence) could - appear before one with a lower number, misleading users about which preset - wins. Output must be sorted by (priority, id) to match - ``PresetRegistry.list_by_priority()``. - """ - def _install(self, temp_dir, project_dir, pack_id, priority): - from specify_cli.presets import PresetManager +class TestPresetUpdateConcurrency: + def _updated_source(self, pack_dir, version="2.0.0"): + source = pack_dir.parent / "concurrent-updated-pack" + shutil.copytree(pack_dir, source) + manifest_path = source / "preset.yml" + data = yaml.safe_load(manifest_path.read_text()) + data["preset"]["version"] = version + manifest_path.write_text(yaml.safe_dump(data)) + return source - src = temp_dir / f"src-{pack_id}" - (src / "templates").mkdir(parents=True) - (src / "templates" / "spec-template.md").write_text("# tmpl\n") - (src / "preset.yml").write_text(yaml.dump({ - "schema_version": "1.0", - "preset": { - "id": pack_id, - "name": pack_id, - "version": "1.0.0", - "description": "plain description", - }, - "requires": {"speckit_version": ">=0.0.1"}, - "provides": {"templates": [{ - "type": "template", - "name": "spec-template", - "file": "templates/spec-template.md", - }]}, - })) - PresetManager(project_dir).install_from_directory(src, "9.9.9", priority) + def test_vanished_install_during_swap_reports_clear_error( + self, project_dir, pack_dir, monkeypatch + ): + """A racing update that removes the live directory mid-swap must yield a + readable message rather than a raw errno.""" + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.0") + source = self._updated_source(pack_dir) + import os - def _invoke(self, project_dir, args): - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app + real_replace = os.replace + current = project_dir / ".specify" / "presets" / "test-pack" - with patch.object(Path, "cwd", return_value=project_dir): - return CliRunner().invoke(app, args) + def racing_replace(src, dst): + if Path(src) == current: + shutil.rmtree(current) + return real_replace(src, dst) - def test_list_sorted_by_priority(self, temp_dir, project_dir): - """Lower priority number is listed first regardless of install order.""" - # Install in an order that does NOT match precedence. - self._install(temp_dir, project_dir, "copilot-sub-agents", priority=100) - self._install(temp_dir, project_dir, "lean", priority=10) + monkeypatch.setattr( + "specify_cli.presets.os.replace", racing_replace + ) - result = self._invoke(project_dir, ["preset", "list"]) - assert result.exit_code == 0, result.output - output = strip_ansi(result.output) - # `lean` (priority 10) must appear before `copilot-sub-agents` (100). - assert output.index("(lean)") < output.index("(copilot-sub-agents)"), output - assert "resolution order" in output, output - assert "Ties are broken by preset id" in output, output + with pytest.raises(PresetError, match="changed on disk during the update"): + manager.update_from_directory(source, "0.1.0", pack_id="test-pack") - def test_list_ties_broken_by_id(self, temp_dir, project_dir): - """Equal priority ties are broken alphabetically by preset id.""" - self._install(temp_dir, project_dir, "zebra", priority=10) - self._install(temp_dir, project_dir, "alpha", priority=10) + assert not list( + (project_dir / ".specify" / "presets").glob(".test-pack.update-*") + ) - result = self._invoke(project_dir, ["preset", "list"]) - assert result.exit_code == 0, result.output - output = strip_ansi(result.output) - assert output.index("(alpha)") < output.index("(zebra)"), output + def test_catalogue_version_is_rechecked_after_lock( + self, project_dir, pack_dir, monkeypatch + ): + """A newer concurrent install must prevent a stale catalogue update.""" + from contextlib import contextmanager + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.0") + source = self._updated_source(pack_dir, version="2.0.0") -class TestConstitutionSyncPreset: - """The bundled opt-in ``constitution-sync`` preset re-adds materialization. + @contextmanager + def racing_transaction(_project_root): + concurrent = PresetManager(project_dir) + concurrent.registry.update("test-pack", {"version": "3.0.0"}) + yield - Follow-up to #3790: core ``/constitution`` no longer propagates guidance - into templates. Issue #3950 also gates install-time constitution seeding on - this preset. Its command override remains a ``wrap`` of core so it stays - forward-compatible with core changes. - """ + monkeypatch.setattr( + "specify_cli.workflows._commands._workflow_install_transaction", + racing_transaction, + ) - PRESET_DIR = Path(__file__).parent.parent / "presets" / "constitution-sync" + with pytest.raises(PresetCompatibilityError, match="newer than catalogue"): + manager.update_from_directory( + source, + "0.1.0", + pack_id="test-pack", + expected_version="2.0.0", + ) - def test_manifest_provides_wrap_of_constitution(self): - manifest = yaml.safe_load((self.PRESET_DIR / "preset.yml").read_text()) - assert manifest["preset"]["id"] == "constitution-sync" - entries = manifest["provides"]["templates"] - assert len(entries) == 1 - entry = entries[0] - assert entry["type"] == "command" - assert entry["name"] == "speckit.constitution" - assert entry["strategy"] == "wrap" - # Must target the post-#3790 baseline so propagation is not double-applied. - assert manifest["requires"]["speckit_version"] == ">=0.14.4" + assert PresetManager(project_dir).registry.get("test-pack")["version"] == "3.0.0" + assert ( + PresetManifest( + project_dir / ".specify" / "presets" / "test-pack" / "preset.yml" + ).version + == "1.0.0" + ) - def test_wrapper_uses_core_template_and_propagates(self): - text = (self.PRESET_DIR / "commands" / "speckit.constitution.md").read_text() + def test_priority_update_rechecks_catalogue_version_after_lock( + self, project_dir, pack_dir, monkeypatch + ): + """A priority change must not permit a stale catalogue downgrade.""" - # Parse the Markdown frontmatter as YAML rather than substring-matching, - # so `strategy: wrap` is asserted structurally (not as text that could - # appear in the body) and {CORE_TEMPLATE} is asserted in the body only. - assert text.startswith("---\n") - _, frontmatter_block, body = text.split("---", 2) - frontmatter = yaml.safe_load(frontmatter_block) - assert frontmatter["strategy"] == "wrap" + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.0", priority=7) + source = self._updated_source(pack_dir, version="2.0.0") + + @contextmanager + def racing_transaction(_project_root): + concurrent = PresetManager(project_dir) + concurrent.registry.update( + "test-pack", {"version": "3.0.0", "priority": 4} + ) + yield - assert "{CORE_TEMPLATE}" in body - assert "strategy: wrap" not in body # only in frontmatter - # The three governed scaffolds the old checklist propagated into. - assert "plan-template.md" in body - assert "spec-template.md" in body - assert "tasks-template.md" in body - # Must not mutate versioned preset/extension artifacts. - assert "Do not edit versioned preset- or extension-provided template or command files" in body + monkeypatch.setattr( + "specify_cli.workflows._commands._workflow_install_transaction", + racing_transaction, + ) - def test_catalog_lists_bundled_preset(self): - manifest = yaml.safe_load((self.PRESET_DIR / "preset.yml").read_text()) - catalog = json.loads((self.PRESET_DIR.parent / "catalog.json").read_text()) - entry = catalog["presets"]["constitution-sync"] - assert entry["bundled"] is True - assert entry["version"] == manifest["preset"]["version"] - assert entry["provides"]["commands"] == 1 - assert entry["provides"]["templates"] == 0 + with pytest.raises(PresetCompatibilityError, match="newer than catalogue"): + manager.update_from_directory( + source, + "0.1.0", + pack_id="test-pack", + priority=5, + expected_version="2.0.0", + ) - def test_wrap_composes_over_core_constitution(self, project_dir): - """Installing the preset yields a wrap layer atop the bundled core.""" - manager = PresetManager(project_dir) - manager.install_from_directory(self.PRESET_DIR, "0.15.0") + metadata = PresetManager(project_dir).registry.get("test-pack") + assert metadata["version"] == "3.0.0" + assert metadata["priority"] == 4 + assert ( + PresetManifest( + project_dir / ".specify" / "presets" / "test-pack" / "preset.yml" + ).version + == "1.0.0" + ) - resolver = PresetResolver(project_dir) - layers = resolver.collect_all_layers("speckit.constitution", "command") - assert len(layers) >= 2, "expected preset wrap layer plus a core base" - assert layers[0]["strategy"] == "wrap" - assert any("constitution-sync" in str(layer["path"]) for layer in layers) - assert layers[-1]["source"] == "core (bundled)" + def test_equal_catalogue_version_priority_update_still_applies_after_lock( + self, project_dir, pack_dir, monkeypatch + ): + """An equal freshly observed version still permits reprioritisation.""" - def test_resolved_content_embeds_core_and_sync_pass(self, project_dir): - """resolve_content substitutes {CORE_TEMPLATE} so the effective command - contains both the bundled core body and the propagation pass.""" manager = PresetManager(project_dir) - manager.install_from_directory(self.PRESET_DIR, "0.15.0") + manager.install_from_directory(pack_dir, "0.1.0", priority=7) + source = self._updated_source(pack_dir, version="2.0.0") - resolver = PresetResolver(project_dir) - content = resolver.resolve_content("speckit.constitution", "command") - assert content is not None - # {CORE_TEMPLATE} must be replaced, not left literal. - assert "{CORE_TEMPLATE}" not in content - # Core body is present (distinctive core-only heading). - assert "## Scope Guard" in content - # The wrapper's propagation pass is present and supersedes the guard. - assert "## Constitution Template Sync" in content - assert "supersedes the \"Scope Guard\" above" in content - assert "plan-template.md" in content + @contextmanager + def racing_transaction(_project_root): + concurrent = PresetManager(project_dir) + concurrent.registry.update("test-pack", {"version": "2.0.0"}) + yield + + monkeypatch.setattr( + "specify_cli.workflows._commands._workflow_install_transaction", + racing_transaction, + ) + + manager.update_from_directory( + source, + "0.1.0", + pack_id="test-pack", + priority=5, + expected_version="2.0.0", + ) + + metadata = PresetManager(project_dir).registry.get("test-pack") + assert metadata["version"] == "2.0.0" + assert metadata["priority"] == 5