""" Unit tests for the preset system. Tests cover: - Preset manifest validation - Preset registry operations - Preset manager installation/removal - Template catalog search - Template resolver priority stack - Extension-provided templates """ import pytest import io import json import tempfile import shutil import warnings import zipfile from contextlib import contextmanager from pathlib import Path from datetime import datetime, timezone from types import SimpleNamespace from unittest.mock import MagicMock import yaml from tests.conftest import strip_ansi from specify_cli.presets import ( PresetManifest, PresetRegistry, PresetManager, PresetCatalog, PresetCatalogEntry, PresetResolver, PresetError, PresetValidationError, PresetCompatibilityError, VALID_PRESET_TEMPLATE_TYPES, ) from specify_cli.extensions import ExtensionRegistry # ===== Fixtures ===== @pytest.fixture def temp_dir(): """Create a temporary directory for tests.""" tmpdir = tempfile.mkdtemp() yield Path(tmpdir) shutil.rmtree(tmpdir) @pytest.fixture def valid_pack_data(): """Valid preset manifest data.""" return { "schema_version": "1.0", "preset": { "id": "test-pack", "name": "Test Preset", "version": "1.0.0", "description": "A test preset", "author": "Test Author", "repository": "https://github.com/test/test-pack", "license": "MIT", }, "requires": { "speckit_version": ">=0.1.0", }, "provides": { "templates": [ { "type": "template", "name": "spec-template", "file": "templates/spec-template.md", "description": "Custom spec template", "replaces": "spec-template", } ] }, "tags": ["testing", "example"], } @pytest.fixture def pack_dir(temp_dir, valid_pack_data): """Create a complete preset directory structure.""" p_dir = temp_dir / "test-pack" p_dir.mkdir() # Write manifest manifest_path = p_dir / "preset.yml" with open(manifest_path, 'w') as f: yaml.dump(valid_pack_data, f) # Create templates directory templates_dir = p_dir / "templates" templates_dir.mkdir() # Write template file tmpl_file = templates_dir / "spec-template.md" tmpl_file.write_text("# Custom Spec Template\n\nThis is a custom template.\n") return p_dir @pytest.fixture def project_dir(temp_dir): """Create a mock spec-kit project directory.""" proj_dir = temp_dir / "project" proj_dir.mkdir() # Create .specify directory specify_dir = proj_dir / ".specify" specify_dir.mkdir() # Create templates directory with core templates templates_dir = specify_dir / "templates" templates_dir.mkdir() # Create core spec-template core_spec = templates_dir / "spec-template.md" core_spec.write_text("# Core Spec Template\n") # Create core plan-template core_plan = templates_dir / "plan-template.md" core_plan.write_text("# Core Plan Template\n") # Create commands subdirectory commands_dir = templates_dir / "commands" commands_dir.mkdir() return proj_dir # ===== PresetManifest Tests ===== class TestPresetManifest: """Test PresetManifest validation and parsing.""" def test_valid_manifest(self, pack_dir): """Test loading a valid manifest.""" manifest = PresetManifest(pack_dir / "preset.yml") assert manifest.id == "test-pack" assert manifest.name == "Test Preset" assert manifest.version == "1.0.0" assert manifest.description == "A test preset" assert manifest.author == "Test Author" assert manifest.requires_speckit_version == ">=0.1.0" assert len(manifest.templates) == 1 assert manifest.tags == ["testing", "example"] def test_missing_manifest(self, temp_dir): """Test that missing manifest raises error.""" with pytest.raises(PresetValidationError, match="Manifest not found"): PresetManifest(temp_dir / "nonexistent.yml") def test_invalid_yaml(self, temp_dir): """Test that invalid YAML raises error.""" bad_file = temp_dir / "bad.yml" bad_file.write_text(": invalid: yaml: {{{") with pytest.raises(PresetValidationError, match="Invalid YAML"): PresetManifest(bad_file) def test_utf8_non_ascii_description_loads(self, temp_dir, valid_pack_data): """Regression for #2325: non-ASCII (UTF-8) description loads on any platform. On Windows, Python's default text-mode encoding is the locale codepage (e.g. cp1252/GBK), which raises UnicodeDecodeError on UTF-8 bytes outside the ASCII range. The loader must open with encoding='utf-8'. """ valid_pack_data["preset"]["description"] = "中文测试 — émojis 🚀" manifest_path = temp_dir / "preset.yml" manifest_path.write_bytes( yaml.safe_dump(valid_pack_data, allow_unicode=True).encode("utf-8") ) manifest = PresetManifest(manifest_path) assert manifest.description == "中文测试 — émojis 🚀" def test_invalid_utf8_bytes_raises_validation_error(self, temp_dir): """Negative case: file containing invalid UTF-8 bytes raises PresetValidationError, not raw UnicodeDecodeError.""" manifest_path = temp_dir / "preset.yml" manifest_path.write_bytes(b"\xff\xfe not valid utf-8 \xff\n") with pytest.raises(PresetValidationError, match="not valid UTF-8"): PresetManifest(manifest_path) def test_non_mapping_yaml_raises_validation_error(self, temp_dir): """Manifest whose YAML root is a scalar or list raises PresetValidationError, not TypeError.""" manifest_path = temp_dir / "preset.yml" for bad_content in ("42\n", "[1, 2]\n"): manifest_path.write_text(bad_content, encoding="utf-8") with pytest.raises(PresetValidationError, match="YAML mapping"): PresetManifest(manifest_path) def test_missing_schema_version(self, temp_dir, valid_pack_data): """Test missing schema_version field.""" del valid_pack_data["schema_version"] manifest_path = temp_dir / "preset.yml" with open(manifest_path, 'w') as f: yaml.dump(valid_pack_data, f) with pytest.raises(PresetValidationError, match="Missing required field: schema_version"): PresetManifest(manifest_path) def test_wrong_schema_version(self, temp_dir, valid_pack_data): """Test unsupported schema version.""" valid_pack_data["schema_version"] = "2.0" manifest_path = temp_dir / "preset.yml" with open(manifest_path, 'w') as f: yaml.dump(valid_pack_data, f) with pytest.raises(PresetValidationError, match="Unsupported schema version"): PresetManifest(manifest_path) def test_missing_pack_id(self, temp_dir, valid_pack_data): """Test missing preset.id field.""" del valid_pack_data["preset"]["id"] manifest_path = temp_dir / "preset.yml" with open(manifest_path, 'w') as f: yaml.dump(valid_pack_data, f) with pytest.raises(PresetValidationError, match="Missing preset.id"): PresetManifest(manifest_path) def test_invalid_pack_id_format(self, temp_dir, valid_pack_data): """Test invalid pack ID format.""" valid_pack_data["preset"]["id"] = "Invalid_ID" manifest_path = temp_dir / "preset.yml" with open(manifest_path, 'w') as f: yaml.dump(valid_pack_data, f) with pytest.raises(PresetValidationError, match="Invalid preset ID"): PresetManifest(manifest_path) def test_invalid_version(self, temp_dir, valid_pack_data): """Test invalid semantic version.""" valid_pack_data["preset"]["version"] = "not-a-version" manifest_path = temp_dir / "preset.yml" with open(manifest_path, 'w') as f: yaml.dump(valid_pack_data, f) with pytest.raises(PresetValidationError, match="Invalid version"): PresetManifest(manifest_path) def test_missing_speckit_version(self, temp_dir, valid_pack_data): """Test missing requires.speckit_version.""" del valid_pack_data["requires"]["speckit_version"] manifest_path = temp_dir / "preset.yml" with open(manifest_path, 'w') as f: yaml.dump(valid_pack_data, f) with pytest.raises(PresetValidationError, match="Missing requires.speckit_version"): PresetManifest(manifest_path) def test_no_templates_provided(self, temp_dir, valid_pack_data): """Test pack with no templates.""" valid_pack_data["provides"]["templates"] = [] manifest_path = temp_dir / "preset.yml" with open(manifest_path, 'w') as f: yaml.dump(valid_pack_data, f) with pytest.raises(PresetValidationError, match="must provide at least one template"): PresetManifest(manifest_path) def test_invalid_template_type(self, temp_dir, valid_pack_data): """Test template with invalid type.""" valid_pack_data["provides"]["templates"][0]["type"] = "invalid" manifest_path = temp_dir / "preset.yml" with open(manifest_path, 'w') as f: yaml.dump(valid_pack_data, f) with pytest.raises(PresetValidationError, match="Invalid template type"): PresetManifest(manifest_path) def test_valid_template_types(self): """Test that all expected template types are valid.""" assert "template" in VALID_PRESET_TEMPLATE_TYPES assert "command" in VALID_PRESET_TEMPLATE_TYPES assert "script" in VALID_PRESET_TEMPLATE_TYPES def test_template_missing_required_fields(self, temp_dir, valid_pack_data): """Test template missing required fields.""" valid_pack_data["provides"]["templates"] = [{"type": "template"}] manifest_path = temp_dir / "preset.yml" with open(manifest_path, 'w') as f: yaml.dump(valid_pack_data, f) with pytest.raises(PresetValidationError, match="missing 'type', 'name', or 'file'"): PresetManifest(manifest_path) def test_invalid_template_name_format(self, temp_dir, valid_pack_data): """Test template with invalid name format.""" valid_pack_data["provides"]["templates"][0]["name"] = "Invalid Name" manifest_path = temp_dir / "preset.yml" with open(manifest_path, 'w') as f: yaml.dump(valid_pack_data, f) with pytest.raises(PresetValidationError, match="Invalid template name"): PresetManifest(manifest_path) def test_get_hash(self, pack_dir): """Test manifest hash calculation.""" manifest = PresetManifest(pack_dir / "preset.yml") hash_val = manifest.get_hash() assert hash_val.startswith("sha256:") assert len(hash_val) > 10 def test_multiple_templates(self, temp_dir, valid_pack_data): """Test pack with multiple templates of different types.""" valid_pack_data["provides"]["templates"] = [ {"type": "template", "name": "spec-template", "file": "templates/spec-template.md"}, {"type": "template", "name": "plan-template", "file": "templates/plan-template.md"}, {"type": "command", "name": "specify", "file": "commands/specify.md"}, {"type": "script", "name": "create-new-feature", "file": "scripts/create-new-feature.sh"}, ] manifest_path = temp_dir / "preset.yml" with open(manifest_path, 'w') as f: yaml.dump(valid_pack_data, f) manifest = PresetManifest(manifest_path) assert len(manifest.templates) == 4 # ===== PresetRegistry Tests ===== class TestPresetRegistry: """Test PresetRegistry operations.""" def test_empty_registry(self, temp_dir): """Test empty registry initialization.""" packs_dir = temp_dir / "packs" packs_dir.mkdir() registry = PresetRegistry(packs_dir) assert registry.list() == {} assert not registry.is_installed("test-pack") def test_add_and_get(self, temp_dir): """Test adding and retrieving a pack.""" packs_dir = temp_dir / "packs" packs_dir.mkdir() registry = PresetRegistry(packs_dir) registry.add("test-pack", {"version": "1.0.0", "source": "local"}) assert registry.is_installed("test-pack") metadata = registry.get("test-pack") assert metadata is not None assert metadata["version"] == "1.0.0" assert "installed_at" in metadata def test_remove(self, temp_dir): """Test removing a pack.""" packs_dir = temp_dir / "packs" packs_dir.mkdir() registry = PresetRegistry(packs_dir) registry.add("test-pack", {"version": "1.0.0"}) assert registry.is_installed("test-pack") registry.remove("test-pack") assert not registry.is_installed("test-pack") def test_remove_nonexistent(self, temp_dir): """Test removing a pack that doesn't exist.""" packs_dir = temp_dir / "packs" packs_dir.mkdir() registry = PresetRegistry(packs_dir) registry.remove("nonexistent") # Should not raise def test_list(self, temp_dir): """Test listing all packs.""" packs_dir = temp_dir / "packs" packs_dir.mkdir() registry = PresetRegistry(packs_dir) registry.add("pack-a", {"version": "1.0.0"}) registry.add("pack-b", {"version": "2.0.0"}) all_packs = registry.list() assert len(all_packs) == 2 assert "pack-a" in all_packs assert "pack-b" in all_packs def test_persistence(self, temp_dir): """Test that registry data persists across instances.""" packs_dir = temp_dir / "packs" packs_dir.mkdir() # Add with first instance registry1 = PresetRegistry(packs_dir) registry1.add("test-pack", {"version": "1.0.0"}) # Load with second instance registry2 = PresetRegistry(packs_dir) assert registry2.is_installed("test-pack") def test_corrupted_registry(self, temp_dir): """Test recovery from corrupted registry file.""" packs_dir = temp_dir / "packs" packs_dir.mkdir() registry_file = packs_dir / ".registry" registry_file.write_text("not valid json{{{") registry = PresetRegistry(packs_dir) assert registry.list() == {} def test_get_nonexistent(self, temp_dir): """Test getting a nonexistent pack.""" packs_dir = temp_dir / "packs" packs_dir.mkdir() registry = PresetRegistry(packs_dir) assert registry.get("nonexistent") is None def test_restore(self, temp_dir): """Test restore() preserves timestamps exactly.""" packs_dir = temp_dir / "packs" packs_dir.mkdir() registry = PresetRegistry(packs_dir) # Create original entry with a specific timestamp original_metadata = { "version": "1.0.0", "source": "local", "installed_at": "2025-01-15T10:30:00+00:00", "enabled": True, } registry.restore("test-pack", original_metadata) # Verify exact restoration restored = registry.get("test-pack") assert restored["installed_at"] == "2025-01-15T10:30:00+00:00" assert restored["version"] == "1.0.0" assert restored["enabled"] is True def test_restore_rejects_none_metadata(self, temp_dir): """Test restore() raises ValueError for None metadata.""" packs_dir = temp_dir / "packs" packs_dir.mkdir() registry = PresetRegistry(packs_dir) with pytest.raises(ValueError, match="metadata must be a dict"): registry.restore("test-pack", None) def test_restore_rejects_non_dict_metadata(self, temp_dir): """Test restore() raises ValueError for non-dict metadata.""" packs_dir = temp_dir / "packs" packs_dir.mkdir() registry = PresetRegistry(packs_dir) with pytest.raises(ValueError, match="metadata must be a dict"): registry.restore("test-pack", "not-a-dict") with pytest.raises(ValueError, match="metadata must be a dict"): registry.restore("test-pack", ["list", "not", "dict"]) def test_restore_uses_deep_copy(self, temp_dir): """Test restore() deep copies metadata to prevent mutation.""" packs_dir = temp_dir / "packs" packs_dir.mkdir() registry = PresetRegistry(packs_dir) original_metadata = { "version": "1.0.0", "nested": {"key": "original"}, } registry.restore("test-pack", original_metadata) # Mutate the original metadata after restore original_metadata["version"] = "MUTATED" original_metadata["nested"]["key"] = "MUTATED" # Registry should have the original values stored = registry.get("test-pack") assert stored["version"] == "1.0.0" assert stored["nested"]["key"] == "original" def test_get_returns_deep_copy(self, temp_dir): """Test that get() returns a deep copy to prevent mutation.""" packs_dir = temp_dir / "packs" packs_dir.mkdir() registry = PresetRegistry(packs_dir) registry.add("test-pack", {"version": "1.0.0", "nested": {"key": "original"}}) # Get and mutate the returned copy metadata = registry.get("test-pack") metadata["version"] = "MUTATED" metadata["nested"]["key"] = "MUTATED" # Original should be unchanged fresh = registry.get("test-pack") assert fresh["version"] == "1.0.0" assert fresh["nested"]["key"] == "original" def test_get_returns_none_for_corrupted_entry(self, temp_dir): """Test that get() returns None for corrupted (non-dict) entries.""" packs_dir = temp_dir / "packs" packs_dir.mkdir() registry = PresetRegistry(packs_dir) # Directly corrupt the registry with non-dict entries registry.data["presets"]["corrupted-string"] = "not a dict" registry.data["presets"]["corrupted-list"] = ["not", "a", "dict"] registry.data["presets"]["corrupted-int"] = 42 registry._save() # All corrupted entries should return None assert registry.get("corrupted-string") is None assert registry.get("corrupted-list") is None assert registry.get("corrupted-int") is None # Non-existent should also return None assert registry.get("nonexistent") is None def test_list_returns_deep_copy(self, temp_dir): """Test that list() returns deep copies to prevent mutation.""" packs_dir = temp_dir / "packs" packs_dir.mkdir() registry = PresetRegistry(packs_dir) registry.add("test-pack", {"version": "1.0.0", "nested": {"key": "original"}}) # Get list and mutate all_packs = registry.list() all_packs["test-pack"]["version"] = "MUTATED" all_packs["test-pack"]["nested"]["key"] = "MUTATED" # Original should be unchanged fresh = registry.get("test-pack") assert fresh["version"] == "1.0.0" assert fresh["nested"]["key"] == "original" def test_list_returns_empty_dict_for_corrupted_registry(self, temp_dir): """Test that list() returns empty dict when presets is not a dict.""" packs_dir = temp_dir / "packs" packs_dir.mkdir() registry = PresetRegistry(packs_dir) # Corrupt the registry - presets is a list instead of dict registry.data["presets"] = ["not", "a", "dict"] registry._save() # list() should return empty dict, not crash result = registry.list() assert result == {} def test_list_by_priority_excludes_disabled(self, temp_dir): """Test that list_by_priority excludes disabled presets by default.""" packs_dir = temp_dir / "packs" packs_dir.mkdir() registry = PresetRegistry(packs_dir) registry.add("pack-enabled", {"version": "1.0.0", "enabled": True, "priority": 5}) registry.add("pack-disabled", {"version": "1.0.0", "enabled": False, "priority": 1}) registry.add("pack-default", {"version": "1.0.0", "priority": 10}) # no enabled field = True # Default: exclude disabled by_priority = registry.list_by_priority() pack_ids = [p[0] for p in by_priority] assert "pack-enabled" in pack_ids assert "pack-default" in pack_ids assert "pack-disabled" not in pack_ids def test_list_by_priority_includes_disabled_when_requested(self, temp_dir): """Test that list_by_priority includes disabled presets when requested.""" packs_dir = temp_dir / "packs" packs_dir.mkdir() registry = PresetRegistry(packs_dir) registry.add("pack-enabled", {"version": "1.0.0", "enabled": True, "priority": 5}) registry.add("pack-disabled", {"version": "1.0.0", "enabled": False, "priority": 1}) # Include disabled by_priority = registry.list_by_priority(include_disabled=True) pack_ids = [p[0] for p in by_priority] assert "pack-enabled" in pack_ids assert "pack-disabled" in pack_ids # Disabled pack has lower priority number, so it comes first when included assert pack_ids[0] == "pack-disabled" # ===== PresetManager Tests ===== class TestPresetManager: """Test PresetManager installation and removal.""" def test_install_from_directory(self, project_dir, pack_dir): """Test installing a preset from a directory.""" manager = PresetManager(project_dir) manifest = manager.install_from_directory(pack_dir, "0.1.5") assert manifest.id == "test-pack" assert manager.registry.is_installed("test-pack") # Verify files are copied installed_dir = project_dir / ".specify" / "presets" / "test-pack" assert installed_dir.exists() assert (installed_dir / "preset.yml").exists() assert (installed_dir / "templates" / "spec-template.md").exists() def test_install_already_installed(self, project_dir, pack_dir): """Test installing an already-installed pack raises error.""" manager = PresetManager(project_dir) manager.install_from_directory(pack_dir, "0.1.5") with pytest.raises(PresetError, match="already installed"): manager.install_from_directory(pack_dir, "0.1.5") def test_install_incompatible(self, project_dir, temp_dir, valid_pack_data): """Test installing an incompatible pack raises error.""" valid_pack_data["requires"]["speckit_version"] = ">=99.0.0" incompat_dir = temp_dir / "incompat-pack" incompat_dir.mkdir() manifest_path = incompat_dir / "preset.yml" with open(manifest_path, 'w') as f: yaml.dump(valid_pack_data, f) (incompat_dir / "templates").mkdir() (incompat_dir / "templates" / "spec-template.md").write_text("test") manager = PresetManager(project_dir) with pytest.raises(PresetCompatibilityError): manager.install_from_directory(incompat_dir, "0.1.5") def test_install_from_zip(self, project_dir, pack_dir, temp_dir): """Test installing from a ZIP file.""" zip_path = temp_dir / "test-pack.zip" with zipfile.ZipFile(zip_path, 'w') as zf: for file_path in pack_dir.rglob('*'): if file_path.is_file(): arcname = file_path.relative_to(pack_dir) zf.write(file_path, arcname) manager = PresetManager(project_dir) manifest = manager.install_from_zip(zip_path, "0.1.5") assert manifest.id == "test-pack" assert manager.registry.is_installed("test-pack") def test_install_from_zip_nested(self, project_dir, pack_dir, temp_dir): """Test installing from ZIP with nested directory.""" zip_path = temp_dir / "test-pack.zip" with zipfile.ZipFile(zip_path, 'w') as zf: for file_path in pack_dir.rglob('*'): if file_path.is_file(): arcname = Path("test-pack-v1.0.0") / file_path.relative_to(pack_dir) zf.write(file_path, arcname) manager = PresetManager(project_dir) manifest = manager.install_from_zip(zip_path, "0.1.5") assert manifest.id == "test-pack" def test_install_from_zip_no_manifest(self, project_dir, temp_dir): """Test installing from ZIP without manifest raises error.""" zip_path = temp_dir / "bad.zip" with zipfile.ZipFile(zip_path, 'w') as zf: zf.writestr("readme.txt", "no manifest here") manager = PresetManager(project_dir) with pytest.raises(PresetValidationError, match="No preset.yml found"): manager.install_from_zip(zip_path, "0.1.5") def test_install_from_zip_rejects_symlink_entry( self, project_dir, pack_dir, temp_dir ): """Preset ZIPs delegate to the shared symlink-safe extractor.""" import stat zip_path = temp_dir / "symlink-preset.zip" link = zipfile.ZipInfo("templates/escape") link.create_system = 3 link.external_attr = (stat.S_IFLNK | 0o777) << 16 with zipfile.ZipFile(zip_path, "w") as zf: for file_path in pack_dir.rglob("*"): if file_path.is_file(): zf.write(file_path, file_path.relative_to(pack_dir)) zf.writestr(link, "../../outside") manager = PresetManager(project_dir) with pytest.raises(PresetValidationError, match="Unsafe symlink"): manager.install_from_zip(zip_path, "0.1.5") assert not manager.registry.is_installed("test-pack") def test_remove(self, project_dir, pack_dir): """Test removing a preset.""" manager = PresetManager(project_dir) manager.install_from_directory(pack_dir, "0.1.5") assert manager.registry.is_installed("test-pack") result = manager.remove("test-pack") assert result is True assert not manager.registry.is_installed("test-pack") installed_dir = project_dir / ".specify" / "presets" / "test-pack" assert not installed_dir.exists() def test_remove_nonexistent(self, project_dir): """Test removing a pack that doesn't exist.""" manager = PresetManager(project_dir) result = manager.remove("nonexistent") assert result is False def test_list_installed(self, project_dir, pack_dir): """Test listing installed packs.""" manager = PresetManager(project_dir) manager.install_from_directory(pack_dir, "0.1.5") installed = manager.list_installed() assert len(installed) == 1 assert installed[0]["id"] == "test-pack" assert installed[0]["name"] == "Test Preset" assert installed[0]["version"] == "1.0.0" assert installed[0]["template_count"] == 1 def test_list_installed_empty(self, project_dir): """Test listing when no packs installed.""" manager = PresetManager(project_dir) assert manager.list_installed() == [] def test_get_pack(self, project_dir, pack_dir): """Test getting a specific installed pack.""" manager = PresetManager(project_dir) manager.install_from_directory(pack_dir, "0.1.5") pack = manager.get_pack("test-pack") assert pack is not None assert pack.id == "test-pack" def test_get_pack_not_installed(self, project_dir): """Test getting a non-installed pack returns None.""" manager = PresetManager(project_dir) assert manager.get_pack("nonexistent") is None def test_check_compatibility_valid(self, pack_dir, temp_dir): """Test compatibility check with valid version.""" manager = PresetManager(temp_dir) manifest = PresetManifest(pack_dir / "preset.yml") assert manager.check_compatibility(manifest, "0.1.5") is True def test_check_compatibility_prerelease(self, pack_dir, temp_dir): """Test compatibility check allows prereleases and fails on boundary.""" manager = PresetManager(temp_dir) manifest = PresetManifest(pack_dir / "preset.yml") # manifest requires >=0.1.0 assert manager.check_compatibility(manifest, "0.8.8.dev0") is True with pytest.raises(PresetCompatibilityError, match="Preset requires spec-kit"): manager.check_compatibility(manifest, "0.1.0.dev0") def test_check_compatibility_invalid(self, pack_dir, temp_dir): """Test compatibility check with invalid specifier.""" manager = PresetManager(temp_dir) manifest = PresetManifest(pack_dir / "preset.yml") manifest.data["requires"]["speckit_version"] = "not-a-specifier" with pytest.raises(PresetCompatibilityError, match="Invalid version specifier"): manager.check_compatibility(manifest, "0.1.5") def test_install_with_priority(self, project_dir, pack_dir): """Test installing a pack with custom priority.""" manager = PresetManager(project_dir) manager.install_from_directory(pack_dir, "0.1.5", priority=5) metadata = manager.registry.get("test-pack") assert metadata is not None assert metadata["priority"] == 5 def test_install_default_priority(self, project_dir, pack_dir): """Test that default priority is 10.""" manager = PresetManager(project_dir) manager.install_from_directory(pack_dir, "0.1.5") metadata = manager.registry.get("test-pack") assert metadata is not None assert metadata["priority"] == 10 def test_list_installed_includes_priority(self, project_dir, pack_dir): """Test that list_installed includes priority.""" manager = PresetManager(project_dir) manager.install_from_directory(pack_dir, "0.1.5", priority=3) installed = manager.list_installed() assert len(installed) == 1 assert installed[0]["priority"] == 3 class TestRegistryPriority: """Test registry priority sorting.""" def test_list_by_priority(self, temp_dir): """Test that list_by_priority sorts by priority number.""" packs_dir = temp_dir / "packs" packs_dir.mkdir() registry = PresetRegistry(packs_dir) registry.add("pack-high", {"version": "1.0.0", "priority": 1}) registry.add("pack-low", {"version": "1.0.0", "priority": 20}) registry.add("pack-mid", {"version": "1.0.0", "priority": 10}) sorted_packs = registry.list_by_priority() assert len(sorted_packs) == 3 assert sorted_packs[0][0] == "pack-high" assert sorted_packs[1][0] == "pack-mid" assert sorted_packs[2][0] == "pack-low" def test_list_by_priority_default(self, temp_dir): """Test that packs without priority default to 10.""" packs_dir = temp_dir / "packs" packs_dir.mkdir() registry = PresetRegistry(packs_dir) registry.add("pack-a", {"version": "1.0.0"}) # no priority, defaults to 10 registry.add("pack-b", {"version": "1.0.0", "priority": 5}) sorted_packs = registry.list_by_priority() assert sorted_packs[0][0] == "pack-b" assert sorted_packs[1][0] == "pack-a" def test_list_by_priority_invalid_priority_defaults(self, temp_dir): """Malformed priority values fall back to the default priority.""" packs_dir = temp_dir / "packs" packs_dir.mkdir() registry = PresetRegistry(packs_dir) registry.add("pack-high", {"version": "1.0.0", "priority": 1}) registry.data["presets"]["pack-invalid"] = { "version": "1.0.0", "priority": "high", } registry._save() sorted_packs = registry.list_by_priority() assert [item[0] for item in sorted_packs] == ["pack-high", "pack-invalid"] assert sorted_packs[1][1]["priority"] == 10 # ===== PresetResolver Tests ===== class TestPresetResolver: """Test PresetResolver priority stack.""" def test_resolve_core_template(self, project_dir): """Test resolving a core template.""" resolver = PresetResolver(project_dir) result = resolver.resolve("spec-template") assert result is not None assert result.name == "spec-template.md" assert "Core Spec Template" in result.read_text() def test_resolve_nonexistent(self, project_dir): """Test resolving a nonexistent template returns None.""" resolver = PresetResolver(project_dir) result = resolver.resolve("nonexistent-template") assert result is None def test_resolve_higher_priority_pack_wins(self, project_dir, temp_dir, valid_pack_data): """Test that a pack with lower priority number wins over higher number.""" manager = PresetManager(project_dir) # Create pack A (priority 10 — lower precedence) pack_a_dir = temp_dir / "pack-a" pack_a_dir.mkdir() data_a = {**valid_pack_data} data_a["preset"] = {**valid_pack_data["preset"], "id": "pack-a", "name": "Pack A"} with open(pack_a_dir / "preset.yml", 'w') as f: yaml.dump(data_a, f) (pack_a_dir / "templates").mkdir() (pack_a_dir / "templates" / "spec-template.md").write_text("# From Pack A\n") # Create pack B (priority 1 — higher precedence) pack_b_dir = temp_dir / "pack-b" pack_b_dir.mkdir() data_b = {**valid_pack_data} data_b["preset"] = {**valid_pack_data["preset"], "id": "pack-b", "name": "Pack B"} with open(pack_b_dir / "preset.yml", 'w') as f: yaml.dump(data_b, f) (pack_b_dir / "templates").mkdir() (pack_b_dir / "templates" / "spec-template.md").write_text("# From Pack B\n") # Install A first (priority 10), B second (priority 1) manager.install_from_directory(pack_a_dir, "0.1.5", priority=10) manager.install_from_directory(pack_b_dir, "0.1.5", priority=1) # Pack B should win because lower priority number resolver = PresetResolver(project_dir) result = resolver.resolve("spec-template") assert result is not None assert "From Pack B" in result.read_text() def test_resolve_override_takes_priority(self, project_dir): """Test that project overrides take priority over core.""" # Create override overrides_dir = project_dir / ".specify" / "templates" / "overrides" overrides_dir.mkdir(parents=True) override = overrides_dir / "spec-template.md" override.write_text("# Override Spec Template\n") resolver = PresetResolver(project_dir) result = resolver.resolve("spec-template") assert result is not None assert "Override Spec Template" in result.read_text() def test_resolve_pack_takes_priority_over_core(self, project_dir, pack_dir): """Test that installed packs take priority over core templates.""" # Install the pack manager = PresetManager(project_dir) manager.install_from_directory(pack_dir, "0.1.5") resolver = PresetResolver(project_dir) result = resolver.resolve("spec-template") assert result is not None assert "Custom Spec Template" in result.read_text() def _install_pack_with_manifest_file(self, project_dir, *, extra_file=False): """Create a pack whose manifest declares a NON-convention file: path. Returns the pack dir under the project. The declared file lives at custom/spec.md (not the convention templates/spec-template.md). """ presets_dir = project_dir / ".specify" / "presets" pack_dir = presets_dir / "mypack" (pack_dir / "custom").mkdir(parents=True) (pack_dir / "custom" / "spec.md").write_text( "# Manifest-declared Spec\n", encoding="utf-8" ) if extra_file: # An undeclared convention-path file the manifest points away from. (pack_dir / "templates").mkdir() (pack_dir / "templates" / "spec-template.md").write_text( "# Stray Convention Spec\n", encoding="utf-8" ) manifest = { "schema_version": "1.0", "preset": { "id": "mypack", "name": "My Pack", "version": "1.0.0", "description": "declares a non-convention file path", }, "requires": {"speckit_version": ">=0.1.0"}, "provides": { "templates": [ { "type": "template", "name": "spec-template", "file": "custom/spec.md", "strategy": "replace", } ] }, } with open(pack_dir / "preset.yml", "w") as f: yaml.dump(manifest, f) PresetRegistry(presets_dir).add( "mypack", {"version": "1.0.0", "priority": 10} ) return pack_dir def test_resolve_uses_manifest_declared_file_path(self, project_dir): """resolve() must honor a manifest-declared non-convention file: path. Previously the tier-2 loop was convention-only, so it returned the core template and resolve_with_source() misattributed source='core', diverging from collect_all_layers()/resolve_content(). """ pack_dir = self._install_pack_with_manifest_file(project_dir) resolver = PresetResolver(project_dir) result = resolver.resolve("spec-template") assert result == pack_dir / "custom" / "spec.md" assert "Manifest-declared Spec" in result.read_text() sourced = resolver.resolve_with_source("spec-template") assert sourced is not None assert "mypack" in sourced["source"] # resolve() must agree with collect_all_layers()'s top layer. layers = resolver.collect_all_layers("spec-template") assert Path(layers[0]["path"]) == pack_dir / "custom" / "spec.md" def test_resolve_manifest_file_wins_over_undeclared_convention_file( self, project_dir ): """A stray convention-path file must not shadow the manifest's file:.""" pack_dir = self._install_pack_with_manifest_file( project_dir, extra_file=True ) resolver = PresetResolver(project_dir) result = resolver.resolve("spec-template") assert result == pack_dir / "custom" / "spec.md" assert "Manifest-declared Spec" in result.read_text() def test_resolve_skips_convention_when_manifest_file_missing(self, project_dir): """When the manifest declares a file: that does not exist, resolve() must NOT fall back to a convention file in the same pack (that would mask a typo) — it skips the pack and resolves core instead.""" presets_dir = project_dir / ".specify" / "presets" pack_dir = presets_dir / "mypack" # Manifest declares custom/spec.md (MISSING); a convention file exists # in the pack and must NOT be used. (pack_dir / "templates").mkdir(parents=True) (pack_dir / "templates" / "spec-template.md").write_text( "# Stray Convention Spec\n", encoding="utf-8" ) manifest = { "schema_version": "1.0", "preset": { "id": "mypack", "name": "My Pack", "version": "1.0.0", "description": "declares a missing file path", }, "requires": {"speckit_version": ">=0.1.0"}, "provides": { "templates": [ { "type": "template", "name": "spec-template", "file": "custom/spec.md", "strategy": "replace", } ] }, } with open(pack_dir / "preset.yml", "w") as f: yaml.dump(manifest, f) PresetRegistry(presets_dir).add( "mypack", {"version": "1.0.0", "priority": 10} ) resolver = PresetResolver(project_dir) result = resolver.resolve("spec-template") assert result is not None content = result.read_text() assert "Stray Convention Spec" not in content # pack convention skipped assert "Core Spec Template" in content # fell through to core def test_resolve_skips_convention_when_manifest_file_is_directory( self, project_dir ): """When the manifest's file: path resolves to a DIRECTORY (not a regular file), resolve()/collect_all_layers() must treat it as missing — exists() would accept it and downstream read_text() on a directory would crash. The pack is skipped (no convention fallback), so core wins.""" presets_dir = project_dir / ".specify" / "presets" pack_dir = presets_dir / "mypack" # Declared file: custom/spec.md is created as a DIRECTORY. (pack_dir / "custom" / "spec.md").mkdir(parents=True) # A convention file also exists and must NOT be used. (pack_dir / "templates").mkdir(parents=True) (pack_dir / "templates" / "spec-template.md").write_text( "# Stray Convention Spec\n", encoding="utf-8" ) manifest = { "schema_version": "1.0", "preset": { "id": "mypack", "name": "My Pack", "version": "1.0.0", "description": "declares a file: that is actually a directory", }, "requires": {"speckit_version": ">=0.1.0"}, "provides": { "templates": [ { "type": "template", "name": "spec-template", "file": "custom/spec.md", "strategy": "replace", } ] }, } with open(pack_dir / "preset.yml", "w") as f: yaml.dump(manifest, f) PresetRegistry(presets_dir).add( "mypack", {"version": "1.0.0", "priority": 10} ) resolver = PresetResolver(project_dir) result = resolver.resolve("spec-template") assert result is not None assert result.is_file() # never a directory content = result.read_text() assert "Stray Convention Spec" not in content # pack convention skipped assert "Core Spec Template" in content # fell through to core # collect_all_layers() must agree: the directory is not a layer. layers = resolver.collect_all_layers("spec-template") assert all(Path(layer["path"]).is_file() for layer in layers) assert all( Path(layer["path"]) != pack_dir / "custom" / "spec.md" for layer in layers ) def test_resolve_override_takes_priority_over_pack(self, project_dir, pack_dir): """Test that overrides take priority over installed packs.""" # Install the pack manager = PresetManager(project_dir) manager.install_from_directory(pack_dir, "0.1.5") # Create override overrides_dir = project_dir / ".specify" / "templates" / "overrides" overrides_dir.mkdir(parents=True) override = overrides_dir / "spec-template.md" override.write_text("# Override Spec Template\n") resolver = PresetResolver(project_dir) result = resolver.resolve("spec-template") assert result is not None assert "Override Spec Template" in result.read_text() def test_resolve_extension_provided_templates(self, project_dir): """Test resolving templates provided by extensions.""" # Create extension with templates ext_dir = project_dir / ".specify" / "extensions" / "my-ext" ext_templates_dir = ext_dir / "templates" ext_templates_dir.mkdir(parents=True) ext_template = ext_templates_dir / "custom-template.md" ext_template.write_text("# Extension Custom Template\n") # Register extension in registry extensions_dir = project_dir / ".specify" / "extensions" ext_registry = ExtensionRegistry(extensions_dir) ext_registry.add("my-ext", {"version": "1.0.0", "priority": 10}) resolver = PresetResolver(project_dir) result = resolver.resolve("custom-template") assert result is not None assert "Extension Custom Template" in result.read_text() def test_resolve_disabled_extension_templates_skipped(self, project_dir): """Test that disabled extension templates are not resolved.""" # Create extension with templates ext_dir = project_dir / ".specify" / "extensions" / "disabled-ext" ext_templates_dir = ext_dir / "templates" ext_templates_dir.mkdir(parents=True) ext_template = ext_templates_dir / "disabled-template.md" ext_template.write_text("# Disabled Extension Template\n") # Register extension as disabled extensions_dir = project_dir / ".specify" / "extensions" ext_registry = ExtensionRegistry(extensions_dir) ext_registry.add("disabled-ext", {"version": "1.0.0", "priority": 1, "enabled": False}) # Template should NOT be resolved because extension is disabled resolver = PresetResolver(project_dir) result = resolver.resolve("disabled-template") assert result is None, "Disabled extension template should not be resolved" def test_resolve_disabled_extension_not_picked_up_as_unregistered(self, project_dir): """Test that disabled extensions are not picked up via unregistered dir scan.""" # Create extension directory with templates ext_dir = project_dir / ".specify" / "extensions" / "test-disabled-ext" ext_templates_dir = ext_dir / "templates" ext_templates_dir.mkdir(parents=True) ext_template = ext_templates_dir / "unique-disabled-template.md" ext_template.write_text("# Should Not Resolve\n") # Register the extension but disable it extensions_dir = project_dir / ".specify" / "extensions" ext_registry = ExtensionRegistry(extensions_dir) ext_registry.add("test-disabled-ext", {"version": "1.0.0", "enabled": False}) # Verify the template is NOT resolved (even though the directory exists) resolver = PresetResolver(project_dir) result = resolver.resolve("unique-disabled-template") assert result is None, "Disabled extension should not be picked up as unregistered" def test_resolve_pack_over_extension(self, project_dir, pack_dir, temp_dir, valid_pack_data): """Test that pack templates take priority over extension templates.""" # Create extension with templates ext_dir = project_dir / ".specify" / "extensions" / "my-ext" ext_templates_dir = ext_dir / "templates" ext_templates_dir.mkdir(parents=True) ext_template = ext_templates_dir / "spec-template.md" ext_template.write_text("# Extension Spec Template\n") # Install a pack with the same template manager = PresetManager(project_dir) manager.install_from_directory(pack_dir, "0.1.5") resolver = PresetResolver(project_dir) result = resolver.resolve("spec-template") assert result is not None # Pack should win over extension assert "Custom Spec Template" in result.read_text() def test_resolve_with_source_core(self, project_dir): """Test resolve_with_source for core template.""" resolver = PresetResolver(project_dir) result = resolver.resolve_with_source("spec-template") assert result is not None assert result["source"] == "core" assert "spec-template.md" in result["path"] def test_resolve_with_source_override(self, project_dir): """Test resolve_with_source for override template.""" overrides_dir = project_dir / ".specify" / "templates" / "overrides" overrides_dir.mkdir(parents=True) override = overrides_dir / "spec-template.md" override.write_text("# Override\n") resolver = PresetResolver(project_dir) result = resolver.resolve_with_source("spec-template") assert result is not None assert result["source"] == "project override" def test_resolve_with_source_pack(self, project_dir, pack_dir): """Test resolve_with_source for pack template.""" manager = PresetManager(project_dir) manager.install_from_directory(pack_dir, "0.1.5") resolver = PresetResolver(project_dir) result = resolver.resolve_with_source("spec-template") assert result is not None assert "test-pack" in result["source"] assert "v1.0.0" in result["source"] def test_resolve_with_source_extension(self, project_dir): """Test resolve_with_source for extension-provided template.""" ext_dir = project_dir / ".specify" / "extensions" / "my-ext" ext_templates_dir = ext_dir / "templates" ext_templates_dir.mkdir(parents=True) ext_template = ext_templates_dir / "unique-template.md" ext_template.write_text("# Unique\n") # Register extension in registry extensions_dir = project_dir / ".specify" / "extensions" ext_registry = ExtensionRegistry(extensions_dir) ext_registry.add("my-ext", {"version": "1.0.0", "priority": 10}) resolver = PresetResolver(project_dir) result = resolver.resolve_with_source("unique-template") assert result is not None assert result["source"] == "extension:my-ext v1.0.0" def test_resolve_with_source_not_found(self, project_dir): """Test resolve_with_source for nonexistent template.""" resolver = PresetResolver(project_dir) result = resolver.resolve_with_source("nonexistent") assert result is None def test_resolve_skips_hidden_extension_dirs(self, project_dir): """Test that hidden directories in extensions are skipped.""" ext_dir = project_dir / ".specify" / "extensions" / ".backup" ext_templates_dir = ext_dir / "templates" ext_templates_dir.mkdir(parents=True) ext_template = ext_templates_dir / "hidden-template.md" ext_template.write_text("# Hidden\n") resolver = PresetResolver(project_dir) result = resolver.resolve("hidden-template") assert result is None def test_collect_all_layers_finds_bundled_core_without_specify_commands( self, project_dir ): """Tier-5 fallback locates the bundled core command when .specify/templates/commands/ has no matching file. Regression test for #3086: a stale ``.parent`` chain made the source-checkout fallback resolve to ``src/templates/...`` (which does not exist), so ``wrap`` presets found no base layer. The fallback must resolve against the real repo-root ``templates/commands`` tree. """ # project_dir's commands dir is empty, so tier-4 cannot satisfy this. resolver = PresetResolver(project_dir) layers = resolver.collect_all_layers("speckit.implement", "command") assert layers, "expected a bundled core base layer to be found" assert layers[-1]["source"] == "core (bundled)" assert layers[-1]["path"].parts[-2:] == ("commands", "implement.md") def test_resolve_command_falls_back_to_bundled_core(self, project_dir): """resolve() tier-5 returns the bundled core command when .specify/templates/commands/ lacks it (regression for #3086).""" resolver = PresetResolver(project_dir) result = resolver.resolve("speckit.implement", "command") assert result is not None assert result.parts[-2:] == ("commands", "implement.md") class TestResolveCore: """Test PresetResolver.resolve_core() skips the installed-presets tier.""" def test_resolve_core_does_not_return_preset_files(self, project_dir): """resolve_core must not return files from .specify/presets/.""" preset_cmd_dir = project_dir / ".specify" / "presets" / "my-preset" / "commands" preset_cmd_dir.mkdir(parents=True) (preset_cmd_dir / "specify.md").write_text("---\ndescription: preset wrap\n---\n\nwrap body\n") resolver = PresetResolver(project_dir) result = resolver.resolve_core("specify", "command") # The preset file must never be returned — but the bundled core may be. if result is not None: assert "presets" not in result.parts def test_resolve_core_returns_core_template(self, project_dir): """resolve_core falls through to core templates (tier 4).""" core_cmd_dir = project_dir / ".specify" / "templates" / "commands" core_cmd_dir.mkdir(parents=True, exist_ok=True) (core_cmd_dir / "specify.md").write_text("---\ndescription: core\n---\n\ncore body\n") # Also place a preset file — resolve_core must still return the core preset_cmd_dir = project_dir / ".specify" / "presets" / "my-preset" / "commands" preset_cmd_dir.mkdir(parents=True) (preset_cmd_dir / "specify.md").write_text("---\ndescription: preset wrap\n---\n\nwrap body\n") resolver = PresetResolver(project_dir) result = resolver.resolve_core("specify", "command") assert result is not None assert "presets" not in result.parts assert result.parts[-3:] == ("templates", "commands", "specify.md") def test_resolve_core_returns_override(self, project_dir): """resolve_core returns tier-1 override if present.""" override_dir = project_dir / ".specify" / "templates" / "overrides" override_dir.mkdir(parents=True) (override_dir / "specify.md").write_text("---\ndescription: override\n---\n\noverride body\n") resolver = PresetResolver(project_dir) result = resolver.resolve_core("specify", "command") assert result is not None assert result.parts[-2:] == ("overrides", "specify.md") def test_resolve_core_returns_extension_template(self, project_dir): """resolve_core returns extension templates (tier 3).""" ext_cmd_dir = project_dir / ".specify" / "extensions" / "myext" / "commands" ext_cmd_dir.mkdir(parents=True) (ext_cmd_dir / "myext-cmd.md").write_text("---\ndescription: ext\n---\n\next body\n") resolver = PresetResolver(project_dir) result = resolver.resolve_core("myext-cmd", "command") assert result is not None assert result.parts[-4:-1] == ("extensions", "myext", "commands") def test_resolve_core_returns_none_when_nothing_found(self, project_dir): """resolve_core returns None when no file found in tiers 1/3/4.""" resolver = PresetResolver(project_dir) result = resolver.resolve_core("nonexistent", "command") assert result is None def test_resolve_extension_command_via_manifest_skips_oserror_manifests(self, project_dir): """resolve_extension_command_via_manifest skips extensions whose manifest raises OSError.""" import unittest.mock as mock ext_dir = project_dir / ".specify" / "extensions" / "bad-ext" cmd_dir = ext_dir / "commands" cmd_dir.mkdir(parents=True) (cmd_dir / "mycmd.md").write_text("---\ndescription: d\n---\n\nbody\n") (ext_dir / "extension.yml").write_text( "schema_version: '1.0'\n" "extension:\n id: bad-ext\n name: Bad\n version: 1.0.0\n" " description: d\n author: a\n repository: https://example.com\n" " license: MIT\n" "requires:\n speckit_version: '>=0.2.0'\n" "provides:\n commands:\n" " - name: speckit.bad-ext.mycmd\n" " file: commands/mycmd.md\n" " description: My command\n" ) resolver = PresetResolver(project_dir) # Simulate a permission error when opening the manifest file. with mock.patch("builtins.open", side_effect=PermissionError("denied")): result = resolver.resolve_extension_command_via_manifest("speckit.bad-ext.mycmd") assert result is None, "OSError during manifest load must be silently skipped" class TestExtensionPriorityResolution: """Test extension priority resolution with registered and unregistered extensions.""" def test_unregistered_beats_registered_with_lower_precedence(self, project_dir): """Unregistered extension (implicit priority 10) beats registered with priority 20.""" extensions_dir = project_dir / ".specify" / "extensions" extensions_dir.mkdir(parents=True, exist_ok=True) # Create registered extension with priority 20 (lower precedence than 10) registered_dir = extensions_dir / "registered-ext" (registered_dir / "templates").mkdir(parents=True) (registered_dir / "templates" / "test-template.md").write_text("# From Registered\n") ext_registry = ExtensionRegistry(extensions_dir) ext_registry.add("registered-ext", {"version": "1.0.0", "priority": 20}) # Create unregistered extension directory (implicit priority 10) unregistered_dir = extensions_dir / "unregistered-ext" (unregistered_dir / "templates").mkdir(parents=True) (unregistered_dir / "templates" / "test-template.md").write_text("# From Unregistered\n") # Unregistered (priority 10) should beat registered (priority 20) resolver = PresetResolver(project_dir) result = resolver.resolve("test-template") assert result is not None assert "From Unregistered" in result.read_text() def test_registered_with_higher_precedence_beats_unregistered(self, project_dir): """Registered extension with priority 5 beats unregistered (implicit priority 10).""" extensions_dir = project_dir / ".specify" / "extensions" extensions_dir.mkdir(parents=True, exist_ok=True) # Create registered extension with priority 5 (higher precedence than 10) registered_dir = extensions_dir / "registered-ext" (registered_dir / "templates").mkdir(parents=True) (registered_dir / "templates" / "test-template.md").write_text("# From Registered\n") ext_registry = ExtensionRegistry(extensions_dir) ext_registry.add("registered-ext", {"version": "1.0.0", "priority": 5}) # Create unregistered extension directory (implicit priority 10) unregistered_dir = extensions_dir / "unregistered-ext" (unregistered_dir / "templates").mkdir(parents=True) (unregistered_dir / "templates" / "test-template.md").write_text("# From Unregistered\n") # Registered (priority 5) should beat unregistered (priority 10) resolver = PresetResolver(project_dir) result = resolver.resolve("test-template") assert result is not None assert "From Registered" in result.read_text() def test_unregistered_attribution_with_priority_ordering(self, project_dir): """Test resolve_with_source correctly attributes unregistered extension.""" extensions_dir = project_dir / ".specify" / "extensions" extensions_dir.mkdir(parents=True, exist_ok=True) # Create registered extension with priority 20 registered_dir = extensions_dir / "registered-ext" (registered_dir / "templates").mkdir(parents=True) (registered_dir / "templates" / "test-template.md").write_text("# From Registered\n") ext_registry = ExtensionRegistry(extensions_dir) ext_registry.add("registered-ext", {"version": "1.0.0", "priority": 20}) # Create unregistered extension (implicit priority 10) unregistered_dir = extensions_dir / "unregistered-ext" (unregistered_dir / "templates").mkdir(parents=True) (unregistered_dir / "templates" / "test-template.md").write_text("# From Unregistered\n") # Attribution should show unregistered extension resolver = PresetResolver(project_dir) result = resolver.resolve_with_source("test-template") assert result is not None assert "unregistered-ext" in result["source"] assert "(unregistered)" in result["source"] def test_same_priority_sorted_alphabetically(self, project_dir): """Extensions with same priority are sorted alphabetically by ID.""" extensions_dir = project_dir / ".specify" / "extensions" extensions_dir.mkdir(parents=True, exist_ok=True) # Create two unregistered extensions (both implicit priority 10) # "aaa-ext" should come before "zzz-ext" alphabetically zzz_dir = extensions_dir / "zzz-ext" (zzz_dir / "templates").mkdir(parents=True) (zzz_dir / "templates" / "test-template.md").write_text("# From ZZZ\n") aaa_dir = extensions_dir / "aaa-ext" (aaa_dir / "templates").mkdir(parents=True) (aaa_dir / "templates" / "test-template.md").write_text("# From AAA\n") # AAA should win due to alphabetical ordering at same priority resolver = PresetResolver(project_dir) result = resolver.resolve("test-template") assert result is not None assert "From AAA" in result.read_text() # ===== PresetCatalog Tests ===== class TestPresetCatalog: """Test template catalog functionality.""" def _inject_github_config(self, monkeypatch, token_env="GH_TOKEN"): from tests.auth_helpers import inject_github_config inject_github_config(monkeypatch, token_env) def test_default_catalog_url(self, project_dir): """Test default catalog URL.""" catalog = PresetCatalog(project_dir) assert catalog.DEFAULT_CATALOG_URL.startswith("https://") assert catalog.DEFAULT_CATALOG_URL.endswith("/presets/catalog.json") def test_community_catalog_url(self, project_dir): """Test community catalog URL.""" catalog = PresetCatalog(project_dir) assert "presets/catalog.community.json" in catalog.COMMUNITY_CATALOG_URL def test_cache_validation_no_cache(self, project_dir): """Test cache validation when no cache exists.""" catalog = PresetCatalog(project_dir) assert catalog.is_cache_valid() is False def test_cache_validation_valid(self, project_dir): """Test cache validation with valid cache.""" catalog = PresetCatalog(project_dir) catalog.cache_dir.mkdir(parents=True, exist_ok=True) catalog.cache_file.write_text(json.dumps({ "schema_version": "1.0", "presets": {}, })) catalog.cache_metadata_file.write_text(json.dumps({ "cached_at": datetime.now(timezone.utc).isoformat(), })) assert catalog.is_cache_valid() is True def test_cache_validation_expired(self, project_dir): """Test cache validation with expired cache.""" catalog = PresetCatalog(project_dir) catalog.cache_dir.mkdir(parents=True, exist_ok=True) catalog.cache_file.write_text(json.dumps({ "schema_version": "1.0", "presets": {}, })) catalog.cache_metadata_file.write_text(json.dumps({ "cached_at": "2020-01-01T00:00:00+00:00", })) assert catalog.is_cache_valid() is False def test_cache_validation_corrupted(self, project_dir): """Test cache validation with corrupted metadata.""" catalog = PresetCatalog(project_dir) catalog.cache_dir.mkdir(parents=True, exist_ok=True) catalog.cache_file.write_text("not json") catalog.cache_metadata_file.write_text("not json") assert catalog.is_cache_valid() is False def test_clear_cache(self, project_dir): """Test clearing the cache.""" catalog = PresetCatalog(project_dir) catalog.cache_dir.mkdir(parents=True, exist_ok=True) catalog.cache_file.write_text("{}") catalog.cache_metadata_file.write_text("{}") catalog.clear_cache() assert not catalog.cache_file.exists() assert not catalog.cache_metadata_file.exists() def test_search_with_cached_data(self, project_dir, monkeypatch): """Test search with cached catalog data.""" from unittest.mock import patch monkeypatch.delenv("SPECKIT_PRESET_CATALOG_URL", raising=False) catalog = PresetCatalog(project_dir) catalog.cache_dir.mkdir(parents=True, exist_ok=True) catalog_data = { "schema_version": "1.0", "presets": { "safe-agile": { "name": "SAFe Agile Templates", "description": "SAFe-aligned templates", "author": "agile-community", "version": "1.0.0", "tags": ["safe", "agile"], }, "healthcare": { "name": "Healthcare Compliance", "description": "HIPAA-compliant templates", "author": "healthcare-org", "version": "1.0.0", "tags": ["healthcare", "hipaa"], }, } } catalog.cache_file.write_text(json.dumps(catalog_data)) catalog.cache_metadata_file.write_text(json.dumps({ "cached_at": datetime.now(timezone.utc).isoformat(), })) # Isolate from community catalog so results are deterministic default_only = [PresetCatalogEntry(url=catalog.DEFAULT_CATALOG_URL, name="default", priority=1, install_allowed=True)] with patch.object(catalog, "get_active_catalogs", return_value=default_only): # Search by query results = catalog.search(query="agile") assert len(results) == 1 assert results[0]["id"] == "safe-agile" # Search by tag results = catalog.search(tag="hipaa") assert len(results) == 1 assert results[0]["id"] == "healthcare" # Search by author results = catalog.search(author="agile-community") assert len(results) == 1 # Search all results = catalog.search() assert len(results) == 2 def test_get_pack_info(self, project_dir): """Test getting info for a specific pack.""" catalog = PresetCatalog(project_dir) catalog.cache_dir.mkdir(parents=True, exist_ok=True) catalog_data = { "schema_version": "1.0", "presets": { "test-pack": { "name": "Test Pack", "version": "1.0.0", }, } } catalog.cache_file.write_text(json.dumps(catalog_data)) catalog.cache_metadata_file.write_text(json.dumps({ "cached_at": datetime.now(timezone.utc).isoformat(), })) info = catalog.get_pack_info("test-pack") assert info is not None assert info["name"] == "Test Pack" assert info["id"] == "test-pack" assert catalog.get_pack_info("nonexistent") is None def test_validate_catalog_url_https(self, project_dir): """Test that HTTPS URLs are accepted.""" catalog = PresetCatalog(project_dir) catalog._validate_catalog_url("https://example.com/catalog.json") def test_validate_catalog_url_http_rejected(self, project_dir): """Test that HTTP URLs are rejected.""" catalog = PresetCatalog(project_dir) with pytest.raises(PresetValidationError, match="must use HTTPS"): catalog._validate_catalog_url("http://example.com/catalog.json") def test_validate_catalog_url_localhost_http_allowed(self, project_dir): """Test that HTTP is allowed for localhost.""" catalog = PresetCatalog(project_dir) catalog._validate_catalog_url("http://localhost:8080/catalog.json") catalog._validate_catalog_url("http://127.0.0.1:8080/catalog.json") @pytest.mark.parametrize( "url", [ "https://:8080", # port only, no host "https://:8080/catalog.json", # port only, with path "https://:0", # port only, no host "https://user@", # userinfo only, no host "https://user:pass@", # userinfo only, no host ], ) def test_validate_catalog_url_hostless_rejected(self, project_dir, url): """Reject host-less URLs whose netloc is truthy but hostname is None (#3209). ``urlparse('https://:8080').netloc`` is ``':8080'`` (truthy) but its ``hostname`` is ``None``, so a netloc-based check would accept a URL with no actual host, contradicting the "valid URL with a host" error. """ catalog = PresetCatalog(project_dir) with pytest.raises(PresetValidationError, match="valid URL with a host"): catalog._validate_catalog_url(url) def test_validate_catalog_url_malformed_rejected(self, project_dir): """A malformed URL raises PresetValidationError, not a raw ValueError. ``urlparse('https://[::1').hostname`` raises ``ValueError: Invalid IPv6 URL`` (unterminated bracket). Without wrapping, that leaks past callers' ``except PresetValidationError`` guards and crashes the CLI. Mirrors the shared ``CatalogStackBase`` (#3435) and ``IntegrationCatalog`` behaviour. """ catalog = PresetCatalog(project_dir) with pytest.raises(PresetValidationError, match="malformed"): catalog._validate_catalog_url("https://[::1") def test_env_var_catalog_url(self, project_dir, monkeypatch): """Test catalog URL from environment variable.""" monkeypatch.setenv("SPECKIT_PRESET_CATALOG_URL", "https://custom.example.com/catalog.json") catalog = PresetCatalog(project_dir) assert catalog.get_catalog_url() == "https://custom.example.com/catalog.json" # --- _make_request / GitHub auth --- def test_make_request_no_token_no_auth_header(self, project_dir, monkeypatch): """Without a token, requests carry no Authorization header.""" monkeypatch.delenv("GITHUB_TOKEN", raising=False) monkeypatch.delenv("GH_TOKEN", raising=False) catalog = PresetCatalog(project_dir) req = catalog._make_request("https://raw.githubusercontent.com/org/repo/main/catalog.json") assert "Authorization" not in req.headers def test_make_request_whitespace_only_github_token_ignored(self, project_dir, monkeypatch): """A whitespace-only GITHUB_TOKEN is treated as unset.""" monkeypatch.setenv("GITHUB_TOKEN", " ") monkeypatch.delenv("GH_TOKEN", raising=False) catalog = PresetCatalog(project_dir) req = catalog._make_request("https://raw.githubusercontent.com/org/repo/main/catalog.json") assert "Authorization" not in req.headers def test_make_request_whitespace_github_token_falls_back_to_gh_token(self, project_dir, monkeypatch): """When GITHUB_TOKEN is whitespace-only, GH_TOKEN is used as fallback.""" monkeypatch.setenv("GITHUB_TOKEN", " ") monkeypatch.setenv("GH_TOKEN", "ghp_fallback") self._inject_github_config(monkeypatch, token_env="GH_TOKEN") catalog = PresetCatalog(project_dir) req = catalog._make_request("https://raw.githubusercontent.com/org/repo/main/catalog.json") assert req.get_header("Authorization") == "Bearer ghp_fallback" def test_make_request_github_token_added_for_github_url(self, project_dir, monkeypatch): """GITHUB_TOKEN is attached for raw.githubusercontent.com URLs.""" monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken") monkeypatch.delenv("GH_TOKEN", raising=False) self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN") catalog = PresetCatalog(project_dir) req = catalog._make_request("https://raw.githubusercontent.com/org/repo/main/catalog.json") assert req.get_header("Authorization") == "Bearer ghp_testtoken" def test_make_request_gh_token_fallback(self, project_dir, monkeypatch): """GH_TOKEN is used when GITHUB_TOKEN is absent.""" monkeypatch.delenv("GITHUB_TOKEN", raising=False) monkeypatch.setenv("GH_TOKEN", "ghp_ghtoken") self._inject_github_config(monkeypatch, token_env="GH_TOKEN") catalog = PresetCatalog(project_dir) req = catalog._make_request("https://github.com/org/repo/releases/download/v1/pack.zip") assert req.get_header("Authorization") == "Bearer ghp_ghtoken" def test_make_request_gh_token_takes_precedence(self, project_dir, monkeypatch): """When auth.json uses GH_TOKEN, that token is used regardless of GITHUB_TOKEN.""" monkeypatch.setenv("GITHUB_TOKEN", "ghp_secondary") monkeypatch.setenv("GH_TOKEN", "ghp_primary") self._inject_github_config(monkeypatch, token_env="GH_TOKEN") catalog = PresetCatalog(project_dir) req = catalog._make_request("https://api.github.com/repos/org/repo") assert req.get_header("Authorization") == "Bearer ghp_primary" def test_make_request_token_added_for_codeload_github_com(self, project_dir, monkeypatch): """GITHUB_TOKEN is attached for codeload.github.com URLs.""" monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken") self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN") catalog = PresetCatalog(project_dir) req = catalog._make_request("https://codeload.github.com/org/repo/zip/refs/tags/v1.0.0") assert req.get_header("Authorization") == "Bearer ghp_testtoken" def test_make_request_no_auth_for_non_matching_host(self, project_dir, monkeypatch): """Auth is NOT attached to hosts not listed in auth.json.""" monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken") self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN") catalog = PresetCatalog(project_dir) req = catalog._make_request("https://internal.example.com/catalog.json") assert "Authorization" not in req.headers def test_make_request_no_auth_when_no_config(self, project_dir, monkeypatch): """No auth header when no auth.json config exists.""" monkeypatch.delenv("GITHUB_TOKEN", raising=False) monkeypatch.delenv("GH_TOKEN", raising=False) catalog = PresetCatalog(project_dir) req = catalog._make_request("https://github.com/org/repo/releases/download/v1/pack.zip") assert "Authorization" not in req.headers def test_fetch_single_catalog_sends_auth_header(self, project_dir, monkeypatch): """_fetch_single_catalog passes Authorization header when configured.""" from unittest.mock import patch, MagicMock monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken") self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN") catalog = PresetCatalog(project_dir) catalog_data = {"schema_version": "1.0", "presets": {}} mock_response = MagicMock() mock_response.read.side_effect = io.BytesIO(json.dumps(catalog_data).encode()).read mock_response.__enter__ = lambda s: s mock_response.__exit__ = MagicMock(return_value=False) mock_response.geturl.return_value = "https://raw.githubusercontent.com/org/repo/main/presets/catalog.json" captured = {} mock_opener = MagicMock() def fake_open(req, timeout=None): captured["req"] = req return mock_response mock_opener.open.side_effect = fake_open entry = PresetCatalogEntry( url="https://raw.githubusercontent.com/org/repo/main/presets/catalog.json", name="private", priority=1, install_allowed=True, ) with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener): catalog._fetch_single_catalog(entry, force_refresh=True) assert captured["req"].get_header("Authorization") == "Bearer ghp_testtoken" def test_fetch_single_catalog_revalidates_redirected_url(self, project_dir): """An HTTPS catalog URL that redirects to http:// must be rejected AFTER the redirect. _open_url follows redirects (auth stripped on downgrade), so without re-validating response.geturl() the http payload would still be fetched and trusted — and it supplies each preset's download_url + sha256, defeating verify_archive_sha256. Parity with the integrations/workflows catalog fetchers.""" catalog = PresetCatalog(project_dir) class _Resp: def __enter__(self): return self def __exit__(self, *a): return False def read(self): return json.dumps({"schema_version": "1.0", "presets": {}}).encode() def geturl(self): return "http://evil.test/catalog.json" # downgraded via redirect catalog._open_url = lambda url, timeout=None, redirect_validator=None: _Resp() entry = PresetCatalogEntry( url="https://good.example/catalog.json", name="c", priority=1, install_allowed=True, ) with pytest.raises(PresetValidationError, match="HTTPS"): catalog._fetch_single_catalog(entry, force_refresh=True) def test_fetch_single_catalog_validates_every_redirect_hop(self, project_dir): """A redirect_validator is passed to _open_url and rejects a non-HTTPS INTERMEDIATE hop — closing the https -> http -> attacker-https chain that a terminal-URL-only check would miss.""" catalog = PresetCatalog(project_dir) captured = {} def fake_open(url, timeout=None, redirect_validator=None): captured["rv"] = redirect_validator # Simulate the hop urllib validates before following the redirect. redirect_validator("https://good.example/catalog.json", "http://evil.test/hop") raise AssertionError("redirect_validator should have raised") catalog._open_url = fake_open entry = PresetCatalogEntry( url="https://good.example/catalog.json", name="c", priority=1, install_allowed=True, ) with pytest.raises(PresetValidationError, match="HTTPS"): catalog._fetch_single_catalog(entry, force_refresh=True) assert captured["rv"] is not None def test_fetch_catalog_legacy_revalidates_redirected_url(self, project_dir): """The legacy single-catalog fetch_catalog() path also rejects an HTTPS -> http redirected payload (final geturl() check), matching _fetch_single_catalog — it previously parsed the body with no check.""" catalog = PresetCatalog(project_dir) class _Resp: def __enter__(self): return self def __exit__(self, *a): return False def read(self): return json.dumps({"schema_version": "1.0", "presets": {}}).encode() def geturl(self): return "http://evil.test/catalog.json" catalog._open_url = lambda url, timeout=None, redirect_validator=None: _Resp() with pytest.raises(PresetError, match="HTTPS"): catalog.fetch_catalog(force_refresh=True) def test_fetch_catalog_legacy_validates_every_redirect_hop(self, project_dir): """The legacy fetch_catalog() path also validates every INTERMEDIATE hop (not just the terminal URL): it must supply a redirect_validator that rejects an insecure hop, so an https -> http -> https chain is caught.""" catalog = PresetCatalog(project_dir) captured = {} def fake_open(url, timeout=None, redirect_validator=None): captured["rv"] = redirect_validator redirect_validator(url, "http://evil.test/hop") raise AssertionError("redirect_validator should have raised") catalog._open_url = fake_open with pytest.raises(PresetError, match="HTTPS"): catalog.fetch_catalog(force_refresh=True) assert captured["rv"] is not None @pytest.mark.parametrize( "payload", [ # Root is not a JSON object. [], "oops", 42, None, # Root is fine but ``presets`` is the wrong type. {"schema_version": "1.0", "presets": []}, {"schema_version": "1.0", "presets": "oops"}, {"schema_version": "1.0", "presets": None}, {"schema_version": "1.0", "presets": 42}, ], ) def test_fetch_single_catalog_rejects_malformed_payload(self, project_dir, payload): """Malformed catalog payloads raise PresetError, not AttributeError. Without this guard, a payload like ``{"presets": []}`` would pass the key-presence check and then crash with ``AttributeError: 'list' object has no attribute 'items'`` deep inside ``_get_merged_packs``. The sibling integration catalog reader already validates both the root object and the nested mapping (see ``integrations/catalog.py``); the preset catalog must stay consistent. """ from unittest.mock import patch, MagicMock catalog = PresetCatalog(project_dir) mock_response = MagicMock() mock_response.read.side_effect = io.BytesIO(json.dumps(payload).encode()).read mock_response.__enter__ = lambda s: s mock_response.__exit__ = MagicMock(return_value=False) # A real urllib response reports the final URL (== request URL with no # redirect); the fetcher re-validates it after redirects. mock_response.geturl.return_value = "https://example.com/catalog.json" entry = PresetCatalogEntry( url="https://example.com/catalog.json", name="default", priority=1, install_allowed=True, ) with patch.object(catalog, "_open_url", return_value=mock_response): with pytest.raises(PresetError, match="Invalid preset catalog format"): catalog._fetch_single_catalog(entry, force_refresh=True) @pytest.mark.parametrize( "cached_payload", [ [], "oops", 42, None, {"schema_version": "1.0", "presets": []}, {"schema_version": "1.0", "presets": "oops"}, {"schema_version": "1.0", "presets": None}, ], ) def test_fetch_single_catalog_rejects_malformed_cached_payload( self, project_dir, cached_payload ): """A poisoned cache silently falls back to the network instead of crashing — cached payloads pass through the same shape validation as freshly-fetched ones. Without this, a cache poisoned by an older spec-kit version (or a manual edit, or an upstream that briefly served a bad payload before the network guards landed) would re-crash every invocation of ``_get_merged_packs`` despite the cache being "valid" by age. The recovery contract is: if the cached payload fails validation, drop it and refetch — never propagate ``AttributeError`` to the caller. """ from unittest.mock import patch, MagicMock catalog = PresetCatalog(project_dir) # Poison the default-URL cache. ``DEFAULT_CATALOG_URL`` and # non-default URLs both flow through the same cache-load branch. cache_file, metadata_file = catalog._get_cache_paths( catalog.DEFAULT_CATALOG_URL ) cache_file.parent.mkdir(parents=True, exist_ok=True) cache_file.write_text(json.dumps(cached_payload)) metadata_file.write_text( json.dumps( { "cached_at": datetime.now(timezone.utc).isoformat(), "catalog_url": catalog.DEFAULT_CATALOG_URL, } ) ) # Network refetch returns a valid payload so the recovery path # can complete. valid = { "schema_version": "1.0", "presets": {"foo": {"name": "Foo", "version": "1.0.0"}}, } mock_response = MagicMock() mock_response.read.side_effect = io.BytesIO(json.dumps(valid).encode()).read mock_response.__enter__ = lambda s: s mock_response.__exit__ = MagicMock(return_value=False) mock_response.geturl.return_value = catalog.DEFAULT_CATALOG_URL entry = PresetCatalogEntry( url=catalog.DEFAULT_CATALOG_URL, name="default", priority=1, install_allowed=True, ) with patch.object(catalog, "_open_url", return_value=mock_response): result = catalog._fetch_single_catalog(entry, force_refresh=False) # The poisoned cache was discarded and the network payload returned. assert result == valid @pytest.mark.parametrize( "payload", [ # Root is not a JSON object. [], "oops", 42, None, # Root is fine but ``presets`` is the wrong type. {"schema_version": "1.0", "presets": []}, {"schema_version": "1.0", "presets": "oops"}, {"schema_version": "1.0", "presets": None}, ], ) def test_fetch_catalog_rejects_malformed_payload(self, project_dir, payload): """Legacy ``fetch_catalog`` reuses the same shape-validation helper. Before this change ``fetch_catalog`` only checked key presence — so a payload like ``42`` would crash with ``TypeError: argument of type 'int' is not iterable`` during the ``"schema_version" in catalog_data`` check, and an entry mapping of the wrong type would crash downstream. Reusing ``_validate_catalog_payload`` keeps the network-side behaviour of the legacy single-catalog method consistent with the multi-catalog ``_fetch_single_catalog`` path. """ from unittest.mock import patch, MagicMock catalog = PresetCatalog(project_dir) mock_response = MagicMock() mock_response.read.side_effect = io.BytesIO(json.dumps(payload).encode()).read mock_response.__enter__ = lambda s: s mock_response.__exit__ = MagicMock(return_value=False) mock_response.geturl.return_value = "https://example.com/catalog.json" with patch.object(catalog, "_open_url", return_value=mock_response): with pytest.raises(PresetError, match="Invalid preset catalog format"): catalog.fetch_catalog(force_refresh=True) def test_fetch_catalog_recovers_from_unreadable_cache(self, project_dir): """An unreadable / wrong-encoded cache file silently refetches. The cache contract is best-effort: a JSON-decode failure, an OS read failure (permissions / disk / handle limit), or an invalid text encoding on a cache file written by an older client must all fall through to the network fetch rather than crash the caller. Covers Copilot's review point that the previous ``except (json.JSONDecodeError, OSError)`` was missing ``UnicodeError``. """ from unittest.mock import patch, MagicMock catalog = PresetCatalog(project_dir) catalog.cache_dir.mkdir(parents=True, exist_ok=True) # Invalid UTF-8 bytes so ``read_text`` raises ``UnicodeDecodeError`` # (a subclass of ``UnicodeError``). catalog.cache_file.write_bytes(b"\xff\xfe\x00not-utf-8") catalog.cache_metadata_file.write_text( json.dumps( { "cached_at": datetime.now(timezone.utc).isoformat(), "catalog_url": catalog.get_catalog_url(), } ), encoding="utf-8", ) valid = { "schema_version": "1.0", "presets": {"foo": {"name": "Foo", "version": "1.0.0"}}, } mock_response = MagicMock() mock_response.read.side_effect = io.BytesIO(json.dumps(valid).encode()).read mock_response.__enter__ = lambda s: s mock_response.__exit__ = MagicMock(return_value=False) mock_response.geturl.return_value = "https://example.com/catalog.json" with patch.object(catalog, "_open_url", return_value=mock_response): result = catalog.fetch_catalog(force_refresh=False) # Recovered via network rather than crashing on the unreadable cache. assert result == valid def test_fetch_catalog_recovers_from_unreadable_metadata(self, project_dir): """A wrongly-encoded metadata file degrades to a cache miss. ``is_cache_valid`` is consulted *before* the cache payload is read; if the metadata file itself can't be decoded (e.g. it was written on a host whose default codec isn't UTF-8) the validity check must return ``False`` rather than propagate ``UnicodeDecodeError``. Without that guard, a corrupted metadata file would crash every invocation instead of falling through to a network refetch. """ from unittest.mock import patch, MagicMock catalog = PresetCatalog(project_dir) catalog.cache_dir.mkdir(parents=True, exist_ok=True) catalog.cache_file.write_text("{}", encoding="utf-8") # Bytes that are not valid UTF-8 — ``read_text(encoding="utf-8")`` # will raise ``UnicodeDecodeError`` (subclass of ``UnicodeError``). catalog.cache_metadata_file.write_bytes(b"\xff\xfe\x00bad") # is_cache_valid must absorb the decode failure, not crash. assert catalog.is_cache_valid() is False valid = { "schema_version": "1.0", "presets": {"foo": {"name": "Foo", "version": "1.0.0"}}, } mock_response = MagicMock() mock_response.read.side_effect = io.BytesIO(json.dumps(valid).encode()).read mock_response.__enter__ = lambda s: s mock_response.__exit__ = MagicMock(return_value=False) mock_response.geturl.return_value = "https://example.com/catalog.json" with patch.object(catalog, "_open_url", return_value=mock_response): result = catalog.fetch_catalog(force_refresh=False) assert result == valid @pytest.mark.parametrize( "non_mapping_metadata", [ "[]", # JSON array '"oops"', # JSON string "42", # JSON number "true", # JSON bool "null", # JSON null ], ) def test_is_cache_valid_handles_non_mapping_metadata( self, project_dir, non_mapping_metadata ): """Metadata that parses to a non-mapping degrades to cache-invalid. The cache-validity check calls ``metadata.get("cached_at", "")`` immediately after ``json.loads``. If the metadata file is valid JSON but parses to a non-mapping (``[]``, ``"oops"``, ``42``, ``true``, ``null``), ``.get`` raises ``AttributeError`` — which previously slipped past the except tuple and crashed the caller. The contract documented on ``is_cache_valid`` says any decode/shape failure should return ``False`` so ``fetch_catalog`` falls through to a network refetch. This test pins that contract across every JSON non-mapping root type. """ catalog = PresetCatalog(project_dir) catalog.cache_dir.mkdir(parents=True, exist_ok=True) catalog.cache_file.write_text("{}", encoding="utf-8") catalog.cache_metadata_file.write_text( non_mapping_metadata, encoding="utf-8" ) # Must not raise — the contract is "any decode/shape failure → False". assert catalog.is_cache_valid() is False def test_fetch_catalog_writes_cache_as_utf8(self, project_dir, monkeypatch): """Cache + metadata writes pass ``encoding="utf-8"``, observably. The earlier version of this test claimed to assert UTF-8 at the byte level but actually only round-tripped a non-ASCII string through ``json.dumps`` and ``read_text(encoding="utf-8")``. Because ``json.dumps`` defaults to ``ensure_ascii=True``, "café" was serialized as the all-ASCII escape ``caf\\u00e9`` before it ever reached ``write_text`` — the bytes on disk were identical regardless of the encoding kwarg. The drift Copilot's review flagged wasn't actually being caught. Fix: directly observe the ``encoding`` argument passed to every ``write_text`` call made against the cache directory. This is the production code's encoding choice, which is exactly what the regression guard cares about. """ from unittest.mock import patch, MagicMock from pathlib import Path as _PathCls catalog = PresetCatalog(project_dir) payload = { "schema_version": "1.0", "presets": {"foo": {"name": "Foo", "version": "1.0.0"}}, } mock_response = MagicMock() mock_response.read.side_effect = io.BytesIO(json.dumps(payload).encode("utf-8")).read mock_response.__enter__ = lambda s: s mock_response.__exit__ = MagicMock(return_value=False) mock_response.geturl.return_value = "https://example.com/catalog.json" # Record every ``write_text`` call's encoding kwarg so the # assertion observes the production writer's argument directly. recorded: list[dict] = [] real_write_text = _PathCls.write_text def recording_write_text(self, data, *args, **kwargs): recorded.append( {"path": str(self), "encoding": kwargs.get("encoding")} ) return real_write_text(self, data, *args, **kwargs) monkeypatch.setattr(_PathCls, "write_text", recording_write_text) with patch.object(catalog, "_open_url", return_value=mock_response): catalog.fetch_catalog(force_refresh=True) cache_writes = [ r for r in recorded if str(catalog.cache_dir) in r["path"] ] assert cache_writes, "fetch_catalog made no writes to the cache dir" for record in cache_writes: assert record["encoding"] == "utf-8", ( f"write_text on {record['path']} used encoding " f"{record['encoding']!r}; expected 'utf-8'" ) def test_fetch_catalog_survives_unwritable_cache(self, project_dir, monkeypatch): """An unwritable cache dir doesn't fail a successful fetch. Cache writes are best-effort, mirroring the read side and the ``integrations/catalog.py`` precedent: if ``mkdir``/``write_text`` raises ``OSError`` (read-only checkout, permissions), the already-fetched-and-validated payload must still be returned — not swallowed into the broad except and re-raised as a ``PresetError``. """ from unittest.mock import patch, MagicMock from pathlib import Path as _PathCls catalog = PresetCatalog(project_dir) valid = { "schema_version": "1.0", "presets": {"foo": {"name": "Foo", "version": "1.0.0"}}, } def make_response(): mock_response = MagicMock() mock_response.read.side_effect = io.BytesIO(json.dumps(valid).encode()).read mock_response.__enter__ = lambda s: s mock_response.__exit__ = MagicMock(return_value=False) mock_response.geturl.return_value = catalog.DEFAULT_CATALOG_URL return mock_response # Simulate an unwritable cache dir: every write_text under the # cache directory raises PermissionError (an OSError subclass). real_write_text = _PathCls.write_text def failing_write_text(self, data, *args, **kwargs): if str(catalog.cache_dir) in str(self): raise PermissionError("cache dir is read-only") return real_write_text(self, data, *args, **kwargs) monkeypatch.setattr(_PathCls, "write_text", failing_write_text) with patch.object(catalog, "_open_url", side_effect=lambda *a, **kw: make_response()): # Legacy single-catalog path. assert catalog.fetch_catalog(force_refresh=True) == valid # Multi-catalog path. entry = PresetCatalogEntry( url=catalog.DEFAULT_CATALOG_URL, name="default", priority=1, install_allowed=True, ) assert ( catalog._fetch_single_catalog(entry, force_refresh=True) == valid ) def test_get_merged_packs_skips_non_mapping_entries(self, project_dir): """Per-entry guard: one malformed entry shouldn't poison the merge. ``_fetch_single_catalog`` validates that ``presets`` is a mapping, but it doesn't (and shouldn't) validate every entry inside it — a single bad entry in an otherwise-valid catalog should be skipped, not crash the whole resolve path. Mirrors the per-entry skip in ``integrations/catalog.py``: a malformed entry returns no error, valid entries continue to merge normally. """ from unittest.mock import patch, MagicMock catalog = PresetCatalog(project_dir) payload = { "schema_version": "1.0", "presets": { "good": {"name": "Good", "version": "1.0.0"}, "bad-list": [], "bad-str": "oops", }, } mock_response = MagicMock() mock_response.read.side_effect = io.BytesIO(json.dumps(payload).encode()).read mock_response.__enter__ = lambda s: s mock_response.__exit__ = MagicMock(return_value=False) mock_response.geturl.return_value = "https://example.com/catalog.json" entry = PresetCatalogEntry( url="https://example.com/catalog.json", name="default", priority=1, install_allowed=True, ) with patch.object(catalog, "_open_url", return_value=mock_response), \ patch.object(catalog, "get_active_catalogs", return_value=[entry]): merged = catalog._get_merged_packs(force_refresh=True) # Only the well-formed entry survives; the two malformed entries are # silently dropped rather than raising or crashing. assert list(merged.keys()) == ["good"] def test_download_pack_sends_auth_header(self, project_dir, monkeypatch): """download_pack passes Authorization header when configured.""" from unittest.mock import patch, MagicMock monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken") self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN") catalog = PresetCatalog(project_dir) import io zip_buf = io.BytesIO() with zipfile.ZipFile(zip_buf, "w") as zf: zf.writestr("preset.yml", "id: test-pack\nname: Test\nversion: 1.0.0\n") zip_bytes = zip_buf.getvalue() release_response = MagicMock() release_response.read.side_effect = io.BytesIO(json.dumps( { "assets": [ { "name": "test-pack.zip", "url": "https://api.github.com/repos/org/repo/releases/assets/1", } ] } ).encode()).read release_response.__enter__ = lambda s: s release_response.__exit__ = MagicMock(return_value=False) asset_response = MagicMock() asset_response.read.side_effect = io.BytesIO(zip_bytes).read asset_response.__enter__ = lambda s: s asset_response.__exit__ = MagicMock(return_value=False) captured = [] mock_opener = MagicMock() def fake_open(req, timeout=None): captured.append(req) if req.full_url.endswith("/releases/tags/v1"): return release_response return asset_response mock_opener.open.side_effect = fake_open pack_info = { "id": "test-pack", "name": "Test Pack", "version": "1.0.0", "download_url": "https://github.com/org/repo/releases/download/v1/test-pack.zip", "_install_allowed": True, } with patch.object(catalog, "get_pack_info", return_value=pack_info), \ patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener): catalog.download_pack("test-pack", target_dir=project_dir) assert captured[0].full_url == "https://api.github.com/repos/org/repo/releases/tags/v1" assert captured[0].get_header("Authorization") == "Bearer ghp_testtoken" assert captured[1].full_url == "https://api.github.com/repos/org/repo/releases/assets/1" assert captured[1].get_header("Authorization") == "Bearer ghp_testtoken" assert captured[1].get_header("Accept") == "application/octet-stream" def _pack_zip_and_response(self): """Build a minimal preset ZIP and a context-manager mock response.""" from unittest.mock import MagicMock import io zip_buf = io.BytesIO() with zipfile.ZipFile(zip_buf, "w") as zf: zf.writestr("preset.yml", "id: test-pack\nname: Test\nversion: 1.0.0\n") zip_bytes = zip_buf.getvalue() resp = MagicMock() resp.read.side_effect = io.BytesIO(zip_bytes).read # Configure the context-manager protocol explicitly so `with resp` # yields `resp` itself, independent of how the protocol is invoked. resp.__enter__.return_value = resp resp.__exit__.return_value = False return zip_bytes, resp def test_fetch_single_catalog_rejects_oversized_body_without_cache( self, project_dir, monkeypatch ): """Catalog bounds are enforced at the preset call site.""" import specify_cli.presets as preset_module from unittest.mock import patch catalog = PresetCatalog(project_dir) entry = PresetCatalogEntry( url="https://example.com/catalog.json", name="default", priority=1, install_allowed=True, ) body = b'{"schema_version":"1.0","presets":{}}' response = MagicMock() response.read.side_effect = io.BytesIO(body).read response.__enter__.return_value = response response.__exit__.return_value = False response.geturl.return_value = entry.url monkeypatch.setattr( preset_module, "MAX_JSON_CATALOG_BYTES", len(body) - 1, ) with patch.object(catalog, "_open_url", return_value=response): with pytest.raises(PresetError, match="exceeds maximum size"): catalog._fetch_single_catalog(entry, force_refresh=True) assert not catalog.cache_dir.exists() or not any(catalog.cache_dir.iterdir()) def test_download_pack_rejects_oversized_body_without_output( self, project_dir, monkeypatch ): """Package bounds fail before checksum verification or disk writes.""" import specify_cli.presets as preset_module from unittest.mock import patch from specify_cli._download_security import ( read_response_limited as real_read_response_limited, ) catalog = PresetCatalog(project_dir) pack_info = { "id": "test-pack", "name": "Test Pack", "version": "1.0.0", "download_url": "https://example.com/test-pack.zip", "_install_allowed": True, } response = MagicMock() response.read.side_effect = io.BytesIO(b"12345").read response.__enter__.return_value = response response.__exit__.return_value = False def read_with_tiny_limit(stream, **kwargs): kwargs.pop("max_bytes", None) return real_read_response_limited(stream, max_bytes=4, **kwargs) monkeypatch.setattr( preset_module, "read_response_limited", read_with_tiny_limit, ) with patch.object(preset_module, "verify_archive_sha256") as verify, \ patch.object(catalog, "get_pack_info", return_value=pack_info), \ patch.object(catalog, "_open_url", return_value=response): with pytest.raises(PresetError, match="exceeds maximum size"): catalog.download_pack("test-pack", target_dir=project_dir) verify.assert_not_called() assert not (project_dir / "test-pack-1.0.0.zip").exists() def test_download_pack_rejects_unsafe_output_filename(self, project_dir): """Catalog-controlled IDs cannot escape the requested target directory.""" from unittest.mock import patch catalog = PresetCatalog(project_dir) outside_stem = project_dir.parent / "outside-preset" pack_id = str(outside_stem) pack_info = { "id": pack_id, "name": "Test Pack", "version": "1.0.0", "download_url": "https://example.com/test-pack.zip", "_install_allowed": True, } with patch.object(catalog, "get_pack_info", return_value=pack_info), \ patch.object(catalog, "_open_url") as open_url: with pytest.raises(PresetError, match="filename"): catalog.download_pack(pack_id, target_dir=project_dir) open_url.assert_not_called() assert not Path(f"{outside_stem}-1.0.0.zip").exists() def test_download_pack_accepts_matching_sha256(self, project_dir): """A catalog ``sha256`` that matches the preset archive is accepted.""" import hashlib from unittest.mock import patch catalog = PresetCatalog(project_dir) zip_bytes, resp = self._pack_zip_and_response() pack_info = { "id": "test-pack", "name": "Test Pack", "version": "1.0.0", "download_url": "https://example.com/test-pack.zip", "sha256": hashlib.sha256(zip_bytes).hexdigest(), "_install_allowed": True, } with patch.object(catalog, "get_pack_info", return_value=pack_info), \ patch.object(catalog, "_open_url", return_value=resp): zip_path = catalog.download_pack("test-pack", target_dir=project_dir) assert zip_path.read_bytes() == zip_bytes def test_download_pack_rejects_sha256_mismatch(self, project_dir): """A catalog ``sha256`` that does not match the archive aborts install.""" from unittest.mock import patch catalog = PresetCatalog(project_dir) _zip_bytes, resp = self._pack_zip_and_response() pack_info = { "id": "test-pack", "name": "Test Pack", "version": "1.0.0", "download_url": "https://example.com/test-pack.zip", "sha256": "0" * 64, # deliberately wrong "_install_allowed": True, } with patch.object(catalog, "get_pack_info", return_value=pack_info), \ patch.object(catalog, "_open_url", return_value=resp): with pytest.raises(PresetError, match="[Ii]ntegrity"): catalog.download_pack("test-pack", target_dir=project_dir) def test_download_pack_malformed_url_raises_preset_error(self, project_dir): """A catalog ``download_url`` with a malformed authority (e.g. an unterminated IPv6 bracket) surfaces a clean ``PresetError`` rather than leaking a raw ``ValueError`` from ``urlparse``/``.hostname`` past the command handler (which only catches ``PresetError``). Mirrors the extensions coverage. """ from unittest.mock import patch catalog = PresetCatalog(project_dir) for bad_url in ( "https://[::1", "https://[not-an-ip]/x", "https://example.com:65536/x", "https:///x", 123, ): pack_info = { "id": "test-pack", "name": "Test Pack", "version": "1.0.0", "download_url": bad_url, "_install_allowed": True, } with patch.object(catalog, "get_pack_info", return_value=pack_info), \ patch.object(catalog, "_open_url") as open_url: with pytest.raises(PresetError, match="malformed"): catalog.download_pack("test-pack", target_dir=project_dir) open_url.assert_not_called() def test_download_pack_without_sha256_skips_verification(self, project_dir): """A catalog entry with no ``sha256`` keeps working: verification is opt-in, so the backwards-compatible path (``pack_info.get("sha256")`` is ``None``) must download without aborting — mirrors the extensions coverage so the helper never silently becomes mandatory for presets. """ from unittest.mock import patch catalog = PresetCatalog(project_dir) zip_bytes, resp = self._pack_zip_and_response() pack_info = { "id": "test-pack", "name": "Test Pack", "version": "1.0.0", "download_url": "https://example.com/test-pack.zip", "_install_allowed": True, } with patch.object(catalog, "get_pack_info", return_value=pack_info), \ patch.object(catalog, "_open_url", return_value=resp): zip_path = catalog.download_pack("test-pack", target_dir=project_dir) assert zip_path.read_bytes() == zip_bytes def test_download_pack_accepts_direct_github_rest_asset_url(self, project_dir, monkeypatch): """download_pack can use a GitHub REST release asset URL directly.""" from unittest.mock import patch, MagicMock monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken") self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN") catalog = PresetCatalog(project_dir) import io zip_buf = io.BytesIO() with zipfile.ZipFile(zip_buf, "w") as zf: zf.writestr("preset.yml", "id: test-pack\nname: Test\nversion: 1.0.0\n") zip_bytes = zip_buf.getvalue() asset_response = MagicMock() asset_response.read.side_effect = io.BytesIO(zip_bytes).read asset_response.__enter__ = lambda s: s asset_response.__exit__ = MagicMock(return_value=False) captured = [] mock_opener = MagicMock() def fake_open(req, timeout=None): captured.append(req) return asset_response mock_opener.open.side_effect = fake_open pack_info = { "id": "test-pack", "name": "Test Pack", "version": "1.0.0", "download_url": "https://api.github.com/repos/org/repo/releases/assets/1", "_install_allowed": True, } with patch.object(catalog, "get_pack_info", return_value=pack_info), \ patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener): catalog.download_pack("test-pack", target_dir=project_dir) assert len(captured) == 1 assert captured[0].full_url == "https://api.github.com/repos/org/repo/releases/assets/1" assert captured[0].get_header("Authorization") == "Bearer ghp_testtoken" assert captured[0].get_header("Accept") == "application/octet-stream" # ===== Integration Tests ===== class TestIntegration: """Integration tests for complete preset workflows.""" def test_full_install_resolve_remove_cycle(self, project_dir, pack_dir): """Test complete lifecycle: install → resolve → remove.""" # Install manager = PresetManager(project_dir) manifest = manager.install_from_directory(pack_dir, "0.1.5") assert manifest.id == "test-pack" # Resolve — pack template should win over core resolver = PresetResolver(project_dir) result = resolver.resolve("spec-template") assert result is not None assert "Custom Spec Template" in result.read_text() # Remove manager.remove("test-pack") # Resolve — should fall back to core result = resolver.resolve("spec-template") assert result is not None assert "Core Spec Template" in result.read_text() def test_override_beats_pack_beats_extension_beats_core(self, project_dir, pack_dir): """Test the full priority stack: override > pack > extension > core.""" resolver = PresetResolver(project_dir) # Core should resolve result = resolver.resolve_with_source("spec-template") assert result["source"] == "core" # Add extension template ext_dir = project_dir / ".specify" / "extensions" / "my-ext" ext_templates_dir = ext_dir / "templates" ext_templates_dir.mkdir(parents=True) (ext_templates_dir / "spec-template.md").write_text("# Extension\n") # Register extension in registry extensions_dir = project_dir / ".specify" / "extensions" ext_registry = ExtensionRegistry(extensions_dir) ext_registry.add("my-ext", {"version": "1.0.0", "priority": 10}) result = resolver.resolve_with_source("spec-template") assert result["source"] == "extension:my-ext v1.0.0" # Install pack — should win over extension manager = PresetManager(project_dir) manager.install_from_directory(pack_dir, "0.1.5") result = resolver.resolve_with_source("spec-template") assert "test-pack" in result["source"] # Add override — should win over pack overrides_dir = project_dir / ".specify" / "templates" / "overrides" overrides_dir.mkdir(parents=True) (overrides_dir / "spec-template.md").write_text("# Override\n") result = resolver.resolve_with_source("spec-template") assert result["source"] == "project override" def test_install_from_zip_then_resolve(self, project_dir, pack_dir, temp_dir): """Test installing from ZIP and then resolving.""" # Create ZIP zip_path = temp_dir / "test-pack.zip" with zipfile.ZipFile(zip_path, 'w') as zf: for file_path in pack_dir.rglob('*'): if file_path.is_file(): arcname = file_path.relative_to(pack_dir) zf.write(file_path, arcname) # Install manager = PresetManager(project_dir) manager.install_from_zip(zip_path, "0.1.5") # Resolve resolver = PresetResolver(project_dir) result = resolver.resolve("spec-template") assert result is not None assert "Custom Spec Template" in result.read_text() # ===== PresetCatalogEntry Tests ===== class TestPresetCatalogEntry: """Test PresetCatalogEntry dataclass.""" def test_create_entry(self): """Test creating a catalog entry.""" entry = PresetCatalogEntry( url="https://example.com/catalog.json", name="test", priority=1, install_allowed=True, description="Test catalog", ) assert entry.url == "https://example.com/catalog.json" assert entry.name == "test" assert entry.priority == 1 assert entry.install_allowed is True assert entry.description == "Test catalog" def test_default_description(self): """Test default empty description.""" entry = PresetCatalogEntry( url="https://example.com/catalog.json", name="test", priority=1, install_allowed=False, ) assert entry.description == "" # ===== Multi-Catalog Tests ===== class TestPresetCatalogMultiCatalog: """Test multi-catalog support in PresetCatalog.""" def test_default_active_catalogs(self, project_dir): """Test that default catalogs are returned when no config exists.""" catalog = PresetCatalog(project_dir) active = catalog.get_active_catalogs() assert len(active) == 2 assert active[0].name == "default" assert active[0].priority == 1 assert active[0].install_allowed is True assert active[1].name == "community" assert active[1].priority == 2 assert active[1].install_allowed is False def test_catalog_list_escapes_rich_markup(self, project_dir): """User-editable catalog name/url/description must not be parsed as Rich markup.""" from typer.testing import CliRunner from unittest.mock import patch from specify_cli import app entry = PresetCatalogEntry( url="https://example.com/[cat].json", name="Bracket [Catalog]", priority=1, install_allowed=True, description="desc [with] brackets", ) runner = CliRunner() with patch.object(Path, "cwd", return_value=project_dir), \ patch.object(PresetCatalog, "get_active_catalogs", return_value=[entry]): result = runner.invoke(app, ["preset", "catalog", "list"]) assert result.exit_code == 0, result.output assert "Bracket [Catalog]" in result.output assert "https://example.com/[cat].json" in result.output assert "desc [with] brackets" in result.output def test_env_var_overrides_catalogs(self, project_dir, monkeypatch): """Test that SPECKIT_PRESET_CATALOG_URL env var overrides defaults.""" monkeypatch.setenv( "SPECKIT_PRESET_CATALOG_URL", "https://custom.example.com/catalog.json", ) catalog = PresetCatalog(project_dir) active = catalog.get_active_catalogs() assert len(active) == 1 assert active[0].name == "custom" assert active[0].url == "https://custom.example.com/catalog.json" assert active[0].install_allowed is True def test_project_config_overrides_defaults(self, project_dir): """Test that project-level config overrides built-in defaults.""" config_path = project_dir / ".specify" / "preset-catalogs.yml" config_path.write_text(yaml.dump({ "catalogs": [ { "name": "my-catalog", "url": "https://my.example.com/catalog.json", "priority": 1, "install_allowed": True, } ] })) catalog = PresetCatalog(project_dir) active = catalog.get_active_catalogs() assert len(active) == 1 assert active[0].name == "my-catalog" assert active[0].url == "https://my.example.com/catalog.json" def test_load_catalog_config_nonexistent(self, project_dir): """Test loading config from nonexistent file returns None.""" catalog = PresetCatalog(project_dir) result = catalog._load_catalog_config( project_dir / ".specify" / "nonexistent.yml" ) assert result is None def test_load_catalog_config_empty(self, project_dir): """Test loading empty config returns None.""" config_path = project_dir / ".specify" / "preset-catalogs.yml" config_path.write_text("") catalog = PresetCatalog(project_dir) result = catalog._load_catalog_config(config_path) assert result is None def test_load_catalog_config_invalid_yaml(self, project_dir): """Test loading invalid YAML raises error.""" config_path = project_dir / ".specify" / "preset-catalogs.yml" config_path.write_text(": invalid: {{{") catalog = PresetCatalog(project_dir) with pytest.raises(PresetValidationError, match="Failed to read"): catalog._load_catalog_config(config_path) def test_load_catalog_config_not_a_list(self, project_dir): """Test that non-list catalogs key raises error.""" config_path = project_dir / ".specify" / "preset-catalogs.yml" config_path.write_text(yaml.dump({"catalogs": "not-a-list"})) catalog = PresetCatalog(project_dir) with pytest.raises(PresetValidationError, match="must be a list"): catalog._load_catalog_config(config_path) def test_load_catalog_config_invalid_entry(self, project_dir): """Test that non-dict entry raises error.""" config_path = project_dir / ".specify" / "preset-catalogs.yml" config_path.write_text(yaml.dump({"catalogs": ["not-a-dict"]})) catalog = PresetCatalog(project_dir) with pytest.raises(PresetValidationError, match="expected a mapping"): catalog._load_catalog_config(config_path) def test_load_catalog_config_http_url_rejected(self, project_dir): """Test that HTTP URLs are rejected.""" config_path = project_dir / ".specify" / "preset-catalogs.yml" config_path.write_text(yaml.dump({ "catalogs": [ { "name": "bad", "url": "http://insecure.example.com/catalog.json", "priority": 1, } ] })) catalog = PresetCatalog(project_dir) with pytest.raises(PresetValidationError, match="must use HTTPS"): catalog._load_catalog_config(config_path) def test_load_catalog_config_priority_sorting(self, project_dir): """Test that catalogs are sorted by priority.""" config_path = project_dir / ".specify" / "preset-catalogs.yml" config_path.write_text(yaml.dump({ "catalogs": [ { "name": "low-priority", "url": "https://low.example.com/catalog.json", "priority": 10, "install_allowed": False, }, { "name": "high-priority", "url": "https://high.example.com/catalog.json", "priority": 1, "install_allowed": True, }, ] })) catalog = PresetCatalog(project_dir) entries = catalog._load_catalog_config(config_path) assert entries is not None assert len(entries) == 2 assert entries[0].name == "high-priority" assert entries[1].name == "low-priority" def test_load_catalog_config_invalid_priority(self, project_dir): """Test that invalid priority raises error.""" config_path = project_dir / ".specify" / "preset-catalogs.yml" config_path.write_text(yaml.dump({ "catalogs": [ { "name": "bad", "url": "https://example.com/catalog.json", "priority": "not-a-number", } ] })) catalog = PresetCatalog(project_dir) with pytest.raises(PresetValidationError, match="Invalid priority"): catalog._load_catalog_config(config_path) def test_load_catalog_config_rejects_boolean_priority(self, project_dir): """A YAML ``priority: true`` is a typo, not a request for priority 1. ``bool`` is a subclass of ``int`` in Python, so ``int(True)`` silently returns ``1``. Without an explicit guard a malformed config like ``priority: yes`` would be accepted as a valid priority of 1 and silently change catalog ordering. The sibling integration-catalog reader rejects this case (see ``catalogs.py``); the preset catalog reader must stay consistent. """ config_path = project_dir / ".specify" / "preset-catalogs.yml" config_path.write_text(yaml.dump({ "catalogs": [ { "name": "bool-priority", "url": "https://example.com/catalog.json", "priority": True, } ] })) catalog = PresetCatalog(project_dir) with pytest.raises(PresetValidationError, match="Invalid priority|expected integer"): catalog._load_catalog_config(config_path) def test_load_catalog_config_rejects_infinite_priority(self, project_dir): """A ``priority: .inf`` yields a clean validation error, not an uncaught OverflowError from int(float('inf')).""" config_path = project_dir / ".specify" / "preset-catalogs.yml" config_path.write_text(yaml.dump({ "catalogs": [ { "name": "inf-priority", "url": "https://example.com/catalog.json", "priority": float("inf"), } ] })) catalog = PresetCatalog(project_dir) with pytest.raises(PresetValidationError, match="Invalid priority|expected integer"): catalog._load_catalog_config(config_path) def test_load_catalog_config_install_allowed_string(self, project_dir): """Test that install_allowed accepts string values.""" config_path = project_dir / ".specify" / "preset-catalogs.yml" config_path.write_text(yaml.dump({ "catalogs": [ { "name": "test", "url": "https://example.com/catalog.json", "priority": 1, "install_allowed": "true", } ] })) catalog = PresetCatalog(project_dir) entries = catalog._load_catalog_config(config_path) assert entries is not None assert entries[0].install_allowed is True def test_get_catalog_url_uses_highest_priority(self, project_dir): """Test that get_catalog_url returns URL of highest priority catalog.""" config_path = project_dir / ".specify" / "preset-catalogs.yml" config_path.write_text(yaml.dump({ "catalogs": [ { "name": "secondary", "url": "https://secondary.example.com/catalog.json", "priority": 5, }, { "name": "primary", "url": "https://primary.example.com/catalog.json", "priority": 1, }, ] })) catalog = PresetCatalog(project_dir) assert catalog.get_catalog_url() == "https://primary.example.com/catalog.json" def test_cache_paths_default_url(self, project_dir): """Test cache paths for default catalog URL use legacy locations.""" catalog = PresetCatalog(project_dir) cache_file, metadata_file = catalog._get_cache_paths( PresetCatalog.DEFAULT_CATALOG_URL ) assert cache_file == catalog.cache_file assert metadata_file == catalog.cache_metadata_file def test_cache_paths_custom_url(self, project_dir): """Test cache paths for custom URLs use hash-based files.""" catalog = PresetCatalog(project_dir) cache_file, metadata_file = catalog._get_cache_paths( "https://custom.example.com/catalog.json" ) assert cache_file != catalog.cache_file assert "catalog-" in cache_file.name assert cache_file.name.endswith(".json") def test_url_cache_valid(self, project_dir): """Test URL-specific cache validation.""" catalog = PresetCatalog(project_dir) url = "https://custom.example.com/catalog.json" cache_file, metadata_file = catalog._get_cache_paths(url) catalog.cache_dir.mkdir(parents=True, exist_ok=True) cache_file.write_text(json.dumps({"schema_version": "1.0", "presets": {}})) metadata_file.write_text(json.dumps({ "cached_at": datetime.now(timezone.utc).isoformat(), })) assert catalog._is_url_cache_valid(url) is True def test_url_cache_expired(self, project_dir): """Test URL-specific cache expiration.""" catalog = PresetCatalog(project_dir) url = "https://custom.example.com/catalog.json" cache_file, metadata_file = catalog._get_cache_paths(url) catalog.cache_dir.mkdir(parents=True, exist_ok=True) cache_file.write_text(json.dumps({"schema_version": "1.0", "presets": {}})) metadata_file.write_text(json.dumps({ "cached_at": "2020-01-01T00:00:00+00:00", })) assert catalog._is_url_cache_valid(url) is False # ===== Self-Test Preset Tests ===== SELF_TEST_PRESET_DIR = Path(__file__).parent.parent / "presets" / "self-test" SELF_TEST_WRAP_WARNING = ( r"Cannot compose command 'speckit\.wrap-test': no base layer\. " r"Stale command files may remain\." ) CORE_TEMPLATE_NAMES = [ "spec-template", "plan-template", "tasks-template", "checklist-template", "constitution-template", ] def install_self_test_preset(manager: PresetManager, speckit_version: str = "0.1.5") -> PresetManifest: """Install self-test while filtering its intentionally missing wrap base.""" with warnings.catch_warnings(): warnings.filterwarnings( "ignore", message=SELF_TEST_WRAP_WARNING, category=UserWarning, module=r"specify_cli\.presets", ) return manager.install_from_directory(SELF_TEST_PRESET_DIR, speckit_version) def _make_convention_constitution_preset(temp_dir: Path) -> Path: """Create a preset whose constitution is found by convention, not its manifest.""" preset_dir = temp_dir / "convention-constitution" (preset_dir / "templates").mkdir(parents=True) (preset_dir / "templates" / "constitution-template.md").write_text( "# Convention Constitution\n" ) (preset_dir / "templates" / "spec-template.md").write_text("# Spec\n") (preset_dir / "preset.yml").write_text( yaml.dump( { "schema_version": "1.0", "preset": { "id": "convention-constitution", "name": "Convention Constitution", "version": "1.0.0", "description": "Convention-based constitution for testing", }, "requires": {"speckit_version": ">=0.1.0"}, "provides": { "templates": [ { "type": "template", "name": "spec-template", "file": "templates/spec-template.md", } ] }, } ) ) return preset_dir class TestSelfTestPreset: """Tests using the self-test preset that ships with the repo. The self-test preset ships a wrap-strategy command (``speckit.wrap-test``) without a corresponding core base layer; reconciliation deliberately surfaces a UserWarning in that case. Tests install via ``install_self_test_preset`` (defined above), which scopes a narrow ``warnings.filterwarnings`` block to that specific message and ``UserWarning`` category — so the expected warning stays quiet without masking unrelated warnings or real reconciliation failures. """ def test_self_test_preset_exists(self): """Verify the self-test preset directory and manifest exist.""" assert SELF_TEST_PRESET_DIR.exists() assert (SELF_TEST_PRESET_DIR / "preset.yml").exists() def test_self_test_manifest_valid(self): """Verify the self-test preset manifest is valid.""" manifest = PresetManifest(SELF_TEST_PRESET_DIR / "preset.yml") assert manifest.id == "self-test" assert manifest.name == "Self-Test Preset" assert manifest.version == "1.0.0" assert len(manifest.templates) == 7 # 5 templates + 2 commands def test_self_test_provides_all_core_templates(self): """Verify the self-test preset provides an override for every core template.""" manifest = PresetManifest(SELF_TEST_PRESET_DIR / "preset.yml") provided_names = {t["name"] for t in manifest.templates} for name in CORE_TEMPLATE_NAMES: assert name in provided_names, f"Self-test preset missing template: {name}" def test_self_test_template_files_exist(self): """Verify that all declared template files actually exist on disk.""" manifest = PresetManifest(SELF_TEST_PRESET_DIR / "preset.yml") for tmpl in manifest.templates: tmpl_path = SELF_TEST_PRESET_DIR / tmpl["file"] assert tmpl_path.exists(), f"Missing template file: {tmpl['file']}" def test_self_test_templates_have_marker(self): """Verify each template contains the preset:self-test marker.""" for name in CORE_TEMPLATE_NAMES: tmpl_path = SELF_TEST_PRESET_DIR / "templates" / f"{name}.md" content = tmpl_path.read_text() assert "preset:self-test" in content, f"{name}.md missing preset:self-test marker" def test_install_self_test_preset(self, project_dir): """Test installing the self-test preset from its directory.""" manager = PresetManager(project_dir) manifest = install_self_test_preset(manager) assert manifest.id == "self-test" assert manager.registry.is_installed("self-test") def test_self_test_overrides_all_core_templates(self, project_dir): """Test that installing self-test overrides every core template.""" # Set up core templates in the project templates_dir = project_dir / ".specify" / "templates" for name in CORE_TEMPLATE_NAMES: (templates_dir / f"{name}.md").write_text(f"# Core {name}\n") # Install self-test preset manager = PresetManager(project_dir) install_self_test_preset(manager) # Every core template should now resolve from the preset resolver = PresetResolver(project_dir) for name in CORE_TEMPLATE_NAMES: result = resolver.resolve(name) assert result is not None, f"{name} did not resolve" content = result.read_text() assert "preset:self-test" in content, ( f"{name} resolved but not from self-test preset" ) def test_self_test_resolve_with_source(self, project_dir): """Test that resolve_with_source attributes templates to self-test.""" templates_dir = project_dir / ".specify" / "templates" for name in CORE_TEMPLATE_NAMES: (templates_dir / f"{name}.md").write_text(f"# Core {name}\n") manager = PresetManager(project_dir) install_self_test_preset(manager) resolver = PresetResolver(project_dir) for name in CORE_TEMPLATE_NAMES: result = resolver.resolve_with_source(name) assert result is not None, f"{name} did not resolve" assert "self-test" in result["source"], ( f"{name} source is '{result['source']}', expected self-test" ) def test_self_test_removal_restores_core(self, project_dir): """Test that removing self-test falls back to core templates.""" templates_dir = project_dir / ".specify" / "templates" for name in CORE_TEMPLATE_NAMES: (templates_dir / f"{name}.md").write_text(f"# Core {name}\n") manager = PresetManager(project_dir) install_self_test_preset(manager) manager.remove("self-test") resolver = PresetResolver(project_dir) for name in CORE_TEMPLATE_NAMES: result = resolver.resolve_with_source(name) assert result is not None assert result["source"] == "core" memory = project_dir / ".specify" / "memory" / "constitution.md" assert memory.read_text() == "# Core constitution-template\n" def test_self_test_removal_preserves_edited_constitution(self, project_dir): """Removing a preset does not overwrite an edited generated constitution.""" templates_dir = project_dir / ".specify" / "templates" (templates_dir / "constitution-template.md").write_text("# Core Constitution\n") manager = PresetManager(project_dir) install_self_test_preset(manager) memory = project_dir / ".specify" / "memory" / "constitution.md" edited = memory.read_text() + "\n## Authored amendment\n" memory.write_text(edited) manager.remove("self-test") assert memory.read_text() == edited def test_self_test_not_in_catalog(self): """Verify the self-test preset is NOT in the catalog (it's local-only).""" catalog_path = Path(__file__).parent.parent / "presets" / "catalog.json" catalog_data = json.loads(catalog_path.read_text()) assert "self-test" not in catalog_data["presets"] def test_self_test_has_command(self): """Verify the self-test preset includes a command override.""" manifest = PresetManifest(SELF_TEST_PRESET_DIR / "preset.yml") commands = [t for t in manifest.templates if t["type"] == "command"] assert len(commands) >= 1 assert commands[0]["name"] == "speckit.specify" def test_self_test_command_file_exists(self): """Verify the self-test command file exists on disk.""" cmd_path = SELF_TEST_PRESET_DIR / "commands" / "speckit.specify.md" assert cmd_path.exists() content = cmd_path.read_text() assert "preset:self-test" in content def test_self_test_registers_commands_for_claude(self, project_dir): """Test that installing self-test registers skills in .claude/skills/.""" # Create Claude skills directory to simulate Claude being set up claude_dir = project_dir / ".claude" / "skills" claude_dir.mkdir(parents=True) manager = PresetManager(project_dir) install_self_test_preset(manager) # Check the skill was registered cmd_file = claude_dir / "speckit-specify" / "SKILL.md" assert cmd_file.exists(), "Skill not registered in .claude/skills/" content = cmd_file.read_text() assert "self-test" in content assert "source:" in content # skill frontmatter includes metadata.source def test_self_test_registers_commands_for_gemini(self, project_dir): """Test that installing self-test registers commands in .gemini/commands/ as TOML.""" # Create Gemini agent directory gemini_dir = project_dir / ".gemini" / "commands" gemini_dir.mkdir(parents=True) manager = PresetManager(project_dir) install_self_test_preset(manager) # Check the command was registered in TOML format cmd_file = gemini_dir / "speckit.specify.toml" assert cmd_file.exists(), "Command not registered in .gemini/commands/" content = cmd_file.read_text() assert "prompt" in content # TOML format has a prompt field assert "{{args}}" in content # Gemini uses {{args}} placeholder def test_self_test_unregisters_commands_on_remove(self, project_dir): """Test that removing self-test cleans up registered commands.""" claude_dir = project_dir / ".claude" / "skills" claude_dir.mkdir(parents=True) manager = PresetManager(project_dir) install_self_test_preset(manager) cmd_file = claude_dir / "speckit-specify" / "SKILL.md" assert cmd_file.exists() manager.remove("self-test") assert not cmd_file.exists(), "Command not cleaned up after preset removal" def test_self_test_no_commands_without_agent_dirs(self, project_dir): """Test that no commands are registered when no agent dirs exist.""" manager = PresetManager(project_dir) install_self_test_preset(manager) metadata = manager.registry.get("self-test") assert metadata["registered_commands"] == {} def test_self_test_seeds_constitution_when_memory_absent(self, project_dir): """Installing a preset seeds memory/constitution.md from its template.""" manager = PresetManager(project_dir) install_self_test_preset(manager) memory = project_dir / ".specify" / "memory" / "constitution.md" assert memory.exists(), "constitution.md was not seeded from the preset" assert "preset:self-test" in memory.read_text(), ( "constitution.md was not seeded from the self-test preset template" ) def test_self_test_reseeds_exact_core_constitution(self, project_dir): """An unchanged core constitution is re-seeded from the preset template.""" resolver = PresetResolver(project_dir) bundled_core = resolver._find_bundled_core( "constitution-template", "template", ".md" ) assert bundled_core is not None core = bundled_core.read_bytes() memory = project_dir / ".specify" / "memory" / "constitution.md" memory.parent.mkdir(parents=True, exist_ok=True) memory.write_bytes(core) manager = PresetManager(project_dir) install_self_test_preset(manager) content = memory.read_text() assert "preset:self-test" in content, "placeholder constitution was not re-seeded" assert "[PROJECT_NAME]" not in content @pytest.mark.parametrize( "provenance_content", [ '{"sha256": "does-not-match", "source": "old-preset"}\n', "{not valid json", ], ids=["hash-mismatch", "malformed"], ) def test_self_test_preserves_core_content_with_existing_invalid_provenance( self, project_dir, provenance_content ): """A present invalid sidecar disables legacy core-template migration.""" resolver = PresetResolver(project_dir) bundled_core = resolver._find_bundled_core( "constitution-template", "template", ".md" ) assert bundled_core is not None memory = project_dir / ".specify" / "memory" / "constitution.md" memory.parent.mkdir(parents=True, exist_ok=True) memory.write_bytes(bundled_core.read_bytes()) (memory.parent / ".constitution-template.json").write_text( provenance_content ) original = memory.read_bytes() manager = PresetManager(project_dir) install_self_test_preset(manager) assert memory.read_bytes() == original def test_self_test_preserves_mutable_project_core_copy(self, project_dir): """A project template copy does not establish generated provenance.""" authored = "# Acme Organization Constitution\n\nOrganization policy.\n" project_template = ( project_dir / ".specify" / "templates" / "constitution-template.md" ) project_template.write_text(authored) memory = project_dir / ".specify" / "memory" / "constitution.md" memory.parent.mkdir(parents=True, exist_ok=True) memory.write_text(authored) manager = PresetManager(project_dir) install_self_test_preset(manager) assert memory.read_text() == authored assert not (memory.parent / ".constitution-template.json").exists() def test_core_prefixed_preset_does_not_establish_generated_provenance( self, project_dir, temp_dir ): """A preset ID beginning with core is not an immutable core source.""" authored = "# Acme Organization Constitution\n\nOrganization policy.\n" memory = project_dir / ".specify" / "memory" / "constitution.md" memory.parent.mkdir(parents=True, exist_ok=True) memory.write_text(authored) preset_dir = temp_dir / "core-company" (preset_dir / "templates").mkdir(parents=True) (preset_dir / "templates" / "constitution-template.md").write_text(authored) (preset_dir / "preset.yml").write_text( yaml.safe_dump( { "schema_version": "1.0", "preset": { "id": "core-company", "name": "Core Company", "version": "1.0.0", "description": "Company constitution preset", "author": "Test Author", "repository": "https://github.com/test/core-company", "license": "MIT", }, "requires": {"speckit_version": ">=0.1.0"}, "provides": { "templates": [ { "type": "template", "name": "constitution-template", "file": "templates/constitution-template.md", "description": "Company constitution", "replaces": "constitution-template", } ] }, } ) ) PresetManager(project_dir).install_from_directory(preset_dir, "0.1.5") assert memory.read_text() == authored assert not (memory.parent / ".constitution-template.json").exists() def test_self_test_preserves_authored_constitution_with_placeholder( self, project_dir ): """A placeholder mention does not establish generated provenance.""" memory = project_dir / ".specify" / "memory" / "constitution.md" memory.parent.mkdir(parents=True, exist_ok=True) authored = "# Acme Constitution\n\nGuidance for [PROJECT_NAME].\n" memory.write_text(authored) manager = PresetManager(project_dir) install_self_test_preset(manager) assert memory.read_text() == authored def test_self_test_preserves_authored_constitution(self, project_dir): """An authored (placeholder-free) constitution is never overwritten.""" memory = project_dir / ".specify" / "memory" / "constitution.md" memory.parent.mkdir(parents=True, exist_ok=True) authored = "# Acme Constitution\n\n### I. Ship It\nAuthored by a human.\n" memory.write_text(authored) manager = PresetManager(project_dir) install_self_test_preset(manager) assert memory.read_text() == authored, "authored constitution was overwritten" def test_self_test_override_resolves_constitution_template(self, project_dir): """The preset override of constitution-template resolves to the preset file.""" templates_dir = project_dir / ".specify" / "templates" (templates_dir / "constitution-template.md").write_text("# Core constitution\n") manager = PresetManager(project_dir) install_self_test_preset(manager) resolver = PresetResolver(project_dir) result = resolver.resolve("constitution-template", "template") assert result is not None assert "preset:self-test" in result.read_text() def test_constitution_seed_composes_wrap_strategy(self, project_dir, temp_dir): """Seeding memory composes wrap constitution-template layers.""" templates_dir = project_dir / ".specify" / "templates" templates_dir.mkdir(parents=True, exist_ok=True) (templates_dir / "constitution-template.md").write_text( "# Core Constitution\n\n## Core Principle\n" ) preset_dir = temp_dir / "constitution-wrap" (preset_dir / "templates").mkdir(parents=True) (preset_dir / "templates" / "constitution-template.md").write_text( "# Wrapper Constitution\n\n{CORE_TEMPLATE}\n\n## Wrapper Footer\n" ) (preset_dir / "preset.yml").write_text( yaml.dump( { "schema_version": "1.0", "preset": { "id": "constitution-wrap", "name": "Constitution Wrap", "version": "1.0.0", "description": "Wrap constitution template for testing", }, "requires": {"speckit_version": ">=0.1.0"}, "provides": { "templates": [ { "type": "template", "name": "constitution-template", "file": "templates/constitution-template.md", "strategy": "wrap", "description": "Wrapped constitution template", } ] }, } ) ) manager = PresetManager(project_dir) manager.install_from_directory(preset_dir, "0.1.5") memory = project_dir / ".specify" / "memory" / "constitution.md" content = memory.read_text() assert "{CORE_TEMPLATE}" not in content assert "# Wrapper Constitution" in content assert "## Core Principle" in content def test_constitution_follows_priority_when_winning_preset_removed( self, project_dir, temp_dir ): """An unchanged generated constitution follows priority and fallback layers.""" manager = PresetManager(project_dir) install_self_test_preset(manager) preset_dir = temp_dir / "higher-priority" (preset_dir / "templates").mkdir(parents=True) (preset_dir / "templates" / "constitution-template.md").write_text( "# Higher Priority Constitution\n" ) (preset_dir / "preset.yml").write_text( yaml.dump( { "schema_version": "1.0", "preset": { "id": "higher-priority", "name": "Higher Priority", "version": "1.0.0", "description": "Higher-priority constitution", }, "requires": {"speckit_version": ">=0.1.0"}, "provides": { "templates": [ { "type": "template", "name": "constitution-template", "file": "templates/constitution-template.md", "strategy": "replace", "description": "Higher-priority constitution", } ] }, } ) ) manager.install_from_directory(preset_dir, "0.1.5", priority=1) memory = project_dir / ".specify" / "memory" / "constitution.md" assert memory.read_text() == "# Higher Priority Constitution\n" manager.remove("higher-priority") assert "preset:self-test" in memory.read_text() def test_convention_constitution_removal_restores_remaining_layer( self, project_dir, temp_dir ): """Removing a convention layer rematerializes the remaining resolver layer.""" manager = PresetManager(project_dir) install_self_test_preset(manager) manager.install_from_directory( _make_convention_constitution_preset(temp_dir), "0.1.5", priority=1 ) memory = project_dir / ".specify" / "memory" / "constitution.md" assert memory.read_text() == "# Convention Constitution\n" manager.remove("convention-constitution") assert "preset:self-test" in memory.read_text() def test_convention_constitution_removal_preserves_edited_content( self, project_dir, temp_dir ): """Removing a convention layer does not overwrite edited generated content.""" from specify_cli.commands.init import ensure_constitution_from_template templates_dir = project_dir / ".specify" / "templates" (templates_dir / "constitution-template.md").write_text("# Core Constitution\n") manager = PresetManager(project_dir) manager.install_from_directory( _make_convention_constitution_preset(temp_dir), "0.1.5" ) ensure_constitution_from_template(project_dir) memory = project_dir / ".specify" / "memory" / "constitution.md" edited = memory.read_text() + "\n## Authored amendment\n" memory.write_text(edited) manager.remove("convention-constitution") assert memory.read_text() == edited def test_custom_constitution_removal_recovers_with_invalid_manifest( self, project_dir, temp_dir ): """Provenance triggers fallback when a custom-path manifest is invalid.""" manager = PresetManager(project_dir) install_self_test_preset(manager) preset_dir = temp_dir / "custom-constitution" (preset_dir / "policy").mkdir(parents=True) (preset_dir / "policy" / "charter.md").write_text("# Custom Constitution\n") (preset_dir / "preset.yml").write_text( yaml.dump( { "schema_version": "1.0", "preset": { "id": "custom-constitution", "name": "Custom Constitution", "version": "1.0.0", "description": "Custom-path constitution for testing", }, "requires": {"speckit_version": ">=0.1.0"}, "provides": { "templates": [ { "type": "template", "name": "constitution-template", "file": "policy/charter.md", } ] }, } ) ) manager.install_from_directory(preset_dir, "0.1.5", priority=1) memory = project_dir / ".specify" / "memory" / "constitution.md" assert memory.read_text() == "# Custom Constitution\n" installed_manifest = ( project_dir / ".specify" / "presets" / "custom-constitution" / "preset.yml" ) installed_manifest.write_text("invalid: [") manager.remove("custom-constitution") assert "preset:self-test" in memory.read_text() def test_constitution_seed_rejects_symlinked_memory_directory( self, project_dir, temp_dir ): """Preset installation cannot seed through a symlinked memory directory.""" outside = temp_dir / "outside" outside.mkdir() try: (project_dir / ".specify" / "memory").symlink_to( outside, target_is_directory=True ) except OSError: pytest.skip("symlinks are unavailable") manager = PresetManager(project_dir) with pytest.warns(UserWarning, match="symlinked"): install_self_test_preset(manager) assert manager.registry.is_installed("self-test") assert not (outside / "constitution.md").exists() def test_constitution_seed_rejects_dangling_destination_symlink( self, project_dir, temp_dir ): """Preset installation cannot seed through a dangling destination symlink.""" memory = project_dir / ".specify" / "memory" memory.mkdir(parents=True) outside = temp_dir / "outside-constitution.md" try: (memory / "constitution.md").symlink_to(outside) except OSError: pytest.skip("symlinks are unavailable") manager = PresetManager(project_dir) with pytest.warns(UserWarning, match="symlinked"): install_self_test_preset(manager) assert manager.registry.is_installed("self-test") assert not outside.exists() def test_constitution_materialization_error_is_nonfatal( self, project_dir, temp_dir ): """An invalid wrap warns without reporting an uninstalled preset.""" preset_dir = temp_dir / "invalid-wrap" (preset_dir / "templates").mkdir(parents=True) (preset_dir / "templates" / "constitution-template.md").write_text( "# Missing core placeholder\n" ) (preset_dir / "preset.yml").write_text( yaml.dump( { "schema_version": "1.0", "preset": { "id": "invalid-wrap", "name": "Invalid Wrap", "version": "1.0.0", "description": "Invalid wrapping constitution", }, "requires": {"speckit_version": ">=0.1.0"}, "provides": { "templates": [ { "type": "template", "name": "constitution-template", "file": "templates/constitution-template.md", "strategy": "wrap", "description": "Invalid wrap", } ] }, } ) ) manager = PresetManager(project_dir) with pytest.warns(UserWarning, match="Failed to seed constitution"): manifest = manager.install_from_directory(preset_dir, "0.1.5") assert manifest.id == "invalid-wrap" assert manager.registry.is_installed("invalid-wrap") def test_extension_command_skipped_when_extension_missing(self, project_dir, temp_dir): """Test that extension command overrides are skipped if the extension isn't installed.""" claude_dir = project_dir / ".claude" / "skills" claude_dir.mkdir(parents=True) preset_dir = temp_dir / "ext-override-preset" preset_dir.mkdir() (preset_dir / "commands").mkdir() (preset_dir / "commands" / "speckit.fakeext.cmd.md").write_text( "---\ndescription: Override fakeext cmd\n---\nOverridden content" ) manifest_data = { "schema_version": "1.0", "preset": { "id": "ext-override", "name": "Ext Override", "version": "1.0.0", "description": "Test", }, "requires": {"speckit_version": ">=0.1.0"}, "provides": { "templates": [ { "type": "command", "name": "speckit.fakeext.cmd", "file": "commands/speckit.fakeext.cmd.md", "description": "Override fakeext cmd", } ] }, } with open(preset_dir / "preset.yml", "w") as f: yaml.dump(manifest_data, f) manager = PresetManager(project_dir) manager.install_from_directory(preset_dir, "0.1.5") # Extension not installed — command should NOT be registered cmd_file = claude_dir / "speckit.fakeext.cmd.md" assert not cmd_file.exists(), "Command registered for missing extension" metadata = manager.registry.get("ext-override") assert metadata["registered_commands"] == {} def test_extension_command_registered_when_extension_present(self, project_dir, temp_dir): """Test that extension command overrides ARE registered when the extension is installed.""" claude_dir = project_dir / ".claude" / "skills" claude_dir.mkdir(parents=True) (project_dir / ".specify" / "extensions" / "fakeext").mkdir(parents=True) preset_dir = temp_dir / "ext-override-preset2" preset_dir.mkdir() (preset_dir / "commands").mkdir() (preset_dir / "commands" / "speckit.fakeext.cmd.md").write_text( "---\ndescription: Override fakeext cmd\n---\nOverridden content" ) manifest_data = { "schema_version": "1.0", "preset": { "id": "ext-override2", "name": "Ext Override", "version": "1.0.0", "description": "Test", }, "requires": {"speckit_version": ">=0.1.0"}, "provides": { "templates": [ { "type": "command", "name": "speckit.fakeext.cmd", "file": "commands/speckit.fakeext.cmd.md", "description": "Override fakeext cmd", } ] }, } with open(preset_dir / "preset.yml", "w") as f: yaml.dump(manifest_data, f) manager = PresetManager(project_dir) manager.install_from_directory(preset_dir, "0.1.5") cmd_file = claude_dir / "speckit-fakeext-cmd" / "SKILL.md" assert cmd_file.exists(), "Skill not registered despite extension being present" # ===== Init Options and Skills Tests ===== class TestInitOptions: """Tests for save_init_options / load_init_options helpers.""" def test_save_and_load_round_trip(self, project_dir): from specify_cli import save_init_options, load_init_options opts = {"ai": "claude", "ai_skills": True, "here": False} save_init_options(project_dir, opts) loaded = load_init_options(project_dir) assert loaded["ai"] == "claude" assert loaded["ai_skills"] is True def test_save_and_load_available_from_init_options_module(self, project_dir): from specify_cli._init_options import load_init_options, save_init_options opts = {"ai": "codex", "ai_skills": True, "script": "sh"} save_init_options(project_dir, opts) assert load_init_options(project_dir) == opts def test_save_uses_utf8_encoding(self, project_dir, monkeypatch): from specify_cli import save_init_options original_write_text = Path.write_text seen: dict[str, str | None] = {} def spy_write_text(path, data, *args, **kwargs): if path == project_dir / ".specify" / "init-options.json": seen["encoding"] = kwargs.get("encoding") return original_write_text(path, data, *args, **kwargs) monkeypatch.setattr(Path, "write_text", spy_write_text) save_init_options(project_dir, {"label": "中文测试"}) assert seen["encoding"] == "utf-8" def test_load_uses_utf8_encoding(self, project_dir, monkeypatch): from specify_cli import load_init_options opts_file = project_dir / ".specify" / "init-options.json" opts_file.parent.mkdir(parents=True, exist_ok=True) opts_file.write_text('{"ai": "codex"}', encoding="utf-8") original_read_text = Path.read_text seen: dict[str, str | None] = {} def spy_read_text(path, *args, **kwargs): if path == opts_file: seen["encoding"] = kwargs.get("encoding") return original_read_text(path, *args, **kwargs) monkeypatch.setattr(Path, "read_text", spy_read_text) assert load_init_options(project_dir) == {"ai": "codex"} assert seen["encoding"] == "utf-8" def test_load_returns_empty_when_missing(self, project_dir): from specify_cli import load_init_options assert load_init_options(project_dir) == {} def test_load_returns_empty_on_invalid_json(self, project_dir): from specify_cli import load_init_options opts_file = project_dir / ".specify" / "init-options.json" opts_file.parent.mkdir(parents=True, exist_ok=True) opts_file.write_text("{bad json") assert load_init_options(project_dir) == {} @pytest.mark.parametrize( "value", ["名前-プロジェクト", "café-résumé", "Ωmega-Δelta", "🚀-launch"], ) def test_save_load_round_trip_preserves_non_ascii(self, project_dir, value): """Non-ASCII values round-trip via explicit UTF-8 encoding. ``Path.write_text`` / ``Path.read_text`` default to the system locale codec on Windows (cp1252 / gb2312 / cp932). Without ``encoding="utf-8"`` pinned on both ends, a project name like ``café`` written on a UTF-8 host becomes garbled or unreadable on a cp1252 host (and vice versa). Pin UTF-8 explicitly so init options round-trip across machines and CI. Note: this test only meaningfully exercises the encoding pin because ``save_init_options`` now writes JSON with ``ensure_ascii=False`` — otherwise ``json.dumps`` would output ASCII-only ``\\uXXXX`` escapes and the encoding pin would be a no-op for any value here. ``test_save_writes_real_utf8_bytes`` below asserts that contract directly. """ from specify_cli import save_init_options, load_init_options save_init_options(project_dir, {"ai": "claude", "project_name": value}) loaded = load_init_options(project_dir) assert loaded["project_name"] == value def test_save_writes_real_utf8_bytes(self, project_dir): """The on-disk file contains real UTF-8 bytes, not ``\\uXXXX`` escapes. Pinning ``encoding="utf-8"`` on ``write_text`` only makes a difference when the serialiser actually emits non-ASCII characters. With ``ensure_ascii=False`` on ``json.dumps`` the non-ASCII bytes hit the file, so the encoding pin is the thing that decides between cp1252 garbage and clean UTF-8 on Windows. This test pins that behaviour: the on-disk bytes are valid UTF-8 and contain the multi-byte encoding of ``café``, not its ``\\u00e9`` escape form. Reviewers can verify that removing ``ensure_ascii=False`` or ``encoding="utf-8"`` from the writer breaks this test, which is what Copilot's review pointed out the original round-trip test failed to do. """ from specify_cli import save_init_options save_init_options(project_dir, {"project_name": "café"}) opts_file = project_dir / ".specify" / "init-options.json" raw = opts_file.read_bytes() # 'café' in UTF-8 ends with bytes 0xC3 0xA9 ('é'). The cp1252 # encoding of 'é' is the single byte 0xE9. The JSON-escape form # would be the 6-byte literal '\\u00e9'. We assert the UTF-8 form # is present so the test pins the actual contract. assert b"caf\xc3\xa9" in raw, ( "Expected UTF-8 bytes for 'café' in the on-disk file, " f"got: {raw!r}" ) # And the whole file decodes cleanly as UTF-8. raw.decode("utf-8") def test_load_returns_empty_on_locale_corrupted_file(self, project_dir): """A file written in a non-UTF-8 codec falls back to {}, not crash. Simulates a file produced by an old client (or by a peer machine with a different default locale) that contains bytes invalid as UTF-8. ``load_init_options`` should fall back to ``{}`` per the existing contract — never propagate a raw ``UnicodeDecodeError`` to the CLI surface. """ from specify_cli import load_init_options opts_file = project_dir / ".specify" / "init-options.json" opts_file.parent.mkdir(parents=True, exist_ok=True) # 0xE9 is 'é' in cp1252 but an invalid lead byte in UTF-8. opts_file.write_bytes(b'{"project_name": "caf\xe9"}') assert load_init_options(project_dir) == {} @pytest.mark.parametrize("payload", ["[]", '"value"', "42", "true", "null"]) def test_load_returns_empty_on_non_object_json(self, project_dir, payload): from specify_cli import load_init_options opts_file = project_dir / ".specify" / "init-options.json" opts_file.parent.mkdir(parents=True, exist_ok=True) opts_file.write_text(payload, encoding="utf-8") assert load_init_options(project_dir) == {} def test_load_returns_empty_on_unicode_decode_error(self, project_dir, monkeypatch): from specify_cli import load_init_options opts_file = project_dir / ".specify" / "init-options.json" opts_file.parent.mkdir(parents=True, exist_ok=True) opts_file.write_bytes(b"{}") original_read_text = Path.read_text def raise_decode_error(path, *args, **kwargs): if path == opts_file: raise UnicodeDecodeError("utf-8", b"\xff", 0, 1, "invalid start byte") return original_read_text(path, *args, **kwargs) monkeypatch.setattr(Path, "read_text", raise_decode_error) assert load_init_options(project_dir) == {} @pytest.mark.parametrize( ("value", "expected"), [ (True, True), (False, False), ("true", False), ("false", False), (1, False), (0, False), (None, False), ], ) def test_is_ai_skills_enabled_requires_boolean_true(self, value, expected): from specify_cli._init_options import is_ai_skills_enabled assert is_ai_skills_enabled({"ai_skills": value}) is expected class TestResolveActiveAgentForRegistration: """Tests for the shared #2948 active-agent resolution helper. ``load_init_options`` collapses "no file", "corrupted file", and "valid file with no active agent" into the same ``{}``. Extensions and presets both need to tell those apart: no file means "legacy project, fall back to all detected agents"; a corrupted or malformed file means "fail closed, register nothing" so a corrupted init-options.json can't silently reintroduce all-agent registration. """ def test_missing_file_returns_sentinel(self, project_dir): from specify_cli._init_options import ( MISSING_INIT_OPTIONS_FILE, resolve_active_agent_for_registration, ) assert ( resolve_active_agent_for_registration(project_dir) is MISSING_INIT_OPTIONS_FILE ) def test_valid_active_agent_returns_string(self, project_dir): from specify_cli import save_init_options from specify_cli._init_options import resolve_active_agent_for_registration save_init_options(project_dir, {"ai": "claude"}) assert resolve_active_agent_for_registration(project_dir) == "claude" def test_corrupted_json_fails_closed(self, project_dir): """A present-but-unparseable file must not behave like "no file".""" from specify_cli._init_options import resolve_active_agent_for_registration opts_file = project_dir / ".specify" / "init-options.json" opts_file.parent.mkdir(parents=True, exist_ok=True) opts_file.write_text("{bad json", encoding="utf-8") assert resolve_active_agent_for_registration(project_dir) is None @pytest.mark.parametrize("value", [[], {}, "", 0, None, ["claude"]]) def test_malformed_ai_value_fails_closed(self, project_dir, value): """A recorded but non-string/empty ``ai`` value fails closed too.""" from specify_cli import save_init_options from specify_cli._init_options import resolve_active_agent_for_registration save_init_options(project_dir, {"ai": value}) assert resolve_active_agent_for_registration(project_dir) is None def test_dangling_symlink_fails_closed(self, project_dir): """A dangling init-options.json symlink must fail closed, not fall back to "no file" (#2948). ``Path.exists()`` follows symlinks and returns False for a broken symlink whose target is missing, so a naive presence check treats a dangling symlink the same as "no file at all" and falls back to legacy all-agent registration. The path is present (just broken), so it must be treated as a corrupted file and fail closed instead. """ from specify_cli._init_options import resolve_active_agent_for_registration opts_file = project_dir / ".specify" / "init-options.json" opts_file.parent.mkdir(parents=True, exist_ok=True) opts_file.symlink_to(project_dir / ".specify" / "does-not-exist.json") assert not opts_file.exists() # sanity: this is what makes it dangling assert opts_file.is_symlink() assert resolve_active_agent_for_registration(project_dir) is None class TestPresetSkills: """Tests for preset skill registration and unregistration. Tests that install the self-test preset use ``install_self_test_preset`` which scopes a narrow filter to the expected wrap-strategy warning. Reconciliation failures remain audible so real regressions surface. """ def _write_init_options(self, project_dir, ai="claude", ai_skills=True, script="sh"): from specify_cli import save_init_options save_init_options(project_dir, {"ai": ai, "ai_skills": ai_skills, "script": script}) def _create_skill(self, skills_dir, skill_name, body="original body"): skill_dir = skills_dir / skill_name skill_dir.mkdir(parents=True, exist_ok=True) (skill_dir / "SKILL.md").write_text( f"---\nname: {skill_name}\n---\n\n{body}\n" ) return skill_dir def _create_command_preset(self, temp_dir, preset_id, command_name, description, body): preset_dir = temp_dir / preset_id preset_dir.mkdir() (preset_dir / "commands").mkdir() command_file = f"{command_name}.md" (preset_dir / "commands" / command_file).write_text( f"---\ndescription: {description}\n---\n\n{body}\n" ) manifest_data = { "schema_version": "1.0", "preset": { "id": preset_id, "name": preset_id, "version": "1.0.0", "description": "Test", }, "requires": {"speckit_version": ">=0.1.0"}, "provides": { "templates": [ { "type": "command", "name": command_name, "file": f"commands/{command_file}", } ] }, } with open(preset_dir / "preset.yml", "w") as f: yaml.dump(manifest_data, f) return preset_dir def _create_multi_command_preset(self, temp_dir, preset_id, command_names): """Install-directory helper for a preset with more than one command. Used to prove partial-result handling: a command's own template entry can genuinely be skipped by registration (missing source file, safety-validation rejection) while sibling commands in the same preset still succeed. """ preset_dir = temp_dir / preset_id preset_dir.mkdir() (preset_dir / "commands").mkdir() templates = [] for command_name in command_names: command_file = f"{command_name}.md" (preset_dir / "commands" / command_file).write_text( f"---\ndescription: {command_name} test command\n---\n\n" f"{command_name} body\n" ) templates.append({ "type": "command", "name": command_name, "file": f"commands/{command_file}", }) manifest_data = { "schema_version": "1.0", "preset": { "id": preset_id, "name": preset_id, "version": "1.0.0", "description": "Test", }, "requires": {"speckit_version": ">=0.1.0"}, "provides": {"templates": templates}, } with open(preset_dir / "preset.yml", "w") as f: yaml.dump(manifest_data, f) return preset_dir def _create_multi_command_preset_with_aliases(self, temp_dir, preset_id, command_specs): """Install-directory helper for a preset whose commands carry aliases. ``command_specs`` is a list of ``(primary_name, [alias, ...])`` tuples. Each command gets its own source file (aliases share the same source/content as their primary — CommandRegistrar renders them from the same command file, just under a different output name (#2948)). """ preset_dir = temp_dir / preset_id preset_dir.mkdir() (preset_dir / "commands").mkdir() templates = [] for primary_name, aliases in command_specs: command_file = f"{primary_name}.md" (preset_dir / "commands" / command_file).write_text( f"---\ndescription: {primary_name} test command\n---\n\n" f"{primary_name} body\n" ) templates.append({ "type": "command", "name": primary_name, "file": f"commands/{command_file}", "aliases": list(aliases), }) manifest_data = { "schema_version": "1.0", "preset": { "id": preset_id, "name": preset_id, "version": "1.0.0", "description": "Test", }, "requires": {"speckit_version": ">=0.1.0"}, "provides": {"templates": templates}, } with open(preset_dir / "preset.yml", "w") as f: yaml.dump(manifest_data, f) return preset_dir def test_skill_overridden_on_preset_install(self, project_dir, temp_dir): """When skills mode was used, a preset command override should update the skill.""" # Simulate skills mode having been used: write init-options + create skill self._write_init_options(project_dir, ai="claude") skills_dir = project_dir / ".claude" / "skills" self._create_skill(skills_dir, "speckit-specify") # Also create the claude commands dir so commands get registered (project_dir / ".claude" / "skills").mkdir(parents=True, exist_ok=True) # Install self-test preset (has a command override for speckit.specify) manager = PresetManager(project_dir) install_self_test_preset(manager) skill_file = skills_dir / "speckit-specify" / "SKILL.md" assert skill_file.exists() content = skill_file.read_text() assert "preset:self-test" in content, "Skill should reference preset source" assert "disable-model-invocation: false" in content # Verify it was recorded in registry, keyed by the active agent metadata = manager.registry.get("self-test") assert "speckit-specify" in metadata.get("registered_skills", {}).get("claude", []) def _install_arg_hint_preset(self, project_dir, temp_dir, ai, skills_dir, description, arg_hint): """Install a preset whose command declares argument-hint; return the SKILL.md path.""" self._write_init_options(project_dir, ai=ai) self._create_skill(skills_dir, "speckit-hinttest-cmd") (project_dir / ".specify" / "extensions" / "hinttest").mkdir(parents=True, exist_ok=True) preset_dir = temp_dir / f"hint-preset-{ai}" preset_dir.mkdir() (preset_dir / "commands").mkdir() (preset_dir / "commands" / "speckit.hinttest.cmd.md").write_text( "---\n" f'description: "{description}"\n' f'argument-hint: "{arg_hint}"\n' "---\n\n" "Preset command body.\n", encoding="utf-8", ) manifest_data = { "schema_version": "1.0", "preset": { "id": f"hint-preset-{ai}", "name": "Hint Preset", "version": "1.0.0", "description": "Test", }, "requires": {"speckit_version": ">=0.1.0"}, "provides": { "templates": [ { "type": "command", "name": "speckit.hinttest.cmd", "file": "commands/speckit.hinttest.cmd.md", } ] }, } with open(preset_dir / "preset.yml", "w") as f: yaml.dump(manifest_data, f) manager = PresetManager(project_dir) manager.install_from_directory(preset_dir, "0.1.5") return skills_dir / "speckit-hinttest-cmd" / "SKILL.md" def test_argument_hint_preserved_for_preset_command(self, project_dir, temp_dir): """argument-hint from a preset command must survive into the SKILL.md. Follow-up to #2903/#2916 for the preset skill generator. The description is long enough to fold across lines when serialized, guarding against an in-place string injection that would split the folded scalar into invalid YAML. """ long_description = ( "Build and maintain a lean, static context/ knowledge folder so " "coding agents load only what is relevant and save tokens" ) arg_hint = " [area] [slug] [-- notes]" skills_dir = project_dir / ".claude" / "skills" skill_file = self._install_arg_hint_preset( project_dir, temp_dir, "claude", skills_dir, long_description, arg_hint ) assert skill_file.exists() parsed = yaml.safe_load(skill_file.read_text(encoding="utf-8").split("---", 2)[1]) assert parsed["argument-hint"] == arg_hint assert parsed["description"] == long_description def test_argument_hint_not_added_for_non_claude_preset_command(self, project_dir, temp_dir): """Non-Claude skills agents must not receive argument-hint in preset skills.""" arg_hint = " [area]" skills_dir = project_dir / ".agents" / "skills" skill_file = self._install_arg_hint_preset( project_dir, temp_dir, "codex", skills_dir, "Build context", arg_hint ) assert skill_file.exists() parsed = yaml.safe_load(skill_file.read_text(encoding="utf-8").split("---", 2)[1]) assert "argument-hint" not in parsed def test_register_skills_resolves_command_refs(self, project_dir, temp_dir): """Preset skill overrides must resolve __SPECKIT_COMMAND_*__ tokens (issue #2717). ``_register_skills()`` previously ran only ``resolve_skill_placeholders()``, so command cross-references leaked into SKILL.md as raw placeholders instead of rendering as ``/speckit-`` like the command layer. """ self._write_init_options(project_dir, ai="claude") skills_dir = project_dir / ".claude" / "skills" self._create_skill(skills_dir, "speckit-specify") preset_dir = self._create_command_preset( temp_dir, "cmdref-install", "speckit.specify", "Override specify", "Run `__SPECKIT_COMMAND_SPECIFY__` then `__SPECKIT_COMMAND_PLAN__`.\n", ) manager = PresetManager(project_dir) manager.install_from_directory(preset_dir, "0.1.5") content = (skills_dir / "speckit-specify" / "SKILL.md").read_text() assert "__SPECKIT_COMMAND_" not in content, "raw command token leaked into SKILL.md" # Claude's invoke_separator is "-", so tokens render as /speckit-. assert "/speckit-specify" in content assert "/speckit-plan" in content def test_restore_skill_resolves_command_refs(self, project_dir, temp_dir): """Skill restore on preset removal must also resolve command tokens (issue #2717).""" self._write_init_options(project_dir, ai="claude") skills_dir = project_dir / ".claude" / "skills" self._create_skill(skills_dir, "speckit-specify") core_cmds = project_dir / ".specify" / "templates" / "commands" core_cmds.mkdir(parents=True, exist_ok=True) (core_cmds / "specify.md").write_text( "---\ndescription: Core specify\n---\n\n" "Then run `__SPECKIT_COMMAND_PLAN__`.\n" ) preset_dir = self._create_command_preset( temp_dir, "cmdref-restore", "speckit.specify", "Override specify", "Override body\n", ) manager = PresetManager(project_dir) manager.install_from_directory(preset_dir, "0.1.5") manager.remove("cmdref-restore") content = (skills_dir / "speckit-specify" / "SKILL.md").read_text() assert "__SPECKIT_COMMAND_" not in content, "raw command token leaked on restore" assert "/speckit-plan" in content def test_restore_skill_preserves_dollar_command_refs(self, project_dir, temp_dir): """Dollar-style core refs remain native when a preset skill is removed.""" self._write_init_options(project_dir, ai="zcode") skills_dir = project_dir / ".zcode" / "skills" self._create_skill(skills_dir, "speckit-specify") core_cmds = project_dir / ".specify" / "templates" / "commands" core_cmds.mkdir(parents=True, exist_ok=True) raw_core = ( "---\ndescription: Core specify\n---\n\n" "Then run `__SPECKIT_COMMAND_PLAN__`.\n" ) (core_cmds / "specify.md").write_text(raw_core) preset_dir = self._create_command_preset( temp_dir, "dollar-cmdref-restore", "speckit.specify", "Override specify", "Override body\n", ) manager = PresetManager(project_dir) manager.install_from_directory(preset_dir, "0.1.5") manager.remove("dollar-cmdref-restore") content = (skills_dir / "speckit-specify" / "SKILL.md").read_text() assert "$speckit-plan" in content assert "/speckit-plan" not in content def test_reconcile_override_skill_resolves_command_refs(self, project_dir, temp_dir): """Reconcile's project-override restore must resolve command tokens (issue #2717). When a preset that overrode a command is removed and a project override becomes the winning layer, ``_reconcile_skills`` rewrites the skill from the override body — which must also render ``__SPECKIT_COMMAND_*__`` tokens. """ self._write_init_options(project_dir, ai="claude") skills_dir = project_dir / ".claude" / "skills" self._create_skill(skills_dir, "speckit-specify") # Project override wins once the preset is removed; its body carries a # command cross-reference token. No core template exists for "specify", # so the skill is restored exclusively via the reconcile override branch. overrides_dir = project_dir / ".specify" / "templates" / "overrides" overrides_dir.mkdir(parents=True, exist_ok=True) (overrides_dir / "speckit.specify.md").write_text( "---\ndescription: Override specify\n---\n\n" "Then run `__SPECKIT_COMMAND_PLAN__`.\n" ) preset_dir = self._create_command_preset( temp_dir, "cmdref-reconcile", "speckit.specify", "Preset specify", "Preset body\n", ) manager = PresetManager(project_dir) manager.install_from_directory(preset_dir, "0.1.5") manager.remove("cmdref-reconcile") content = (skills_dir / "speckit-specify" / "SKILL.md").read_text() assert "override:speckit.specify" in content, "skill should be restored from the project override" assert "__SPECKIT_COMMAND_" not in content, "raw command token leaked on reconcile" assert "/speckit-plan" in content def test_extension_restore_resolves_command_refs(self, project_dir, temp_dir): """Extension-backed skill restore must resolve command tokens (issue #2717). When a preset override is removed and the skill is restored from an extension command body, ``__SPECKIT_COMMAND_*__`` tokens in that body must render as slash-command invocations like the core-template path. """ self._write_init_options(project_dir, ai="claude") skills_dir = project_dir / ".claude" / "skills" self._create_skill(skills_dir, "speckit-fakeext-cmd", body="original extension skill") extension_dir = project_dir / ".specify" / "extensions" / "fakeext" (extension_dir / "commands").mkdir(parents=True, exist_ok=True) (extension_dir / "commands" / "cmd.md").write_text( "---\ndescription: Extension fakeext cmd\n---\n\n" "Then run `__SPECKIT_COMMAND_PLAN__`.\n" ) extension_manifest = { "schema_version": "1.0", "extension": { "id": "fakeext", "name": "Fake Extension", "version": "1.0.0", "description": "Test", }, "requires": {"speckit_version": ">=0.1.0"}, "provides": { "commands": [ { "name": "speckit.fakeext.cmd", "file": "commands/cmd.md", "description": "Fake extension command", } ] }, } with open(extension_dir / "extension.yml", "w") as f: yaml.dump(extension_manifest, f) preset_dir = self._create_command_preset( temp_dir, "cmdref-ext-restore", "speckit.fakeext.cmd", "Override fakeext cmd", "Override body\n", ) manager = PresetManager(project_dir) manager.install_from_directory(preset_dir, "0.1.5") manager.remove("cmdref-ext-restore") content = (skills_dir / "speckit-fakeext-cmd" / "SKILL.md").read_text() assert "source: extension:fakeext" in content, "skill should be restored from the extension" assert "__SPECKIT_COMMAND_" not in content, "raw command token leaked on extension restore" assert "/speckit-plan" in content def test_core_command_override_skill_uses_preset_command_description(self, project_dir, temp_dir): """Preset skill overrides for core commands should keep preset frontmatter descriptions.""" self._write_init_options(project_dir, ai="claude") skills_dir = project_dir / ".claude" / "skills" self._create_skill(skills_dir, "speckit-taskstoissues") preset_dir = temp_dir / "taskstoissues-description" preset_dir.mkdir() (preset_dir / "commands").mkdir() (preset_dir / "commands" / "speckit.repro.taskstoissues.md").write_text( "---\n" "description: COMMAND-FRONTMATTER-DESCRIPTION\n" "---\n\n" "# Repro command body\n" ) manifest_data = { "schema_version": "1.0", "preset": { "id": "taskstoissues-description", "name": "Taskstoissues Description", "version": "1.0.0", "description": "Test", }, "requires": {"speckit_version": ">=0.1.0"}, "provides": { "templates": [ { "type": "command", "name": "speckit.taskstoissues", "file": "commands/speckit.repro.taskstoissues.md", "description": "MANIFEST-DESCRIPTION", "replaces": "speckit.taskstoissues", "strategy": "replace", } ] }, } with open(preset_dir / "preset.yml", "w") as f: yaml.dump(manifest_data, f) manager = PresetManager(project_dir) manager.install_from_directory(preset_dir, "0.1.5") skill_file = skills_dir / "speckit-taskstoissues" / "SKILL.md" content = skill_file.read_text() assert "description: COMMAND-FRONTMATTER-DESCRIPTION" in content assert "Convert tasks from tasks.md into GitHub issues." not in content assert "source: preset:taskstoissues-description" in content def test_core_skill_restore_uses_core_command_description(self, project_dir, temp_dir): """Core skill restore should keep core command frontmatter descriptions.""" self._write_init_options(project_dir, ai="claude") skills_dir = project_dir / ".claude" / "skills" self._create_skill(skills_dir, "speckit-taskstoissues") core_cmds = project_dir / ".specify" / "templates" / "commands" core_cmds.mkdir(parents=True, exist_ok=True) (core_cmds / "taskstoissues.md").write_text( "---\n" "description: CORE-FRONTMATTER-DESCRIPTION\n" "---\n\n" "core taskstoissues body\n" ) preset_dir = self._create_command_preset( temp_dir, "taskstoissues-restore", "speckit.taskstoissues", "PRESET-FRONTMATTER-DESCRIPTION", "preset taskstoissues body\n", ) manager = PresetManager(project_dir) manager.install_from_directory(preset_dir, "0.1.5") manager.remove("taskstoissues-restore") skill_file = skills_dir / "speckit-taskstoissues" / "SKILL.md" content = skill_file.read_text() assert "description: CORE-FRONTMATTER-DESCRIPTION" in content assert "Convert tasks from tasks.md into GitHub issues." not in content assert "source: templates/commands/taskstoissues.md" in content assert "core taskstoissues body" in content def test_override_skill_reconcile_uses_override_command_description(self, project_dir, temp_dir): """Override skill reconciliation should keep override frontmatter descriptions.""" self._write_init_options(project_dir, ai="claude") skills_dir = project_dir / ".claude" / "skills" self._create_skill(skills_dir, "speckit-taskstoissues") overrides_dir = project_dir / ".specify" / "templates" / "overrides" overrides_dir.mkdir(parents=True) (overrides_dir / "speckit.taskstoissues.md").write_text( "---\n" "description: OVERRIDE-FRONTMATTER-DESCRIPTION\n" "---\n\n" "override taskstoissues body\n" ) preset_dir = self._create_command_preset( temp_dir, "taskstoissues-reconcile", "speckit.taskstoissues", "PRESET-FRONTMATTER-DESCRIPTION", "preset taskstoissues body\n", ) manager = PresetManager(project_dir) manager.install_from_directory(preset_dir, "0.1.5") skill_file = skills_dir / "speckit-taskstoissues" / "SKILL.md" content = skill_file.read_text() assert "description: OVERRIDE-FRONTMATTER-DESCRIPTION" in content assert "Convert tasks from tasks.md into GitHub issues." not in content assert "source: override:speckit.taskstoissues" in content assert "override taskstoissues body" in content def test_skill_not_updated_when_ai_skills_disabled(self, project_dir, temp_dir): """When skills mode was NOT used, preset install should not touch skills.""" self._write_init_options(project_dir, ai="qwen", ai_skills=False) skills_dir = project_dir / ".qwen" / "skills" self._create_skill(skills_dir, "speckit-specify", body="untouched") manager = PresetManager(project_dir) install_self_test_preset(manager) skill_file = skills_dir / "speckit-specify" / "SKILL.md" content = skill_file.read_text() assert "untouched" in content, "Skill should not be modified when ai_skills=False" def test_get_skills_dir_returns_none_for_non_string_ai(self, project_dir): """Corrupted init-options ai values should not crash preset skill resolution.""" init_options = project_dir / ".specify" / "init-options.json" init_options.parent.mkdir(parents=True, exist_ok=True) init_options.write_text('{"ai":["codex"],"ai_skills":true,"script":"sh"}') manager = PresetManager(project_dir) assert manager._get_skills_dir() is None def test_get_skills_dir_returns_none_for_non_dict_init_options(self, project_dir): """Corrupted non-dict init-options payloads should fail closed.""" init_options = project_dir / ".specify" / "init-options.json" init_options.parent.mkdir(parents=True, exist_ok=True) init_options.write_text("[]") manager = PresetManager(project_dir) assert manager._get_skills_dir() is None def test_skill_not_updated_without_init_options(self, project_dir, temp_dir): """When no init-options.json exists, preset install should not touch skills.""" skills_dir = project_dir / ".qwen" / "skills" self._create_skill(skills_dir, "speckit-specify", body="untouched") manager = PresetManager(project_dir) install_self_test_preset(manager) skill_file = skills_dir / "speckit-specify" / "SKILL.md" file_content = skill_file.read_text() assert "untouched" in file_content def test_skill_restored_on_preset_remove(self, project_dir, temp_dir): """When a preset is removed, skills should be restored from core templates.""" self._write_init_options(project_dir, ai="claude") skills_dir = project_dir / ".claude" / "skills" self._create_skill(skills_dir, "speckit-specify") (project_dir / ".claude" / "skills").mkdir(parents=True, exist_ok=True) # Set up core command template in the project so restoration works core_cmds = project_dir / ".specify" / "templates" / "commands" core_cmds.mkdir(parents=True, exist_ok=True) (core_cmds / "specify.md").write_text("---\ndescription: Core specify command\n---\n\nCore specify body\n") manager = PresetManager(project_dir) install_self_test_preset(manager) # Verify preset content is in the skill skill_file = skills_dir / "speckit-specify" / "SKILL.md" assert "preset:self-test" in skill_file.read_text() # Remove the preset manager.remove("self-test") # Skill should be restored (core specify.md template exists) assert skill_file.exists(), "Skill should still exist after preset removal" content = skill_file.read_text() assert "preset:self-test" not in content, "Preset content should be gone" assert "templates/commands/specify.md" in content, "Should reference core template" assert "disable-model-invocation: false" in content def test_skill_restored_on_remove_resolves_script_placeholders(self, project_dir): """Core restore should resolve {SCRIPT}/{ARGS} placeholders like other skill paths.""" self._write_init_options(project_dir, ai="claude", ai_skills=True, script="sh") skills_dir = project_dir / ".claude" / "skills" self._create_skill(skills_dir, "speckit-specify", body="old") (project_dir / ".claude" / "skills").mkdir(parents=True, exist_ok=True) core_cmds = project_dir / ".specify" / "templates" / "commands" core_cmds.mkdir(parents=True, exist_ok=True) (core_cmds / "specify.md").write_text( "---\n" "description: Core specify command\n" "scripts:\n" " sh: .specify/scripts/bash/create-new-feature.sh --json \"{ARGS}\"\n" "---\n\n" "Run:\n" "{SCRIPT}\n" ) manager = PresetManager(project_dir) install_self_test_preset(manager) manager.remove("self-test") content = (skills_dir / "speckit-specify" / "SKILL.md").read_text() assert "{SCRIPT}" not in content assert "{ARGS}" not in content assert ".specify/scripts/bash/create-new-feature.sh --json \"$ARGUMENTS\"" in content def test_skill_not_overridden_when_skill_path_is_file(self, project_dir): """Preset install should skip non-directory skill targets.""" self._write_init_options(project_dir, ai="qwen") skills_dir = project_dir / ".qwen" / "skills" skills_dir.mkdir(parents=True, exist_ok=True) (skills_dir / "speckit-specify").write_text("not-a-directory") manager = PresetManager(project_dir) install_self_test_preset(manager) assert (skills_dir / "speckit-specify").is_file() metadata = manager.registry.get("self-test") assert "speckit-specify" not in metadata.get("registered_skills", {}).get("qwen", []) def test_no_skills_registered_when_skills_mode_disabled(self, project_dir, temp_dir): """Skills should not be created when skills mode is disabled.""" self._write_init_options(project_dir, ai="claude", ai_skills=False) manager = PresetManager(project_dir) install_self_test_preset(manager) metadata = manager.registry.get("self-test") assert metadata.get("registered_skills", {}) == {} def test_extension_skill_override_matches_hyphenated_multisegment_name(self, project_dir, temp_dir): """Preset overrides for speckit.. should target speckit-- skills.""" self._write_init_options(project_dir, ai="codex") skills_dir = project_dir / ".agents" / "skills" self._create_skill(skills_dir, "speckit-fakeext-cmd", body="untouched") (project_dir / ".specify" / "extensions" / "fakeext").mkdir(parents=True, exist_ok=True) preset_dir = temp_dir / "ext-skill-override" preset_dir.mkdir() (preset_dir / "commands").mkdir() (preset_dir / "commands" / "speckit.fakeext.cmd.md").write_text( "---\ndescription: Override fakeext cmd\n---\n\npreset:ext-skill-override\n" ) manifest_data = { "schema_version": "1.0", "preset": { "id": "ext-skill-override", "name": "Ext Skill Override", "version": "1.0.0", "description": "Test", }, "requires": {"speckit_version": ">=0.1.0"}, "provides": { "templates": [ { "type": "command", "name": "speckit.fakeext.cmd", "file": "commands/speckit.fakeext.cmd.md", } ] }, } with open(preset_dir / "preset.yml", "w") as f: yaml.dump(manifest_data, f) manager = PresetManager(project_dir) manager.install_from_directory(preset_dir, "0.1.5") skill_file = skills_dir / "speckit-fakeext-cmd" / "SKILL.md" assert skill_file.exists() content = skill_file.read_text() assert "preset:ext-skill-override" in content assert "name: speckit-fakeext-cmd" in content assert "# Speckit Fakeext Cmd Skill" in content metadata = manager.registry.get("ext-skill-override") assert "speckit-fakeext-cmd" in metadata.get("registered_skills", {}).get("codex", []) def test_extension_skill_restored_on_preset_remove(self, project_dir, temp_dir): """Preset removal should restore an extension-backed skill instead of deleting it.""" self._write_init_options(project_dir, ai="codex") skills_dir = project_dir / ".agents" / "skills" self._create_skill(skills_dir, "speckit-fakeext-cmd", body="original extension skill") extension_dir = project_dir / ".specify" / "extensions" / "fakeext" (extension_dir / "commands").mkdir(parents=True, exist_ok=True) (extension_dir / "agents" / "control").mkdir(parents=True, exist_ok=True) (extension_dir / "agents" / "control" / "commander.md").write_text("# Commander\n") (extension_dir / "commands" / "cmd.md").write_text( "---\n" "description: Extension fakeext cmd\n" "scripts:\n" " sh: ../../scripts/bash/setup-plan.sh --json \"{ARGS}\"\n" "---\n\n" "extension:fakeext\n" "Run {SCRIPT}\n" "Read agents/control/commander.md for context.\n" ) extension_manifest = { "schema_version": "1.0", "extension": { "id": "fakeext", "name": "Fake Extension", "version": "1.0.0", "description": "Test", }, "requires": {"speckit_version": ">=0.1.0"}, "provides": { "commands": [ { "name": "speckit.fakeext.cmd", "file": "commands/cmd.md", "description": "Fake extension command", } ] }, } with open(extension_dir / "extension.yml", "w") as f: yaml.dump(extension_manifest, f) preset_dir = temp_dir / "ext-skill-restore" preset_dir.mkdir() (preset_dir / "commands").mkdir() (preset_dir / "commands" / "speckit.fakeext.cmd.md").write_text( "---\ndescription: Override fakeext cmd\n---\n\npreset:ext-skill-restore\n" ) preset_manifest = { "schema_version": "1.0", "preset": { "id": "ext-skill-restore", "name": "Ext Skill Restore", "version": "1.0.0", "description": "Test", }, "requires": {"speckit_version": ">=0.1.0"}, "provides": { "templates": [ { "type": "command", "name": "speckit.fakeext.cmd", "file": "commands/speckit.fakeext.cmd.md", } ] }, } with open(preset_dir / "preset.yml", "w") as f: yaml.dump(preset_manifest, f) manager = PresetManager(project_dir) manager.install_from_directory(preset_dir, "0.1.5") skill_file = skills_dir / "speckit-fakeext-cmd" / "SKILL.md" assert "preset:ext-skill-restore" in skill_file.read_text() manager.remove("ext-skill-restore") assert skill_file.exists() content = skill_file.read_text() assert "preset:ext-skill-restore" not in content assert "source: extension:fakeext" in content assert "extension:fakeext" in content assert '.specify/scripts/bash/setup-plan.sh --json "$ARGUMENTS"' in content # Extension-relative subdir references must resolve to their # installed location on restore too (#2101), not just on first # registration. assert ".specify/extensions/fakeext/agents/control/commander.md" in content assert "Read agents/control" not in content assert "# Fakeext Cmd Skill" in content def test_skill_composed_over_extension_base_rewrites_subdir_paths( self, project_dir, temp_dir ): """When a preset composes (append) over an extension-provided base command, the resulting skill (read from the .composed output) must still resolve the extension's own subdir references (#2101), not just when the extension wins outright (replace).""" self._write_init_options(project_dir, ai="codex") skills_dir = project_dir / ".agents" / "skills" self._create_skill(skills_dir, "speckit-fakeext-cmd", body="original extension skill") extension_dir = project_dir / ".specify" / "extensions" / "fakeext" (extension_dir / "commands").mkdir(parents=True, exist_ok=True) (extension_dir / "agents" / "control").mkdir(parents=True, exist_ok=True) (extension_dir / "agents" / "control" / "commander.md").write_text("# Commander\n") (extension_dir / "commands" / "cmd.md").write_text( "---\ndescription: Extension fakeext cmd\n---\n\n" "Read agents/control/commander.md for context.\n" ) extension_manifest = { "schema_version": "1.0", "extension": { "id": "fakeext", "name": "Fake Extension", "version": "1.0.0", "description": "Test", }, "requires": {"speckit_version": ">=0.1.0"}, "provides": { "commands": [ { "name": "speckit.fakeext.cmd", "file": "commands/cmd.md", "description": "Fake extension command", } ] }, } with open(extension_dir / "extension.yml", "w") as f: yaml.dump(extension_manifest, f) preset_dir = temp_dir / "ext-base-append-skill" preset_dir.mkdir() (preset_dir / "commands").mkdir() (preset_dir / "commands" / "speckit.fakeext.cmd.md").write_text( "---\ndescription: Preset overlay\n---\n\n## Extra\n" ) preset_manifest = { "schema_version": "1.0", "preset": { "id": "ext-base-append-skill", "name": "Ext Base Append Skill", "version": "1.0.0", "description": "Test", }, "requires": {"speckit_version": ">=0.1.0"}, "provides": { "templates": [ { "type": "command", "name": "speckit.fakeext.cmd", "file": "commands/speckit.fakeext.cmd.md", "strategy": "append", } ] }, } with open(preset_dir / "preset.yml", "w") as f: yaml.dump(preset_manifest, f) manager = PresetManager(project_dir) manager.install_from_directory(preset_dir, "0.1.5") skill_file = skills_dir / "speckit-fakeext-cmd" / "SKILL.md" content = skill_file.read_text() assert ".specify/extensions/fakeext/agents/control/commander.md" in content assert "Read agents/control" not in content assert "## Extra" in content def test_preset_remove_skips_skill_dir_without_skill_file(self, project_dir, temp_dir): """Preset removal should not delete arbitrary directories missing SKILL.md.""" self._write_init_options(project_dir, ai="codex") skills_dir = project_dir / ".agents" / "skills" stray_skill_dir = skills_dir / "speckit-fakeext-cmd" stray_skill_dir.mkdir(parents=True, exist_ok=True) note_file = stray_skill_dir / "notes.txt" note_file.write_text("user content", encoding="utf-8") preset_dir = temp_dir / "ext-skill-missing-file" preset_dir.mkdir() (preset_dir / "commands").mkdir() (preset_dir / "commands" / "speckit.fakeext.cmd.md").write_text( "---\ndescription: Override fakeext cmd\n---\n\npreset:ext-skill-missing-file\n" ) preset_manifest = { "schema_version": "1.0", "preset": { "id": "ext-skill-missing-file", "name": "Ext Skill Missing File", "version": "1.0.0", "description": "Test", }, "requires": {"speckit_version": ">=0.1.0"}, "provides": { "templates": [ { "type": "command", "name": "speckit.fakeext.cmd", "file": "commands/speckit.fakeext.cmd.md", } ] }, } with open(preset_dir / "preset.yml", "w") as f: yaml.dump(preset_manifest, f) manager = PresetManager(project_dir) installed_preset_dir = manager.presets_dir / "ext-skill-missing-file" shutil.copytree(preset_dir, installed_preset_dir) manager.registry.add( "ext-skill-missing-file", { "version": "1.0.0", "source": str(preset_dir), "provides_templates": ["speckit.fakeext.cmd"], "registered_skills": ["speckit-fakeext-cmd"], "priority": 10, }, ) manager.remove("ext-skill-missing-file") assert stray_skill_dir.is_dir() assert note_file.read_text(encoding="utf-8") == "user content" def test_kimi_legacy_dotted_skill_override_still_applies(self, project_dir, temp_dir): """Preset overrides should still target legacy dotted-named skill dirs. This exercises legacy *naming* (``speckit.specify``) under the current ``.kimi-code/`` base — distinct from the legacy ``.kimi/`` *location*. """ self._write_init_options(project_dir, ai="kimi") skills_dir = project_dir / ".kimi-code" / "skills" self._create_skill(skills_dir, "speckit.specify", body="untouched") (project_dir / ".kimi-code" / "commands").mkdir(parents=True, exist_ok=True) manager = PresetManager(project_dir) install_self_test_preset(manager) skill_file = skills_dir / "speckit.specify" / "SKILL.md" assert skill_file.exists() content = skill_file.read_text() assert "preset:self-test" in content assert "name: speckit.specify" in content metadata = manager.registry.get("self-test") assert "speckit.specify" in metadata.get("registered_skills", {}).get("kimi", []) def test_kimi_legacy_dotted_skill_reconciles_priority_winner( self, project_dir, temp_dir ): """Reconciliation must carry forward recorded legacy skill names.""" self._write_init_options(project_dir, ai="kimi") skills_dir = project_dir / ".kimi-code" / "skills" self._create_skill(skills_dir, "speckit.specify", body="untouched") (project_dir / ".kimi-code" / "commands").mkdir( parents=True, exist_ok=True ) higher_dir = self._create_command_preset( temp_dir, "higher-kimi-preset", "speckit.specify", "Higher preset", "Higher body", ) lower_dir = self._create_command_preset( temp_dir, "lower-kimi-preset", "speckit.specify", "Lower preset", "Lower body", ) manager = PresetManager(project_dir) manager.install_from_directory(higher_dir, "0.1.5", priority=10) manager.install_from_directory(lower_dir, "0.1.5", priority=20) skill_file = skills_dir / "speckit.specify" / "SKILL.md" for preset_id in ("higher-kimi-preset", "lower-kimi-preset"): manager.registry.update( preset_id, {"registered_skills": {"kimi": ["speckit.specify"]}}, ) skill_file.write_text( "---\nname: speckit.specify\n---\n\nLower body\n", encoding="utf-8", ) manager._reconcile_skills(["speckit.specify"]) assert "Higher body" in skill_file.read_text(encoding="utf-8"), ( "reconciliation must replace lower-priority raw content in a " "recorded legacy dotted skill directory" ) def test_kimi_legacy_dotted_skill_receives_project_override( self, project_dir, temp_dir ): """Project overrides must update the recorded legacy path in place.""" self._write_init_options(project_dir, ai="kimi") skills_dir = project_dir / ".kimi-code" / "skills" self._create_skill(skills_dir, "speckit.specify", body="untouched") (project_dir / ".kimi-code" / "commands").mkdir( parents=True, exist_ok=True ) preset_dir = self._create_command_preset( temp_dir, "kimi-override-preset", "speckit.specify", "Preset", "Preset body", ) manager = PresetManager(project_dir) manager.install_from_directory(preset_dir, "0.1.5") manager.registry.update( "kimi-override-preset", {"registered_skills": {"kimi": ["speckit.specify"]}}, ) modern_skill_dir = skills_dir / "speckit-specify" if modern_skill_dir.exists(): shutil.rmtree(modern_skill_dir) overrides_dir = ( project_dir / ".specify" / "templates" / "overrides" ) overrides_dir.mkdir(parents=True) (overrides_dir / "speckit.specify.md").write_text( "---\ndescription: Project override\n---\n\nOverride body\n", encoding="utf-8", ) manager._reconcile_skills(["speckit.specify"]) legacy_file = skills_dir / "speckit.specify" / "SKILL.md" assert "Override body" in legacy_file.read_text(encoding="utf-8") assert not modern_skill_dir.exists(), ( "legacy-only ownership must not create an untracked modern path" ) def test_kimi_skill_updated_even_when_ai_skills_disabled(self, project_dir, temp_dir): """Kimi presets should still propagate command overrides to existing skills.""" self._write_init_options(project_dir, ai="kimi", ai_skills=False) skills_dir = project_dir / ".kimi-code" / "skills" self._create_skill(skills_dir, "speckit-specify", body="untouched") (project_dir / ".kimi-code" / "commands").mkdir(parents=True, exist_ok=True) manager = PresetManager(project_dir) install_self_test_preset(manager) skill_file = skills_dir / "speckit-specify" / "SKILL.md" assert skill_file.exists() content = skill_file.read_text() assert "preset:self-test" in content assert "name: speckit-specify" in content metadata = manager.registry.get("self-test") assert "speckit-specify" in metadata.get("registered_skills", {}).get("kimi", []) def test_kimi_new_skill_created_even_when_ai_skills_disabled(self, project_dir, temp_dir): """Kimi native skills should still receive brand-new preset commands.""" self._write_init_options(project_dir, ai="kimi", ai_skills=False) skills_dir = project_dir / ".kimi-code" / "skills" skills_dir.mkdir(parents=True, exist_ok=True) preset_dir = temp_dir / "kimi-new-skill" preset_dir.mkdir() (preset_dir / "commands").mkdir() (preset_dir / "commands" / "speckit.research.md").write_text( "---\n" "description: Kimi research workflow\n" "---\n\n" "preset:kimi-new-skill\n" ) manifest_data = { "schema_version": "1.0", "preset": { "id": "kimi-new-skill", "name": "Kimi New Skill", "version": "1.0.0", "description": "Test", }, "requires": {"speckit_version": ">=0.1.0"}, "provides": { "templates": [ { "type": "command", "name": "speckit.research", "file": "commands/speckit.research.md", } ] }, } with open(preset_dir / "preset.yml", "w") as f: yaml.dump(manifest_data, f) manager = PresetManager(project_dir) manager.install_from_directory(preset_dir, "0.1.5") skill_file = skills_dir / "speckit-research" / "SKILL.md" assert skill_file.exists() content = skill_file.read_text() assert "preset:kimi-new-skill" in content assert "name: speckit-research" in content metadata = manager.registry.get("kimi-new-skill") assert "speckit-research" in metadata.get("registered_skills", {}).get("kimi", []) def test_kimi_preset_skill_override_resolves_script_placeholders(self, project_dir, temp_dir): """Kimi preset skill overrides should resolve placeholders and rewrite project paths.""" self._write_init_options(project_dir, ai="kimi", ai_skills=False, script="sh") skills_dir = project_dir / ".kimi-code" / "skills" self._create_skill(skills_dir, "speckit-specify", body="untouched") (project_dir / ".kimi-code" / "commands").mkdir(parents=True, exist_ok=True) preset_dir = temp_dir / "kimi-placeholder-override" preset_dir.mkdir() (preset_dir / "commands").mkdir() (preset_dir / "commands" / "speckit.specify.md").write_text( "---\n" "description: Kimi placeholder override\n" "scripts:\n" " sh: scripts/bash/create-new-feature.sh --json \"{ARGS}\"\n" "---\n\n" "Execute `{SCRIPT}` for __AGENT__\n" "Review templates/checklist.md and memory/constitution.md\n" ) manifest_data = { "schema_version": "1.0", "preset": { "id": "kimi-placeholder-override", "name": "Kimi Placeholder Override", "version": "1.0.0", "description": "Test", }, "requires": {"speckit_version": ">=0.1.0"}, "provides": { "templates": [ { "type": "command", "name": "speckit.specify", "file": "commands/speckit.specify.md", } ] }, } with open(preset_dir / "preset.yml", "w") as f: yaml.dump(manifest_data, f) manager = PresetManager(project_dir) manager.install_from_directory(preset_dir, "0.1.5") content = (skills_dir / "speckit-specify" / "SKILL.md").read_text() assert "{SCRIPT}" not in content assert "__AGENT__" not in content assert ".specify/scripts/bash/create-new-feature.sh --json \"$ARGUMENTS\"" in content assert ".specify/templates/checklist.md" in content assert ".specify/memory/constitution.md" in content assert "for kimi" in content def test_agy_skill_restored_on_preset_remove(self, project_dir, temp_dir): """Agy preset removal should restore native skills instead of deleting them.""" self._write_init_options(project_dir, ai="agy", ai_skills=True) skills_dir = project_dir / ".agents" / "skills" self._create_skill(skills_dir, "speckit-specify", body="before override") core_command = project_dir / ".specify" / "templates" / "commands" / "specify.md" core_command.write_text( "---\n" "description: Restored core specify workflow\n" "---\n\n" "restored core body\n" ) preset_dir = temp_dir / "agy-override" preset_dir.mkdir() (preset_dir / "commands").mkdir() (preset_dir / "commands" / "speckit.specify.md").write_text( "---\n" "description: Agy override\n" "---\n\n" "preset agy body\n" ) manifest_data = { "schema_version": "1.0", "preset": { "id": "agy-override", "name": "Agy Override", "version": "1.0.0", "description": "Test", }, "requires": {"speckit_version": ">=0.1.0"}, "provides": { "templates": [ { "type": "command", "name": "speckit.specify", "file": "commands/speckit.specify.md", } ] }, } with open(preset_dir / "preset.yml", "w") as f: yaml.dump(manifest_data, f) manager = PresetManager(project_dir) manager.install_from_directory(preset_dir, "0.1.5") skill_file = skills_dir / "speckit-specify" / "SKILL.md" assert "preset agy body" in skill_file.read_text() assert manager.remove("agy-override") is True assert skill_file.exists() restored = skill_file.read_text() assert "restored core body" in restored assert "name: speckit-specify" in restored def test_preset_skill_registration_handles_non_dict_init_options(self, project_dir, temp_dir): """Non-dict init-options payloads should not crash preset install/remove flows.""" init_options = project_dir / ".specify" / "init-options.json" init_options.parent.mkdir(parents=True, exist_ok=True) init_options.write_text("[]") skills_dir = project_dir / ".qwen" / "skills" self._create_skill(skills_dir, "speckit-specify", body="untouched") manager = PresetManager(project_dir) install_self_test_preset(manager) skill_content = (skills_dir / "speckit-specify" / "SKILL.md").read_text() assert "untouched" in skill_content def test_preset_add_corrupted_init_options_fails_closed(self, project_dir, temp_dir): """Corrupted (but present) init-options.json must not back-fill every detected agent for preset command registration. Before the shared ``resolve_active_agent_for_registration`` fix, ``load_init_options`` returning ``{}`` for a corrupted file was indistinguishable from "no file at all", so ``_register_commands`` treated it like a legacy pre-init-options project and registered the preset's command override for every detected agent (#2948). """ init_options = project_dir / ".specify" / "init-options.json" init_options.parent.mkdir(parents=True, exist_ok=True) init_options.write_text("{not valid json", encoding="utf-8") gemini_dir = project_dir / ".gemini" / "commands" gemini_dir.mkdir(parents=True) preset_dir = self._create_command_preset( temp_dir, "corrupt-init-preset", "speckit.specify", "Corrupt init test", "preset body", ) manager = PresetManager(project_dir) manager.install_from_directory(preset_dir, "0.1.5") metadata = manager.registry.get("corrupt-init-preset") assert metadata.get("registered_commands") == {}, ( "a corrupted init-options.json must fail closed, not " "back-fill every detected agent (#2948)" ) assert not list(gemini_dir.glob("*specify*")), ( "no command file should be written for any agent when " "init-options.json is corrupted" ) def test_reconciliation_restricted_to_active_agent(self, project_dir, temp_dir): """Reconciliation after install/remove must also respect the single-active rule, not just the initial registration. ``_reconcile_composed_commands`` (invoked after ``install_from_directory``/``remove``) resolves composition winners via ``register_commands_for_non_skill_agents``, a separate code path from ``_register_commands``'s initial registration. Before the fix it ignored the active-agent restriction entirely and wrote the winning content for every detected non-skill agent, leaving untracked orphaned artifacts in inactive integrations (#2948). """ self._write_init_options(project_dir, ai="claude", ai_skills=True) gemini_dir = project_dir / ".gemini" / "commands" gemini_dir.mkdir(parents=True) # A non-replace (append) strategy command forces reconciliation to # run register_commands_for_non_skill_agents for every non-skill # agent directory it detects. preset_dir = temp_dir / "reconcile-active-only" preset_dir.mkdir() (preset_dir / "commands").mkdir() (preset_dir / "commands" / "speckit.specify.md").write_text( "---\ndescription: Appended\nstrategy: append\n---\n\nAppended body\n", encoding="utf-8", ) manifest_data = { "schema_version": "1.0", "preset": { "id": "reconcile-active-only", "name": "Reconcile Active Only", "version": "1.0.0", "description": "Test", }, "requires": {"speckit_version": ">=0.1.0"}, "provides": { "templates": [{ "type": "command", "name": "speckit.specify", "file": "commands/speckit.specify.md", "strategy": "append", }] }, } with open(preset_dir / "preset.yml", "w") as f: yaml.dump(manifest_data, f) manager = PresetManager(project_dir) manager.install_from_directory(preset_dir, "0.1.5") assert not list(gemini_dir.glob("*specify*")), ( "reconciliation must not write command files for a detected " "but inactive non-skill agent (#2948)" ) def test_use_rescaffold_reconciles_project_override(self, project_dir, temp_dir): """``integration use``/``switch`` rescaffolding must reconcile the full priority stack, not just write each preset's own content. Project overrides are the highest-priority layer, above every preset. ``register_enabled_presets_for_agent`` (invoked by ``integration use``/``switch``) calls ``_register_commands`` for each enabled preset directly, the same as ``install_from_directory`` — but unlike install/remove, it never followed up with ``_reconcile_composed_commands``. Before the fix, rescaffolding a newly activated agent could leave the preset's raw content in place instead of resolving the real winner (the project override) from the full stack (#2948). """ self._write_init_options(project_dir, ai="claude", ai_skills=True) overrides_dir = project_dir / ".specify" / "templates" / "overrides" overrides_dir.mkdir(parents=True, exist_ok=True) (overrides_dir / "speckit.specify.md").write_text( "---\ndescription: Override specify\n---\n\nOverride body\n", encoding="utf-8", ) gemini_dir = project_dir / ".gemini" / "commands" gemini_dir.mkdir(parents=True) preset_dir = self._create_command_preset( temp_dir, "use-reconcile-preset", "speckit.specify", "Preset specify", "Preset body", ) manager = PresetManager(project_dir) manager.install_from_directory(preset_dir, "0.1.5") # Simulate `integration use gemini`: switch the active agent and # rescaffold enabled presets for it, mirroring what the CLI does. self._write_init_options(project_dir, ai="gemini", ai_skills=False) manager.register_enabled_presets_for_agent("gemini") cmd_file = gemini_dir / "speckit.specify.toml" assert cmd_file.exists(), "sanity: gemini should get a command file at all" content = cmd_file.read_text() assert "Override body" in content, ( "the project override must still win after rescaffold " "reconciliation, not the preset's raw content (#2948)" ) assert "Preset body" not in content def test_hermes_rescaffold_reconciles_global_skill_output( self, project_dir, temp_dir, monkeypatch ): home = temp_dir / "home" home.mkdir() monkeypatch.setattr(Path, "home", lambda: home) (home / ".hermes" / "skills").mkdir(parents=True) self._write_init_options(project_dir, ai="hermes", ai_skills=True) (project_dir / ".hermes" / "skills").mkdir(parents=True) overrides_dir = project_dir / ".specify" / "templates" / "overrides" overrides_dir.mkdir(parents=True, exist_ok=True) (overrides_dir / "speckit.specify.md").write_text( "---\ndescription: Override specify\n---\n\nOverride body\n", encoding="utf-8", ) preset_dir = self._create_command_preset( temp_dir, "hermes-reconcile-preset", "speckit.specify", "Preset specify", "Preset body", ) manager = PresetManager(project_dir) manager.install_from_directory(preset_dir, "0.1.5") manager.register_enabled_presets_for_agent("hermes") skill_file = ( home / ".hermes" / "skills" / "speckit-specify" / "SKILL.md" ) assert skill_file.exists() content = skill_file.read_text(encoding="utf-8") assert "Override body" in content assert "Preset body" not in content assert not list( (project_dir / ".hermes" / "skills").glob("speckit-*/SKILL.md") ) (overrides_dir / "speckit.specify.md").unlink() assert manager.remove("hermes-reconcile-preset") is True if skill_file.exists(): restored = skill_file.read_text(encoding="utf-8") assert "Override body" not in restored assert "Preset body" not in restored def test_rescaffold_persists_commands_before_fallible_skills_phase( self, project_dir, temp_dir ): """A failure in the skills phase must not lose track of command files the commands phase already wrote to disk. ``register_enabled_presets_for_agent`` computes both ``registered_commands`` and ``registered_skills`` and persists them together in a single ``registry.update()`` call after both phases run. If ``_register_skills`` raises, the whole per-preset ``try`` block is caught and ``registry.update()`` is never reached — even though ``_register_commands`` already wrote a real command file to disk. That file becomes untracked and preset removal can no longer clean it up. ``install_from_directory`` avoids this by persisting ``registered_commands`` immediately after the commands phase, before starting the independently fallible skills phase; rescaffold must do the same (#2948). """ self._write_init_options(project_dir, ai="claude", ai_skills=True) preset_dir = self._create_command_preset( temp_dir, "rescaffold-persist-preset", "speckit.specify", "Rescaffold persist test", "preset body", ) manager = PresetManager(project_dir) manager.install_from_directory(preset_dir, "0.1.5") # Switch to gemini (a plain command-file agent, so _register_commands # writes a real file) and make the *skills* phase blow up. self._write_init_options(project_dir, ai="gemini", ai_skills=False) gemini_commands_dir = project_dir / ".gemini" / "commands" gemini_commands_dir.mkdir(parents=True) from unittest.mock import patch with patch.object( PresetManager, "_register_skills", side_effect=RuntimeError("simulated skills failure"), ): manager.register_enabled_presets_for_agent("gemini") cmd_file = gemini_commands_dir / "speckit.specify.toml" assert cmd_file.exists(), ( "sanity: the commands phase must have written the file before " "the skills phase raised" ) metadata = manager.registry.get("rescaffold-persist-preset") assert metadata["registered_commands"].get("gemini"), ( "registered_commands must be persisted immediately after the " "commands phase, not only after the (fallible) skills phase " "also succeeds — otherwise the file written above is untracked " "and preset removal can't clean it up (#2948)" ) def test_rescaffold_reconciles_override_even_when_skills_phase_fails( self, project_dir, temp_dir ): """A project override must still win after rescaffold even if the independently-fallible skills phase raises for that preset. ``register_enabled_presets_for_agent`` only records a preset's command names into ``affected_cmd_names`` — the set later passed to ``_reconcile_composed_commands``/``_reconcile_skills`` — in the ``for tmpl in manifest.templates`` loop that runs *after* ``_register_skills`` inside the per-preset ``try`` block. If ``_register_skills`` raises, the per-preset ``except`` catches it and ``continue``s before that loop ever runs, so this preset's command names never make it into ``affected_cmd_names`` even though ``_register_commands`` already wrote its raw content to disk. The final reconciliation call is skipped for this preset entirely, leaving the raw preset content in place instead of the project override that should win (#2948). """ self._write_init_options(project_dir, ai="claude", ai_skills=True) overrides_dir = project_dir / ".specify" / "templates" / "overrides" overrides_dir.mkdir(parents=True, exist_ok=True) (overrides_dir / "speckit.specify.md").write_text( "---\ndescription: Override specify\n---\n\nOverride body\n", encoding="utf-8", ) gemini_dir = project_dir / ".gemini" / "commands" gemini_dir.mkdir(parents=True) preset_dir = self._create_command_preset( temp_dir, "reconcile-despite-skills-failure", "speckit.specify", "Preset specify", "Preset body", ) manager = PresetManager(project_dir) manager.install_from_directory(preset_dir, "0.1.5") # Simulate `integration use gemini` with the skills phase failing # for this preset (e.g. a symlink/permission error unrelated to the # commands phase, which already succeeded). self._write_init_options(project_dir, ai="gemini", ai_skills=False) from unittest.mock import patch with patch.object( PresetManager, "_register_skills", side_effect=RuntimeError("simulated skills failure"), ): manager.register_enabled_presets_for_agent("gemini") cmd_file = gemini_dir / "speckit.specify.toml" assert cmd_file.exists(), "sanity: gemini should get a command file at all" content = cmd_file.read_text() assert "Override body" in content, ( "the project override must still win after rescaffold, even " "though this preset's skills phase raised — a fallible skills " "phase must not skip reconciliation for command writes that " "already succeeded (#2948)" ) assert "Preset body" not in content def test_rescaffold_reconciles_partial_command_write_after_failure( self, project_dir, temp_dir, monkeypatch ): """A command written before _register_commands raises must still be included in final priority-stack reconciliation.""" self._write_init_options(project_dir, ai="claude", ai_skills=True) overrides_dir = project_dir / ".specify" / "templates" / "overrides" overrides_dir.mkdir(parents=True, exist_ok=True) (overrides_dir / "speckit.specify.md").write_text( "---\ndescription: Override specify\n---\n\nOverride body\n", encoding="utf-8", ) preset_dir = self._create_command_preset( temp_dir, "partial-command-failure-preset", "speckit.specify", "Preset specify", "Preset body", ) manager = PresetManager(project_dir) manager.install_from_directory(preset_dir, "0.1.5") self._write_init_options(project_dir, ai="gemini", ai_skills=False) gemini_dir = project_dir / ".gemini" / "commands" gemini_dir.mkdir(parents=True) cmd_file = gemini_dir / "speckit.specify.toml" def partial_register(manifest, pack_dir): cmd_file.write_text("Partially written preset body\n", encoding="utf-8") raise RuntimeError("simulated partial command failure") monkeypatch.setattr(manager, "_register_commands", partial_register) manager.register_enabled_presets_for_agent("gemini") content = cmd_file.read_text(encoding="utf-8") assert "Override body" in content assert "Partially written preset body" not in content def test_copilot_skills_mode_skips_command_registration(self, project_dir, temp_dir): """``integration use copilot`` with skills mode enabled must only write the SKILL.md mirror, not also copilot's static command file. Copilot is command-backed (``extension: ".agent.md"``), but when ``ai_skills`` is enabled its preset overrides are meant to render exclusively as skills via ``_register_skills``. Before the fix, ``_register_commands`` had no ``ai_skills`` guard (unlike the extensions path), so both a stale ``.agent.md`` command file and the ``SKILL.md`` mirror were written for the same override (#2948). """ self._write_init_options(project_dir, ai="copilot", ai_skills=True) copilot_commands_dir = project_dir / ".github" / "agents" copilot_commands_dir.mkdir(parents=True) skills_dir = project_dir / ".github" / "skills" self._create_skill(skills_dir, "speckit-specify") preset_dir = self._create_command_preset( temp_dir, "copilot-skills-preset", "speckit.specify", "Copilot skills test", "preset body", ) manager = PresetManager(project_dir) manager.install_from_directory(preset_dir, "0.1.5") assert not list(copilot_commands_dir.glob("*specify*")), ( "command-mode and skills-mode artifacts are mutually exclusive: " "no .agent.md command file should be written when copilot is " "running in skills mode (#2948)" ) skill_file = skills_dir / "speckit-specify" / "SKILL.md" assert "preset:copilot-skills-preset" in skill_file.read_text() def test_rescaffold_toggle_command_to_skills_removes_stale_command_file( self, project_dir, temp_dir ): """Toggling the *same* agent from command mode to skills mode must remove the stale command-mode artifact, not just add the new one. Copilot stays the active agent throughout (``integration upgrade copilot`` after flipping ``ai_skills``, not a switch to a different agent). Before the fix, ``_register_commands``'s ``ai_skills`` guard made rescaffold a no-op for the commands phase once skills mode was on, leaving the previously written ``.agent.md`` file and its ``registered_commands`` entry behind even though ``_register_skills`` went on to also write the ``SKILL.md`` mirror — violating the command/skill mutual-exclusion invariant this PR otherwise enforces (#2948). """ self._write_init_options(project_dir, ai="copilot", ai_skills=False) copilot_commands_dir = project_dir / ".github" / "agents" copilot_commands_dir.mkdir(parents=True) preset_dir = self._create_command_preset( temp_dir, "toggle-cmd-to-skill-preset", "speckit.specify", "Toggle test", "preset body", ) manager = PresetManager(project_dir) manager.install_from_directory(preset_dir, "0.1.5") cmd_file = copilot_commands_dir / "speckit.specify.agent.md" assert cmd_file.exists(), ( "sanity: command mode should have written copilot's command file" ) metadata = manager.registry.get("toggle-cmd-to-skill-preset") assert metadata["registered_commands"].get("copilot"), ( "sanity: the command-mode write should be tracked for copilot" ) # Flip ai_skills on for the *same* active agent and rescaffold, as # `integration upgrade copilot` would after the mode toggle. self._write_init_options(project_dir, ai="copilot", ai_skills=True) manager.register_enabled_presets_for_agent("copilot") assert not cmd_file.exists(), ( "the stale command-mode file must be removed once copilot has " "toggled to skills mode for the same agent (#2948)" ) metadata = manager.registry.get("toggle-cmd-to-skill-preset") assert not metadata["registered_commands"].get("copilot"), ( "registered_commands must stop tracking copilot once its " "artifact has been unregistered, or removal will try to clean " "up a file that no longer exists (#2948)" ) skill_file = project_dir / ".github" / "skills" / "speckit-specify" / "SKILL.md" assert "preset:toggle-cmd-to-skill-preset" in skill_file.read_text(), ( "sanity: the new skills-mode artifact should still be written" ) def test_rescaffold_skips_extension_commands_when_extension_not_installed( self, project_dir, temp_dir ): """Rescaffold must not materialize extension-scoped commands (``speckit..``) when the extension isn't installed. ``_register_commands`` refuses them, but the rescaffold seeded its final reconciliation pass with every command template name unfiltered, so ``_reconcile_composed_commands`` wrote the command file anyway — an artifact no registry entry tracks (review 3623357358). """ self._write_init_options(project_dir, ai="copilot", ai_skills=False) commands_dir = project_dir / ".github" / "agents" commands_dir.mkdir(parents=True) preset_dir = self._create_command_preset( temp_dir, "ext-scoped-preset", "speckit.git.feature", "Ext override", "ext body", ) manager = PresetManager(project_dir) manager.install_from_directory(preset_dir, "0.1.5") ext_cmd = commands_dir / "speckit.git.feature.agent.md" assert not ext_cmd.exists(), ( "sanity: install must not write an extension command when the " "extension isn't installed" ) manager.register_enabled_presets_for_agent("copilot") assert not ext_cmd.exists(), ( "rescaffold must not materialize an extension-scoped command " "whose extension isn't installed" ) metadata = manager.registry.get("ext-scoped-preset") assert not (metadata.get("registered_commands") or {}).get("copilot") def test_rescaffold_skips_extension_skills_when_extension_not_installed( self, project_dir, temp_dir ): """Historical tracking must not recreate a missing extension's skill.""" self._write_init_options(project_dir, ai="copilot", ai_skills=True) skills_dir = project_dir / ".github" / "skills" skills_dir.mkdir(parents=True) preset_dir = self._create_command_preset( temp_dir, "ext-scoped-skill-preset", "speckit.git.feature", "Ext override", "preset body", ) manager = PresetManager(project_dir) manager.install_from_directory(preset_dir, "0.1.5") skill_name = "speckit-git-feature" skill_file = skills_dir / skill_name / "SKILL.md" assert not skill_file.exists() manager.registry.update( "ext-scoped-skill-preset", {"registered_skills": {"copilot": [skill_name]}}, ) overrides_dir = ( project_dir / ".specify" / "templates" / "overrides" ) overrides_dir.mkdir(parents=True) (overrides_dir / "speckit.git.feature.md").write_text( "---\ndescription: Project override\n---\n\nOverride body\n", encoding="utf-8", ) manager.register_enabled_presets_for_agent("copilot") assert not skill_file.exists(), ( "rescaffold must not materialize an extension-scoped skill " "whose extension isn't installed" ) def test_same_mode_partial_command_rescaffold_keeps_skipped_tracking( self, project_dir, temp_dir ): """A partial command refresh must keep still-live skipped artifacts tracked.""" self._write_init_options(project_dir, ai="copilot", ai_skills=False) commands_dir = project_dir / ".github" / "agents" commands_dir.mkdir(parents=True) preset_dir = self._create_multi_command_preset( temp_dir, "same-mode-partial-command-preset", ["speckit.specify", "speckit.plan"], ) manager = PresetManager(project_dir) manager.install_from_directory(preset_dir, "0.1.5") installed_dir = manager.presets_dir / "same-mode-partial-command-preset" (installed_dir / "commands" / "speckit.plan.md").unlink() manager.register_enabled_presets_for_agent("copilot") metadata = manager.registry.get("same-mode-partial-command-preset") assert set(metadata["registered_commands"]["copilot"]) == { "speckit.specify", "speckit.plan", } assert (commands_dir / "speckit.plan.agent.md").exists() def test_same_mode_partial_skill_rescaffold_keeps_skipped_tracking( self, project_dir, temp_dir ): """A partial skill refresh must keep still-live skipped artifacts tracked.""" self._write_init_options(project_dir, ai="copilot", ai_skills=True) skills_dir = project_dir / ".github" / "skills" self._create_skill(skills_dir, "speckit-specify") self._create_skill(skills_dir, "speckit-plan") preset_dir = self._create_multi_command_preset( temp_dir, "same-mode-partial-skill-preset", ["speckit.specify", "speckit.plan"], ) manager = PresetManager(project_dir) manager.install_from_directory(preset_dir, "0.1.5") installed_dir = manager.presets_dir / "same-mode-partial-skill-preset" (installed_dir / "commands" / "speckit.plan.md").unlink() manager.register_enabled_presets_for_agent("copilot") metadata = manager.registry.get("same-mode-partial-skill-preset") assert set(metadata["registered_skills"]["copilot"]) == { "speckit-specify", "speckit-plan", } def test_toggle_command_to_skills_preserves_old_command_on_skills_failure( self, project_dir, temp_dir, monkeypatch ): """A command->skills toggle must not destroy the old command artifact before the new skill registration has actually succeeded. Before the fix, the stale command-mode file/tracking was unregistered unconditionally as soon as ``_register_commands``'s ``ai_skills`` guard made the commands phase a no-op — regardless of whether the subsequent, independently-fallible ``_register_skills()`` call actually succeeded. If skills raises (e.g. a transient I/O error), the per-preset exception handler just logs and continues, leaving neither the old command file nor a new skill file — the preset's command override vanishes entirely from copilot until the next successful rescaffold (#2948). """ self._write_init_options(project_dir, ai="copilot", ai_skills=False) copilot_commands_dir = project_dir / ".github" / "agents" copilot_commands_dir.mkdir(parents=True) preset_dir = self._create_command_preset( temp_dir, "toggle-failure-preset", "speckit.specify", "Toggle failure test", "preset body", ) manager = PresetManager(project_dir) manager.install_from_directory(preset_dir, "0.1.5") cmd_file = copilot_commands_dir / "speckit.specify.agent.md" assert cmd_file.exists(), ( "sanity: command mode should have written copilot's command file" ) metadata = manager.registry.get("toggle-failure-preset") assert metadata["registered_commands"].get("copilot"), ( "sanity: the command-mode write should be tracked for copilot" ) # Flip ai_skills on for the *same* active agent and rescaffold, as # `integration upgrade copilot` would, but with skills registration # injected to fail. self._write_init_options(project_dir, ai="copilot", ai_skills=True) def _raise_register_skills(*args, **kwargs): raise OSError("simulated skills-phase failure") monkeypatch.setattr(manager, "_register_skills", _raise_register_skills) manager.register_enabled_presets_for_agent("copilot") assert cmd_file.exists(), ( "the old command-mode artifact must survive when the " "replacement skills registration fails — deleting it before " "the new artifact is confirmed leaves neither in place (#2948)" ) metadata = manager.registry.get("toggle-failure-preset") assert metadata["registered_commands"].get("copilot"), ( "registered_commands must keep tracking copilot's still-live " "command file when the skills replacement failed, or a later " "removal/rescaffold will believe there is nothing to clean up " "even though the file is still on disk (#2948)" ) def test_toggle_command_to_skills_empty_result_preserves_old_command( self, project_dir, temp_dir ): """A non-raising but empty skills result must not delete the old command. Before the fix, the stale command-mode artifact was retired as soon as ``_register_skills()`` completed without raising — regardless of whether it actually wrote anything for this agent. Deleting the preset's own command source file (simulating a missing/corrupted override) makes ``_register_skills`` return ``{}`` for copilot without raising at all, which must leave the old command file and its tracking untouched (#2948). """ self._write_init_options(project_dir, ai="copilot", ai_skills=False) copilot_commands_dir = project_dir / ".github" / "agents" copilot_commands_dir.mkdir(parents=True) preset_dir = self._create_command_preset( temp_dir, "toggle-empty-result-preset", "speckit.specify", "Toggle empty-result test", "preset body", ) manager = PresetManager(project_dir) manager.install_from_directory(preset_dir, "0.1.5") cmd_file = copilot_commands_dir / "speckit.specify.agent.md" assert cmd_file.exists(), ( "sanity: command mode should have written copilot's command file" ) # Remove the *installed* copy of the preset's source file (not the # original temp source) so _register_skills can find nothing to # render — a real "missing source" case, not an exception — leaving # registered_skills empty for copilot. (manager.presets_dir / "toggle-empty-result-preset" / "commands" / "speckit.specify.md").unlink() self._write_init_options(project_dir, ai="copilot", ai_skills=True) manager.register_enabled_presets_for_agent("copilot") assert cmd_file.exists(), ( "an empty (non-raising) skills registration result must not " "cause the old command-mode artifact to be deleted (#2948)" ) metadata = manager.registry.get("toggle-empty-result-preset") assert metadata["registered_commands"].get("copilot"), ( "registered_commands must keep tracking copilot's still-live " "command file when nothing was actually replaced (#2948)" ) skill_file = project_dir / ".github" / "skills" / "speckit-specify" / "SKILL.md" assert not skill_file.exists(), ( "sanity: no skill should have been written when the source " "was missing" ) def test_toggle_command_to_skills_partial_result_only_removes_replaced_command( self, project_dir, temp_dir ): """Only the command whose skill replacement actually landed is retired. A two-command preset where one command's source file goes missing right before the toggle: ``_register_skills`` genuinely returns a partial result (one name present, one silently skipped) without raising. The command whose skill was written must be retired; the other must keep both its old command file and its registry tracking, since no replacement for it actually landed (#2948). """ self._write_init_options(project_dir, ai="copilot", ai_skills=False) copilot_commands_dir = project_dir / ".github" / "agents" copilot_commands_dir.mkdir(parents=True) preset_dir = self._create_multi_command_preset( temp_dir, "toggle-partial-result-preset", ["speckit.specify", "speckit.plan"], ) manager = PresetManager(project_dir) manager.install_from_directory(preset_dir, "0.1.5") specify_cmd_file = copilot_commands_dir / "speckit.specify.agent.md" plan_cmd_file = copilot_commands_dir / "speckit.plan.agent.md" assert specify_cmd_file.exists() and plan_cmd_file.exists(), ( "sanity: command mode should have written both command files" ) metadata = manager.registry.get("toggle-partial-result-preset") assert set(metadata["registered_commands"].get("copilot", [])) == { "speckit.specify", "speckit.plan", }, "sanity: both commands should be tracked for copilot" # Remove only the plan command's *installed* source so its skill # replacement is silently skipped (missing source), while # specify's succeeds — a genuine partial result, not an injected # exception. (manager.presets_dir / "toggle-partial-result-preset" / "commands" / "speckit.plan.md").unlink() self._write_init_options(project_dir, ai="copilot", ai_skills=True) manager.register_enabled_presets_for_agent("copilot") assert not specify_cmd_file.exists(), ( "the specify command's old artifact must be retired since its " "skill replacement actually landed (#2948)" ) assert plan_cmd_file.exists(), ( "the plan command's old artifact must survive since its skill " "replacement never landed (missing source) (#2948)" ) metadata = manager.registry.get("toggle-partial-result-preset") tracked_commands = metadata["registered_commands"].get("copilot", []) assert "speckit.specify" not in tracked_commands, ( "specify must stop being tracked as a command once its " "artifact has been unregistered (#2948)" ) assert "speckit.plan" in tracked_commands, ( "plan must keep being tracked as a command since its old " "artifact is still on disk (#2948)" ) specify_skill_file = ( project_dir / ".github" / "skills" / "speckit-specify" / "SKILL.md" ) assert "preset:toggle-partial-result-preset" in specify_skill_file.read_text(), ( "sanity: specify's new skill artifact should exist" ) plan_skill_file = project_dir / ".github" / "skills" / "speckit-plan" assert not plan_skill_file.exists(), ( "sanity: no skill should have been written for plan since its " "source was missing" ) def test_remove_after_partial_command_to_skills_toggle_keeps_skills_mode_agent_command_free( self, project_dir, temp_dir ): """Removal must not recreate a command file for a skills-mode agent. A partially failed command→skills toggle leaves the active agent's stale ``registered_commands`` entry behind. Removing that preset records the agent in ``extra_agents`` for post-removal reconciliation, and ``register_commands_for_non_skill_agents`` admits every ``extra_agents`` member even when the active-only ``only_agent`` guard excludes the agent — so the surviving lower-priority preset's command file was recreated for an agent now running in skills mode (#2948). """ self._write_init_options(project_dir, ai="copilot", ai_skills=False) copilot_commands_dir = project_dir / ".github" / "agents" copilot_commands_dir.mkdir(parents=True) lower_dir = self._create_command_preset( temp_dir, "stale-toggle-lower-preset", "speckit.plan", "Lower preset", "Lower body", ) higher_dir = self._create_command_preset( temp_dir, "stale-toggle-higher-preset", "speckit.plan", "Higher preset", "Higher body", ) manager = PresetManager(project_dir) manager.install_from_directory(lower_dir, "0.1.5", priority=20) manager.install_from_directory(higher_dir, "0.1.5", priority=10) command_file = copilot_commands_dir / "speckit.plan.agent.md" assert "Higher body" in command_file.read_text(encoding="utf-8"), ( "sanity: command mode should have written the winning preset" ) # Break the higher preset's installed source so its skill # replacement is silently skipped during the toggle — a genuine # partial command→skills toggle that leaves the stale # registered_commands entry for copilot behind. (manager.presets_dir / "stale-toggle-higher-preset" / "commands" / "speckit.plan.md").unlink() self._write_init_options(project_dir, ai="copilot", ai_skills=True) manager.register_enabled_presets_for_agent("copilot") metadata = manager.registry.get("stale-toggle-higher-preset") assert "speckit.plan" in metadata["registered_commands"].get("copilot", []), ( "sanity: the partial toggle must leave the stale command " "tracking behind" ) manager.remove("stale-toggle-higher-preset") assert not command_file.exists(), ( "removing the preset while copilot runs in skills mode must " "not recreate its command file from the surviving lower " "preset via the stale extra_agents entry (#2948)" ) def test_remove_after_partial_skills_to_command_toggle_deletes_stale_skill( self, project_dir, temp_dir ): """Removal must delete, not restore, a command-mode agent's stale skill. The inverse partial toggle: a skills→command conversion that could not replace one command leaves that skill tracked in ``registered_skills``. Removing the preset while the agent is now in command mode sent it through ``_unregister_skills()``, which restored a core/extension ``SKILL.md``, and ``extra_skills_dirs`` then let ``_reconcile_skills`` reapply the surviving lower preset — leaving the active command-mode agent with a skill artifact it must not have (#2948). """ self._write_init_options(project_dir, ai="copilot", ai_skills=True) copilot_commands_dir = project_dir / ".github" / "agents" copilot_commands_dir.mkdir(parents=True) lower_dir = self._create_command_preset( temp_dir, "inverse-toggle-lower-preset", "speckit.plan", "Lower preset", "Lower body", ) higher_dir = self._create_command_preset( temp_dir, "inverse-toggle-higher-preset", "speckit.plan", "Higher preset", "Higher body", ) manager = PresetManager(project_dir) manager.install_from_directory(lower_dir, "0.1.5", priority=20) manager.install_from_directory(higher_dir, "0.1.5", priority=10) skill_dir = project_dir / ".github" / "skills" / "speckit-plan" assert (skill_dir / "SKILL.md").exists(), ( "sanity: skills mode should have written the skill" ) # Break the higher preset's installed source so its command # replacement never lands during the skills→command toggle, # leaving the skill tracked for copilot. (manager.presets_dir / "inverse-toggle-higher-preset" / "commands" / "speckit.plan.md").unlink() self._write_init_options(project_dir, ai="copilot", ai_skills=False) manager.register_enabled_presets_for_agent("copilot") metadata = manager.registry.get("inverse-toggle-higher-preset") registered_skills = metadata.get("registered_skills") or {} assert registered_skills.get("copilot"), ( "sanity: the partial toggle must leave the stale skill " "tracking behind" ) assert (skill_dir / "SKILL.md").exists(), ( "sanity: the stale skill artifact must survive the partial toggle" ) manager.remove("inverse-toggle-higher-preset") assert not skill_dir.exists(), ( "removing the preset while copilot runs in command mode must " "delete the stale preset-owned skill instead of restoring core " "content or reapplying the surviving lower preset (#2948)" ) def test_lower_priority_skill_does_not_remove_failed_winner_command( self, project_dir, temp_dir ): """Only a successfully rendered winning layer may retire a command.""" self._write_init_options(project_dir, ai="copilot", ai_skills=False) commands_dir = project_dir / ".github" / "agents" commands_dir.mkdir(parents=True) lower_dir = self._create_command_preset( temp_dir, "lower-toggle-preset", "speckit.specify", "Lower preset", "Lower body", ) higher_dir = self._create_command_preset( temp_dir, "higher-toggle-preset", "speckit.specify", "Higher preset", "Higher body", ) manager = PresetManager(project_dir) manager.install_from_directory(lower_dir, "0.1.5", priority=20) manager.install_from_directory(higher_dir, "0.1.5", priority=10) command_file = commands_dir / "speckit.specify.agent.md" assert "Higher body" in command_file.read_text(encoding="utf-8") higher_source = ( manager.presets_dir / "higher-toggle-preset" / "commands" / "speckit.specify.md" ) higher_source.unlink() self._write_init_options(project_dir, ai="copilot", ai_skills=True) manager.register_enabled_presets_for_agent("copilot") assert command_file.exists(), ( "a lower-priority skill replacement must not remove the existing " "higher-priority command when the winning layer did not render" ) assert "Higher body" in command_file.read_text(encoding="utf-8") higher_source.write_text( "---\ndescription: Higher preset\n---\n\nHigher body\n", encoding="utf-8", ) manager.register_enabled_presets_for_agent("copilot") assert not command_file.exists(), ( "the old command should be retired after the winning skill " "layer renders successfully" ) for preset_id in ("lower-toggle-preset", "higher-toggle-preset"): metadata = manager.registry.get(preset_id) assert not metadata["registered_commands"].get("copilot"), ( "all layers sharing the retired command output must drop " "their stale command tracking" ) def test_successful_winner_without_stale_tracking_retires_lower_command( self, project_dir, temp_dir ): """Winner success is independent of whether that layer tracked a command.""" self._write_init_options(project_dir, ai="copilot", ai_skills=False) commands_dir = project_dir / ".github" / "agents" commands_dir.mkdir(parents=True) lower_dir = self._create_command_preset( temp_dir, "lower-command-preset", "speckit.specify", "Lower preset", "Lower body", ) higher_dir = self._create_command_preset( temp_dir, "higher-skill-preset", "speckit.specify", "Higher preset", "Higher body", ) manager = PresetManager(project_dir) manager.install_from_directory(lower_dir, "0.1.5", priority=20) command_file = commands_dir / "speckit.specify.agent.md" assert command_file.exists() self._write_init_options(project_dir, ai="copilot", ai_skills=True) manager.install_from_directory(higher_dir, "0.1.5", priority=10) higher_metadata = manager.registry.get("higher-skill-preset") assert not higher_metadata["registered_commands"].get("copilot") manager.register_enabled_presets_for_agent("copilot") assert not command_file.exists(), ( "a successfully rendered winning skill must retire a lower " "layer's stale command even when the winner never tracked one" ) lower_metadata = manager.registry.get("lower-command-preset") assert not lower_metadata["registered_commands"].get("copilot") def test_lower_priority_command_does_not_remove_failed_winner_skill( self, project_dir, temp_dir ): """Only a successfully rendered winning command may retire a skill.""" self._write_init_options(project_dir, ai="copilot", ai_skills=True) commands_dir = project_dir / ".github" / "agents" commands_dir.mkdir(parents=True) lower_dir = self._create_command_preset( temp_dir, "lower-skill-preset", "speckit.specify", "Lower preset", "Lower body", ) higher_dir = self._create_command_preset( temp_dir, "higher-command-preset", "speckit.specify", "Higher preset", "Higher body", ) manager = PresetManager(project_dir) manager.install_from_directory(lower_dir, "0.1.5", priority=20) manager.install_from_directory(higher_dir, "0.1.5", priority=10) skill_file = ( project_dir / ".github" / "skills" / "speckit-specify" / "SKILL.md" ) assert "Higher body" in skill_file.read_text(encoding="utf-8") higher_source = ( manager.presets_dir / "higher-command-preset" / "commands" / "speckit.specify.md" ) higher_source.unlink() self._write_init_options(project_dir, ai="copilot", ai_skills=False) manager.register_enabled_presets_for_agent("copilot") assert skill_file.exists(), ( "a lower-priority command replacement must not remove the " "existing higher-priority skill when the winner did not render" ) assert "Higher body" in skill_file.read_text(encoding="utf-8") higher_source.write_text( "---\ndescription: Higher preset\n---\n\nHigher body\n", encoding="utf-8", ) manager.register_enabled_presets_for_agent("copilot") assert not skill_file.exists(), ( "the old skill should be retired after the winning command " "layer renders successfully" ) for preset_id in ("lower-skill-preset", "higher-command-preset"): metadata = manager.registry.get(preset_id) assert not metadata["registered_skills"].get("copilot"), ( "all layers sharing the retired skill output must drop " "their stale skill tracking" ) def test_project_override_command_retires_stale_preset_skill( self, project_dir, temp_dir ): """A reconciled project override can replace a stale preset skill.""" self._write_init_options(project_dir, ai="copilot", ai_skills=True) commands_dir = project_dir / ".github" / "agents" commands_dir.mkdir(parents=True) preset_dir = self._create_command_preset( temp_dir, "override-toggle-preset", "speckit.specify", "Preset", "Preset body", ) manager = PresetManager(project_dir) manager.install_from_directory(preset_dir, "0.1.5") skill_file = ( project_dir / ".github" / "skills" / "speckit-specify" / "SKILL.md" ) assert skill_file.exists() overrides_dir = ( project_dir / ".specify" / "templates" / "overrides" ) overrides_dir.mkdir(parents=True) (overrides_dir / "speckit.specify.md").write_text( "---\ndescription: Project override\n---\n\nOverride body\n", encoding="utf-8", ) ( manager.presets_dir / "override-toggle-preset" / "commands" / "speckit.specify.md" ).unlink() self._write_init_options(project_dir, ai="copilot", ai_skills=False) manager.register_enabled_presets_for_agent("copilot") command_file = commands_dir / "speckit.specify.agent.md" assert "Override body" in command_file.read_text(encoding="utf-8") assert not skill_file.exists(), ( "the stale preset skill must be retired once the project " "override command is successfully reconciled" ) metadata = manager.registry.get("override-toggle-preset") assert not metadata["registered_skills"].get("copilot") def test_project_override_command_retires_reconciled_override_skill( self, project_dir, temp_dir ): self._write_init_options(project_dir, ai="copilot", ai_skills=True) commands_dir = project_dir / ".github" / "agents" commands_dir.mkdir(parents=True) skills_dir = project_dir / ".github" / "skills" self._create_skill(skills_dir, "speckit-specify") preset_dir = self._create_command_preset( temp_dir, "reconciled-override-toggle-preset", "speckit.specify", "Preset", "Preset body", ) manager = PresetManager(project_dir) manager.install_from_directory(preset_dir, "0.1.5") overrides_dir = ( project_dir / ".specify" / "templates" / "overrides" ) overrides_dir.mkdir(parents=True) (overrides_dir / "speckit.specify.md").write_text( "---\ndescription: Project override\n---\n\nOverride body\n", encoding="utf-8", ) ( manager.presets_dir / "reconciled-override-toggle-preset" / "commands" / "speckit.specify.md" ).unlink() manager.register_enabled_presets_for_agent("copilot") skill_file = skills_dir / "speckit-specify" / "SKILL.md" assert "override:speckit.specify" in skill_file.read_text( encoding="utf-8" ) self._write_init_options(project_dir, ai="copilot", ai_skills=False) manager.register_enabled_presets_for_agent("copilot") assert "Override body" in ( commands_dir / "speckit.specify.agent.md" ).read_text(encoding="utf-8") assert not skill_file.exists() metadata = manager.registry.get( "reconciled-override-toggle-preset" ) assert not metadata["registered_skills"].get("copilot") def test_project_override_skill_retires_stale_preset_command( self, project_dir, temp_dir ): """A reconciled override skill may retire a stale preset command.""" self._write_init_options(project_dir, ai="copilot", ai_skills=False) commands_dir = project_dir / ".github" / "agents" commands_dir.mkdir(parents=True) preset_dir = self._create_command_preset( temp_dir, "override-skill-toggle-preset", "speckit.specify", "Preset", "Preset body", ) manager = PresetManager(project_dir) manager.install_from_directory(preset_dir, "0.1.5") command_file = commands_dir / "speckit.specify.agent.md" assert command_file.exists() skills_dir = project_dir / ".github" / "skills" self._create_skill(skills_dir, "speckit-specify") manager.registry.update( "override-skill-toggle-preset", {"registered_skills": {"copilot": ["speckit-specify"]}}, ) overrides_dir = ( project_dir / ".specify" / "templates" / "overrides" ) overrides_dir.mkdir(parents=True) (overrides_dir / "speckit.specify.md").write_text( "---\ndescription: Project override\n---\n\nOverride body\n", encoding="utf-8", ) ( manager.presets_dir / "override-skill-toggle-preset" / "commands" / "speckit.specify.md" ).unlink() self._write_init_options(project_dir, ai="copilot", ai_skills=True) manager.register_enabled_presets_for_agent("copilot") skill_file = skills_dir / "speckit-specify" / "SKILL.md" assert "Override body" in skill_file.read_text(encoding="utf-8") assert not command_file.exists(), ( "the stale preset command must be retired after the project " "override skill is successfully reconciled" ) metadata = manager.registry.get("override-skill-toggle-preset") assert not metadata["registered_commands"].get("copilot") def test_partial_skill_registration_is_persisted_before_later_failure( self, project_dir, temp_dir, monkeypatch ): """A successful earlier skill write remains tracked if a later read fails.""" self._write_init_options(project_dir, ai="copilot", ai_skills=False) commands_dir = project_dir / ".github" / "agents" commands_dir.mkdir(parents=True) preset_dir = self._create_multi_command_preset( temp_dir, "partial-skill-failure-preset", ["speckit.specify", "speckit.plan"], ) manager = PresetManager(project_dir) manager.install_from_directory(preset_dir, "0.1.5") self._write_init_options(project_dir, ai="copilot", ai_skills=True) original_read_text = Path.read_text def fail_plan_source(path, *args, **kwargs): if ( path.name == "speckit.plan.md" and path.parent.name == "commands" and "partial-skill-failure-preset" in path.parts ): raise UnicodeDecodeError("utf-8", b"\xff", 0, 1, "invalid") return original_read_text(path, *args, **kwargs) monkeypatch.setattr(Path, "read_text", fail_plan_source) manager.register_enabled_presets_for_agent("copilot") metadata = manager.registry.get("partial-skill-failure-preset") assert "speckit-specify" in metadata["registered_skills"].get( "copilot", [] ), ( "the first successful write must be persisted before the later " "template failure aborts the registration call" ) monkeypatch.setattr(Path, "read_text", original_read_text) assert manager.remove("partial-skill-failure-preset") is True remaining_skill = ( project_dir / ".github" / "skills" / "speckit-specify" / "SKILL.md" ) assert ( not remaining_skill.exists() or "preset:partial-skill-failure-preset" not in remaining_skill.read_text(encoding="utf-8") ), "persisted partial ownership must remain removable" def test_remove_cleans_native_skill_missing_from_partial_skill_map( self, project_dir, temp_dir ): """Command cleanup must cover native skills absent from a partial map.""" self._write_init_options(project_dir, ai="claude", ai_skills=True) (project_dir / ".claude" / "skills").mkdir(parents=True) preset_dir = self._create_command_preset( temp_dir, "partial-agent-skill-map-preset", "speckit.partial-native", "Partial native skill", "preset body", ) manager = PresetManager(project_dir) manager.install_from_directory(preset_dir, "0.1.5") metadata = manager.registry.get("partial-agent-skill-map-preset") assert metadata["registered_skills"].get("claude") self._write_init_options(project_dir, ai="codex", ai_skills=True) (project_dir / ".agents" / "skills").mkdir(parents=True) from unittest.mock import patch with patch.object( PresetManager, "_register_skills", side_effect=RuntimeError("simulated skills phase failure"), ): manager.register_enabled_presets_for_agent("codex") codex_skill = ( project_dir / ".agents" / "skills" / "speckit-partial-native" / "SKILL.md" ) assert codex_skill.exists() metadata = manager.registry.get("partial-agent-skill-map-preset") assert "speckit.partial-native" in metadata[ "registered_commands" ].get("codex", []) assert not metadata["registered_skills"].get("codex") assert manager.remove("partial-agent-skill-map-preset") is True assert not codex_skill.exists(), ( "native skill written by the commands phase must not be orphaned " "when another agent makes registered_skills globally non-empty" ) def test_partial_skill_install_failure_rolls_back_persisted_writes( self, project_dir, temp_dir, monkeypatch ): """Install rollback must reload partial skill ownership before removal.""" self._write_init_options(project_dir, ai="copilot", ai_skills=True) (project_dir / ".github" / "agents").mkdir(parents=True) preset_dir = self._create_multi_command_preset( temp_dir, "partial-install-failure-preset", ["speckit.specify", "speckit.plan"], ) manager = PresetManager(project_dir) original_read_text = Path.read_text def fail_plan_source(path, *args, **kwargs): if ( path.name == "speckit.plan.md" and path.parent.name == "commands" and "partial-install-failure-preset" in path.parts ): raise UnicodeDecodeError("utf-8", b"\xff", 0, 1, "invalid") return original_read_text(path, *args, **kwargs) monkeypatch.setattr(Path, "read_text", fail_plan_source) with pytest.raises(UnicodeDecodeError): manager.install_from_directory(preset_dir, "0.1.5") assert not manager.registry.is_installed( "partial-install-failure-preset" ) skill_file = ( project_dir / ".github" / "skills" / "speckit-specify" / "SKILL.md" ) assert ( not skill_file.exists() or "preset:partial-install-failure-preset" not in original_read_text(skill_file, encoding="utf-8") ), "rollback must not orphan a skill written before the later failure" def test_toggle_skills_to_command_empty_result_preserves_old_skill( self, project_dir, temp_dir ): """A non-raising but empty command result must not delete the old skill. Mirror image of the empty-result command->skills case: deleting the preset's own source file makes ``_register_commands`` return ``{}`` for copilot without raising, which must leave the old SKILL.md and its tracking untouched (#2948). """ self._write_init_options(project_dir, ai="copilot", ai_skills=True) copilot_commands_dir = project_dir / ".github" / "agents" copilot_commands_dir.mkdir(parents=True) skills_dir = project_dir / ".github" / "skills" self._create_skill(skills_dir, "speckit-specify") preset_dir = self._create_command_preset( temp_dir, "toggle-skill-empty-result-preset", "speckit.specify", "Toggle empty-result test", "preset body", ) manager = PresetManager(project_dir) manager.install_from_directory(preset_dir, "0.1.5") skill_file = skills_dir / "speckit-specify" / "SKILL.md" assert "preset:toggle-skill-empty-result-preset" in skill_file.read_text(), ( "sanity: skills mode should have written the SKILL.md mirror" ) # Remove the preset's own *installed* source file so # _register_commands can find nothing to render — a real "missing # source" case, not an exception — leaving registered_commands # empty for copilot. (manager.presets_dir / "toggle-skill-empty-result-preset" / "commands" / "speckit.specify.md").unlink() self._write_init_options(project_dir, ai="copilot", ai_skills=False) manager.register_enabled_presets_for_agent("copilot") assert "preset:toggle-skill-empty-result-preset" in skill_file.read_text(), ( "an empty (non-raising) command registration result must not " "cause the old skills-mode artifact to be deleted/reverted " "(#2948)" ) metadata = manager.registry.get("toggle-skill-empty-result-preset") assert "speckit-specify" in metadata["registered_skills"].get("copilot", []), ( "registered_skills must keep tracking copilot's still-live " "skill file when nothing was actually replaced (#2948)" ) # Note: a command file may still exist here — general command # reconciliation independently restores the next-best (e.g. core) # layer for the *command name*, regardless of this preset's own # missing source. That's an orthogonal, existing behaviour; the # invariant under test is specifically that the *skill* mirror and # its tracking survive the empty registration result. def test_toggle_skills_to_command_partial_result_only_removes_replaced_skill( self, project_dir, temp_dir ): """Only the skill whose command replacement actually landed is retired. Mirror image of the partial-result command->skills case: a two-command preset where one command's source file goes missing right before the toggle, so ``_register_commands`` genuinely returns a partial result. The skill whose command was written must be retired; the other must keep both its old SKILL.md and its registry tracking, since no replacement for it landed (#2948). """ self._write_init_options(project_dir, ai="copilot", ai_skills=True) copilot_commands_dir = project_dir / ".github" / "agents" copilot_commands_dir.mkdir(parents=True) skills_dir = project_dir / ".github" / "skills" self._create_skill(skills_dir, "speckit-specify") self._create_skill(skills_dir, "speckit-plan") # A core template lets the retired skill restore to core content # instead of being removed entirely (it has nothing else to fall # back to), matching _unregister_skills's behaviour elsewhere. core_cmds = project_dir / ".specify" / "templates" / "commands" core_cmds.mkdir(parents=True, exist_ok=True) (core_cmds / "specify.md").write_text( "---\ndescription: Core specify command\n---\n\nCore specify body\n", encoding="utf-8", ) preset_dir = self._create_multi_command_preset( temp_dir, "toggle-skill-partial-result-preset", ["speckit.specify", "speckit.plan"], ) manager = PresetManager(project_dir) manager.install_from_directory(preset_dir, "0.1.5") specify_skill_file = skills_dir / "speckit-specify" / "SKILL.md" plan_skill_file = skills_dir / "speckit-plan" / "SKILL.md" assert "preset:toggle-skill-partial-result-preset" in specify_skill_file.read_text() assert "preset:toggle-skill-partial-result-preset" in plan_skill_file.read_text() metadata = manager.registry.get("toggle-skill-partial-result-preset") assert set(metadata["registered_skills"].get("copilot", [])) == { "speckit-specify", "speckit-plan", }, "sanity: both skills should be tracked for copilot" # Remove only the plan command's *installed* source so its command # replacement is silently skipped (missing source), while # specify's succeeds. (manager.presets_dir / "toggle-skill-partial-result-preset" / "commands" / "speckit.plan.md").unlink() self._write_init_options(project_dir, ai="copilot", ai_skills=False) manager.register_enabled_presets_for_agent("copilot") assert "preset:toggle-skill-partial-result-preset" not in specify_skill_file.read_text(), ( "the specify skill's old artifact must be retired/reverted " "since its command replacement actually landed (#2948)" ) assert "preset:toggle-skill-partial-result-preset" in plan_skill_file.read_text(), ( "the plan skill's old artifact must survive since its command " "replacement never landed (missing source) (#2948)" ) metadata = manager.registry.get("toggle-skill-partial-result-preset") tracked_skills = metadata["registered_skills"].get("copilot", []) assert "speckit-specify" not in tracked_skills, ( "specify must stop being tracked as a skill once its artifact " "has been unregistered/reverted (#2948)" ) assert "speckit-plan" in tracked_skills, ( "plan must keep being tracked as a skill since its old " "artifact is still on disk (#2948)" ) assert (copilot_commands_dir / "speckit.specify.agent.md").exists(), ( "sanity: specify's new command artifact should exist" ) def test_toggle_command_to_skills_retires_alias_group_when_primary_skill_lands( self, project_dir, temp_dir ): """A command's aliases must be retired together with its primary once the primary's skill replacement lands. ``CommandRegistrar.register_commands()`` tracks and returns primary + alias names flattened together, but ``_register_skills()`` only ever renders/returns the *primary* command name's skill. The alias's own name run through ``_skill_names_for_command()`` never matches anything real, so without grouping by primary, the alias command artifact and its tracking entry would survive forever even after mutual exclusion is otherwise enforced for the primary (#2948). """ self._write_init_options(project_dir, ai="copilot", ai_skills=False) copilot_commands_dir = project_dir / ".github" / "agents" copilot_commands_dir.mkdir(parents=True) preset_dir = self._create_multi_command_preset_with_aliases( temp_dir, "alias-group-success-preset", [("speckit.specify", ["speckit.spec"])], ) manager = PresetManager(project_dir) manager.install_from_directory(preset_dir, "0.1.5") primary_cmd_file = copilot_commands_dir / "speckit.specify.agent.md" alias_cmd_file = copilot_commands_dir / "speckit.spec.agent.md" assert primary_cmd_file.exists() and alias_cmd_file.exists(), ( "sanity: command mode should have written both the primary " "and alias command files" ) metadata = manager.registry.get("alias-group-success-preset") assert set(metadata["registered_commands"].get("copilot", [])) == { "speckit.specify", "speckit.spec", }, "sanity: both primary and alias should be tracked for copilot" self._write_init_options(project_dir, ai="copilot", ai_skills=True) manager.register_enabled_presets_for_agent("copilot") assert not primary_cmd_file.exists(), ( "the primary's old command artifact must be retired once its " "skill replacement lands (#2948)" ) assert not alias_cmd_file.exists(), ( "the alias's old command artifact must be retired together " "with its primary once the primary's skill replacement lands " "(#2948)" ) metadata = manager.registry.get("alias-group-success-preset") registered_commands = metadata.get("registered_commands", {}) assert not registered_commands.get("copilot"), ( "neither the primary nor the alias should remain tracked as " "commands once both artifacts are retired (#2948)" ) skill_file = project_dir / ".github" / "skills" / "speckit-specify" / "SKILL.md" assert skill_file.exists(), "sanity: the primary's skill should have been written" def test_toggle_command_to_skills_keeps_alias_group_when_primary_skill_missing( self, project_dir, temp_dir ): """A command's aliases must survive together with its primary when the primary's skill replacement never lands. Mirror image of the group-retirement case: deleting the preset's own installed command source makes ``_register_skills`` genuinely return nothing for ``speckit.specify``, so neither the primary nor its alias have a real replacement — both old command artifacts and their tracking must survive (#2948). """ self._write_init_options(project_dir, ai="copilot", ai_skills=False) copilot_commands_dir = project_dir / ".github" / "agents" copilot_commands_dir.mkdir(parents=True) preset_dir = self._create_multi_command_preset_with_aliases( temp_dir, "alias-group-failure-preset", [("speckit.specify", ["speckit.spec"])], ) manager = PresetManager(project_dir) manager.install_from_directory(preset_dir, "0.1.5") primary_cmd_file = copilot_commands_dir / "speckit.specify.agent.md" alias_cmd_file = copilot_commands_dir / "speckit.spec.agent.md" assert primary_cmd_file.exists() and alias_cmd_file.exists(), ( "sanity: command mode should have written both the primary " "and alias command files" ) # Remove the installed source so _register_skills can find nothing # to render for the primary — a real "missing source" case. (manager.presets_dir / "alias-group-failure-preset" / "commands" / "speckit.specify.md").unlink() self._write_init_options(project_dir, ai="copilot", ai_skills=True) manager.register_enabled_presets_for_agent("copilot") assert primary_cmd_file.exists(), ( "the primary's old command artifact must survive since its " "skill replacement never landed (#2948)" ) assert alias_cmd_file.exists(), ( "the alias's old command artifact must survive together with " "its primary since neither has a real replacement (#2948)" ) metadata = manager.registry.get("alias-group-failure-preset") assert set(metadata["registered_commands"].get("copilot", [])) == { "speckit.specify", "speckit.spec", }, ( "both the primary and alias must keep being tracked since " "nothing was actually replaced (#2948)" ) skill_file = project_dir / ".github" / "skills" / "speckit-specify" / "SKILL.md" assert not skill_file.exists(), ( "sanity: no skill should have been written when the source " "was missing" ) def test_toggle_command_to_skills_partial_multi_group_only_retires_successful_group( self, project_dir, temp_dir ): """With two independent alias groups, only the group whose primary skill actually lands is retired; the other survives intact. A preset with two commands (``speckit.specify`` with alias ``speckit.spec``, and ``speckit.plan`` with alias ``speckit.plan-alt``) where only ``speckit.plan``'s installed source goes missing: ``speckit.specify``'s group (primary + alias) must be fully retired, while ``speckit.plan``'s entire group (primary + alias) must survive together, since grouping is per-primary, not per-individual-name (#2948). """ self._write_init_options(project_dir, ai="copilot", ai_skills=False) copilot_commands_dir = project_dir / ".github" / "agents" copilot_commands_dir.mkdir(parents=True) preset_dir = self._create_multi_command_preset_with_aliases( temp_dir, "alias-group-partial-preset", [ ("speckit.specify", ["speckit.spec"]), ("speckit.plan", ["speckit.plan-alt"]), ], ) manager = PresetManager(project_dir) manager.install_from_directory(preset_dir, "0.1.5") specify_cmd = copilot_commands_dir / "speckit.specify.agent.md" spec_alias_cmd = copilot_commands_dir / "speckit.spec.agent.md" plan_cmd = copilot_commands_dir / "speckit.plan.agent.md" plan_alias_cmd = copilot_commands_dir / "speckit.plan-alt.agent.md" assert all( f.exists() for f in (specify_cmd, spec_alias_cmd, plan_cmd, plan_alias_cmd) ), "sanity: command mode should have written all four command files" # Remove only plan's installed source so its group's skill # replacement is silently skipped, while specify's group succeeds. (manager.presets_dir / "alias-group-partial-preset" / "commands" / "speckit.plan.md").unlink() self._write_init_options(project_dir, ai="copilot", ai_skills=True) manager.register_enabled_presets_for_agent("copilot") assert not specify_cmd.exists() and not spec_alias_cmd.exists(), ( "specify's whole group (primary + alias) must be retired " "since its skill replacement landed (#2948)" ) assert plan_cmd.exists() and plan_alias_cmd.exists(), ( "plan's whole group (primary + alias) must survive together " "since its skill replacement never landed (#2948)" ) metadata = manager.registry.get("alias-group-partial-preset") tracked_commands = set(metadata["registered_commands"].get("copilot", [])) assert tracked_commands == {"speckit.plan", "speckit.plan-alt"}, ( "only plan's group should remain tracked as commands; " "specify's group must be fully untracked (#2948)" ) def test_rescaffold_toggle_skills_to_command_removes_stale_skill_file( self, project_dir, temp_dir ): """Toggling the *same* agent from skills mode to command mode must remove the stale skills-mode artifact, not just add the new one. Mirror image of the command-to-skills toggle: once ``ai_skills`` is turned off for copilot (still the active agent), ``_get_skills_dir`` stops resolving a skills directory for it, so ``_register_skills`` becomes a no-op — but the ``SKILL.md`` written while skills mode was on, and its ``registered_skills`` entry, were left behind even though ``_register_commands`` went on to (re)write the ``.agent.md`` command file, again breaking mutual exclusion (#2948). """ self._write_init_options(project_dir, ai="copilot", ai_skills=True) copilot_commands_dir = project_dir / ".github" / "agents" copilot_commands_dir.mkdir(parents=True) skills_dir = project_dir / ".github" / "skills" self._create_skill(skills_dir, "speckit-specify") # A core template lets the stale skill restore to core content # (instead of being removed entirely, since it has nothing to fall # back to), matching how `_unregister_skills` behaves elsewhere. core_cmds = project_dir / ".specify" / "templates" / "commands" core_cmds.mkdir(parents=True, exist_ok=True) (core_cmds / "specify.md").write_text( "---\ndescription: Core specify command\n---\n\nCore specify body\n", encoding="utf-8", ) preset_dir = self._create_command_preset( temp_dir, "toggle-skill-to-cmd-preset", "speckit.specify", "Toggle test", "preset body", ) manager = PresetManager(project_dir) manager.install_from_directory(preset_dir, "0.1.5") skill_file = skills_dir / "speckit-specify" / "SKILL.md" assert "preset:toggle-skill-to-cmd-preset" in skill_file.read_text(), ( "sanity: skills mode should have written the SKILL.md mirror" ) metadata = manager.registry.get("toggle-skill-to-cmd-preset") assert metadata["registered_skills"].get("copilot"), ( "sanity: the skills-mode write should be tracked for copilot" ) # Flip ai_skills off for the *same* active agent and rescaffold, as # `integration upgrade copilot` would after the mode toggle. self._write_init_options(project_dir, ai="copilot", ai_skills=False) manager.register_enabled_presets_for_agent("copilot") restored_content = skill_file.read_text() assert "preset:toggle-skill-to-cmd-preset" not in restored_content, ( "the stale skills-mode artifact must be reverted once copilot " "has toggled to command mode for the same agent (#2948)" ) assert "Core specify body" in restored_content, ( "sanity: the skill should fall back to core content, not just " "lose the preset's override" ) metadata = manager.registry.get("toggle-skill-to-cmd-preset") assert not metadata["registered_skills"].get("copilot"), ( "registered_skills must stop tracking copilot once its " "artifact has been unregistered/restored (#2948)" ) cmd_file = copilot_commands_dir / "speckit.specify.agent.md" assert cmd_file.exists(), ( "sanity: the new command-mode artifact should still be written" ) assert "preset body" in cmd_file.read_text() def test_skill_switch_then_remove_restores_every_skill_agent_dir( self, project_dir, temp_dir ): """Switching between two skill-mode agents before removing a preset must restore both agents' directories, not just the currently active one. ``registered_skills`` records exactly which agent directories the preset wrote to (``{agent_name: [skill_name, ...]}``); switching to codex and re-registering adds a "codex" entry alongside the original "claude" entry, so removal restores both. Before the provenance fix, ``_unregister_skills`` only restored the currently active agent's skills directory; a preset used first under Claude and later switched to Codex would have its Claude override left behind permanently on removal (#2948). """ self._write_init_options(project_dir, ai="claude", ai_skills=True) core_cmds = project_dir / ".specify" / "templates" / "commands" core_cmds.mkdir(parents=True, exist_ok=True) (core_cmds / "specify.md").write_text( "---\ndescription: Core specify command\n---\n\nCore specify body\n", encoding="utf-8", ) # Native skill agents only materialize a *brand-new* preset skill # when their skills directory already exists (mirrors every other # skill test in this class); pre-create both agents' directories so # install and the later switch both find an existing skill to # overwrite via _register_commands/_register_skills. claude_skills_dir = project_dir / ".claude" / "skills" self._create_skill(claude_skills_dir, "speckit-specify") codex_skills_dir = project_dir / ".agents" / "skills" self._create_skill(codex_skills_dir, "speckit-specify") preset_dir = self._create_command_preset( temp_dir, "multi-skill-agent-preset", "speckit.specify", "Multi skill agent test", "preset body", ) manager = PresetManager(project_dir) manager.install_from_directory(preset_dir, "0.1.5") claude_skill = claude_skills_dir / "speckit-specify" / "SKILL.md" assert "preset:multi-skill-agent-preset" in claude_skill.read_text() # Switch the active agent to codex (a different skill-mode agent) # and re-register enabled presets for it, mirroring what # `integration use codex` does. self._write_init_options(project_dir, ai="codex", ai_skills=True) manager.register_enabled_presets_for_agent("codex") codex_skill = codex_skills_dir / "speckit-specify" / "SKILL.md" assert "preset:multi-skill-agent-preset" in codex_skill.read_text(), ( "sanity: switching to codex should rescaffold the preset there" ) assert "preset:multi-skill-agent-preset" in claude_skill.read_text(), ( "sanity: the previous agent's registration is preserved on switch" ) metadata = manager.registry.get("multi-skill-agent-preset") registered_skills = metadata.get("registered_skills", {}) assert set(registered_skills) == {"claude", "codex"}, ( "registered_skills must record both agent directories this " "preset actually wrote to (#2948)" ) assert manager.remove("multi-skill-agent-preset") is True for skill_file, label in ((claude_skill, "claude"), (codex_skill, "codex")): assert skill_file.exists(), f"{label} skill file should still exist after removal" content = skill_file.read_text() assert "preset:multi-skill-agent-preset" not in content, ( f"{label}'s preset override must be restored on removal, " "not orphaned permanently (#2948)" ) assert "Core specify body" in content def test_native_skill_activation_recreates_deleted_skills_root( self, project_dir, temp_dir ): self._write_init_options(project_dir, ai="claude", ai_skills=True) claude_skills_dir = project_dir / ".claude" / "skills" self._create_skill(claude_skills_dir, "speckit-specify") preset_dir = self._create_command_preset( temp_dir, "native-root-recovery-preset", "speckit.specify", "Native root recovery", "preset body", ) manager = PresetManager(project_dir) manager.install_from_directory(preset_dir, "0.1.5") codex_skills_dir = project_dir / ".agents" / "skills" assert not codex_skills_dir.exists() self._write_init_options(project_dir, ai="codex", ai_skills=True) manager.register_enabled_presets_for_agent("codex") skill_file = codex_skills_dir / "speckit-specify" / "SKILL.md" assert "preset:native-root-recovery-preset" in skill_file.read_text() metadata = manager.registry.get("native-root-recovery-preset") assert "speckit.specify" in metadata["registered_commands"]["codex"] def test_rescaffold_migrates_legacy_flat_list_registered_skills( self, project_dir, temp_dir ): """Rescaffolding a preset with a legacy flat-list ``registered_skills`` entry must persist the migrated per-agent dict even when the rescaffolded skill names are unchanged from before. ``_normalize_registered_skills`` converts a legacy flat ``List[str]`` (predating per-agent provenance) into ``{agent_name: [...]}`` in memory, but the persistence check compared only the *normalized* ``merged_skills`` against the *normalized* ``existing_skills`` — both derived from the same raw legacy list. When the freshly registered names are identical to what the legacy list already held (the common case: nothing about the preset or skill actually changed), that comparison is a no-op and ``registry.update()`` is skipped, leaving the *raw* on-disk value as the un-migrated flat list. A later switch to a different skill-mode agent and removal then follows the legacy best-effort restore path (only the currently active agent's directory) instead of the per-agent provenance path, orphaning the first agent's override (#2948). """ self._write_init_options(project_dir, ai="claude", ai_skills=True) core_cmds = project_dir / ".specify" / "templates" / "commands" core_cmds.mkdir(parents=True, exist_ok=True) (core_cmds / "specify.md").write_text( "---\ndescription: Core specify command\n---\n\nCore specify body\n", encoding="utf-8", ) claude_skills_dir = project_dir / ".claude" / "skills" self._create_skill(claude_skills_dir, "speckit-specify") codex_skills_dir = project_dir / ".agents" / "skills" self._create_skill(codex_skills_dir, "speckit-specify") preset_dir = self._create_command_preset( temp_dir, "legacy-skills-preset", "speckit.specify", "Legacy skills test", "preset body", ) manager = PresetManager(project_dir) manager.install_from_directory(preset_dir, "0.1.5") # Simulate a registry entry written by a pre-#2948 spec-kit version: # registered_skills stored as a flat list with no per-agent # provenance, rather than the dict shape install_from_directory # writes today. manager.registry.update( "legacy-skills-preset", {"registered_skills": ["speckit-specify"]}, ) metadata = manager.registry.get("legacy-skills-preset") assert isinstance(metadata["registered_skills"], list), ( "sanity: the injected legacy format is a flat list" ) # Rescaffold for the *same* active agent (claude) with no actual # change to the registered skill names, mirroring `integration # upgrade claude` re-running registration for the active # integration. manager.register_enabled_presets_for_agent("claude") metadata = manager.registry.get("legacy-skills-preset") registered_skills = metadata.get("registered_skills") assert isinstance(registered_skills, dict), ( "rescaffold must migrate a legacy flat-list registered_skills " "entry to the per-agent dict format even when the " "rescaffolded names are unchanged, or the raw registry stays " "un-migrated and later removal loses per-agent provenance " "(#2948)" ) assert registered_skills.get("claude") == ["speckit-specify"] # Switch to a different skill-mode agent and rescaffold again — # with the dict format now in place, both directories should be # tracked and therefore restorable on removal. self._write_init_options(project_dir, ai="codex", ai_skills=True) manager.register_enabled_presets_for_agent("codex") metadata = manager.registry.get("legacy-skills-preset") assert set(metadata.get("registered_skills", {})) == {"claude", "codex"}, ( "the migrated dict must keep recording every agent directory " "the preset actually wrote to, exactly like a preset that was " "always in dict format (#2948)" ) assert manager.remove("legacy-skills-preset") is True claude_skill = claude_skills_dir / "speckit-specify" / "SKILL.md" codex_skill = codex_skills_dir / "speckit-specify" / "SKILL.md" for skill_file, label in ((claude_skill, "claude"), (codex_skill, "codex")): assert skill_file.exists(), f"{label} skill file should still exist after removal" content = skill_file.read_text() assert "preset:legacy-skills-preset" not in content, ( f"{label}'s preset override must be restored on removal, " "not orphaned because the registry stayed in legacy " "flat-list format (#2948)" ) assert "Core specify body" in content def test_rescaffold_legacy_flat_list_direct_switch_preserves_original_agent( self, project_dir, temp_dir ): """A legacy flat-list ``registered_skills`` entry must not be misattributed to the wrong agent when the *first* post-upgrade operation is a direct switch to a different skill-mode agent. Blindly attributing every legacy flat-list name to ``agent_name`` — the agent currently being (re)activated — loses the actual writer whenever that first operation is ``integration use codex`` (or ``switch``) run directly against a legacy Claude override, without an intervening same-agent rescaffold for Claude first. The migrated dict then only records ``{"codex": [...]}``, so a later ``remove()`` restores Codex but permanently orphans the Claude override that was never in the registry to begin with (#2948). """ self._write_init_options(project_dir, ai="claude", ai_skills=True) core_cmds = project_dir / ".specify" / "templates" / "commands" core_cmds.mkdir(parents=True, exist_ok=True) (core_cmds / "specify.md").write_text( "---\ndescription: Core specify command\n---\n\nCore specify body\n", encoding="utf-8", ) claude_skills_dir = project_dir / ".claude" / "skills" self._create_skill(claude_skills_dir, "speckit-specify") codex_skills_dir = project_dir / ".agents" / "skills" self._create_skill(codex_skills_dir, "speckit-specify") preset_dir = self._create_command_preset( temp_dir, "legacy-direct-switch-preset", "speckit.specify", "Legacy direct switch test", "preset body", ) manager = PresetManager(project_dir) manager.install_from_directory(preset_dir, "0.1.5") # install_from_directory wrote the preset's override to Claude's # skill directory (the active agent at install time) — sanity-check # that the marker is actually there before simulating the legacy # registry format. claude_skill = claude_skills_dir / "speckit-specify" / "SKILL.md" assert "preset:legacy-direct-switch-preset" in claude_skill.read_text(), ( "sanity: install should have written the override under claude" ) # Simulate a pre-#2948 registry: a flat list with no per-agent # provenance, even though the file on disk was actually written # under claude's directory. manager.registry.update( "legacy-direct-switch-preset", {"registered_skills": ["speckit-specify"]}, ) # Directly switch to codex — no intervening rescaffold for claude — # mirroring `integration use codex` / `switch codex` run right after # upgrading spec-kit versions. self._write_init_options(project_dir, ai="codex", ai_skills=True) manager.register_enabled_presets_for_agent("codex") metadata = manager.registry.get("legacy-direct-switch-preset") registered_skills = metadata.get("registered_skills") assert isinstance(registered_skills, dict) assert set(registered_skills) == {"claude", "codex"}, ( "migrating a legacy flat-list entry on a direct switch must " "infer the actual writer (claude) from the existing on-disk " "SKILL.md provenance, not attribute every name to whichever " "agent happens to be activated first after the upgrade " "(#2948)" ) assert manager.remove("legacy-direct-switch-preset") is True codex_skill = codex_skills_dir / "speckit-specify" / "SKILL.md" for skill_file, label in ((claude_skill, "claude"), (codex_skill, "codex")): assert skill_file.exists(), f"{label} skill file should still exist after removal" content = skill_file.read_text() assert "preset:legacy-direct-switch-preset" not in content, ( f"{label}'s preset override must be restored on removal, " "not permanently orphaned by a misattributed legacy " "migration (#2948)" ) assert "Core specify body" in content def test_rescaffold_legacy_flat_list_infers_command_backed_skills_owner( self, project_dir, temp_dir ): """Legacy provenance inference must also probe command-backed agents that were running in skills mode, not only agents whose command registrar config is statically ``/SKILL.md``-only. Copilot is command-backed (``extension: ".agent.md"``), but with ``ai_skills`` enabled its preset overrides render as ``SKILL.md`` files under ``.github/skills`` exactly like a native skill-only agent (claude, codex, ...). Before the fix, ``_infer_legacy_skill_provenance`` only probed agents whose registrar config has a static ``extension == "/SKILL.md"``, so a real preset-owned ``.github/skills/.../SKILL.md`` written while Copilot was the active, skills-mode agent was never found — the legacy flat list was misattributed entirely to whichever agent the first post-upgrade switch happened to activate, permanently orphaning Copilot's override on later removal (#2948). """ self._write_init_options(project_dir, ai="copilot", ai_skills=True) core_cmds = project_dir / ".specify" / "templates" / "commands" core_cmds.mkdir(parents=True, exist_ok=True) (core_cmds / "specify.md").write_text( "---\ndescription: Core specify command\n---\n\nCore specify body\n", encoding="utf-8", ) copilot_skills_dir = project_dir / ".github" / "skills" self._create_skill(copilot_skills_dir, "speckit-specify") claude_skills_dir = project_dir / ".claude" / "skills" self._create_skill(claude_skills_dir, "speckit-specify") preset_dir = self._create_command_preset( temp_dir, "legacy-copilot-skills-preset", "speckit.specify", "Legacy copilot skills test", "preset body", ) manager = PresetManager(project_dir) manager.install_from_directory(preset_dir, "0.1.5") copilot_skill = copilot_skills_dir / "speckit-specify" / "SKILL.md" assert "preset:legacy-copilot-skills-preset" in copilot_skill.read_text(), ( "sanity: install should have written the override under " "copilot's skills directory while copilot was active in " "skills mode" ) # Sanity: no command-mode artifact was written either — copilot's # command file and skills file are mutually exclusive. assert not list((project_dir / ".github" / "agents").glob("*specify*")), ( "sanity: copilot in skills mode must not also write a command " "file that could be falsely attributed instead" ) # Simulate a pre-#2948 registry: a flat list with no per-agent # provenance, even though the file on disk was actually written # under copilot's skills directory. manager.registry.update( "legacy-copilot-skills-preset", {"registered_skills": ["speckit-specify"]}, ) # Directly switch to claude — no intervening rescaffold for # copilot — mirroring `integration use claude` run right after # upgrading spec-kit versions. self._write_init_options(project_dir, ai="claude", ai_skills=True) manager.register_enabled_presets_for_agent("claude") metadata = manager.registry.get("legacy-copilot-skills-preset") registered_skills = metadata.get("registered_skills") assert isinstance(registered_skills, dict) assert set(registered_skills) == {"copilot", "claude"}, ( "migrating a legacy flat-list entry on a direct switch must " "infer the actual writer (copilot, running in skills mode) " "even though copilot's registrar config is command-backed, " "not just agents with a static /SKILL.md extension (#2948)" ) assert manager.remove("legacy-copilot-skills-preset") is True claude_skill = claude_skills_dir / "speckit-specify" / "SKILL.md" for skill_file, label in ((copilot_skill, "copilot"), (claude_skill, "claude")): assert skill_file.exists(), f"{label} skill file should still exist after removal" content = skill_file.read_text() assert "preset:legacy-copilot-skills-preset" not in content, ( f"{label}'s preset override must be restored on removal, " "not permanently orphaned by a legacy migration that " "failed to probe command-backed skills-mode agents (#2948)" ) assert "Core specify body" in content def test_infer_legacy_skill_provenance_does_not_falsely_attribute_command_mode_copilot( self, project_dir, temp_dir ): """Broadening provenance inference to command-backed agents must not falsely attribute ownership to an agent's directory that has no preset-owned marker. Copilot stays in plain command mode throughout (no skills ever rendered there), so ``.github/skills`` never receives this preset's ``SKILL.md``. Probing copilot's skills directory anyway (now that inference isn't restricted to static ``/SKILL.md`` agents) must find nothing there and must not invent a false ``"copilot"`` entry (#2948). """ self._write_init_options(project_dir, ai="claude", ai_skills=True) core_cmds = project_dir / ".specify" / "templates" / "commands" core_cmds.mkdir(parents=True, exist_ok=True) (core_cmds / "specify.md").write_text( "---\ndescription: Core specify command\n---\n\nCore specify body\n", encoding="utf-8", ) claude_skills_dir = project_dir / ".claude" / "skills" self._create_skill(claude_skills_dir, "speckit-specify") # Copilot has never been active; its command directory holds an # unrelated file so the directory exists, but no skills directory # or SKILL.md was ever written for it. copilot_commands_dir = project_dir / ".github" / "agents" copilot_commands_dir.mkdir(parents=True) preset_dir = self._create_command_preset( temp_dir, "no-false-attribution-preset", "speckit.specify", "No false attribution test", "preset body", ) manager = PresetManager(project_dir) manager.install_from_directory(preset_dir, "0.1.5") manager.registry.update( "no-false-attribution-preset", {"registered_skills": ["speckit-specify"]}, ) # Rescaffold again for the same agent (claude) with unchanged # names, triggering the legacy migration path. manager.register_enabled_presets_for_agent("claude") metadata = manager.registry.get("no-false-attribution-preset") registered_skills = metadata.get("registered_skills") assert isinstance(registered_skills, dict) assert set(registered_skills) == {"claude"}, ( "copilot must not appear in the migrated registry when it has " "never actually rendered this preset's skill — probing its " "directory for a marker match must not create a false " "attribution (#2948)" ) assert not (project_dir / ".github" / "skills").exists(), ( "no .github/skills directory should have been created as a " "side effect of probing for provenance (#2948)" ) def test_infer_legacy_skill_provenance_skips_invalid_utf8( self, project_dir ): self._write_init_options(project_dir, ai="claude", ai_skills=True) skill_dir = ( project_dir / ".claude" / "skills" / "speckit-specify" ) skill_dir.mkdir(parents=True) (skill_dir / "SKILL.md").write_bytes(b"\xff") manager = PresetManager(project_dir) assert manager._infer_legacy_skill_provenance( ["speckit-specify"], "some-pack", "claude" ) == {"claude": ["speckit-specify"]} def test_infer_legacy_skill_provenance_excludes_home_outputs( self, project_dir, temp_dir, monkeypatch ): home = temp_dir / "home" monkeypatch.setattr(Path, "home", lambda: home) skill_dir = home / ".hermes" / "skills" / "speckit-specify" skill_dir.mkdir(parents=True) (skill_dir / "SKILL.md").write_text( "---\n" "metadata:\n" " source: preset:some-pack\n" "---\n\n" "Other project\n", encoding="utf-8", ) manager = PresetManager(project_dir) inferred = manager._infer_legacy_skill_provenance( ["speckit-specify"], "some-pack", "claude" ) assert inferred == {"claude": ["speckit-specify"]} assert skill_dir.exists() def test_remove_infers_legacy_flat_list_provenance_without_prior_rescaffold( self, project_dir, temp_dir ): """``preset remove`` on a legacy flat-list registry must restore every previously active agent's directory, not just the currently active one, even when it is the *very first* post-upgrade operation (no intervening ``use``/``upgrade``/rescaffold). Pre-#2948 registries recorded a flat ``registered_skills`` list because presets were rendered for every detected skill-mode agent at once, not just the active one. Migrating that legacy format to the per-agent dict form previously only happened as a side effect of ``register_enabled_presets_for_agent`` (i.e. a rescaffold or ``integration use``/``switch``). If the user's first action after upgrading is instead directly running ``preset remove``, the legacy branch of ``_unregister_skills`` restored only the currently active agent's directory (via ``_get_skills_dir()``), permanently leaving this preset's override in every other, previously active agent's directory (#2948). """ self._write_init_options(project_dir, ai="claude", ai_skills=True) core_cmds = project_dir / ".specify" / "templates" / "commands" core_cmds.mkdir(parents=True, exist_ok=True) (core_cmds / "specify.md").write_text( "---\ndescription: Core specify command\n---\n\nCore specify body\n", encoding="utf-8", ) claude_skills_dir = project_dir / ".claude" / "skills" self._create_skill(claude_skills_dir, "speckit-specify") codex_skills_dir = project_dir / ".agents" / "skills" self._create_skill(codex_skills_dir, "speckit-specify") preset_dir = self._create_command_preset( temp_dir, "remove-legacy-no-rescaffold-preset", "speckit.specify", "Remove legacy no rescaffold test", "preset body", ) manager = PresetManager(project_dir) manager.install_from_directory(preset_dir, "0.1.5") claude_skill = claude_skills_dir / "speckit-specify" / "SKILL.md" assert "preset:remove-legacy-no-rescaffold-preset" in claude_skill.read_text(), ( "sanity: install should have written the override under " "claude's skill directory" ) # Simulate the pre-#2948 "register for every detected agent" # install behaviour by also placing the marker under codex's # directory directly (mirroring the old, non-active-only # rendering that predates this PR). codex_skill = codex_skills_dir / "speckit-specify" / "SKILL.md" codex_skill.write_text(claude_skill.read_text(), encoding="utf-8") # Simulate a pre-#2948 registry: a flat list with no per-agent # provenance, even though both directories actually hold this # preset's marker on disk. manager.registry.update( "remove-legacy-no-rescaffold-preset", {"registered_skills": ["speckit-specify"]}, ) # No intervening use/upgrade/rescaffold: remove() is the very # first operation run after the legacy registry was written. assert manager.remove("remove-legacy-no-rescaffold-preset") is True for skill_file, label in ((claude_skill, "claude"), (codex_skill, "codex")): assert skill_file.exists(), f"{label} skill file should still exist after removal" content = skill_file.read_text() assert "preset:remove-legacy-no-rescaffold-preset" not in content, ( f"{label}'s preset override must be restored on removal " "even with no prior rescaffold to migrate the legacy " "flat-list format first — remove() must infer real " "per-agent ownership from on-disk provenance itself " "(#2948)" ) assert "Core specify body" in content def test_symlinked_skills_dir_rejected_on_removal(self, project_dir, temp_dir): """Removal must validate a recorded skill directory before touching it. If an agent's skills directory is replaced with a symlink escaping the project root between install and removal, restoration must refuse to write/rmtree through it rather than trusting the recorded agent name blindly. The unsafe directory is skipped best-effort; removal still succeeds and doesn't crash (#2948). """ self._write_init_options(project_dir, ai="claude", ai_skills=True) claude_skills_dir = project_dir / ".claude" / "skills" self._create_skill(claude_skills_dir, "speckit-specify") preset_dir = self._create_command_preset( temp_dir, "symlink-guard-preset", "speckit.specify", "Symlink guard test", "preset body", ) manager = PresetManager(project_dir) manager.install_from_directory(preset_dir, "0.1.5") metadata = manager.registry.get("symlink-guard-preset") assert "speckit-specify" in metadata.get("registered_skills", {}).get("claude", []) # Simulate the claude skills directory being replaced with a symlink # that escapes the project root, containing an external # "speckit-specify" directory that must not be touched. outside_target = temp_dir / "outside-claude-skills" outside_skill_dir = outside_target / "speckit-specify" outside_skill_dir.mkdir(parents=True) sentinel = outside_skill_dir / "SKILL.md" sentinel.write_text("do-not-touch") shutil.rmtree(claude_skills_dir) claude_skills_dir.symlink_to(outside_target, target_is_directory=True) assert manager.remove("symlink-guard-preset") is True assert sentinel.read_text() == "do-not-touch", ( "removal must not follow a symlinked skills directory outside " "the project root (#2948)" ) assert outside_skill_dir.is_dir(), ( "the external directory must not be rmtree'd through a " "symlinked skills path" ) assert claude_skills_dir.is_symlink(), ( "the symlink itself should be left alone, not rmtree'd through" ) def test_preset_removal_does_not_touch_other_presets_skill_dir( self, project_dir, temp_dir ): """Removing a preset must only touch directories it actually wrote to. Preset A is installed while Claude is active and preset B is installed while Codex is active; both override the same command name, so both materialize a ``speckit-specify`` skill, but in *different* agent directories. Before the provenance fix, removing B enumerated every existing skill-mode directory (including Claude's) and restored/overwrote anything named ``speckit-specify`` found there, corrupting A's override even though B never touched Claude's directory (#2948). """ self._write_init_options(project_dir, ai="claude", ai_skills=True) claude_skills_dir = project_dir / ".claude" / "skills" self._create_skill(claude_skills_dir, "speckit-specify") preset_a_dir = self._create_command_preset( temp_dir, "preset-a", "speckit.specify", "Preset A", "preset A body", ) manager = PresetManager(project_dir) manager.install_from_directory(preset_a_dir, "0.1.5") claude_skill_file = claude_skills_dir / "speckit-specify" / "SKILL.md" assert "preset:preset-a" in claude_skill_file.read_text() # Switch to codex and install a second preset overriding the same # command; codex's skills directory is entirely separate. self._write_init_options(project_dir, ai="codex", ai_skills=True) codex_skills_dir = project_dir / ".agents" / "skills" self._create_skill(codex_skills_dir, "speckit-specify") preset_b_dir = self._create_command_preset( temp_dir, "preset-b", "speckit.specify", "Preset B", "preset B body", ) manager.install_from_directory(preset_b_dir, "0.1.5") metadata_b = manager.registry.get("preset-b") assert "claude" not in metadata_b.get("registered_skills", {}), ( "preset B never wrote to claude's skills directory and must " "not record it as touched" ) assert manager.remove("preset-b") is True assert "preset:preset-a" in claude_skill_file.read_text(), ( "removing preset B must not disturb preset A's Claude override (#2948)" ) def test_remove_does_not_recreate_empty_skill_dir( self, project_dir, temp_dir ): self._write_init_options(project_dir, ai="claude", ai_skills=True) skills_dir = project_dir / ".claude" / "skills" self._create_skill(skills_dir, "speckit-orphan") preset_dir = self._create_command_preset( temp_dir, "orphan-skill-preset", "speckit.orphan", "Orphan", "Preset-only body", ) manager = PresetManager(project_dir) manager.install_from_directory(preset_dir, "0.1.5") skill_dir = skills_dir / "speckit-orphan" assert skill_dir.exists() assert manager.remove("orphan-skill-preset") is True assert not skill_dir.exists() def test_remove_preserves_non_owned_skill_during_reconciliation( self, project_dir, temp_dir ): self._write_init_options(project_dir, ai="claude", ai_skills=True) skills_dir = project_dir / ".claude" / "skills" self._create_skill(skills_dir, "speckit-specify") lower_dir = self._create_command_preset( temp_dir, "non-owned-lower-preset", "speckit.specify", "Lower", "Lower preset body", ) higher_dir = self._create_command_preset( temp_dir, "non-owned-higher-preset", "speckit.specify", "Higher", "Higher preset body", ) manager = PresetManager(project_dir) manager.install_from_directory(lower_dir, "0.1.5", priority=10) manager.install_from_directory(higher_dir, "0.1.5", priority=1) skill_file = skills_dir / "speckit-specify" / "SKILL.md" skill_file.write_text( "---\nname: speckit-specify\n---\n\nUser-owned body\n", encoding="utf-8", ) assert manager.remove("non-owned-higher-preset") is True assert skill_file.read_text(encoding="utf-8") == ( "---\nname: speckit-specify\n---\n\nUser-owned body\n" ) def test_shared_skills_dir_restored_once_using_active_agent( self, project_dir, temp_dir ): """Removal must restore a physical skills directory shared by multiple agents exactly once, using the active agent's renderer. Codex and Antigravity (agy) both resolve their skills directory to ``.agents/skills``. Registering a preset under codex, switching to agy, then switching back to codex records provenance for *both* agent keys even though they share one physical directory. Before the fix, ``_unregister_skills`` restored once per recorded agent key rather than once per unique directory, so the directory was written twice on removal with whichever agent was iterated *last* silently winning — regardless of which agent is actually active (#2948). """ self._write_init_options(project_dir, ai="codex", ai_skills=True) shared_skills_dir = project_dir / ".agents" / "skills" self._create_skill(shared_skills_dir, "speckit-specify") core_cmds = project_dir / ".specify" / "templates" / "commands" core_cmds.mkdir(parents=True, exist_ok=True) (core_cmds / "specify.md").write_text( "---\ndescription: Core specify command\n---\n\nCore specify body\n", encoding="utf-8", ) preset_dir = self._create_command_preset( temp_dir, "shared-dir-preset", "speckit.specify", "Shared dir test", "preset body", ) manager = PresetManager(project_dir) manager.install_from_directory(preset_dir, "0.1.5") # Switch to agy (shares .agents/skills with codex) and back to # codex, mirroring `integration use agy` then `integration use # codex`. Both agent keys end up recorded in registered_skills even # though they refer to the same physical directory. self._write_init_options(project_dir, ai="agy", ai_skills=True) manager.register_enabled_presets_for_agent("agy") self._write_init_options(project_dir, ai="codex", ai_skills=True) manager.register_enabled_presets_for_agent("codex") metadata = manager.registry.get("shared-dir-preset") registered_skills = metadata.get("registered_skills", {}) assert set(registered_skills) == {"codex", "agy"}, ( "both agent keys must be recorded even though they share one " "physical directory (#2948)" ) from unittest.mock import patch # Exercise `_unregister_skills` directly (the method this fix # changed) rather than the full `remove()` flow, which separately # triggers post-removal reconciliation that may also touch the # active agent's directory — an unrelated call this test isn't # targeting. with patch.object( manager, "_unregister_skills_in_dir", wraps=manager._unregister_skills_in_dir, ) as spy: manager._unregister_skills(registered_skills, preset_dir) assert spy.call_count == 1, ( "a physical directory shared by multiple recorded agents must " "be restored exactly once, not once per agent key (#2948)" ) (_names, called_dir, called_agent), _kwargs = spy.call_args assert called_dir == shared_skills_dir assert called_agent == "codex", ( "the currently active agent must be used as the renderer when " "it shares the restored directory, not whichever agent was " "recorded last (#2948)" ) skill_file = shared_skills_dir / "speckit-specify" / "SKILL.md" content = skill_file.read_text() assert "preset:shared-dir-preset" not in content assert "Core specify body" in content def test_remove_higher_priority_skills_only_preset_restores_lower_preset( self, project_dir, temp_dir ): """Removing a skills-mode preset must reconcile against the surviving stack, not fall back to core/extension content. Copilot in skills mode never populates ``registered_commands`` for its overrides (``_register_commands``'s ``ai_skills`` guard skips command-file registration entirely), so with two presets overriding the same command, the removed preset's command name was never added to ``removed_cmd_names`` and reconciliation was skipped outright. ``_unregister_skills`` then restored straight to core/extension content instead of resolving the lower-priority preset that should now win (#2948). """ self._write_init_options(project_dir, ai="copilot", ai_skills=True) preset_a_dir = self._create_command_preset( temp_dir, "skills-preset-a", "speckit.specify", "Preset A", "preset A body", ) preset_b_dir = self._create_command_preset( temp_dir, "skills-preset-b", "speckit.specify", "Preset B", "preset B body", ) manager = PresetManager(project_dir) # Lower priority number = higher precedence. manager.install_from_directory(preset_a_dir, "0.1.5", priority=5) manager.install_from_directory(preset_b_dir, "0.1.5", priority=10) skills_dir = project_dir / ".github" / "skills" skill_file = skills_dir / "speckit-specify" / "SKILL.md" assert "preset:skills-preset-a" in skill_file.read_text(), ( "sanity: the higher-precedence preset should win initially" ) assert manager.remove("skills-preset-a") is True content = skill_file.read_text() assert "preset:skills-preset-b" in content, ( "removing the higher-precedence skills-mode preset must " "restore the surviving lower-precedence preset's override, " "not fall back to core/extension content (#2948)" ) assert "preset:skills-preset-a" not in content def test_composed_none_unregister_respects_active_agent( self, project_dir, temp_dir ): """Unregistering a stale composed command must only touch the active agent's directory, not every configured non-skill agent. When a wrap preset's base layer is removed, ``resolve_content`` can no longer find a replace layer to compose onto and returns ``None``, triggering the "composed is None" branch of ``_reconcile_composed_commands``. Before the fix, that unregistration mapping covered every configured non-skill agent regardless of ``only_agent``, deleting historical artifacts from integrations that were never active for this preset (#2948). """ self._write_init_options(project_dir, ai="gemini", ai_skills=False) gemini_commands_dir = project_dir / ".gemini" / "commands" gemini_commands_dir.mkdir(parents=True) # A made-up command name with no bundled/core equivalent, so the # *only* base layer is the "compose-base" preset installed below — # once it's removed, no base remains for the wrap preset to compose # onto. cmd_name = "speckit.fake-compose-test" base_dir = self._create_command_preset( temp_dir, "compose-base", cmd_name, "Base", "base body", ) manager = PresetManager(project_dir) manager.install_from_directory(base_dir, "0.1.5", priority=10) wrap_dir = temp_dir / "compose-wrap" wrap_dir.mkdir() (wrap_dir / "commands").mkdir() (wrap_dir / "commands" / f"{cmd_name}.md").write_text( "---\ndescription: Wrap\nstrategy: wrap\n---\n\n" "wrap start\n{CORE_TEMPLATE}\nwrap end\n" ) manifest_data = { "schema_version": "1.0", "preset": { "id": "compose-wrap", "name": "compose-wrap", "version": "1.0.0", "description": "Test", }, "requires": {"speckit_version": ">=0.1.0"}, "provides": { "templates": [ { "type": "command", "name": cmd_name, "file": f"commands/{cmd_name}.md", "strategy": "wrap", } ] }, } with open(wrap_dir / "preset.yml", "w") as f: yaml.dump(manifest_data, f) manager.install_from_directory(wrap_dir, "0.1.5", priority=5) cmd_file = gemini_commands_dir / f"{cmd_name}.toml" assert cmd_file.exists(), ( "sanity: the composed command should register for the active agent" ) # Simulate a pre-existing artifact for an inactive agent, predating # this preset entirely — active-only unregistration must never # touch it. opencode_dir = project_dir / ".opencode" / "commands" opencode_dir.mkdir(parents=True, exist_ok=True) opencode_stale_file = opencode_dir / f"{cmd_name}.md" opencode_stale_file.write_text("stale opencode content\n") assert manager.remove("compose-base") is True assert not cmd_file.exists(), ( "sanity: the active agent's now-uncomposable command file must " "be unregistered" ) assert opencode_stale_file.read_text() == "stale opencode content\n", ( "unregistering a stale composed command must not touch an " "inactive agent's directory (#2948)" ) def test_remove_reconciles_command_for_every_historical_agent( self, project_dir, temp_dir ): """Removing a preset must reconcile every historical agent its ``registered_commands`` actually targeted, not only the currently active one. Preset B (lower precedence, survives) is installed while gemini is active, then preset A (higher precedence) overrides the same command while gemini is still active. Switching the active integration to opencode and rescaffolding re-registers both presets under opencode too, so preset A's ``registered_commands`` now spans two agents: gemini (now inactive) and opencode (active). Removing A deletes its command file from *both* directories via ``_unregister_commands``, but active-only reconciliation used to recreate the surviving preset B's content only for the active agent (opencode), leaving gemini's directory with a stale/missing file (#2948). """ self._write_init_options(project_dir, ai="gemini", ai_skills=False) gemini_dir = project_dir / ".gemini" / "commands" gemini_dir.mkdir(parents=True) preset_b_dir = self._create_command_preset( temp_dir, "hist-preset-b", "speckit.specify", "Preset B", "preset B body", ) preset_a_dir = self._create_command_preset( temp_dir, "hist-preset-a", "speckit.specify", "Preset A", "preset A body", ) manager = PresetManager(project_dir) manager.install_from_directory(preset_b_dir, "0.1.5", priority=10) manager.install_from_directory(preset_a_dir, "0.1.5", priority=1) gemini_cmd_files = list(gemini_dir.glob("*specify*")) assert gemini_cmd_files, "sanity: gemini should have the command file" assert "preset A body" in gemini_cmd_files[0].read_text(), ( "sanity: preset A (higher precedence) should win initially" ) # Switch the active integration to opencode and rescaffold, mirroring # `integration use opencode`. This merges opencode into both # presets' registered_commands alongside the pre-existing gemini # entry recorded while gemini was active. self._write_init_options(project_dir, ai="opencode", ai_skills=False) opencode_dir = project_dir / ".opencode" / "commands" opencode_dir.mkdir(parents=True, exist_ok=True) manager.register_enabled_presets_for_agent("opencode") metadata_a = manager.registry.get("hist-preset-a") assert set(metadata_a.get("registered_commands", {})) == {"gemini", "opencode"}, ( "sanity: preset A's registered_commands must span both the " "historical (gemini) and currently active (opencode) agents" ) assert manager.remove("hist-preset-a") is True gemini_cmd_files = list(gemini_dir.glob("*specify*")) opencode_cmd_files = list(opencode_dir.glob("*specify*")) assert gemini_cmd_files, "gemini's command file must still exist after removal" assert opencode_cmd_files, "opencode's command file must still exist after removal" assert "preset B body" in gemini_cmd_files[0].read_text(), ( "removing the higher-precedence preset must restore the " "surviving preset's content in the historical (inactive) " "agent's directory too, not only the active agent's (#2948)" ) assert "preset B body" in opencode_cmd_files[0].read_text(), ( "the surviving preset's content must also be restored for the " "currently active agent" ) def test_remove_reconciliation_tracks_new_historical_agent_for_survivor( self, project_dir, temp_dir ): """Historical-agent reconciliation writes must be recorded in the surviving preset's own ``registered_commands``, not just written to disk and forgotten. Preset A is installed while gemini is active, then survives to be active under opencode too (so A's ``registered_commands`` spans both gemini and opencode). Preset B is installed *only* while opencode is active — B's ``registered_commands`` is ``{"opencode": [...]}`` and never mentions gemini. Removing A triggers reconciliation that writes B's content into gemini's directory (an agent B never wrote to before) via ``extra_agents``, but if that write isn't merged back into B's own ``registered_commands``, B's registry entry still only says ``{"opencode": [...]}`` even though B's content now lives in gemini's directory too. A later ``remove('b')`` then only cleans up opencode, leaving the gemini file — reconciled there entirely by side effect of removing A — as a permanent orphan with no preset tracking it (#2948). """ self._write_init_options(project_dir, ai="gemini", ai_skills=False) gemini_dir = project_dir / ".gemini" / "commands" gemini_dir.mkdir(parents=True) preset_a_dir = self._create_command_preset( temp_dir, "orphan-preset-a", "speckit.specify", "Preset A", "preset A body", ) manager = PresetManager(project_dir) manager.install_from_directory(preset_a_dir, "0.1.5", priority=1) self._write_init_options(project_dir, ai="opencode", ai_skills=False) opencode_dir = project_dir / ".opencode" / "commands" opencode_dir.mkdir(parents=True, exist_ok=True) manager.register_enabled_presets_for_agent("opencode") metadata_a = manager.registry.get("orphan-preset-a") assert set(metadata_a.get("registered_commands", {})) == {"gemini", "opencode"}, ( "sanity: preset A must be tracked under both agents" ) # Preset B is installed only now, while opencode is the sole # active agent — it never writes to or tracks gemini. preset_b_dir = self._create_command_preset( temp_dir, "orphan-preset-b", "speckit.specify", "Preset B", "preset B body", ) manager.install_from_directory(preset_b_dir, "0.1.5", priority=10) metadata_b = manager.registry.get("orphan-preset-b") assert set(metadata_b.get("registered_commands", {})) == {"opencode"}, ( "sanity: preset B must only be tracked for opencode before " "preset A is removed" ) assert manager.remove("orphan-preset-a") is True # B is now written into gemini's directory as a side effect of # reconciling A's removal, via the historical-agent extra_agents # pass. gemini_cmd_files = list(gemini_dir.glob("*specify*")) assert gemini_cmd_files, "sanity: gemini's directory must have B's restored content" assert "preset B body" in gemini_cmd_files[0].read_text(encoding="utf-8") metadata_b = manager.registry.get("orphan-preset-b") assert set(metadata_b.get("registered_commands", {})) == {"gemini", "opencode"}, ( "preset B's own registered_commands must be updated to " "include gemini once reconciliation actually writes content " "there on its behalf — otherwise B's registry entry silently " "lies about which directories it owns (#2948)" ) assert manager.remove("orphan-preset-b") is True # No preset is installed any more, so gemini's file must have been # reconciled down to the core bundled template (or removed # entirely) — but it must NOT still contain B's stale content, # which would mean B's write there was never tracked for cleanup. remaining_gemini_files = list(gemini_dir.glob("*specify*")) for f in remaining_gemini_files: assert "preset B body" not in f.read_text(encoding="utf-8"), ( "removing preset B must clean up gemini's directory too, " "since B's registered_commands was updated to include it " "— otherwise B's stale content is orphaned there forever " "with no preset left to track or clean it up (#2948)" ) def test_extension_reconciliation_tracks_new_historical_agent( self, project_dir ): from specify_cli.extensions import ExtensionRegistry self._write_init_options(project_dir, ai="opencode", ai_skills=False) (project_dir / ".opencode" / "commands").mkdir(parents=True) (project_dir / ".gemini" / "commands").mkdir(parents=True) ext_dir = project_dir / ".specify" / "extensions" / "tracked-ext" (ext_dir / "commands").mkdir(parents=True) (ext_dir / "commands" / "tracked.md").write_text( "---\ndescription: tracked\n---\n\nExtension body\n", encoding="utf-8", ) (ext_dir / "extension.yml").write_text( "schema_version: '1.0'\n" "extension:\n id: tracked-ext\n name: Tracked\n version: 1.0.0\n" " description: test\n author: test\n repository: https://example.com\n" " license: MIT\n" "requires:\n speckit_version: '>=0.2.0'\n" "provides:\n" " commands:\n" " - name: speckit.tracked\n" " file: commands/tracked.md\n" " description: Tracked command\n", encoding="utf-8", ) ExtensionRegistry(ext_dir.parent).add( "tracked-ext", { "version": "1.0.0", "source": "dev", "enabled": True, "registered_commands": { "opencode": ["speckit.tracked-ext.tracked"] }, }, ) manager = PresetManager(project_dir) manager._reconcile_composed_commands( ["speckit.tracked-ext.tracked"], extra_agents={"gemini"} ) assert list((project_dir / ".gemini" / "commands").glob("*tracked*")) metadata = ExtensionRegistry(ext_dir.parent).get("tracked-ext") assert set(metadata["registered_commands"]) == {"gemini", "opencode"} def test_remove_reconciles_skill_for_every_historical_agent( self, project_dir, temp_dir ): """Removing a preset must reconcile every historical skills directory its ``registered_skills`` actually targeted, not only the currently active one. Preset B (survives) is installed while claude is active, then preset A (higher precedence) overrides the same command while claude is still active. Switching to codex and rescaffolding records codex too, so preset A's ``registered_skills`` spans both claude (now inactive) and codex (active) directories. Removing A restores both directories to core/extension via ``_unregister_skills``, but ``_reconcile_skills`` used to only resolve/apply the surviving winner for the currently active skills directory, leaving claude's directory reverted to core/extension content instead of preset B's override (#2948). """ self._write_init_options(project_dir, ai="claude", ai_skills=True) claude_skills_dir = project_dir / ".claude" / "skills" # A core template fallback is required so unregistering the # top-priority preset's SKILL.md restores core content rather than # deleting the skill directory outright when no preset remains to # apply on top of it (mirrors the pre-existing skills-reconciliation # fixtures elsewhere in this file). core_cmds = project_dir / ".specify" / "templates" / "commands" core_cmds.mkdir(parents=True, exist_ok=True) (core_cmds / "specify.md").write_text( "---\ndescription: Core specify command\n---\n\nCore specify body\n", encoding="utf-8", ) preset_b_dir = self._create_command_preset( temp_dir, "hist-skill-preset-b", "speckit.specify", "Preset B", "preset B body", ) preset_a_dir = self._create_command_preset( temp_dir, "hist-skill-preset-a", "speckit.specify", "Preset A", "preset A body", ) manager = PresetManager(project_dir) manager.install_from_directory(preset_b_dir, "0.1.5", priority=10) manager.install_from_directory(preset_a_dir, "0.1.5", priority=1) claude_skill_file = claude_skills_dir / "speckit-specify" / "SKILL.md" assert "preset:hist-skill-preset-a" in claude_skill_file.read_text(), ( "sanity: preset A (higher precedence) should win initially" ) # Switch the active integration to codex (a distinct skills # directory) and rescaffold, mirroring `integration use codex`. self._write_init_options(project_dir, ai="codex", ai_skills=True) codex_skills_dir = project_dir / ".agents" / "skills" manager.register_enabled_presets_for_agent("codex") metadata_a = manager.registry.get("hist-skill-preset-a") assert set(metadata_a.get("registered_skills", {})) == {"claude", "codex"}, ( "sanity: preset A's registered_skills must span both the " "historical (claude) and currently active (codex) agents" ) assert manager.remove("hist-skill-preset-a") is True codex_skill_file = codex_skills_dir / "speckit-specify" / "SKILL.md" assert claude_skill_file.exists(), "claude's skill file must still exist after removal" assert codex_skill_file.exists(), "codex's skill file must still exist after removal" assert "preset:hist-skill-preset-b" in claude_skill_file.read_text(), ( "removing the higher-precedence preset must restore the " "surviving preset's override in the historical (inactive) " "agent's directory too, not only the active agent's (#2948)" ) assert "preset:hist-skill-preset-b" in codex_skill_file.read_text(), ( "the surviving preset's override must also be restored for " "the currently active agent" ) def test_skill_reconciliation_preserves_per_directory_names( self, project_dir, temp_dir ): self._write_init_options(project_dir, ai="claude", ai_skills=True) claude_dir = project_dir / ".claude" / "skills" self._create_skill(claude_dir, "speckit-alpha") alpha_dir = self._create_command_preset( temp_dir, "partial-alpha", "speckit.alpha", "Alpha", "alpha body", ) manager = PresetManager(project_dir) manager.install_from_directory(alpha_dir, "0.1.5") self._write_init_options(project_dir, ai="codex", ai_skills=True) codex_dir = project_dir / ".agents" / "skills" self._create_skill(codex_dir, "speckit-beta") beta_dir = self._create_command_preset( temp_dir, "partial-beta", "speckit.beta", "Beta", "beta body", ) manager.install_from_directory(beta_dir, "0.1.5") affected = manager._unregister_skills( { "claude": ["speckit-alpha"], "codex": ["speckit-beta"], }, manager.presets_dir / "partial-alpha", ) manager._reconcile_skills( ["speckit.alpha", "speckit.beta"], extra_skills_dirs=affected, ) assert (claude_dir / "speckit-alpha" / "SKILL.md").exists() assert (codex_dir / "speckit-beta" / "SKILL.md").exists() assert not (claude_dir / "speckit-beta").exists() assert not (codex_dir / "speckit-alpha").exists() def test_skill_reconciliation_rejects_unsafe_managed_names( self, project_dir, temp_dir ): self._write_init_options(project_dir, ai="claude", ai_skills=True) skills_dir = project_dir / ".claude" / "skills" skills_dir.mkdir(parents=True) core_cmds = project_dir / ".specify" / "templates" / "commands" core_cmds.mkdir(parents=True, exist_ok=True) (core_cmds / "specify.md").write_text( "---\ndescription: Core specify\n---\n\nCore body\n", encoding="utf-8", ) absolute_escape = temp_dir / "absolute-escape" traversal_escape = project_dir / ".claude" / "traversal-escape" manager = PresetManager(project_dir) manager._reconcile_skills( ["speckit.specify"], extra_skills_dirs={ skills_dir: ( "claude", [str(absolute_escape), "../traversal-escape"], ) }, ) assert not absolute_escape.exists() assert not traversal_escape.exists() def test_remove_reconciliation_tracks_new_historical_skill_agent_for_survivor( self, project_dir, temp_dir ): """Historical-agent skill reconciliation writes must be recorded in the surviving preset's own ``registered_skills``, mirroring ``test_remove_reconciliation_tracks_new_historical_agent_for_survivor`` for the command side. Preset A is installed while claude is active, then survives to be active under codex too (so A's ``registered_skills`` spans both claude and codex). Preset B is installed *only* while codex is active — B's ``registered_skills`` is ``{"codex": [...]}`` and never mentions claude. Removing A triggers reconciliation that renders B's SKILL.md into claude's directory (an agent B never wrote to before) via ``extra_skills_dirs``, but if that write isn't merged back into B's own ``registered_skills``, a later ``remove('b')`` only cleans up codex, leaving claude's SKILL.md — rendered there entirely by side effect of removing A — untracked by any preset (#2948). """ self._write_init_options(project_dir, ai="claude", ai_skills=True) claude_skills_dir = project_dir / ".claude" / "skills" # Pre-create the skill so _register_commands/_register_skills find # an existing skill to overwrite (mirrors every other skill test # in this class — native skill agents only overwrite already # existing skill directories, they don't materialize brand-new # ones outside of active-agent creation). self._create_skill(claude_skills_dir, "speckit-specify") core_cmds = project_dir / ".specify" / "templates" / "commands" core_cmds.mkdir(parents=True, exist_ok=True) (core_cmds / "specify.md").write_text( "---\ndescription: Core specify command\n---\n\nCore specify body\n", encoding="utf-8", ) preset_a_dir = self._create_command_preset( temp_dir, "orphan-skill-preset-a", "speckit.specify", "Preset A", "preset A body", ) manager = PresetManager(project_dir) manager.install_from_directory(preset_a_dir, "0.1.5", priority=1) self._write_init_options(project_dir, ai="codex", ai_skills=True) codex_skills_dir = project_dir / ".agents" / "skills" self._create_skill(codex_skills_dir, "speckit-specify") manager.register_enabled_presets_for_agent("codex") metadata_a = manager.registry.get("orphan-skill-preset-a") assert set(metadata_a.get("registered_skills", {})) == {"claude", "codex"}, ( "sanity: preset A must be tracked under both agents" ) # Preset B is installed only now, while codex is the sole active # agent — it never writes to or tracks claude. preset_b_dir = self._create_command_preset( temp_dir, "orphan-skill-preset-b", "speckit.specify", "Preset B", "preset B body", ) manager.install_from_directory(preset_b_dir, "0.1.5", priority=10) metadata_b = manager.registry.get("orphan-skill-preset-b") assert set(metadata_b.get("registered_skills", {})) == {"codex"}, ( "sanity: preset B must only be tracked for codex before " "preset A is removed" ) assert manager.remove("orphan-skill-preset-a") is True claude_skill_file = claude_skills_dir / "speckit-specify" / "SKILL.md" assert claude_skill_file.exists(), ( "sanity: claude's skill file must have been restored by " "reconciliation" ) assert "preset:orphan-skill-preset-b" in claude_skill_file.read_text(), ( "sanity: claude's SKILL.md must reflect preset B's content " "after preset A is removed" ) metadata_b = manager.registry.get("orphan-skill-preset-b") assert set(metadata_b.get("registered_skills", {})) == {"claude", "codex"}, ( "preset B's own registered_skills must be updated to include " "claude once reconciliation actually renders content there " "on its behalf — otherwise B's registry entry silently lies " "about which directories it owns (#2948)" ) assert manager.remove("orphan-skill-preset-b") is True # No preset is installed any more, so claude's SKILL.md must have # been reconciled down to the core bundled template (or removed # entirely) — but it must NOT still contain B's stale content, # which would mean B's write there was never tracked for cleanup. if claude_skill_file.exists(): assert "preset:orphan-skill-preset-b" not in claude_skill_file.read_text(), ( "removing preset B must clean up claude's directory too, " "since B's registered_skills was updated to include it — " "otherwise B's stale content is orphaned there forever " "with no preset left to track or clean it up (#2948)" ) def test_symlinked_skill_subdir_rejected_on_restore(self, project_dir, temp_dir): """Restore must validate each per-skill subdirectory, not just its parent. ``_safe_skills_dir_for_agent`` only validates the parent skills directory (e.g. ``.claude/skills``); a symlink planted one level deeper at the individual skill's own subdirectory (e.g. ``.claude/skills/speckit-specify``) has a perfectly safe parent and would otherwise slip past that check, since ``is_dir()`` follows symlinks. Restoration must refuse to write/rmtree through it (#2948). """ self._write_init_options(project_dir, ai="claude", ai_skills=True) claude_skills_dir = project_dir / ".claude" / "skills" claude_skills_dir.mkdir(parents=True) outside_target = temp_dir / "outside-skill-subdir" outside_target.mkdir() sentinel = outside_target / "SKILL.md" sentinel.write_text("do-not-touch") (claude_skills_dir / "speckit-specify").symlink_to( outside_target, target_is_directory=True ) manager = PresetManager(project_dir) manager._unregister_skills_in_dir( ["speckit-specify"], claude_skills_dir, "claude" ) assert sentinel.read_text() == "do-not-touch", ( "restoration must not follow a symlinked skill subdirectory " "to write/delete outside the project (#2948)" ) assert (claude_skills_dir / "speckit-specify").is_symlink(), ( "the symlink itself should be left alone, not rmtree'd through" ) def test_symlinked_skill_subdir_rejected_on_write(self, project_dir, temp_dir): """Registration must validate each per-skill subdirectory before writing. A symlink planted at an individual skill's own subdirectory (safe parent, unsafe leaf) would otherwise pass the existing ``skill_subdir.exists() and not skill_subdir.is_dir()`` guard (``is_dir()`` follows symlinks) and have ``SKILL.md`` written through it to an arbitrary location (#2948). """ self._write_init_options(project_dir, ai="copilot", ai_skills=True) copilot_commands_dir = project_dir / ".github" / "agents" copilot_commands_dir.mkdir(parents=True) skills_dir = project_dir / ".github" / "skills" skills_dir.mkdir(parents=True) outside_target = temp_dir / "outside-skill-write-target" outside_target.mkdir() (skills_dir / "speckit-specify").symlink_to( outside_target, target_is_directory=True ) preset_dir = self._create_command_preset( temp_dir, "symlink-write-preset", "speckit.specify", "Symlink write test", "preset body", ) manager = PresetManager(project_dir) manager.install_from_directory(preset_dir, "0.1.5") assert not (outside_target / "SKILL.md").exists(), ( "registration must not follow a symlinked skill subdirectory " "to write outside the project (#2948)" ) assert (skills_dir / "speckit-specify").is_symlink(), ( "the symlink itself should be left alone" ) def test_symlinked_skill_file_rejected_on_write(self, project_dir, temp_dir): """Registration must not follow a symlinked SKILL.md destination.""" self._write_init_options(project_dir, ai="copilot", ai_skills=True) (project_dir / ".github" / "agents").mkdir(parents=True) skill_dir = ( project_dir / ".github" / "skills" / "speckit-specify" ) skill_dir.mkdir(parents=True) outside_file = temp_dir / "outside-registration.md" outside_file.write_text("do-not-touch", encoding="utf-8") (skill_dir / "SKILL.md").symlink_to(outside_file) preset_dir = self._create_command_preset( temp_dir, "symlink-file-write-preset", "speckit.specify", "Symlink file write", "preset body", ) manager = PresetManager(project_dir) with pytest.raises(ValueError): manager.install_from_directory(preset_dir, "0.1.5") assert outside_file.read_text(encoding="utf-8") == "do-not-touch" assert (skill_dir / "SKILL.md").is_symlink() def test_symlinked_skill_file_rejected_on_restore( self, project_dir, temp_dir ): """Restoration must not follow a symlinked SKILL.md destination.""" self._write_init_options(project_dir, ai="claude", ai_skills=True) core_commands = project_dir / ".specify" / "templates" / "commands" (core_commands / "specify.md").write_text( "---\ndescription: Core specify\n---\n\nCore body\n", encoding="utf-8", ) skill_dir = ( project_dir / ".claude" / "skills" / "speckit-specify" ) skill_dir.mkdir(parents=True) outside_file = temp_dir / "outside-restoration.md" outside_file.write_text("do-not-touch", encoding="utf-8") (skill_dir / "SKILL.md").symlink_to(outside_file) manager = PresetManager(project_dir) with pytest.raises(ValueError): manager._unregister_skills_in_dir( ["speckit-specify"], skill_dir.parent, "claude" ) assert outside_file.read_text(encoding="utf-8") == "do-not-touch" assert (skill_dir / "SKILL.md").is_symlink() def test_symlinked_skill_file_rejected_on_override_reconcile( self, project_dir, temp_dir ): """Project-override reconciliation must not follow SKILL.md symlinks.""" self._write_init_options(project_dir, ai="copilot", ai_skills=True) skill_dir = ( project_dir / ".github" / "skills" / "speckit-specify" ) skill_dir.mkdir(parents=True) outside_file = temp_dir / "outside-reconciliation.md" outside_file.write_text("do-not-touch", encoding="utf-8") (skill_dir / "SKILL.md").symlink_to(outside_file) preset_dir = self._create_command_preset( temp_dir, "symlink-override-preset", "speckit.specify", "Preset", "Preset body", ) manager = PresetManager(project_dir) manager.registry.add( "symlink-override-preset", { "version": "1.0.0", "source": "local", "enabled": True, "priority": 10, "registered_commands": {}, "registered_skills": { "copilot": ["speckit-specify"] }, }, ) installed_dir = ( manager.presets_dir / "symlink-override-preset" ) shutil.copytree(preset_dir, installed_dir) overrides_dir = ( project_dir / ".specify" / "templates" / "overrides" ) overrides_dir.mkdir(parents=True) (overrides_dir / "speckit.specify.md").write_text( "---\ndescription: Override\n---\n\nOverride body\n", encoding="utf-8", ) manager._reconcile_skills(["speckit.specify"]) assert outside_file.read_text(encoding="utf-8") == "do-not-touch" assert (skill_dir / "SKILL.md").is_symlink() def test_is_safe_registry_skill_name_rejects_unsafe_values(self, project_dir): """Unit-test the centralized registry skill-name boundary guard. ``registered_skills`` entries are persisted registry data, not manifest-derived, so every preset cleanup/provenance loop that joins one onto a directory must first reject: non-strings, empty strings, absolute paths, multi-component paths (containing ``/``), and the literal traversal components ``"."``/``".."`` — the last of which is *not* caught by a naive ``is_absolute() or len(parts) != 1`` check alone, since ``Path("..").parts`` is a single-element tuple (#2948). """ manager = PresetManager(project_dir) is_safe = manager._is_safe_registry_skill_name assert is_safe("speckit-specify") is True assert is_safe("") is False assert is_safe(None) is False assert is_safe(123) is False assert is_safe(["speckit-specify"]) is False assert is_safe(".") is False assert is_safe("..") is False assert is_safe("/etc/passwd") is False assert is_safe(str(project_dir / "important-data")) is False assert is_safe("foo/bar") is False assert is_safe("foo/..") is False assert is_safe("../foo") is False def test_unregister_skills_rejects_unknown_agent_provenance( self, project_dir ): """Unknown registry agent keys must not fall back to shared skills.""" shared_skills_dir = project_dir / ".agents" / "skills" skill_dir = self._create_skill( shared_skills_dir, "speckit-specify", "user-owned content" ) core_commands = project_dir / ".specify" / "templates" / "commands" (core_commands / "specify.md").write_text( "---\ndescription: Core specify\n---\n\nCore body\n", encoding="utf-8", ) manager = PresetManager(project_dir) manager._unregister_skills( {"unknown": ["speckit-specify"]}, project_dir ) assert (skill_dir / "SKILL.md").read_text(encoding="utf-8") == ( "---\nname: speckit-specify\n---\n\nuser-owned content\n" ) def test_unregister_legacy_fallback_skips_non_owned_skill( self, project_dir ): """Legacy fallback provenance must not overwrite a user-owned skill.""" self._write_init_options(project_dir, ai="claude", ai_skills=True) skills_dir = project_dir / ".claude" / "skills" skill_dir = self._create_skill( skills_dir, "speckit-specify", "user-owned content" ) core_commands = project_dir / ".specify" / "templates" / "commands" (core_commands / "specify.md").write_text( "---\ndescription: Core specify\n---\n\nCore body\n", encoding="utf-8", ) manager = PresetManager(project_dir) manager._unregister_skills( ["speckit-specify"], "removed-preset" ) assert (skill_dir / "SKILL.md").read_text(encoding="utf-8") == ( "---\nname: speckit-specify\n---\n\nuser-owned content\n" ) def test_unregister_skills_in_dir_rejects_absolute_registry_name( self, project_dir ): """A corrupted ``registered_skills`` entry with an absolute path must not escape. ``Path`` join with an absolute right-hand operand discards the left side entirely (``skills_dir / "/abs/path"`` == ``"/abs/path"``), so an absolute in-project path stored in the registry would bypass ``skills_dir`` altogether if not rejected before the join (#2948). """ self._write_init_options(project_dir, ai="claude", ai_skills=True) claude_skills_dir = project_dir / ".claude" / "skills" claude_skills_dir.mkdir(parents=True) precious_dir = project_dir / "important-data" precious_dir.mkdir() precious_file = precious_dir / "SKILL.md" precious_file.write_text("precious-absolute-target-marker") manager = PresetManager(project_dir) manager._unregister_skills_in_dir( [str(precious_dir)], claude_skills_dir, "claude" ) assert precious_dir.is_dir(), ( "an absolute registry entry must not let cleanup escape " "skills_dir to an unrelated project directory (#2948)" ) assert precious_file.read_text() == "precious-absolute-target-marker" def test_infer_legacy_skill_provenance_rejects_absolute_registry_name( self, project_dir ): """Legacy provenance inference must reject an absolute registry name. ``_infer_legacy_skill_provenance`` receives its ``skill_names`` directly from a legacy flat-list ``registered_skills`` value — registry data, not manifest-derived — and joins each name onto a candidate agent's resolved skills directory the same way ``_unregister_skills_in_dir`` does. An absolute in-project name discards the candidate directory entirely (Python's ``/`` operator drops the left side for an absolute right side), so it can read an unrelated project directory's ``SKILL.md`` and, if its frontmatter happens to carry a matching preset source marker, falsely attribute an unrelated directory as this preset's own skill override under whichever agent is being probed (#2948). """ self._write_init_options(project_dir, ai="claude", ai_skills=True) claude_skills_dir = project_dir / ".claude" / "skills" claude_skills_dir.mkdir(parents=True) precious_dir = project_dir / "important-data" precious_dir.mkdir() (precious_dir / "SKILL.md").write_text( "---\n" "metadata:\n" " source: preset:some-pack\n" "---\n\n" "# Unrelated directory, not a real preset skill\n" ) manager = PresetManager(project_dir) inferred = manager._infer_legacy_skill_provenance( [str(precious_dir)], "some-pack", "claude" ) for names in inferred.values(): assert str(precious_dir) not in names, ( "an absolute registry entry must not be falsely attributed " "as preset-owned provenance by probing outside the " "intended skills subtree (#2948)" ) def test_copilot_skills_registration_restored_after_process_restart( self, project_dir, temp_dir ): """Copilot skills-mode registrations must restore even when the transient ``_skills_mode`` integration attribute has been reset, simulating a fresh CLI process. ``_skills_mode`` is set during ``setup()`` and is never persisted; after switching the active agent and running ``preset remove`` in a brand-new process, a naive "is this integration currently in skills mode" check would be False even though Copilot's ``.github/skills`` directory holds a live override this preset wrote. Restoration must rely on the persisted per-agent provenance recorded at write time, not on runtime integration state (#2948). """ self._write_init_options(project_dir, ai="copilot", ai_skills=True) core_cmds = project_dir / ".specify" / "templates" / "commands" core_cmds.mkdir(parents=True, exist_ok=True) (core_cmds / "specify.md").write_text( "---\ndescription: Core specify command\n---\n\nCore specify body\n", encoding="utf-8", ) copilot_skills_dir = project_dir / ".github" / "skills" self._create_skill(copilot_skills_dir, "speckit-specify") preset_dir = self._create_command_preset( temp_dir, "copilot-fresh-process-preset", "speckit.specify", "Copilot fresh process test", "preset body", ) manager = PresetManager(project_dir) manager.install_from_directory(preset_dir, "0.1.5") skill_file = copilot_skills_dir / "speckit-specify" / "SKILL.md" assert "preset:copilot-fresh-process-preset" in skill_file.read_text() metadata = manager.registry.get("copilot-fresh-process-preset") assert "copilot" in metadata.get("registered_skills", {}) # Switch the active agent away from copilot, then simulate a fresh # CLI process (a brand-new PresetManager, so any transient # `_skills_mode` state set during a prior setup() call is gone) # removing the preset. self._write_init_options(project_dir, ai="claude", ai_skills=True) fresh_manager = PresetManager(project_dir) assert fresh_manager.remove("copilot-fresh-process-preset") is True assert "preset:copilot-fresh-process-preset" not in skill_file.read_text(), ( "removal must restore copilot's .github/skills override even " "when copilot's transient skills-mode state isn't set in this " "process (#2948)" ) assert "Core specify body" in skill_file.read_text() def test_unregister_agent_artifacts_scoped_to_target_agent_only( self, project_dir, temp_dir ): """``unregister_agent_artifacts`` must remove only the target agent's own tracked command/skill artifacts. Used by ``integration switch`` when deactivating the previous integration for a not-yet-installed target (#2948): without this, a preset's command override -- including a custom preset command -- and skill mirror rendered for the old agent remain orphaned once a different integration becomes active. Another agent's own registrations (files and registry tracking) must survive untouched, and no priority-stack reconciliation should run as a side effect. """ self._write_init_options(project_dir, ai="auggie", ai_skills=False) # Registration only writes to an agent's directory once it's # "detected" on disk (mirroring a real `integration install` # having already created it), so pre-create both agents' # directories before installing the preset. (project_dir / ".augment" / "commands").mkdir(parents=True) (project_dir / ".opencode" / "commands").mkdir(parents=True) preset_dir = self._create_command_preset( temp_dir, "switch-cleanup-preset", "speckit.specify", "Custom preset command", "preset body", ) manager = PresetManager(project_dir) manager.install_from_directory(preset_dir, "0.1.5") auggie_cmd = project_dir / ".augment" / "commands" / "speckit.specify.md" assert auggie_cmd.exists(), "sanity: preset command registered for auggie" # Simulate a later `integration use opencode` rescaffold that also # registered the preset for opencode, while auggie's own # registration (from before the switch) is still present in the # registry. self._write_init_options(project_dir, ai="opencode", ai_skills=False) manager.register_enabled_presets_for_agent("opencode") opencode_cmd = project_dir / ".opencode" / "commands" / "speckit.specify.md" assert opencode_cmd.exists(), "sanity: preset command registered for opencode" metadata = manager.registry.get("switch-cleanup-preset") registered_commands = metadata.get("registered_commands", {}) assert "auggie" in registered_commands and "opencode" in registered_commands manager.unregister_agent_artifacts("auggie") assert not auggie_cmd.exists(), ( "auggie's own preset command must be removed when switching " "away from auggie to a not-yet-installed integration (#2948)" ) assert opencode_cmd.exists(), ( "opencode's preset command must survive unregistering auggie's " "artifacts -- cleanup must stay scoped to the target agent" ) metadata = manager.registry.get("switch-cleanup-preset") registered_commands = metadata.get("registered_commands", {}) assert "auggie" not in registered_commands, ( "auggie's tracking must be dropped after unregistering its artifacts" ) assert "opencode" in registered_commands, ( "opencode's tracking must be preserved untouched" ) def test_unregister_agent_artifacts_deletes_marker_owned_skill( self, project_dir, temp_dir ): self._write_init_options(project_dir, ai="copilot", ai_skills=True) core_cmds = project_dir / ".specify" / "templates" / "commands" core_cmds.mkdir(parents=True, exist_ok=True) (core_cmds / "specify.md").write_text( "---\ndescription: Core specify\n---\n\nCore body\n", encoding="utf-8", ) skills_dir = project_dir / ".github" / "skills" self._create_skill(skills_dir, "speckit-specify") preset_dir = self._create_command_preset( temp_dir, "deactivated-skill-preset", "speckit.specify", "Deactivation cleanup", "preset body", ) manager = PresetManager(project_dir) manager.install_from_directory(preset_dir, "0.1.5") skill_dir = skills_dir / "speckit-specify" assert "preset:deactivated-skill-preset" in ( skill_dir / "SKILL.md" ).read_text() manager.unregister_agent_artifacts("copilot") assert not skill_dir.exists() def test_unregister_agent_artifacts_deletes_reconciled_override_skill( self, project_dir, temp_dir ): self._write_init_options(project_dir, ai="copilot", ai_skills=True) skills_dir = project_dir / ".github" / "skills" self._create_skill(skills_dir, "speckit-specify") preset_dir = self._create_command_preset( temp_dir, "deactivated-override-preset", "speckit.specify", "Deactivation override cleanup", "preset body", ) manager = PresetManager(project_dir) manager.install_from_directory(preset_dir, "0.1.5") overrides_dir = ( project_dir / ".specify" / "templates" / "overrides" ) overrides_dir.mkdir(parents=True) (overrides_dir / "speckit.specify.md").write_text( "---\ndescription: Project override\n---\n\nOverride body\n", encoding="utf-8", ) ( manager.presets_dir / "deactivated-override-preset" / "commands" / "speckit.specify.md" ).unlink() manager.register_enabled_presets_for_agent("copilot") skill_dir = skills_dir / "speckit-specify" assert "override:speckit.specify" in ( skill_dir / "SKILL.md" ).read_text(encoding="utf-8") manager.unregister_agent_artifacts("copilot") assert not skill_dir.exists() metadata = manager.registry.get("deactivated-override-preset") assert "copilot" not in metadata.get("registered_skills", {}) def test_unregister_native_agent_persists_skills_metadata_pop( self, project_dir, temp_dir ): self._write_init_options(project_dir, ai="agy", ai_skills=True) (project_dir / ".agents" / "skills").mkdir(parents=True) preset_dir = self._create_command_preset( temp_dir, "native-metadata-cleanup-preset", "speckit.shared-cleanup", "Shared cleanup", "preset body", ) manager = PresetManager(project_dir) manager.install_from_directory(preset_dir, "0.1.5") metadata = manager.registry.get("native-metadata-cleanup-preset") assert "agy" in metadata.get("registered_commands", {}) manager.registry.update( "native-metadata-cleanup-preset", {"registered_skills": {"agy": ["speckit-shared-cleanup"]}}, ) manager.unregister_agent_artifacts("agy") metadata = manager.registry.get("native-metadata-cleanup-preset") assert "agy" not in metadata.get("registered_commands", {}) assert "agy" not in metadata.get("registered_skills", {}) def test_unregister_native_agent_preserves_shared_output_owner( self, project_dir, temp_dir ): self._write_init_options(project_dir, ai="agy", ai_skills=True) (project_dir / ".agents" / "skills").mkdir(parents=True) preset_dir = self._create_command_preset( temp_dir, "shared-native-output-preset", "speckit.shared-owner", "Shared owner", "preset body", ) manager = PresetManager(project_dir) manager.install_from_directory(preset_dir, "0.1.5") self._write_init_options(project_dir, ai="codex", ai_skills=True) manager.register_enabled_presets_for_agent("codex") skill_file = ( project_dir / ".agents" / "skills" / "speckit-shared-owner" / "SKILL.md" ) assert skill_file.exists() metadata = manager.registry.get("shared-native-output-preset") registered_commands = metadata.get("registered_commands", {}) assert "agy" in registered_commands and "codex" in registered_commands manager.unregister_agent_artifacts("agy") assert skill_file.exists(), ( "shared SKILL.md must survive while codex still owns the same " "physical output" ) metadata = manager.registry.get("shared-native-output-preset") registered_commands = metadata.get("registered_commands", {}) assert "agy" not in registered_commands assert "codex" in registered_commands def test_unregister_agent_artifacts_migrates_legacy_skill_list_scoped( self, project_dir, temp_dir ): """Unregistering an agent's artifacts from a legacy flat-list ``registered_skills`` entry must infer real per-agent ownership before removing anything, so only the target agent's own share is cleaned up and any other agent's still-live mirror survives, rather than either guessing every name belongs to the target agent or dropping all tracking wholesale (#2948). Uses Copilot (command-backed, rendering skills via ``ai_skills``) as the agent being switched away from, and Claude (a native SKILL.md agent) as the separate, still-live owner — mirroring the established legacy-provenance test pattern used elsewhere for this exact registry shape. """ self._write_init_options(project_dir, ai="copilot", ai_skills=True) core_cmds = project_dir / ".specify" / "templates" / "commands" core_cmds.mkdir(parents=True, exist_ok=True) (core_cmds / "specify.md").write_text( "---\ndescription: Core specify command\n---\n\nCore specify body\n", encoding="utf-8", ) copilot_skills_dir = project_dir / ".github" / "skills" claude_skills_dir = project_dir / ".claude" / "skills" self._create_skill(copilot_skills_dir, "speckit-specify") self._create_skill(claude_skills_dir, "speckit-specify") preset_dir = self._create_command_preset( temp_dir, "switch-legacy-skill-preset", "speckit.specify", "Legacy skill switch test", "preset body", ) manager = PresetManager(project_dir) manager.install_from_directory(preset_dir, "0.1.5") copilot_skill = copilot_skills_dir / "speckit-specify" / "SKILL.md" assert "preset:switch-legacy-skill-preset" in copilot_skill.read_text(), ( "sanity: install wrote the override under copilot" ) # A separate activation under claude (before provenance tracking # existed) also left a live, marker-verified mirror there. claude_skill = claude_skills_dir / "speckit-specify" / "SKILL.md" claude_skill.write_text( "---\nname: speckit-specify\nmetadata:\n source: preset:switch-legacy-skill-preset\n" "---\n\npreset body\n", encoding="utf-8", ) # Simulate a pre-#2948 registry: a flat list with no per-agent # provenance for either writer. manager.registry.update( "switch-legacy-skill-preset", {"registered_skills": ["speckit-specify"]}, ) manager.unregister_agent_artifacts("copilot") assert not copilot_skill.parent.exists(), ( "copilot's marker-owned preset skill must be deleted when " "switching away from copilot" ) assert "preset:switch-legacy-skill-preset" in claude_skill.read_text(), ( "claude's own, separately-written mirror must survive " "unregistering copilot's artifacts -- legacy-list inference " "must not misattribute or drop claude's real ownership (#2948)" ) metadata = manager.registry.get("switch-legacy-skill-preset") registered_skills = metadata.get("registered_skills") assert isinstance(registered_skills, dict), ( "legacy flat-list value must migrate to per-agent form" ) assert "copilot" not in registered_skills assert "claude" in registered_skills and "speckit-specify" in registered_skills["claude"], ( "claude's real ownership must be preserved in the migrated tracking" ) class TestPresetSetPriority: """Test preset set-priority CLI command.""" def test_set_priority_changes_priority(self, project_dir, pack_dir): """Test set-priority command changes preset priority.""" from typer.testing import CliRunner from unittest.mock import patch from specify_cli import app runner = CliRunner() # Install preset with default priority manager = PresetManager(project_dir) manager.install_from_directory(pack_dir, "0.1.5") # Verify default priority assert manager.registry.get("test-pack")["priority"] == 10 with patch.object(Path, "cwd", return_value=project_dir): result = runner.invoke(app, ["preset", "set-priority", "test-pack", "5"]) assert result.exit_code == 0, result.output plain = strip_ansi(result.output) assert "priority changed: 10 → 5" in plain # Reload registry to see updated value manager2 = PresetManager(project_dir) assert manager2.registry.get("test-pack")["priority"] == 5 def test_set_priority_reconciles_generated_constitution( self, project_dir, temp_dir ): """Changing priority rematerializes an unchanged generated constitution.""" from typer.testing import CliRunner from unittest.mock import patch from specify_cli import app manager = PresetManager(project_dir) install_self_test_preset(manager) manager.install_from_directory( _make_convention_constitution_preset(temp_dir), "0.1.5", priority=20 ) memory = project_dir / ".specify" / "memory" / "constitution.md" assert "preset:self-test" in memory.read_text() with patch.object(Path, "cwd", return_value=project_dir): result = CliRunner().invoke( app, ["preset", "set-priority", "convention-constitution", "1"], ) assert result.exit_code == 0, result.output assert memory.read_text() == "# Convention Constitution\n" def test_set_priority_same_value_no_change(self, project_dir, pack_dir): """Test set-priority with same value shows already set message.""" from typer.testing import CliRunner from unittest.mock import patch from specify_cli import app runner = CliRunner() # Install preset with priority 5 manager = PresetManager(project_dir) manager.install_from_directory(pack_dir, "0.1.5", priority=5) with patch.object(Path, "cwd", return_value=project_dir): result = runner.invoke(app, ["preset", "set-priority", "test-pack", "5"]) assert result.exit_code == 0, result.output plain = strip_ansi(result.output) assert "already has priority 5" in plain def test_set_priority_repairs_corrupted_bool(self, project_dir, pack_dir): """A corrupted boolean priority must be repaired, not skipped. ``isinstance(True, int)`` is True and ``True == 1`` in Python, so a stored ``True`` priority would short-circuit the ``already has priority 1`` skip path and never get rewritten to a real int — contradicting the comment that promises corrupted values are repaired. The guard must exclude bools (like normalize_priority). """ from typer.testing import CliRunner from unittest.mock import patch from specify_cli import app runner = CliRunner() manager = PresetManager(project_dir) manager.install_from_directory(pack_dir, "0.1.5", priority=5) # Inject a corrupted boolean priority (True == 1). manager.registry.update("test-pack", {"priority": True}) with patch.object(Path, "cwd", return_value=project_dir): result = runner.invoke(app, ["preset", "set-priority", "test-pack", "1"]) assert result.exit_code == 0, result.output plain = strip_ansi(result.output) # The corrupted bool must be repaired, not reported as already-set. assert "already has priority" not in plain assert "priority changed" in plain # The stored value is now a real int, not a bool. reloaded = PresetManager(project_dir).registry.get("test-pack") assert reloaded["priority"] == 1 assert not isinstance(reloaded["priority"], bool) def test_set_priority_invalid_value(self, project_dir, pack_dir): """Test set-priority rejects invalid priority values.""" from typer.testing import CliRunner from unittest.mock import patch from specify_cli import app runner = CliRunner() # Install preset manager = PresetManager(project_dir) manager.install_from_directory(pack_dir, "0.1.5") with patch.object(Path, "cwd", return_value=project_dir): result = runner.invoke(app, ["preset", "set-priority", "test-pack", "0"]) assert result.exit_code == 1, result.output assert "Priority must be a positive integer" in result.output def test_set_priority_not_installed(self, project_dir): """Test set-priority fails for non-installed preset.""" 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): result = runner.invoke(app, ["preset", "set-priority", "nonexistent", "5"]) assert result.exit_code == 1, result.output assert "not installed" in result.output.lower() class TestPresetPriorityBackwardsCompatibility: """Test backwards compatibility for presets installed before priority feature.""" def test_legacy_preset_without_priority_field(self, temp_dir): """Presets installed before priority feature should default to 10.""" presets_dir = temp_dir / ".specify" / "presets" presets_dir.mkdir(parents=True) # Simulate legacy registry entry without priority field registry = PresetRegistry(presets_dir) registry.data["presets"]["legacy-pack"] = { "version": "1.0.0", "source": "local", "enabled": True, "installed_at": "2025-01-01T00:00:00Z", # No "priority" field - simulates pre-feature preset } registry._save() # Reload registry registry2 = PresetRegistry(presets_dir) # list_by_priority should use default of 10 result = registry2.list_by_priority() assert len(result) == 1 assert result[0][0] == "legacy-pack" # Priority defaults to 10 and is normalized in returned metadata assert result[0][1]["priority"] == 10 def test_legacy_preset_in_list_installed(self, project_dir, pack_dir): """list_installed returns priority=10 for legacy presets without priority field.""" manager = PresetManager(project_dir) # Install preset normally manager.install_from_directory(pack_dir, "0.1.5") # Manually remove priority to simulate legacy preset pack_data = manager.registry.data["presets"]["test-pack"] del pack_data["priority"] manager.registry._save() # list_installed should still return priority=10 installed = manager.list_installed() assert len(installed) == 1 assert installed[0]["priority"] == 10 def test_mixed_legacy_and_new_presets_ordering(self, temp_dir): """Legacy presets (no priority) sort with default=10 among prioritized presets.""" presets_dir = temp_dir / ".specify" / "presets" presets_dir.mkdir(parents=True) registry = PresetRegistry(presets_dir) # Add preset with explicit priority=5 registry.add("pack-with-priority", {"version": "1.0.0", "priority": 5}) # Add legacy preset without priority (manually) registry.data["presets"]["legacy-pack"] = { "version": "1.0.0", "source": "local", "enabled": True, # No priority field } # Add another preset with priority=15 registry.add("low-priority-pack", {"version": "1.0.0", "priority": 15}) registry._save() # Reload and check ordering registry2 = PresetRegistry(presets_dir) sorted_presets = registry2.list_by_priority() # Should be: pack-with-priority (5), legacy-pack (default 10), low-priority-pack (15) assert [p[0] for p in sorted_presets] == [ "pack-with-priority", "legacy-pack", "low-priority-pack", ] class TestPresetEnableDisable: """Test preset enable/disable CLI commands.""" def test_disable_preset(self, project_dir, pack_dir): """Test disable command sets enabled=False.""" from typer.testing import CliRunner from unittest.mock import patch from specify_cli import app runner = CliRunner() # Install preset manager = PresetManager(project_dir) manager.install_from_directory(pack_dir, "0.1.5") # Verify initially enabled assert manager.registry.get("test-pack").get("enabled", True) is True with patch.object(Path, "cwd", return_value=project_dir): result = runner.invoke(app, ["preset", "disable", "test-pack"]) assert result.exit_code == 0, result.output assert "disabled" in result.output.lower() # Reload registry to see updated value manager2 = PresetManager(project_dir) assert manager2.registry.get("test-pack")["enabled"] is False def test_enable_preset(self, project_dir, pack_dir): """Test enable command sets enabled=True.""" from typer.testing import CliRunner from unittest.mock import patch from specify_cli import app runner = CliRunner() # Install preset and disable it manager = PresetManager(project_dir) manager.install_from_directory(pack_dir, "0.1.5") manager.registry.update("test-pack", {"enabled": False}) # Verify disabled assert manager.registry.get("test-pack")["enabled"] is False with patch.object(Path, "cwd", return_value=project_dir): result = runner.invoke(app, ["preset", "enable", "test-pack"]) assert result.exit_code == 0, result.output assert "enabled" in result.output.lower() # Reload registry to see updated value manager2 = PresetManager(project_dir) assert manager2.registry.get("test-pack")["enabled"] is True def test_enable_disable_reconciles_generated_constitution( self, project_dir, temp_dir ): """Enable and disable rematerialize the winning constitution layer.""" from typer.testing import CliRunner from unittest.mock import patch from specify_cli import app manager = PresetManager(project_dir) install_self_test_preset(manager) manager.install_from_directory( _make_convention_constitution_preset(temp_dir), "0.1.5", priority=1 ) memory = project_dir / ".specify" / "memory" / "constitution.md" assert memory.read_text() == "# Convention Constitution\n" runner = CliRunner() with patch.object(Path, "cwd", return_value=project_dir): disabled = runner.invoke( app, ["preset", "disable", "convention-constitution"] ) assert disabled.exit_code == 0, disabled.output assert "preset:self-test" in memory.read_text() with patch.object(Path, "cwd", return_value=project_dir): enabled = runner.invoke( app, ["preset", "enable", "convention-constitution"] ) assert enabled.exit_code == 0, enabled.output assert memory.read_text() == "# Convention Constitution\n" def test_stack_changes_do_not_create_missing_constitution( self, project_dir, pack_dir ): """Stack changes for non-providers do not seed a missing constitution.""" from typer.testing import CliRunner from unittest.mock import patch from specify_cli import app PresetManager(project_dir).install_from_directory(pack_dir, "0.1.5") memory = project_dir / ".specify" / "memory" / "constitution.md" runner = CliRunner() for args in ( ["preset", "set-priority", "test-pack", "5"], ["preset", "disable", "test-pack"], ["preset", "enable", "test-pack"], ): with patch.object(Path, "cwd", return_value=project_dir): result = runner.invoke(app, args) assert result.exit_code == 0, result.output assert not memory.exists() def test_disable_already_disabled(self, project_dir, pack_dir): """Test disable on already disabled preset shows warning.""" from typer.testing import CliRunner from unittest.mock import patch from specify_cli import app runner = CliRunner() # Install preset and disable it manager = PresetManager(project_dir) manager.install_from_directory(pack_dir, "0.1.5") manager.registry.update("test-pack", {"enabled": False}) with patch.object(Path, "cwd", return_value=project_dir): result = runner.invoke(app, ["preset", "disable", "test-pack"]) assert result.exit_code == 0, result.output assert "already disabled" in result.output.lower() def test_enable_already_enabled(self, project_dir, pack_dir): """Test enable on already enabled preset shows warning.""" from typer.testing import CliRunner from unittest.mock import patch from specify_cli import app runner = CliRunner() # Install preset (enabled by default) manager = PresetManager(project_dir) manager.install_from_directory(pack_dir, "0.1.5") with patch.object(Path, "cwd", return_value=project_dir): result = runner.invoke(app, ["preset", "enable", "test-pack"]) assert result.exit_code == 0, result.output assert "already enabled" in result.output.lower() def test_disable_not_installed(self, project_dir): """Test disable fails for non-installed preset.""" 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): result = runner.invoke(app, ["preset", "disable", "nonexistent"]) assert result.exit_code == 1, result.output assert "not installed" in result.output.lower() def test_enable_not_installed(self, project_dir): """Test enable fails for non-installed preset.""" 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): result = runner.invoke(app, ["preset", "enable", "nonexistent"]) assert result.exit_code == 1, result.output assert "not installed" in result.output.lower() def test_disabled_preset_excluded_from_resolution(self, project_dir, pack_dir): """Test that disabled presets are excluded from template resolution.""" # Install preset with a template manager = PresetManager(project_dir) manager.install_from_directory(pack_dir, "0.1.5") # Create a template in the preset directory preset_template = project_dir / ".specify" / "presets" / "test-pack" / "templates" / "test-template.md" preset_template.parent.mkdir(parents=True, exist_ok=True) preset_template.write_text("# Template from test-pack") resolver = PresetResolver(project_dir) # Template should be found when enabled result = resolver.resolve("test-template", "template") assert result is not None assert "test-pack" in str(result) # Disable the preset manager.registry.update("test-pack", {"enabled": False}) # Template should NOT be found when disabled resolver2 = PresetResolver(project_dir) result2 = resolver2.resolve("test-template", "template") assert result2 is None def test_enable_corrupted_registry_entry(self, project_dir, pack_dir): """Test enable fails gracefully for corrupted registry entry.""" 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", "enable", "test-pack"]) 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.""" 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() # ===== Lean Preset Tests ===== LEAN_PRESET_DIR = Path(__file__).parent.parent / "presets" / "lean" CORE_CONSTITUTION_COMMAND = ( Path(__file__).parent.parent / "templates" / "commands" / "constitution.md" ) LEAN_COMMAND_NAMES = [ "speckit.specify", "speckit.plan", "speckit.tasks", "speckit.implement", "speckit.constitution", ] @pytest.mark.parametrize( "command_path", [ CORE_CONSTITUTION_COMMAND, LEAN_PRESET_DIR / "commands" / "speckit.constitution.md", ], ids=["core", "lean"], ) def test_constitution_commands_guard_against_non_governance_work(command_path): """Constitution commands defer non-governance work instead of executing it.""" content = command_path.read_text() lower_content = content.lower() normalized_content = " ".join(lower_content.split()) assert "## Scope Guard" in content assert "**MUST NOT**" in content assert "Classify every part" in content assert "application source files" in content assert "non-governance intent" in content assert "`Next Actions`" in content assert "__SPECKIT_COMMAND_SPECIFY__" in content assert "omit" in lower_content assert "do not invoke it" in normalized_content or "without invoking it" in normalized_content class TestLeanPreset: """Tests for the lean preset that ships with the repo.""" def test_lean_preset_exists(self): """Verify the lean preset directory and manifest exist.""" assert LEAN_PRESET_DIR.exists() assert (LEAN_PRESET_DIR / "preset.yml").exists() def test_lean_manifest_valid(self): """Verify the lean preset manifest is valid.""" manifest = PresetManifest(LEAN_PRESET_DIR / "preset.yml") assert manifest.id == "lean" assert manifest.name == "Lean Workflow" assert manifest.version == "1.0.0" assert len(manifest.templates) == 5 # 5 commands def test_lean_provides_core_workflow_commands(self): """Verify the lean preset provides overrides for core workflow commands.""" manifest = PresetManifest(LEAN_PRESET_DIR / "preset.yml") provided_names = {t["name"] for t in manifest.templates} for name in LEAN_COMMAND_NAMES: assert name in provided_names, f"Lean preset missing command: {name}" def test_lean_command_files_exist(self): """Verify that all declared command files actually exist on disk.""" manifest = PresetManifest(LEAN_PRESET_DIR / "preset.yml") for tmpl in manifest.templates: tmpl_path = LEAN_PRESET_DIR / tmpl["file"] assert tmpl_path.exists(), f"Missing command file: {tmpl['file']}" def test_lean_commands_have_no_scripts(self): """Verify lean commands have no scripts in frontmatter.""" from specify_cli.agents import CommandRegistrar for name in LEAN_COMMAND_NAMES: cmd_path = LEAN_PRESET_DIR / "commands" / f"speckit.{name.split('.')[-1]}.md" content = cmd_path.read_text() frontmatter, _ = CommandRegistrar.parse_frontmatter(content) assert "scripts" not in frontmatter, f"{name} should not have scripts in frontmatter" def test_lean_commands_have_no_hooks(self): """Verify lean commands do not contain extension hook boilerplate.""" for name in LEAN_COMMAND_NAMES: cmd_path = LEAN_PRESET_DIR / "commands" / f"speckit.{name.split('.')[-1]}.md" content = cmd_path.read_text() assert "hooks." not in content, f"{name} should not reference extension hooks" assert "extensions.yml" not in content, f"{name} should not reference extensions.yml" def test_install_lean_preset(self, project_dir): """Test installing the lean preset from its directory.""" manager = PresetManager(project_dir) manifest = manager.install_from_directory(LEAN_PRESET_DIR, "0.6.0") assert manifest.id == "lean" assert manager.registry.is_installed("lean") def test_lean_overrides_commands(self, project_dir): """Test that lean preset overrides are resolved correctly.""" manager = PresetManager(project_dir) manager.install_from_directory(LEAN_PRESET_DIR, "0.6.0") resolver = PresetResolver(project_dir) for name in LEAN_COMMAND_NAMES: result = resolver.resolve(name, template_type="command") assert result is not None, f"Lean override for {name} not resolved" # ===== Bundled Preset Locator Tests ===== class TestBundledPresetLocator: """Tests for _locate_bundled_preset discovery function.""" def test_locate_bundled_lean_preset(self): """_locate_bundled_preset finds the lean preset.""" from specify_cli import _locate_bundled_preset path = _locate_bundled_preset("lean") assert path is not None assert (path / "preset.yml").is_file() def test_locate_bundled_preset_not_found(self): """_locate_bundled_preset returns None for nonexistent preset.""" from specify_cli import _locate_bundled_preset path = _locate_bundled_preset("nonexistent-preset") assert path is None def test_locate_bundled_preset_rejects_invalid_id(self): """_locate_bundled_preset rejects IDs with invalid characters.""" from specify_cli import _locate_bundled_preset assert _locate_bundled_preset("../escape") is None assert _locate_bundled_preset("UPPERCASE") is None assert _locate_bundled_preset("has spaces") is None def test_bundled_preset_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() 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"]) assert result.exit_code == 0, result.output assert "Lean Workflow" in result.output assert "installed" in result.output.lower() 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 class FakeResponse(io.BytesIO): def __enter__(self): return self def __exit__(self, exc_type, exc, tb): return False 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): assert redirect_validator is not None redirect_validator(url, "http://example.com/preset.zip") return FakeResponse(b"zip") monkeypatch.setattr("specify_cli.authentication.http.open_url", fake_open_url) installed = False 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 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"]) 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 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.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 == 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_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 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, ) 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_url_out_of_range_port_exits_cleanly(self, project_dir): """An out-of-range port raises ValueError lazily on .port access. 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.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 == 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. ``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 bad_url = "https://[not-an-ip]/x" catalog_data = { "test-pack": { "name": "Test Pack", "version": "1.0.0", "download_url": bad_url, } } 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, ) 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 @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. 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 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 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 class FakeResponse(io.BytesIO): def __enter__(self): return self def __exit__(self, exc_type, exc, tb): return False def geturl(self): return "https:///preset.zip" 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) 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 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 = [] def __enter__(self): return self def __exit__(self, exc_type, exc, tb): return False def geturl(self): return "https://example.com/preset.zip" def read(self, size=-1): assert size not in (-1, None) self.read_sizes.append(size) return super().read(size) response = FakeResponse(b"zip-bytes") 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") 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) 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"zip-bytes", "speckit_version": "0.6.0", "priority": 7, } 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 class FakeResponse(io.BytesIO): def __enter__(self): return self def __exit__(self, exc_type, exc, tb): return False def geturl(self): return "https://example.com/preset.zip" def read_with_tiny_limit(response, **kwargs): kwargs.pop("max_bytes", None) return real_read_response_limited(response, max_bytes=4, **kwargs) installed = False 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) 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, ) 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_bundled_preset_in_catalog(self): """Verify the lean preset is listed in catalog.json with bundled marker.""" catalog_path = Path(__file__).parent.parent / "presets" / "catalog.json" catalog = json.loads(catalog_path.read_text()) assert "lean" in catalog["presets"] assert catalog["presets"]["lean"]["bundled"] is True assert "download_url" not in catalog["presets"]["lean"] def test_bundled_preset_download_raises_error(self, project_dir): """download_pack raises PresetError for bundled presets without download_url.""" catalog = PresetCatalog(project_dir) catalog_data = { "test-bundled": { "name": "Test Bundled", "version": "1.0.0", "bundled": True, } } from unittest.mock import patch with patch.object(catalog, "_get_merged_packs", return_value=catalog_data): with pytest.raises(PresetError, match="bundled with spec-kit"): catalog.download_pack("test-bundled") 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 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"]) # 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 class TestPresetAddFromUrlResolution: """CLI-level tests for preset add --from GitHub release resolution.""" 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 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)) 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 # Set up a core command template core_dir = project_dir / ".specify" / "templates" / "commands" core_dir.mkdir(parents=True, exist_ok=True) (core_dir / "specify.md").write_text( "---\ndescription: core\n---\n\n# Core Specify\n\nDo the thing.\n" ) registrar = CommandRegistrar() body = "## Pre-Logic\n\nBefore stuff.\n\n{CORE_TEMPLATE}\n\n## Post-Logic\n\nAfter stuff.\n" result, core_fm = _substitute_core_template(body, "specify", project_dir, registrar) assert "{CORE_TEMPLATE}" not in result assert "# Core Specify" in result assert "## Pre-Logic" in result assert "## Post-Logic" in result assert core_fm.get("description") == "core" def test_substitute_core_template_no_op_when_placeholder_absent(self, project_dir): """Returns body unchanged when {CORE_TEMPLATE} is not present.""" from specify_cli.presets import _substitute_core_template from specify_cli.agents import CommandRegistrar core_dir = project_dir / ".specify" / "templates" / "commands" core_dir.mkdir(parents=True, exist_ok=True) (core_dir / "specify.md").write_text("---\ndescription: core\n---\n\nCore body.\n") registrar = CommandRegistrar() body = "## No placeholder here.\n" result, core_fm = _substitute_core_template(body, "specify", project_dir, registrar) assert result == body assert core_fm == {} def test_substitute_core_template_no_op_when_core_missing(self, project_dir): """Returns body unchanged when core template file does not exist.""" from specify_cli.presets import _substitute_core_template from specify_cli.agents import CommandRegistrar registrar = CommandRegistrar() body = "Pre.\n\n{CORE_TEMPLATE}\n\nPost.\n" result, core_fm = _substitute_core_template(body, "nonexistent", project_dir, registrar) assert result == body assert "{CORE_TEMPLATE}" in result assert core_fm == {} def test_register_commands_substitutes_core_template_for_wrap_strategy(self, project_dir): """register_commands substitutes {CORE_TEMPLATE} when strategy: wrap.""" from specify_cli.agents import CommandRegistrar # Set up core command template core_dir = project_dir / ".specify" / "templates" / "commands" core_dir.mkdir(parents=True, exist_ok=True) (core_dir / "specify.md").write_text( "---\ndescription: core\n---\n\n# Core Specify\n\nCore body here.\n" ) # Create a preset command dir with a wrap-strategy command cmd_dir = project_dir / "preset" / "commands" cmd_dir.mkdir(parents=True, exist_ok=True) (cmd_dir / "speckit.specify.md").write_text( "---\ndescription: wrap test\nstrategy: wrap\n---\n\n" "## Pre\n\n{CORE_TEMPLATE}\n\n## Post\n" ) commands = [{"name": "speckit.specify", "file": "commands/speckit.specify.md"}] registrar = CommandRegistrar() # Use a generic agent that writes markdown to commands/ agent_dir = project_dir / ".claude" / "commands" agent_dir.mkdir(parents=True, exist_ok=True) # Patch AGENT_CONFIGS to use a simple markdown agent pointing at our dir import copy original = copy.deepcopy(registrar.AGENT_CONFIGS) registrar.AGENT_CONFIGS["test-agent"] = { "dir": str(agent_dir.relative_to(project_dir)), "format": "markdown", "args": "$ARGUMENTS", "extension": ".md", "strip_frontmatter_keys": [], } try: registrar.register_commands( "test-agent", commands, "test-preset", project_dir / "preset", project_dir ) finally: CommandRegistrar.AGENT_CONFIGS.clear() CommandRegistrar.AGENT_CONFIGS.update(original) written = (agent_dir / "speckit.specify.md").read_text() assert "{CORE_TEMPLATE}" not in written assert "# Core Specify" in written assert "## Pre" in written assert "## Post" in written def test_end_to_end_wrap_via_self_test_preset(self, project_dir): """Installing self-test preset with a wrap command substitutes {CORE_TEMPLATE}.""" from specify_cli.presets import PresetManager # Install a core template that wrap-test will wrap around core_dir = project_dir / ".specify" / "templates" / "commands" core_dir.mkdir(parents=True, exist_ok=True) (core_dir / "wrap-test.md").write_text( "---\ndescription: core wrap-test\n---\n\n# Core Wrap-Test Body\n" ) # Set up skills dir (simulating --integration claude) skills_dir = project_dir / ".claude" / "skills" skills_dir.mkdir(parents=True, exist_ok=True) skill_subdir = skills_dir / "speckit-wrap-test" skill_subdir.mkdir() (skill_subdir / "SKILL.md").write_text("---\nname: speckit-wrap-test\n---\n\nold content\n") # Write init-options so _register_skills finds the claude skills dir import json (project_dir / ".specify" / "init-options.json").write_text( json.dumps({"ai": "claude", "ai_skills": True}) ) manager = PresetManager(project_dir) install_self_test_preset(manager) written = (skill_subdir / "SKILL.md").read_text() assert "{CORE_TEMPLATE}" not in written assert "# Core Wrap-Test Body" in written assert "preset:self-test wrap-pre" in written assert "preset:self-test wrap-post" in written def test_substitute_core_template_returns_core_scripts(self, project_dir): """core_frontmatter in the returned tuple includes scripts/agent_scripts.""" from specify_cli.presets import _substitute_core_template from specify_cli.agents import CommandRegistrar core_dir = project_dir / ".specify" / "templates" / "commands" core_dir.mkdir(parents=True, exist_ok=True) (core_dir / "specify.md").write_text( "---\ndescription: core\nscripts:\n sh: run.sh\nagent_scripts:\n sh: agent-run.sh\n---\n\n# Body\n" ) registrar = CommandRegistrar() body = "## Wrapper\n\n{CORE_TEMPLATE}\n" result, core_fm = _substitute_core_template(body, "specify", project_dir, registrar) assert "# Body" in result assert core_fm.get("scripts") == {"sh": "run.sh"} assert core_fm.get("agent_scripts") == {"sh": "agent-run.sh"} def test_register_skills_inherits_scripts_from_core_when_preset_omits_them(self, project_dir): """_register_skills merges scripts/agent_scripts from core when preset lacks them.""" from specify_cli.presets import PresetManager import json # Core template with scripts core_dir = project_dir / ".specify" / "templates" / "commands" core_dir.mkdir(parents=True, exist_ok=True) (core_dir / "wrap-test.md").write_text( "---\ndescription: core\nscripts:\n sh: .specify/scripts/run.sh\n---\n\n" "Run: {SCRIPT}\n" ) # Skills dir for claude skills_dir = project_dir / ".claude" / "skills" skills_dir.mkdir(parents=True, exist_ok=True) skill_subdir = skills_dir / "speckit-wrap-test" skill_subdir.mkdir() (skill_subdir / "SKILL.md").write_text("---\nname: speckit-wrap-test\n---\n\nold\n") (project_dir / ".specify" / "init-options.json").write_text( json.dumps({"ai": "claude", "ai_skills": True}) ) manager = PresetManager(project_dir) install_self_test_preset(manager) written = (skill_subdir / "SKILL.md").read_text() # {SCRIPT} should have been resolved (not left as a literal placeholder) assert "{SCRIPT}" not in written def test_register_skills_preset_scripts_take_precedence_over_core(self, project_dir): """preset-defined scripts/agent_scripts are not overwritten by core frontmatter.""" from specify_cli.presets import _substitute_core_template from specify_cli.agents import CommandRegistrar core_dir = project_dir / ".specify" / "templates" / "commands" core_dir.mkdir(parents=True, exist_ok=True) (core_dir / "specify.md").write_text( "---\ndescription: core\nscripts:\n sh: core-run.sh\n---\n\nCore body.\n" ) registrar = CommandRegistrar() body = "{CORE_TEMPLATE}" _, core_fm = _substitute_core_template(body, "specify", project_dir, registrar) # Simulate preset frontmatter that already defines scripts preset_fm = {"description": "preset", "strategy": "wrap", "scripts": {"sh": "preset-run.sh"}} for key in ("scripts", "agent_scripts"): if key not in preset_fm and key in core_fm: preset_fm[key] = core_fm[key] # Preset's scripts must not be overwritten by core assert preset_fm["scripts"] == {"sh": "preset-run.sh"} def test_register_commands_inherits_scripts_from_core(self, project_dir): """register_commands merges scripts/agent_scripts from core and normalizes paths.""" from specify_cli.agents import CommandRegistrar import copy core_dir = project_dir / ".specify" / "templates" / "commands" core_dir.mkdir(parents=True, exist_ok=True) (core_dir / "specify.md").write_text( "---\ndescription: core\nscripts:\n sh: .specify/scripts/run.sh {ARGS}\n---\n\n" "Run: {SCRIPT}\n" ) cmd_dir = project_dir / "preset" / "commands" cmd_dir.mkdir(parents=True, exist_ok=True) # Preset has strategy: wrap but no scripts of its own (cmd_dir / "speckit.specify.md").write_text( "---\ndescription: wrap no scripts\nstrategy: wrap\n---\n\n" "## Pre\n\n{CORE_TEMPLATE}\n\n## Post\n" ) agent_dir = project_dir / ".claude" / "commands" agent_dir.mkdir(parents=True, exist_ok=True) registrar = CommandRegistrar() original = copy.deepcopy(registrar.AGENT_CONFIGS) registrar.AGENT_CONFIGS["test-agent"] = { "dir": str(agent_dir.relative_to(project_dir)), "format": "markdown", "args": "$ARGUMENTS", "extension": ".md", "strip_frontmatter_keys": [], } try: registrar.register_commands( "test-agent", [{"name": "speckit.specify", "file": "commands/speckit.specify.md"}], "test-preset", project_dir / "preset", project_dir, ) finally: CommandRegistrar.AGENT_CONFIGS.clear() CommandRegistrar.AGENT_CONFIGS.update(original) written = (agent_dir / "speckit.specify.md").read_text() assert "{CORE_TEMPLATE}" not in written assert "Run:" in written assert "scripts:" in written assert "run.sh" in written def test_register_commands_toml_resolves_inherited_scripts(self, project_dir): """TOML agents resolve {SCRIPT} from inherited core scripts when preset omits them.""" from specify_cli.agents import CommandRegistrar import copy core_dir = project_dir / ".specify" / "templates" / "commands" core_dir.mkdir(parents=True, exist_ok=True) (core_dir / "specify.md").write_text( "---\ndescription: core\nscripts:\n sh: .specify/scripts/run.sh {ARGS}\n---\n\n" "Run: {SCRIPT}\n" ) cmd_dir = project_dir / "preset" / "commands" cmd_dir.mkdir(parents=True, exist_ok=True) (cmd_dir / "speckit.specify.md").write_text( "---\ndescription: toml wrap\nstrategy: wrap\n---\n\n" "## Pre\n\n{CORE_TEMPLATE}\n\n## Post\n" ) toml_dir = project_dir / ".gemini" / "commands" toml_dir.mkdir(parents=True, exist_ok=True) registrar = CommandRegistrar() original = copy.deepcopy(registrar.AGENT_CONFIGS) registrar.AGENT_CONFIGS["test-toml-agent"] = { "dir": str(toml_dir.relative_to(project_dir)), "format": "toml", "args": "{{args}}", "extension": ".toml", "strip_frontmatter_keys": [], } try: registrar.register_commands( "test-toml-agent", [{"name": "speckit.specify", "file": "commands/speckit.specify.md"}], "test-preset", project_dir / "preset", project_dir, ) finally: CommandRegistrar.AGENT_CONFIGS.clear() CommandRegistrar.AGENT_CONFIGS.update(original) written = (toml_dir / "speckit.specify.toml").read_text() assert "{CORE_TEMPLATE}" not in written assert "{SCRIPT}" not in written assert "run.sh" in written # args token must use TOML format, not the intermediate $ARGUMENTS assert "$ARGUMENTS" not in written assert "{{args}}" in written def test_register_commands_markdown_resolves_inherited_scripts(self, project_dir): """Markdown agents resolve {SCRIPT} from inherited core scripts when preset omits them.""" from specify_cli.agents import CommandRegistrar import copy core_dir = project_dir / ".specify" / "templates" / "commands" core_dir.mkdir(parents=True, exist_ok=True) (core_dir / "specify.md").write_text( "---\ndescription: core\nscripts:\n sh: .specify/scripts/run.sh {ARGS}\n---\n\n" "Run: {SCRIPT}\n" ) 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() body = "## Wrapper\n\n{CORE_TEMPLATE}\n" result, _ = _substitute_core_template(body, "speckit.selftest.extension", project_dir, registrar) assert "# Selftest Core" in result assert "{CORE_TEMPLATE}" not in result # ===== _replay_wraps_for_command Tests ===== def _make_wrap_preset_dir( base: Path, preset_id: str, cmd_name: str, pre: str, post: str, aliases: list[str] | None = None, file_rel: str | None = None, ) -> Path: """Create a minimal wrap-strategy preset directory for testing.""" preset_dir = base / preset_id cmd_dir = preset_dir / "commands" cmd_dir.mkdir(parents=True) file_rel = file_rel or f"commands/{cmd_name}.md" template = { "type": "command", "name": cmd_name, "file": file_rel, "description": f"{preset_id} wrap", } if aliases is not None: template["aliases"] = aliases manifest = { "schema_version": "1.0", "preset": { "id": preset_id, "name": preset_id, "version": "1.0.0", "description": f"Preset {preset_id}", "author": "test", "repository": "https://example.com", "license": "MIT", }, "requires": {"speckit_version": ">=0.1.0"}, "provides": { "templates": [template] }, "tags": [], } import yaml as _yaml (preset_dir / "preset.yml").write_text(_yaml.dump(manifest)) command_path = preset_dir / file_rel command_path.parent.mkdir(parents=True, exist_ok=True) command_path.write_text( f"---\ndescription: {preset_id} wrap\nstrategy: wrap\n---\n\n" f"[{pre}]\n\n{{CORE_TEMPLATE}}\n\n[{post}]\n" ) return preset_dir class TestCompositionStrategyValidation: """Test strategy field validation in PresetManifest.""" def test_valid_replace_strategy(self, temp_dir, valid_pack_data): """Test that replace strategy is accepted.""" valid_pack_data["provides"]["templates"][0]["strategy"] = "replace" manifest_path = temp_dir / "preset.yml" with open(manifest_path, 'w') as f: yaml.dump(valid_pack_data, f) (temp_dir / "templates").mkdir(exist_ok=True) (temp_dir / "templates" / "spec-template.md").write_text("test") manifest = PresetManifest(manifest_path) assert manifest.templates[0]["strategy"] == "replace" def test_valid_prepend_strategy(self, temp_dir, valid_pack_data): """Test that prepend strategy is accepted for templates.""" valid_pack_data["provides"]["templates"][0]["strategy"] = "prepend" manifest_path = temp_dir / "preset.yml" with open(manifest_path, 'w') as f: yaml.dump(valid_pack_data, f) (temp_dir / "templates").mkdir(exist_ok=True) (temp_dir / "templates" / "spec-template.md").write_text("test") manifest = PresetManifest(manifest_path) assert manifest.templates[0]["strategy"] == "prepend" def test_valid_append_strategy(self, temp_dir, valid_pack_data): """Test that append strategy is accepted for templates.""" valid_pack_data["provides"]["templates"][0]["strategy"] = "append" manifest_path = temp_dir / "preset.yml" with open(manifest_path, 'w') as f: yaml.dump(valid_pack_data, f) (temp_dir / "templates").mkdir(exist_ok=True) (temp_dir / "templates" / "spec-template.md").write_text("test") manifest = PresetManifest(manifest_path) assert manifest.templates[0]["strategy"] == "append" def test_valid_wrap_strategy(self, temp_dir, valid_pack_data): """Test that wrap strategy is accepted for templates.""" valid_pack_data["provides"]["templates"][0]["strategy"] = "wrap" manifest_path = temp_dir / "preset.yml" with open(manifest_path, 'w') as f: yaml.dump(valid_pack_data, f) (temp_dir / "templates").mkdir(exist_ok=True) (temp_dir / "templates" / "spec-template.md").write_text("test") manifest = PresetManifest(manifest_path) assert manifest.templates[0]["strategy"] == "wrap" def test_default_strategy_is_replace(self, pack_dir): """Test that omitting strategy defaults to replace (key is absent).""" manifest = PresetManifest(pack_dir / "preset.yml") # Strategy key should not be present in the manifest data assert "strategy" not in manifest.templates[0] # But consumers should treat missing strategy as "replace" assert manifest.templates[0].get("strategy", "replace") == "replace" def test_invalid_strategy_rejected(self, temp_dir, valid_pack_data): """Test that invalid strategy values are rejected.""" valid_pack_data["provides"]["templates"][0]["strategy"] = "merge" manifest_path = temp_dir / "preset.yml" with open(manifest_path, 'w') as f: yaml.dump(valid_pack_data, f) with pytest.raises(PresetValidationError, match="Invalid strategy"): PresetManifest(manifest_path) def test_prepend_rejected_for_scripts(self, temp_dir, valid_pack_data): """Test that prepend strategy is rejected for scripts.""" valid_pack_data["provides"]["templates"] = [{ "type": "script", "name": "create-new-feature", "file": "scripts/create-new-feature.sh", "strategy": "prepend", }] manifest_path = temp_dir / "preset.yml" with open(manifest_path, 'w') as f: yaml.dump(valid_pack_data, f) with pytest.raises(PresetValidationError, match="Invalid strategy.*for script"): PresetManifest(manifest_path) def test_append_rejected_for_scripts(self, temp_dir, valid_pack_data): """Test that append strategy is rejected for scripts.""" valid_pack_data["provides"]["templates"] = [{ "type": "script", "name": "create-new-feature", "file": "scripts/create-new-feature.sh", "strategy": "append", }] manifest_path = temp_dir / "preset.yml" with open(manifest_path, 'w') as f: yaml.dump(valid_pack_data, f) with pytest.raises(PresetValidationError, match="Invalid strategy.*for script"): PresetManifest(manifest_path) def test_wrap_accepted_for_scripts(self, temp_dir, valid_pack_data): """Test that wrap strategy is accepted for scripts.""" valid_pack_data["provides"]["templates"] = [{ "type": "script", "name": "create-new-feature", "file": "scripts/create-new-feature.sh", "strategy": "wrap", }] manifest_path = temp_dir / "preset.yml" with open(manifest_path, 'w') as f: yaml.dump(valid_pack_data, f) manifest = PresetManifest(manifest_path) assert manifest.templates[0]["strategy"] == "wrap" def test_replace_accepted_for_scripts(self, temp_dir, valid_pack_data): """Test that replace strategy is accepted for scripts.""" valid_pack_data["provides"]["templates"] = [{ "type": "script", "name": "create-new-feature", "file": "scripts/create-new-feature.sh", "strategy": "replace", }] manifest_path = temp_dir / "preset.yml" with open(manifest_path, 'w') as f: yaml.dump(valid_pack_data, f) manifest = PresetManifest(manifest_path) assert manifest.templates[0]["strategy"] == "replace" def test_prepend_accepted_for_commands(self, temp_dir, valid_pack_data): """Test that prepend strategy is accepted for commands.""" valid_pack_data["provides"]["templates"] = [{ "type": "command", "name": "speckit.specify", "file": "commands/speckit.specify.md", "strategy": "prepend", }] manifest_path = temp_dir / "preset.yml" with open(manifest_path, 'w') as f: yaml.dump(valid_pack_data, f) manifest = PresetManifest(manifest_path) assert manifest.templates[0]["strategy"] == "prepend" class TestResolveContent: """Test PresetResolver.resolve_content() composition.""" def test_resolve_content_core_template(self, project_dir): """Test resolve_content returns core template when no composition.""" resolver = PresetResolver(project_dir) content = resolver.resolve_content("spec-template") assert content is not None assert "Core Spec Template" in content def test_resolve_content_nonexistent(self, project_dir): """Test resolve_content returns None for nonexistent template.""" resolver = PresetResolver(project_dir) content = resolver.resolve_content("nonexistent") assert content is None def test_resolve_content_replace_strategy(self, project_dir, temp_dir, valid_pack_data): """Test resolve_content with default replace strategy.""" manager = PresetManager(project_dir) manager.install_from_directory( _create_pack(temp_dir, valid_pack_data, "replace-pack", "# Replaced Content\n"), "0.1.5" ) resolver = PresetResolver(project_dir) content = resolver.resolve_content("spec-template") assert content is not None assert "Replaced Content" in content assert "Core Spec Template" not in content def test_resolve_content_append_strategy(self, project_dir, temp_dir, valid_pack_data): """Test resolve_content with append strategy.""" pack_data = {**valid_pack_data} pack_data["preset"] = {**valid_pack_data["preset"], "id": "append-pack", "name": "Append"} pack_data["provides"] = { "templates": [{ "type": "template", "name": "spec-template", "file": "templates/spec-template.md", "strategy": "append", }] } pack_dir = temp_dir / "append-pack" pack_dir.mkdir() with open(pack_dir / "preset.yml", 'w') as f: yaml.dump(pack_data, f) (pack_dir / "templates").mkdir() (pack_dir / "templates" / "spec-template.md").write_text("## Appended Section\n") manager = PresetManager(project_dir) manager.install_from_directory(pack_dir, "0.1.5") resolver = PresetResolver(project_dir) content = resolver.resolve_content("spec-template") assert content is not None assert "Core Spec Template" in content assert "Appended Section" in content # Core should come first, appended after assert content.index("Core Spec Template") < content.index("Appended Section") def test_resolve_content_prepend_strategy(self, project_dir, temp_dir, valid_pack_data): """Test resolve_content with prepend strategy.""" pack_data = {**valid_pack_data} pack_data["preset"] = {**valid_pack_data["preset"], "id": "prepend-pack", "name": "Prepend"} pack_data["provides"] = { "templates": [{ "type": "template", "name": "spec-template", "file": "templates/spec-template.md", "strategy": "prepend", }] } pack_dir = temp_dir / "prepend-pack" pack_dir.mkdir() with open(pack_dir / "preset.yml", 'w') as f: yaml.dump(pack_data, f) (pack_dir / "templates").mkdir() (pack_dir / "templates" / "spec-template.md").write_text("## Security Header\n") manager = PresetManager(project_dir) manager.install_from_directory(pack_dir, "0.1.5") resolver = PresetResolver(project_dir) content = resolver.resolve_content("spec-template") assert content is not None assert "Security Header" in content assert "Core Spec Template" in content # Prepended content should come first assert content.index("Security Header") < content.index("Core Spec Template") def test_resolve_content_wrap_strategy(self, project_dir, temp_dir, valid_pack_data): """Test resolve_content with wrap strategy for templates.""" pack_data = {**valid_pack_data} pack_data["preset"] = {**valid_pack_data["preset"], "id": "wrap-pack", "name": "Wrap"} pack_data["provides"] = { "templates": [{ "type": "template", "name": "spec-template", "file": "templates/spec-template.md", "strategy": "wrap", }] } pack_dir = temp_dir / "wrap-pack" pack_dir.mkdir() with open(pack_dir / "preset.yml", 'w') as f: yaml.dump(pack_data, f) (pack_dir / "templates").mkdir() (pack_dir / "templates" / "spec-template.md").write_text( "# Wrapper Start\n\n{CORE_TEMPLATE}\n\n# Wrapper End\n" ) manager = PresetManager(project_dir) manager.install_from_directory(pack_dir, "0.1.5") resolver = PresetResolver(project_dir) content = resolver.resolve_content("spec-template") assert content is not None assert "Wrapper Start" in content assert "Core Spec Template" in content assert "Wrapper End" in content # Wrapper should surround core assert content.index("Wrapper Start") < content.index("Core Spec Template") assert content.index("Core Spec Template") < content.index("Wrapper End") def test_resolve_content_wrap_strategy_script(self, project_dir, temp_dir, valid_pack_data): """Test resolve_content with wrap strategy for scripts uses $CORE_SCRIPT.""" # Create core script scripts_dir = project_dir / ".specify" / "templates" / "scripts" scripts_dir.mkdir(parents=True, exist_ok=True) (scripts_dir / "test-script.sh").write_text("echo 'core script'\n") pack_data = {**valid_pack_data} pack_data["preset"] = {**valid_pack_data["preset"], "id": "script-wrap", "name": "Script Wrap"} pack_data["provides"] = { "templates": [{ "type": "script", "name": "test-script", "file": "scripts/test-script.sh", "strategy": "wrap", }] } pack_dir = temp_dir / "script-wrap" pack_dir.mkdir() with open(pack_dir / "preset.yml", 'w') as f: yaml.dump(pack_data, f) (pack_dir / "scripts").mkdir() (pack_dir / "scripts" / "test-script.sh").write_text( "#!/bin/bash\necho 'before'\n$CORE_SCRIPT\necho 'after'\n" ) manager = PresetManager(project_dir) manager.install_from_directory(pack_dir, "0.1.5") resolver = PresetResolver(project_dir) content = resolver.resolve_content("test-script", "script") assert content is not None assert "echo 'before'" in content assert "echo 'core script'" in content assert "echo 'after'" in content def test_resolve_content_multi_preset_chain(self, project_dir, temp_dir, valid_pack_data): """Test multi-preset composition chain: prepend + append stacking.""" # Create preset A (priority 1): prepend security header pack_a_data = {**valid_pack_data} pack_a_data["preset"] = {**valid_pack_data["preset"], "id": "preset-a", "name": "A"} pack_a_data["provides"] = { "templates": [{ "type": "template", "name": "spec-template", "file": "templates/spec-template.md", "strategy": "prepend", }] } pack_a_dir = temp_dir / "preset-a" pack_a_dir.mkdir() with open(pack_a_dir / "preset.yml", 'w') as f: yaml.dump(pack_a_data, f) (pack_a_dir / "templates").mkdir() (pack_a_dir / "templates" / "spec-template.md").write_text("## Security Header\n") # Create preset B (priority 2): append compliance footer pack_b_data = {**valid_pack_data} pack_b_data["preset"] = {**valid_pack_data["preset"], "id": "preset-b", "name": "B"} pack_b_data["provides"] = { "templates": [{ "type": "template", "name": "spec-template", "file": "templates/spec-template.md", "strategy": "append", }] } pack_b_dir = temp_dir / "preset-b" pack_b_dir.mkdir() with open(pack_b_dir / "preset.yml", 'w') as f: yaml.dump(pack_b_data, f) (pack_b_dir / "templates").mkdir() (pack_b_dir / "templates" / "spec-template.md").write_text("## Compliance Footer\n") manager = PresetManager(project_dir) manager.install_from_directory(pack_a_dir, "0.1.5", priority=1) manager.install_from_directory(pack_b_dir, "0.1.5", priority=2) resolver = PresetResolver(project_dir) content = resolver.resolve_content("spec-template") assert content is not None # Result: + + assert "Security Header" in content assert "Core Spec Template" in content assert "Compliance Footer" in content assert content.index("Security Header") < content.index("Core Spec Template") assert content.index("Core Spec Template") < content.index("Compliance Footer") def test_resolve_content_override_trumps_composition(self, project_dir, temp_dir, valid_pack_data): """Test that project overrides trump composition (replace at top priority).""" # Install a composing preset pack_data = {**valid_pack_data} pack_data["preset"] = {**valid_pack_data["preset"], "id": "append-pack", "name": "Append"} pack_data["provides"] = { "templates": [{ "type": "template", "name": "spec-template", "file": "templates/spec-template.md", "strategy": "append", }] } pack_dir = temp_dir / "append-pack" pack_dir.mkdir() with open(pack_dir / "preset.yml", 'w') as f: yaml.dump(pack_data, f) (pack_dir / "templates").mkdir() (pack_dir / "templates" / "spec-template.md").write_text("## Appended\n") manager = PresetManager(project_dir) manager.install_from_directory(pack_dir, "0.1.5") # Create project override (replaces everything) overrides_dir = project_dir / ".specify" / "templates" / "overrides" overrides_dir.mkdir(parents=True) (overrides_dir / "spec-template.md").write_text("# Override Only\n") resolver = PresetResolver(project_dir) content = resolver.resolve_content("spec-template") assert content is not None assert "Override Only" in content # Override replaces, so appended content should not be visible assert "Core Spec Template" not in content def test_resolve_content_command_type(self, project_dir, temp_dir, valid_pack_data): """Test resolve_content with command template type.""" # Create core command using stem naming (matches real layout: plan.md, not speckit.plan.md) commands_dir = project_dir / ".specify" / "templates" / "commands" commands_dir.mkdir(parents=True, exist_ok=True) (commands_dir / "plan.md").write_text("# Core Plan Command\n") pack_data = {**valid_pack_data} pack_data["preset"] = {**valid_pack_data["preset"], "id": "cmd-append", "name": "CmdAppend"} pack_data["provides"] = { "templates": [{ "type": "command", "name": "speckit.plan", "file": "commands/speckit.plan.md", "strategy": "append", }] } pack_dir = temp_dir / "cmd-append" pack_dir.mkdir() with open(pack_dir / "preset.yml", 'w') as f: yaml.dump(pack_data, f) (pack_dir / "commands").mkdir() (pack_dir / "commands" / "speckit.plan.md").write_text("## Additional Instructions\n") manager = PresetManager(project_dir) manager.install_from_directory(pack_dir, "0.1.5") resolver = PresetResolver(project_dir) content = resolver.resolve_content("speckit.plan", "command") assert content is not None assert "Core Plan Command" in content assert "Additional Instructions" in content def test_resolve_content_command_frontmatter_stripping(self, project_dir, temp_dir, valid_pack_data): """Test that command composition strips frontmatter from lower layers and reattaches only the highest-priority frontmatter.""" # Create core command with frontmatter commands_dir = project_dir / ".specify" / "templates" / "commands" commands_dir.mkdir(parents=True, exist_ok=True) (commands_dir / "check.md").write_text( "---\ndescription: Core check command\n---\nCore body content\n" ) pack_data = {**valid_pack_data} pack_data["preset"] = {**valid_pack_data["preset"], "id": "fm-test", "name": "FmTest"} pack_data["provides"] = { "templates": [{ "type": "command", "name": "speckit.check", "file": "commands/speckit.check.md", "strategy": "append", }] } pack_dir = temp_dir / "fm-test" pack_dir.mkdir() with open(pack_dir / "preset.yml", 'w') as f: yaml.dump(pack_data, f) (pack_dir / "commands").mkdir() (pack_dir / "commands" / "speckit.check.md").write_text( "---\ndescription: Preset check override\n---\nPreset body content\n" ) manager = PresetManager(project_dir) manager.install_from_directory(pack_dir, "0.1.5") resolver = PresetResolver(project_dir) content = resolver.resolve_content("speckit.check", "command") assert content is not None # Should have the preset (highest-priority) frontmatter assert "Preset check override" in content # Should have both bodies assert "Core body content" in content assert "Preset body content" in content # Core frontmatter should NOT appear in the body assert content.count("---") == 2 # only one frontmatter block (opening + closing) def test_resolve_content_blank_line_separator(self, project_dir, temp_dir, valid_pack_data): """Test that prepend/append use blank line separator.""" pack_data = {**valid_pack_data} pack_data["preset"] = {**valid_pack_data["preset"], "id": "sep-test", "name": "SepTest"} pack_data["provides"] = { "templates": [{ "type": "template", "name": "spec-template", "file": "templates/spec-template.md", "strategy": "append", }] } pack_dir = temp_dir / "sep-test" pack_dir.mkdir() with open(pack_dir / "preset.yml", 'w') as f: yaml.dump(pack_data, f) (pack_dir / "templates").mkdir() (pack_dir / "templates" / "spec-template.md").write_text("appended") manager = PresetManager(project_dir) manager.install_from_directory(pack_dir, "0.1.5") resolver = PresetResolver(project_dir) content = resolver.resolve_content("spec-template") # Should have blank line separator assert "\n\n" in content def test_resolve_content_replace_over_wrap(self, project_dir, temp_dir, valid_pack_data): """Top-priority replace layer should win even if a lower layer uses wrap.""" # Install a low-priority wrap preset (with no placeholder — would fail if evaluated) wrap_data = {**valid_pack_data} wrap_data["preset"] = {**valid_pack_data["preset"], "id": "wrap-lo", "name": "WrapLo"} wrap_data["provides"] = { "templates": [{ "type": "template", "name": "spec-template", "file": "templates/spec-template.md", "strategy": "wrap", }] } wrap_dir = temp_dir / "wrap-lo" wrap_dir.mkdir() with open(wrap_dir / "preset.yml", "w") as f: yaml.dump(wrap_data, f) (wrap_dir / "templates").mkdir() # Intentionally missing {CORE_TEMPLATE} — would error if composition ran (wrap_dir / "templates" / "spec-template.md").write_text("wrapper without placeholder") manager = PresetManager(project_dir) manager.install_from_directory(wrap_dir, "0.1.5", priority=10) # Install a high-priority replace preset rep_data = {**valid_pack_data} rep_data["preset"] = {**valid_pack_data["preset"], "id": "rep-hi", "name": "RepHi"} rep_data["provides"] = { "templates": [{ "type": "template", "name": "spec-template", "file": "templates/spec-template.md", }] } rep_dir = temp_dir / "rep-hi" rep_dir.mkdir() with open(rep_dir / "preset.yml", "w") as f: yaml.dump(rep_data, f) (rep_dir / "templates").mkdir() (rep_dir / "templates" / "spec-template.md").write_text("# Replaced content\n") manager.install_from_directory(rep_dir, "0.1.5", priority=1) resolver = PresetResolver(project_dir) content = resolver.resolve_content("spec-template") assert content == "# Replaced content\n" @pytest.mark.parametrize("strategy", ["append", "prepend", "wrap"]) def test_resolve_content_rewrites_extension_base_subdir_paths( self, project_dir, temp_dir, strategy ): """Composing over an extension-provided base command must resolve the extension's own subdir references (agents/, knowledge-base/) to their installed location (#2101), not just when the extension wins outright. """ extension_dir = project_dir / ".specify" / "extensions" / "fakeext" (extension_dir / "commands").mkdir(parents=True, exist_ok=True) (extension_dir / "agents" / "control").mkdir(parents=True, exist_ok=True) (extension_dir / "agents" / "control" / "commander.md").write_text("# Commander\n") (extension_dir / "commands" / "cmd.md").write_text( "---\ndescription: Extension fakeext cmd\n---\n\n" "Read agents/control/commander.md for context.\n" ) extension_manifest = { "schema_version": "1.0", "extension": { "id": "fakeext", "name": "Fake Extension", "version": "1.0.0", "description": "Test", }, "requires": {"speckit_version": ">=0.1.0"}, "provides": { "commands": [ { "name": "speckit.fakeext.cmd", "file": "commands/cmd.md", "description": "Fake extension command", } ] }, } with open(extension_dir / "extension.yml", "w") as f: yaml.dump(extension_manifest, f) preset_dir = temp_dir / f"ext-base-{strategy}" preset_dir.mkdir() (preset_dir / "commands").mkdir() overlay_body = ( "{CORE_TEMPLATE}\n## Extra\n" if strategy == "wrap" else "## Extra\n" ) (preset_dir / "commands" / "speckit.fakeext.cmd.md").write_text( f"---\ndescription: Preset overlay\n---\n\n{overlay_body}" ) preset_manifest = { "schema_version": "1.0", "preset": { "id": f"ext-base-{strategy}", "name": "Ext Base", "version": "1.0.0", "description": "Test", }, "requires": {"speckit_version": ">=0.1.0"}, "provides": { "templates": [ { "type": "command", "name": "speckit.fakeext.cmd", "file": "commands/speckit.fakeext.cmd.md", "strategy": strategy, } ] }, } with open(preset_dir / "preset.yml", "w") as f: yaml.dump(preset_manifest, f) manager = PresetManager(project_dir) manager.install_from_directory(preset_dir, "0.1.5") resolver = PresetResolver(project_dir) content = resolver.resolve_content("speckit.fakeext.cmd", "command") assert content is not None assert ".specify/extensions/fakeext/agents/control/commander.md" in content assert "Read agents/control" not in content assert "## Extra" in content class TestCollectAllLayers: """Test PresetResolver.collect_all_layers() method.""" def test_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. The constitution is the only template materialized to a live file rather than resolved on demand. These tests pin the regression from issue #3272: a preset-provided ``constitution-template`` must seed memory, 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) # Remove the memory file seeded during install to test ensure() in # isolation; it must re-seed from the preset, not the core template. memory = project_dir / ".specify" / "memory" / "constitution.md" memory.unlink() 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") # Ensure we validate ensure() behavior directly. memory = project_dir / ".specify" / "memory" / "constitution.md" memory.unlink() 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