""" Unit tests for the extension system. Tests cover: - Extension manifest validation - Extension registry operations - Extension manager installation/removal - Command registration - Catalog stack (multi-catalog support) """ import pytest import json import os import platform import tempfile import shutil import tomllib from contextlib import contextmanager from pathlib import Path from datetime import datetime, timezone from unittest.mock import MagicMock from tests.conftest import strip_ansi from specify_cli.extensions import ( CatalogEntry, CORE_COMMAND_NAMES, DEFAULT_HOOK_PRIORITY, VALID_EFFECTS, ExtensionManifest, ExtensionRegistry, ExtensionManager, CommandRegistrar, HookExecutor, ExtensionCatalog, ExtensionError, ValidationError, CompatibilityError, normalize_priority, version_satisfies, ) # Minimal valid ZIP (empty end-of-central-directory record). Passes # zipfile.is_zipfile() so --from download tests exercise the content guard. _MINIMAL_ZIP_BYTES = b"PK\x05\x06" + b"\x00" * 18 def can_create_symlink(tmp_path: Path) -> bool: """Return True when the current platform/user can create file symlinks.""" target = tmp_path / "symlink-target.txt" link = tmp_path / "symlink-link.txt" target.write_text("ok", encoding="utf-8") try: os.symlink(target, link) except OSError: return False return link.is_symlink() # ===== 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_manifest_data(): """Valid extension manifest data.""" return { "schema_version": "1.0", "extension": { "id": "test-ext", "name": "Test Extension", "version": "1.0.0", "description": "A test extension", "author": "Test Author", "repository": "https://github.com/test/test-ext", "license": "MIT", }, "requires": { "speckit_version": ">=0.1.0", "commands": ["speckit.tasks"], }, "provides": { "commands": [ { "name": "speckit.test-ext.hello", "file": "commands/hello.md", "description": "Test command", } ] }, "hooks": { "after_tasks": { "command": "speckit.test-ext.hello", "optional": True, "prompt": "Run test?", } }, "tags": ["testing", "example"], } @pytest.fixture def extension_dir(temp_dir, valid_manifest_data): """Create a complete extension directory structure.""" ext_dir = temp_dir / "test-ext" ext_dir.mkdir() # Write manifest import yaml manifest_path = ext_dir / "extension.yml" with open(manifest_path, 'w') as f: yaml.dump(valid_manifest_data, f) # Create commands directory commands_dir = ext_dir / "commands" commands_dir.mkdir() # Write command file cmd_file = commands_dir / "hello.md" cmd_file.write_text("""--- description: "Test hello command" --- # Test Hello Command $ARGUMENTS """) return ext_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() return proj_dir # ===== normalize_priority Tests ===== class TestNormalizePriority: """Test normalize_priority helper function.""" def test_valid_integer(self): """Test with valid integer priority.""" assert normalize_priority(5) == 5 assert normalize_priority(1) == 1 assert normalize_priority(100) == 100 def test_valid_string_number(self): """Test with string that can be converted to int.""" assert normalize_priority("5") == 5 assert normalize_priority("10") == 10 def test_zero_returns_default(self): """Test that zero priority returns default.""" assert normalize_priority(0) == 10 assert normalize_priority(0, default=5) == 5 def test_negative_returns_default(self): """Test that negative priority returns default.""" assert normalize_priority(-1) == 10 assert normalize_priority(-100, default=5) == 5 def test_none_returns_default(self): """Test that None returns default.""" assert normalize_priority(None) == 10 assert normalize_priority(None, default=5) == 5 def test_invalid_string_returns_default(self): """Test that non-numeric string returns default.""" assert normalize_priority("invalid") == 10 assert normalize_priority("abc", default=5) == 5 def test_float_truncates(self): """Test that float is truncated to int.""" assert normalize_priority(5.9) == 5 assert normalize_priority(3.1) == 3 def test_empty_string_returns_default(self): """Test that empty string returns default.""" assert normalize_priority("") == 10 def test_custom_default(self): """Test custom default value.""" assert normalize_priority(None, default=20) == 20 assert normalize_priority("invalid", default=1) == 1 def test_boolean_returns_default(self): """Booleans fall back to the default rather than acting as int 0/1.""" assert normalize_priority(True) == 10 assert normalize_priority(False) == 10 assert normalize_priority(True, default=5) == 5 # ===== ExtensionManifest Tests ===== class TestExtensionManifest: """Test ExtensionManifest validation and parsing.""" def test_valid_manifest(self, extension_dir): """Test loading a valid manifest.""" manifest_path = extension_dir / "extension.yml" manifest = ExtensionManifest(manifest_path) assert manifest.id == "test-ext" assert manifest.name == "Test Extension" assert manifest.version == "1.0.0" assert manifest.description == "A test extension" assert len(manifest.commands) == 1 assert manifest.commands[0]["name"] == "speckit.test-ext.hello" def test_core_command_names_match_bundled_templates(self): """Core command reservations should stay aligned with bundled templates.""" commands_dir = Path(__file__).resolve().parent.parent / "templates" / "commands" expected = { command_file.stem for command_file in commands_dir.iterdir() if command_file.is_file() and command_file.suffix == ".md" } assert CORE_COMMAND_NAMES == expected def test_missing_required_field(self, temp_dir): """Test manifest missing required field.""" import yaml manifest_path = temp_dir / "extension.yml" with open(manifest_path, 'w') as f: yaml.dump({"schema_version": "1.0"}, f) # Missing 'extension' with pytest.raises(ValidationError, match="Missing required field"): ExtensionManifest(manifest_path) def test_non_mapping_yaml_raises_validation_error(self, temp_dir): """Manifest whose YAML root is a scalar or list raises ValidationError, not TypeError.""" manifest_path = temp_dir / "extension.yml" for bad_content in ("42\n", "[]\n", "null\n"): manifest_path.write_text(bad_content) with pytest.raises(ValidationError, match="YAML mapping"): ExtensionManifest(manifest_path) def test_utf8_non_ascii_description_loads(self, temp_dir, valid_manifest_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'. """ import yaml valid_manifest_data["extension"]["description"] = "中文测试 — émojis 🚀" manifest_path = temp_dir / "extension.yml" # Write UTF-8 bytes explicitly so the test exercises the read path, # not the (locale-dependent) write path. manifest_path.write_bytes( yaml.safe_dump(valid_manifest_data, allow_unicode=True).encode("utf-8") ) manifest = ExtensionManifest(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 ValidationError, not raw UnicodeDecodeError.""" manifest_path = temp_dir / "extension.yml" # 0xFF/0xFE are not valid UTF-8 lead bytes. manifest_path.write_bytes(b"\xff\xfe not valid utf-8 \xff\n") with pytest.raises(ValidationError, match="not valid UTF-8"): ExtensionManifest(manifest_path) def test_invalid_extension_id(self, temp_dir, valid_manifest_data): """Test manifest with invalid extension ID format.""" import yaml valid_manifest_data["extension"]["id"] = "Invalid_ID" # Uppercase not allowed manifest_path = temp_dir / "extension.yml" with open(manifest_path, 'w') as f: yaml.dump(valid_manifest_data, f) with pytest.raises(ValidationError, match="Invalid extension ID"): ExtensionManifest(manifest_path) def test_invalid_version(self, temp_dir, valid_manifest_data): """Test manifest with invalid semantic version.""" import yaml valid_manifest_data["extension"]["version"] = "invalid" manifest_path = temp_dir / "extension.yml" with open(manifest_path, 'w') as f: yaml.dump(valid_manifest_data, f) with pytest.raises(ValidationError, match="Invalid version"): ExtensionManifest(manifest_path) def test_valid_category(self, temp_dir, valid_manifest_data): """Test manifest with various category values (free-form string).""" import yaml for category in ("docs", "code", "process", "integration", "visibility", "custom-category"): valid_manifest_data["extension"]["category"] = category manifest_path = temp_dir / "extension.yml" with open(manifest_path, 'w') as f: yaml.dump(valid_manifest_data, f) manifest = ExtensionManifest(manifest_path) assert manifest.category == category def test_valid_effect(self, temp_dir, valid_manifest_data): """Test manifest with valid effect values.""" import yaml for effect in sorted(VALID_EFFECTS): valid_manifest_data["extension"]["effect"] = effect manifest_path = temp_dir / "extension.yml" with open(manifest_path, 'w') as f: yaml.dump(valid_manifest_data, f) manifest = ExtensionManifest(manifest_path) assert manifest.effect == effect def test_invalid_category(self, temp_dir, valid_manifest_data): """Test manifest with empty category raises ValidationError.""" import yaml valid_manifest_data["extension"]["category"] = "" manifest_path = temp_dir / "extension.yml" with open(manifest_path, 'w') as f: yaml.dump(valid_manifest_data, f) with pytest.raises(ValidationError, match="Invalid extension.category"): ExtensionManifest(manifest_path) def test_invalid_effect(self, temp_dir, valid_manifest_data): """Test manifest with invalid effect raises ValidationError.""" import yaml valid_manifest_data["extension"]["effect"] = "write-only" manifest_path = temp_dir / "extension.yml" with open(manifest_path, 'w') as f: yaml.dump(valid_manifest_data, f) with pytest.raises(ValidationError, match="Invalid extension.effect"): ExtensionManifest(manifest_path) def test_category_and_effect_optional(self, temp_dir, valid_manifest_data): """Test that omitting category and effect still passes validation.""" import yaml # Ensure no category/effect in data valid_manifest_data["extension"].pop("category", None) valid_manifest_data["extension"].pop("effect", None) manifest_path = temp_dir / "extension.yml" with open(manifest_path, 'w') as f: yaml.dump(valid_manifest_data, f) manifest = ExtensionManifest(manifest_path) assert manifest.category is None assert manifest.effect is None def test_invalid_command_name(self, temp_dir, valid_manifest_data): """Test manifest with command name that cannot be auto-corrected raises ValidationError.""" import yaml valid_manifest_data["provides"]["commands"][0]["name"] = "invalid-name" manifest_path = temp_dir / "extension.yml" with open(manifest_path, 'w') as f: yaml.dump(valid_manifest_data, f) with pytest.raises(ValidationError, match="Invalid command name"): ExtensionManifest(manifest_path) @pytest.mark.parametrize( "bad_file", ["../../../outside.md", "../escape.md", "a/../../escape.md", "/abs/outside.md", "C:escape.md", "C:\\Windows\\x.md", "..\\..\\escape.md"], ) def test_command_file_traversal_rejected(self, temp_dir, valid_manifest_data, bad_file): """Manifest 'file' field with traversal/absolute path raises ValidationError. Defense-in-depth for GHSA-w5fv-7w9x-7fc5. """ import yaml valid_manifest_data["provides"]["commands"][0]["file"] = bad_file manifest_path = temp_dir / "extension.yml" with open(manifest_path, 'w', encoding='utf-8') as f: yaml.safe_dump(valid_manifest_data, f) with pytest.raises(ValidationError, match="Invalid command 'file'"): ExtensionManifest(manifest_path) @pytest.mark.parametrize("bad_file", [" commands/hello.md", "commands/hello.md ", "\tcommands/hello.md"]) def test_command_file_whitespace_rejected(self, temp_dir, valid_manifest_data, bad_file): """Manifest 'file' with leading/trailing whitespace raises ValidationError.""" import yaml valid_manifest_data["provides"]["commands"][0]["file"] = bad_file manifest_path = temp_dir / "extension.yml" with open(manifest_path, 'w', encoding='utf-8') as f: yaml.safe_dump(valid_manifest_data, f) with pytest.raises(ValidationError, match="leading or trailing whitespace"): ExtensionManifest(manifest_path) def test_command_name_autocorrect_speckit_prefix(self, temp_dir, valid_manifest_data): """Test that 'speckit.command' is auto-corrected to 'speckit.{ext_id}.command'.""" import yaml valid_manifest_data["provides"]["commands"][0]["name"] = "speckit.hello" manifest_path = temp_dir / "extension.yml" with open(manifest_path, 'w') as f: yaml.dump(valid_manifest_data, f) manifest = ExtensionManifest(manifest_path) assert manifest.commands[0]["name"] == "speckit.test-ext.hello" assert len(manifest.warnings) == 1 assert "speckit.hello" in manifest.warnings[0] assert "speckit.test-ext.hello" in manifest.warnings[0] def test_command_name_autocorrect_matching_ext_id_prefix(self, temp_dir, valid_manifest_data): """Test that '{ext_id}.command' is auto-corrected to 'speckit.{ext_id}.command'.""" import yaml # Set ext_id to match the legacy namespace so correction is valid valid_manifest_data["extension"]["id"] = "docguard" valid_manifest_data["provides"]["commands"][0]["name"] = "docguard.guard" manifest_path = temp_dir / "extension.yml" with open(manifest_path, 'w') as f: yaml.dump(valid_manifest_data, f) manifest = ExtensionManifest(manifest_path) assert manifest.commands[0]["name"] == "speckit.docguard.guard" assert len(manifest.warnings) == 1 assert "docguard.guard" in manifest.warnings[0] assert "speckit.docguard.guard" in manifest.warnings[0] def test_command_name_mismatched_namespace_not_corrected(self, temp_dir, valid_manifest_data): """Test that 'X.command' is NOT corrected when X doesn't match ext_id.""" import yaml # ext_id is "test-ext" but command uses a different namespace valid_manifest_data["provides"]["commands"][0]["name"] = "docguard.guard" manifest_path = temp_dir / "extension.yml" with open(manifest_path, 'w') as f: yaml.dump(valid_manifest_data, f) with pytest.raises(ValidationError, match="Invalid command name"): ExtensionManifest(manifest_path) def test_alias_free_form_accepted(self, temp_dir, valid_manifest_data): """Aliases are free-form — a 'speckit.command' alias must be accepted unchanged.""" import yaml valid_manifest_data["provides"]["commands"][0]["aliases"] = ["speckit.hello"] manifest_path = temp_dir / "extension.yml" with open(manifest_path, 'w') as f: yaml.dump(valid_manifest_data, f) manifest = ExtensionManifest(manifest_path) assert manifest.commands[0]["aliases"] == ["speckit.hello"] assert manifest.warnings == [] def test_valid_command_name_has_no_warnings(self, temp_dir, valid_manifest_data): """Test that a correctly-named command produces no warnings.""" import yaml manifest_path = temp_dir / "extension.yml" with open(manifest_path, 'w') as f: yaml.dump(valid_manifest_data, f) manifest = ExtensionManifest(manifest_path) assert manifest.warnings == [] def test_no_commands_no_hooks(self, temp_dir, valid_manifest_data): """Test manifest with no commands and no hooks provided.""" import yaml valid_manifest_data["provides"]["commands"] = [] valid_manifest_data.pop("hooks", None) manifest_path = temp_dir / "extension.yml" with open(manifest_path, 'w') as f: yaml.dump(valid_manifest_data, f) with pytest.raises(ValidationError, match="must provide at least one command or hook"): ExtensionManifest(manifest_path) def test_hooks_only_extension(self, temp_dir, valid_manifest_data): """Test manifest with hooks but no commands is valid.""" import yaml valid_manifest_data["provides"]["commands"] = [] valid_manifest_data["hooks"] = { "after_specify": { "command": "speckit.test-ext.notify", "optional": True, "prompt": "Run notification?", } } manifest_path = temp_dir / "extension.yml" with open(manifest_path, 'w') as f: yaml.dump(valid_manifest_data, f) manifest = ExtensionManifest(manifest_path) assert manifest.id == valid_manifest_data["extension"]["id"] assert len(manifest.commands) == 0 assert len(manifest.hooks) == 1 def test_commands_null_rejected(self, temp_dir, valid_manifest_data): """Test manifest with commands: null is rejected.""" import yaml valid_manifest_data["provides"]["commands"] = None manifest_path = temp_dir / "extension.yml" with open(manifest_path, 'w') as f: yaml.dump(valid_manifest_data, f) with pytest.raises(ValidationError, match="Invalid provides.commands"): ExtensionManifest(manifest_path) def test_hooks_not_dict_rejected(self, temp_dir, valid_manifest_data): """Test manifest with hooks as a list is rejected.""" import yaml valid_manifest_data["hooks"] = ["not", "a", "dict"] manifest_path = temp_dir / "extension.yml" with open(manifest_path, 'w') as f: yaml.dump(valid_manifest_data, f) with pytest.raises(ValidationError, match="Invalid hooks"): ExtensionManifest(manifest_path) def test_non_dict_hook_entry_raises_validation_error(self, temp_dir, valid_manifest_data): """Non-mapping hook entries must raise ValidationError, not silently skip.""" import yaml valid_manifest_data["hooks"]["after_tasks"] = "speckit.test-ext.hello" manifest_path = temp_dir / "extension.yml" with open(manifest_path, 'w') as f: yaml.dump(valid_manifest_data, f) with pytest.raises(ValidationError, match="Invalid hook 'after_tasks'"): ExtensionManifest(manifest_path) def test_hook_single_mapping_still_accepted(self, extension_dir): """Existing single-mapping hook manifests parse unchanged (regression).""" manifest_path = extension_dir / "extension.yml" manifest = ExtensionManifest(manifest_path) assert "after_tasks" in manifest.hooks assert isinstance(manifest.hooks["after_tasks"], dict) assert manifest.hooks["after_tasks"]["command"] == "speckit.test-ext.hello" def test_hook_list_of_mappings_accepted(self, temp_dir, valid_manifest_data): """A hook event may be configured as a list of mappings.""" import yaml valid_manifest_data["provides"]["commands"].append({ "name": "speckit.test-ext.bye", "file": "commands/bye.md", "description": "Second test command", }) valid_manifest_data["hooks"]["after_tasks"] = [ {"command": "speckit.test-ext.hello", "description": "first"}, {"command": "speckit.test-ext.bye", "description": "second"}, ] manifest_path = temp_dir / "extension.yml" with open(manifest_path, 'w', encoding="utf-8") as f: yaml.dump(valid_manifest_data, f) manifest = ExtensionManifest(manifest_path) entries = manifest.hooks["after_tasks"] assert isinstance(entries, list) assert [e["command"] for e in entries] == [ "speckit.test-ext.hello", "speckit.test-ext.bye", ] def test_hook_list_with_non_mapping_entry_rejected(self, temp_dir, valid_manifest_data): """A list entry that is not a mapping must raise ValidationError.""" import yaml valid_manifest_data["hooks"]["after_tasks"] = [ {"command": "speckit.test-ext.hello"}, "not-a-mapping", ] manifest_path = temp_dir / "extension.yml" with open(manifest_path, 'w', encoding="utf-8") as f: yaml.dump(valid_manifest_data, f) with pytest.raises( ValidationError, match="Invalid hook 'after_tasks': expected a mapping or list of mappings", ): ExtensionManifest(manifest_path) def test_hook_list_command_refs_normalized(self, temp_dir, valid_manifest_data): """Alias-form command refs are lifted to canonical form for every entry in a list hook, each emitting a warning.""" import yaml valid_manifest_data["provides"]["commands"].append({ "name": "speckit.test-ext.bye", "file": "commands/bye.md", "description": "Second test command", }) valid_manifest_data["hooks"]["after_tasks"] = [ {"command": "test-ext.hello"}, {"command": "test-ext.bye"}, ] manifest_path = temp_dir / "extension.yml" with open(manifest_path, 'w', encoding="utf-8") as f: yaml.dump(valid_manifest_data, f) manifest = ExtensionManifest(manifest_path) assert [e["command"] for e in manifest.hooks["after_tasks"]] == [ "speckit.test-ext.hello", "speckit.test-ext.bye", ] lifted = [w for w in manifest.warnings if "updated to canonical form" in w] assert len(lifted) == 2 def test_hook_empty_list_rejected(self, temp_dir, valid_manifest_data): """An empty list for a hook event is rejected rather than silently registering nothing.""" import yaml valid_manifest_data["hooks"]["after_tasks"] = [] manifest_path = temp_dir / "extension.yml" with open(manifest_path, 'w', encoding="utf-8") as f: yaml.dump(valid_manifest_data, f) with pytest.raises(ValidationError, match="must contain at least one entry"): ExtensionManifest(manifest_path) def test_hook_priority_field_validation(self, temp_dir, valid_manifest_data): """Hook entry ``priority`` must be a positive integer when provided.""" import yaml manifest_path = temp_dir / "extension.yml" valid_manifest_data["hooks"]["after_tasks"] = { "command": "speckit.test-ext.hello", "priority": "high", } with open(manifest_path, 'w', encoding="utf-8") as f: yaml.dump(valid_manifest_data, f) with pytest.raises(ValidationError, match="invalid 'priority'.*integer"): ExtensionManifest(manifest_path) valid_manifest_data["hooks"]["after_tasks"]["priority"] = 0 with open(manifest_path, 'w', encoding="utf-8") as f: yaml.dump(valid_manifest_data, f) with pytest.raises(ValidationError, match="invalid 'priority'.*>= 1"): ExtensionManifest(manifest_path) # bool is a subclass of int, so it must be rejected explicitly. valid_manifest_data["hooks"]["after_tasks"]["priority"] = True with open(manifest_path, 'w', encoding="utf-8") as f: yaml.dump(valid_manifest_data, f) with pytest.raises(ValidationError, match="invalid 'priority'.*integer"): ExtensionManifest(manifest_path) valid_manifest_data["hooks"]["after_tasks"]["priority"] = 5 with open(manifest_path, 'w', encoding="utf-8") as f: yaml.dump(valid_manifest_data, f) manifest = ExtensionManifest(manifest_path) assert manifest.hooks["after_tasks"]["priority"] == 5 def test_manifest_hash(self, extension_dir): """Test manifest hash calculation.""" manifest_path = extension_dir / "extension.yml" manifest = ExtensionManifest(manifest_path) hash_value = manifest.get_hash() assert hash_value.startswith("sha256:") assert len(hash_value) > 10 # ===== ExtensionRegistry Tests ===== class TestExtensionRegistry: """Test ExtensionRegistry operations.""" def test_empty_registry(self, temp_dir): """Test creating a new empty registry.""" extensions_dir = temp_dir / "extensions" extensions_dir.mkdir() registry = ExtensionRegistry(extensions_dir) assert registry.data["schema_version"] == "1.0" assert registry.data["extensions"] == {} assert len(registry.list()) == 0 def test_add_extension(self, temp_dir): """Test adding an extension to registry.""" extensions_dir = temp_dir / "extensions" extensions_dir.mkdir() registry = ExtensionRegistry(extensions_dir) metadata = { "version": "1.0.0", "source": "local", "enabled": True, } registry.add("test-ext", metadata) assert registry.is_installed("test-ext") ext_data = registry.get("test-ext") assert ext_data["version"] == "1.0.0" assert "installed_at" in ext_data def test_remove_extension(self, temp_dir): """Test removing an extension from registry.""" extensions_dir = temp_dir / "extensions" extensions_dir.mkdir() registry = ExtensionRegistry(extensions_dir) registry.add("test-ext", {"version": "1.0.0"}) assert registry.is_installed("test-ext") registry.remove("test-ext") assert not registry.is_installed("test-ext") assert registry.get("test-ext") is None def test_registry_persistence(self, temp_dir): """Test that registry persists to disk.""" extensions_dir = temp_dir / "extensions" extensions_dir.mkdir() # Create registry and add extension registry1 = ExtensionRegistry(extensions_dir) registry1.add("test-ext", {"version": "1.0.0"}) # Load new registry instance registry2 = ExtensionRegistry(extensions_dir) # Should still have the extension assert registry2.is_installed("test-ext") assert registry2.get("test-ext")["version"] == "1.0.0" def test_update_preserves_installed_at(self, temp_dir): """Test that update() preserves the original installed_at timestamp.""" extensions_dir = temp_dir / "extensions" extensions_dir.mkdir() registry = ExtensionRegistry(extensions_dir) registry.add("test-ext", {"version": "1.0.0", "enabled": True}) # Get original installed_at original_data = registry.get("test-ext") original_installed_at = original_data["installed_at"] # Update with new metadata registry.update("test-ext", {"version": "2.0.0", "enabled": False}) # Verify installed_at is preserved updated_data = registry.get("test-ext") assert updated_data["installed_at"] == original_installed_at assert updated_data["version"] == "2.0.0" assert updated_data["enabled"] is False def test_update_merges_with_existing(self, temp_dir): """Test that update() merges new metadata with existing fields.""" extensions_dir = temp_dir / "extensions" extensions_dir.mkdir() registry = ExtensionRegistry(extensions_dir) registry.add("test-ext", { "version": "1.0.0", "enabled": True, "registered_commands": {"claude": ["cmd1", "cmd2"]}, }) # Update with partial metadata (only enabled field) registry.update("test-ext", {"enabled": False}) # Verify existing fields are preserved updated_data = registry.get("test-ext") assert updated_data["enabled"] is False assert updated_data["version"] == "1.0.0" # Preserved assert updated_data["registered_commands"] == {"claude": ["cmd1", "cmd2"]} # Preserved def test_update_raises_for_missing_extension(self, temp_dir): """Test that update() raises KeyError for non-installed extension.""" extensions_dir = temp_dir / "extensions" extensions_dir.mkdir() registry = ExtensionRegistry(extensions_dir) with pytest.raises(KeyError, match="not installed"): registry.update("nonexistent-ext", {"enabled": False}) def test_restore_overwrites_completely(self, temp_dir): """Test that restore() overwrites the registry entry completely.""" extensions_dir = temp_dir / "extensions" extensions_dir.mkdir() registry = ExtensionRegistry(extensions_dir) registry.add("test-ext", {"version": "2.0.0", "enabled": True}) # Restore with complete backup data backup_data = { "version": "1.0.0", "enabled": False, "installed_at": "2024-01-01T00:00:00+00:00", "registered_commands": {"claude": ["old-cmd"]}, } registry.restore("test-ext", backup_data) # Verify entry is exactly as restored restored_data = registry.get("test-ext") assert restored_data == backup_data def test_restore_can_recreate_removed_entry(self, temp_dir): """Test that restore() can recreate an entry after remove().""" extensions_dir = temp_dir / "extensions" extensions_dir.mkdir() registry = ExtensionRegistry(extensions_dir) registry.add("test-ext", {"version": "1.0.0"}) # Save backup and remove backup = registry.get("test-ext").copy() registry.remove("test-ext") assert not registry.is_installed("test-ext") # Restore should recreate the entry registry.restore("test-ext", backup) assert registry.is_installed("test-ext") assert registry.get("test-ext")["version"] == "1.0.0" def test_restore_rejects_none_metadata(self, temp_dir): """Test restore() raises ValueError for None metadata.""" extensions_dir = temp_dir / "extensions" extensions_dir.mkdir() registry = ExtensionRegistry(extensions_dir) with pytest.raises(ValueError, match="metadata must be a dict"): registry.restore("test-ext", None) def test_restore_rejects_non_dict_metadata(self, temp_dir): """Test restore() raises ValueError for non-dict metadata.""" extensions_dir = temp_dir / "extensions" extensions_dir.mkdir() registry = ExtensionRegistry(extensions_dir) with pytest.raises(ValueError, match="metadata must be a dict"): registry.restore("test-ext", "not-a-dict") with pytest.raises(ValueError, match="metadata must be a dict"): registry.restore("test-ext", ["list", "not", "dict"]) def test_restore_uses_deep_copy(self, temp_dir): """Test restore() deep copies metadata to prevent mutation.""" extensions_dir = temp_dir / "extensions" extensions_dir.mkdir() registry = ExtensionRegistry(extensions_dir) original_metadata = { "version": "1.0.0", "nested": {"key": "original"}, } registry.restore("test-ext", 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-ext") assert stored["version"] == "1.0.0" assert stored["nested"]["key"] == "original" def test_get_returns_deep_copy(self, temp_dir): """Test that get() returns deep copies for nested structures.""" extensions_dir = temp_dir / "extensions" extensions_dir.mkdir() registry = ExtensionRegistry(extensions_dir) metadata = { "version": "1.0.0", "registered_commands": {"claude": ["cmd1"]}, } registry.add("test-ext", metadata) fetched = registry.get("test-ext") fetched["registered_commands"]["claude"].append("cmd2") # Internal registry must remain unchanged. internal = registry.data["extensions"]["test-ext"] assert internal["registered_commands"] == {"claude": ["cmd1"]} def test_get_returns_none_for_corrupted_entry(self, temp_dir): """Test that get() returns None for corrupted (non-dict) entries.""" extensions_dir = temp_dir / "extensions" extensions_dir.mkdir() registry = ExtensionRegistry(extensions_dir) # Directly corrupt the registry with non-dict entries registry.data["extensions"]["corrupted-string"] = "not a dict" registry.data["extensions"]["corrupted-list"] = ["not", "a", "dict"] registry.data["extensions"]["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 for nested structures.""" extensions_dir = temp_dir / "extensions" extensions_dir.mkdir() registry = ExtensionRegistry(extensions_dir) metadata = { "version": "1.0.0", "registered_commands": {"claude": ["cmd1"]}, } registry.add("test-ext", metadata) listed = registry.list() listed["test-ext"]["registered_commands"]["claude"].append("cmd2") # Internal registry must remain unchanged. internal = registry.data["extensions"]["test-ext"] assert internal["registered_commands"] == {"claude": ["cmd1"]} def test_list_returns_empty_dict_for_corrupted_registry(self, temp_dir): """Test that list() returns empty dict when extensions is not a dict.""" extensions_dir = temp_dir / "extensions" extensions_dir.mkdir() registry = ExtensionRegistry(extensions_dir) # Corrupt the registry - extensions is a list instead of dict registry.data["extensions"] = ["not", "a", "dict"] registry._save() # list() should return empty dict, not crash result = registry.list() assert result == {} # ===== ExtensionManager Tests ===== class TestExtensionManager: """Test ExtensionManager installation and removal.""" def test_check_compatibility_valid(self, extension_dir, project_dir): """Test compatibility check with valid version.""" manager = ExtensionManager(project_dir) manifest = ExtensionManifest(extension_dir / "extension.yml") # Should not raise result = manager.check_compatibility(manifest, "0.1.0") assert result is True def test_check_compatibility_invalid(self, extension_dir, project_dir): """Test compatibility check with invalid version.""" manager = ExtensionManager(project_dir) manifest = ExtensionManifest(extension_dir / "extension.yml") # Requires >=0.1.0, but we have 0.0.1 with pytest.raises(CompatibilityError, match="Extension requires spec-kit"): manager.check_compatibility(manifest, "0.0.1") def test_install_from_directory(self, extension_dir, project_dir): """Test installing extension from directory.""" manager = ExtensionManager(project_dir) manifest = manager.install_from_directory( extension_dir, "0.1.0", register_commands=False # Skip command registration for now ) assert manifest.id == "test-ext" assert manager.registry.is_installed("test-ext") # Check extension directory was copied ext_dir = project_dir / ".specify" / "extensions" / "test-ext" assert ext_dir.exists() assert (ext_dir / "extension.yml").exists() assert (ext_dir / "commands" / "hello.md").exists() def test_install_from_directory_explicitly_recovers_active_skills_dir( self, extension_dir, project_dir, monkeypatch ): """Extension install should explicitly request active skills-dir recovery.""" captured = {} def fake_register_all( self, manifest, extension_dir, project_root, link_outputs=False, create_missing_active_skills_dir=False, ): captured["create_missing_active_skills_dir"] = ( create_missing_active_skills_dir ) return {} monkeypatch.setattr( CommandRegistrar, "register_commands_for_all_agents", fake_register_all, ) manager = ExtensionManager(project_dir) manager.install_from_directory(extension_dir, "0.1.0", register_commands=True) assert captured["create_missing_active_skills_dir"] is True def test_command_registrar_default_does_not_recover_active_skills_dir( self, extension_dir, project_dir, monkeypatch ): """The extension wrapper should preserve the core registrar's conservative default.""" from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar captured = {} def fake_register_all( self, commands, source_id, source_dir, project_root, context_note=None, link_outputs=False, create_missing_active_skills_dir=False, ): captured["create_missing_active_skills_dir"] = ( create_missing_active_skills_dir ) return {} monkeypatch.setattr( AgentCommandRegistrar, "register_commands_for_all_agents", fake_register_all, ) manifest = ExtensionManifest(extension_dir / "extension.yml") registrar = CommandRegistrar() registrar.register_commands_for_all_agents(manifest, extension_dir, project_dir) assert captured["create_missing_active_skills_dir"] is False def test_install_duplicate(self, extension_dir, project_dir): """Test installing already installed extension.""" manager = ExtensionManager(project_dir) # Install once manager.install_from_directory(extension_dir, "0.1.0", register_commands=False) # Try to install again with pytest.raises(ExtensionError, match="already installed"): manager.install_from_directory(extension_dir, "0.1.0", register_commands=False) def test_install_force_reinstall(self, extension_dir, project_dir): """Test force-reinstalling an already-installed extension.""" manager = ExtensionManager(project_dir) # Install once manager.install_from_directory( extension_dir, "0.1.0", register_commands=False ) assert manager.registry.is_installed("test-ext") # Force-reinstall manifest2 = manager.install_from_directory( extension_dir, "0.1.0", register_commands=False, force=True ) assert manifest2.id == "test-ext" assert manager.registry.is_installed("test-ext") # Check extension directory was recreated ext_dir = project_dir / ".specify" / "extensions" / "test-ext" assert ext_dir.exists() assert (ext_dir / "extension.yml").exists() assert (ext_dir / "commands" / "hello.md").exists() def test_install_force_config_preserved(self, extension_dir, project_dir): """Test that config files are preserved when force-reinstalling.""" manager = ExtensionManager(project_dir) # Install once manager.install_from_directory( extension_dir, "0.1.0", register_commands=False ) # Create a config file in the installed extension directory ext_dir = project_dir / ".specify" / "extensions" / "test-ext" config_file = ext_dir / "test-ext-config.yml" config_file.write_text("test: config") # Force-reinstall manager.install_from_directory( extension_dir, "0.1.0", register_commands=False, force=True ) # Config file should still exist after reinstall new_config = ext_dir / "test-ext-config.yml" assert new_config.exists() assert new_config.read_text() == "test: config" def test_install_force_without_existing(self, extension_dir, project_dir): """Test force-install when extension is NOT already installed (works normally).""" manager = ExtensionManager(project_dir) manifest = manager.install_from_directory( extension_dir, "0.1.0", register_commands=False, force=True ) assert manifest.id == "test-ext" assert manager.registry.is_installed("test-ext") def test_install_from_install_dir_is_rejected_without_data_loss( self, extension_dir, project_dir ): """Installing from an extension's own install dir must fail without deleting it (regression for issue #2990).""" manager = ExtensionManager(project_dir) # Install once so the extension lives at its install destination. manager.install_from_directory(extension_dir, "0.1.0", register_commands=False) install_dir = project_dir / ".specify" / "extensions" / "test-ext" assert install_dir.exists() # Re-installing from that same directory with --force must be rejected. with pytest.raises(ValidationError, match="install destination"): manager.install_from_directory( install_dir, "0.1.0", register_commands=False, force=True ) # The directory and its contents must be left intact (no data loss). assert install_dir.exists() assert (install_dir / "extension.yml").exists() assert (install_dir / "commands" / "hello.md").exists() def test_install_from_install_dir_is_rejected_when_resolve_fails( self, extension_dir, project_dir, monkeypatch ): """Resolution failures must not bypass the self-install guard.""" manager = ExtensionManager(project_dir) manager.install_from_directory(extension_dir, "0.1.0", register_commands=False) install_dir = project_dir / ".specify" / "extensions" / "test-ext" original_resolve = Path.resolve def fail_resolve(self, *args, **kwargs): if self in {install_dir, manager.extensions_dir / "test-ext"}: raise OSError("cannot resolve path") return original_resolve(self, *args, **kwargs) monkeypatch.setattr(Path, "resolve", fail_resolve) with pytest.raises(ValidationError, match="install destination"): manager.install_from_directory( install_dir, "0.1.0", register_commands=False, force=True ) assert install_dir.exists() assert (install_dir / "extension.yml").exists() assert (install_dir / "commands" / "hello.md").exists() def test_install_zip_force_reinstall(self, extension_dir, project_dir): """Test force-reinstalling from ZIP when already installed.""" import zipfile import tempfile manager = ExtensionManager(project_dir) # Install once from directory manager.install_from_directory(extension_dir, "0.1.0", register_commands=False) # Create a ZIP of the extension in a temp directory (not NamedTemporaryFile, # which can fail on Windows due to file locking). with tempfile.TemporaryDirectory() as tmpdir: zip_path = Path(tmpdir) / "test-ext.zip" with zipfile.ZipFile(zip_path, "w") as zf: for f in extension_dir.rglob("*"): if f.is_file(): zf.write(f, f.relative_to(extension_dir)) # Force-reinstall from ZIP manifest = manager.install_from_zip( zip_path, "0.1.0", force=True ) assert manifest.id == "test-ext" assert manager.registry.is_installed("test-ext") ext_dir = project_dir / ".specify" / "extensions" / "test-ext" assert ext_dir.exists() def test_install_duplicate_error_mentions_force(self, extension_dir, project_dir): """Test that duplicate install error message suggests --force.""" manager = ExtensionManager(project_dir) manager.install_from_directory(extension_dir, "0.1.0", register_commands=False) with pytest.raises(ExtensionError, match="--force"): manager.install_from_directory(extension_dir, "0.1.0", register_commands=False) def test_install_rejects_extension_id_in_core_namespace(self, temp_dir, project_dir): """Install should reject extension IDs that shadow core commands.""" import yaml ext_dir = temp_dir / "analyze-ext" ext_dir.mkdir() (ext_dir / "commands").mkdir() manifest_data = { "schema_version": "1.0", "extension": { "id": "analyze", "name": "Analyze Extension", "version": "1.0.0", "description": "Test", }, "requires": {"speckit_version": ">=0.1.0"}, "provides": { "commands": [ { "name": "speckit.analyze.extra", "file": "commands/cmd.md", } ] }, } (ext_dir / "extension.yml").write_text(yaml.dump(manifest_data)) (ext_dir / "commands" / "cmd.md").write_text("---\ndescription: Test\n---\n\nBody") manager = ExtensionManager(project_dir) with pytest.raises(ValidationError, match="conflicts with core command namespace"): manager.install_from_directory(ext_dir, "0.1.0", register_commands=False) def test_install_accepts_free_form_alias(self, temp_dir, project_dir): """Aliases are free-form — a short 'speckit.shortcut' alias must be preserved unchanged.""" import yaml ext_dir = temp_dir / "alias-shortcut" ext_dir.mkdir() (ext_dir / "commands").mkdir() manifest_data = { "schema_version": "1.0", "extension": { "id": "alias-shortcut", "name": "Alias Shortcut", "version": "1.0.0", "description": "Test", }, "requires": {"speckit_version": ">=0.1.0"}, "provides": { "commands": [ { "name": "speckit.alias-shortcut.cmd", "file": "commands/cmd.md", "aliases": ["speckit.shortcut"], } ] }, } (ext_dir / "extension.yml").write_text(yaml.dump(manifest_data)) (ext_dir / "commands" / "cmd.md").write_text("---\ndescription: Test\n---\n\nBody") manager = ExtensionManager(project_dir) manifest = manager.install_from_directory(ext_dir, "0.1.0", register_commands=False) assert manifest.commands[0]["aliases"] == ["speckit.shortcut"] assert manifest.warnings == [] def test_install_rejects_namespace_squatting(self, temp_dir, project_dir): """Install should reject commands and aliases outside the extension namespace.""" import yaml ext_dir = temp_dir / "squat-ext" ext_dir.mkdir() (ext_dir / "commands").mkdir() manifest_data = { "schema_version": "1.0", "extension": { "id": "squat-ext", "name": "Squat Extension", "version": "1.0.0", "description": "Test", }, "requires": {"speckit_version": ">=0.1.0"}, "provides": { "commands": [ { "name": "speckit.other-ext.cmd", "file": "commands/cmd.md", "aliases": ["speckit.squat-ext.ok"], } ] }, } (ext_dir / "extension.yml").write_text(yaml.dump(manifest_data)) (ext_dir / "commands" / "cmd.md").write_text("---\ndescription: Test\n---\n\nBody") manager = ExtensionManager(project_dir) with pytest.raises(ValidationError, match="must use extension namespace 'squat-ext'"): manager.install_from_directory(ext_dir, "0.1.0", register_commands=False) def test_install_rejects_command_collision_with_installed_extension(self, temp_dir, project_dir): """Install should reject names already claimed by an installed legacy extension.""" import yaml first_dir = temp_dir / "ext-one" first_dir.mkdir() (first_dir / "commands").mkdir() first_manifest = { "schema_version": "1.0", "extension": { "id": "ext-one", "name": "Extension One", "version": "1.0.0", "description": "Test", }, "requires": {"speckit_version": ">=0.1.0"}, "provides": { "commands": [ { "name": "speckit.ext-one.sync", "file": "commands/cmd.md", "aliases": ["speckit.shared.sync"], } ] }, } (first_dir / "extension.yml").write_text(yaml.dump(first_manifest)) (first_dir / "commands" / "cmd.md").write_text("---\ndescription: Test\n---\n\nBody") installed_ext_dir = project_dir / ".specify" / "extensions" / "ext-one" installed_ext_dir.parent.mkdir(parents=True, exist_ok=True) shutil.copytree(first_dir, installed_ext_dir) second_dir = temp_dir / "ext-two" second_dir.mkdir() (second_dir / "commands").mkdir() second_manifest = { "schema_version": "1.0", "extension": { "id": "shared", "name": "Shared Extension", "version": "1.0.0", "description": "Test", }, "requires": {"speckit_version": ">=0.1.0"}, "provides": { "commands": [ { "name": "speckit.shared.sync", "file": "commands/cmd.md", } ] }, } (second_dir / "extension.yml").write_text(yaml.dump(second_manifest)) (second_dir / "commands" / "cmd.md").write_text("---\ndescription: Test\n---\n\nBody") manager = ExtensionManager(project_dir) manager.registry.add("ext-one", {"version": "1.0.0", "source": "local"}) with pytest.raises(ValidationError, match="already provided by extension 'ext-one'"): manager.install_from_directory(second_dir, "0.1.0", register_commands=False) def test_remove_extension(self, extension_dir, project_dir): """Test removing an installed extension.""" manager = ExtensionManager(project_dir) # Install extension manager.install_from_directory(extension_dir, "0.1.0", register_commands=False) ext_dir = project_dir / ".specify" / "extensions" / "test-ext" assert ext_dir.exists() # Remove extension result = manager.remove("test-ext", keep_config=False) assert result is True assert not manager.registry.is_installed("test-ext") assert not ext_dir.exists() def test_remove_nonexistent(self, project_dir): """Test removing non-existent extension.""" manager = ExtensionManager(project_dir) result = manager.remove("nonexistent") assert result is False def test_list_installed(self, extension_dir, project_dir): """Test listing installed extensions.""" manager = ExtensionManager(project_dir) # Initially empty assert len(manager.list_installed()) == 0 # Install extension manager.install_from_directory(extension_dir, "0.1.0", register_commands=False) # Should have one extension installed = manager.list_installed() assert len(installed) == 1 assert installed[0]["id"] == "test-ext" assert installed[0]["name"] == "Test Extension" assert installed[0]["version"] == "1.0.0" assert installed[0]["command_count"] == 1 assert installed[0]["hook_count"] == 1 def test_config_backup_on_remove(self, extension_dir, project_dir): """Test that config files are backed up on removal.""" manager = ExtensionManager(project_dir) # Install extension manager.install_from_directory(extension_dir, "0.1.0", register_commands=False) # Create a config file ext_dir = project_dir / ".specify" / "extensions" / "test-ext" config_file = ext_dir / "test-ext-config.yml" config_file.write_text("test: config") # Remove extension (without keep_config) manager.remove("test-ext", keep_config=False) # Check backup was created (now in subdirectory per extension) backup_dir = project_dir / ".specify" / "extensions" / ".backup" / "test-ext" backup_file = backup_dir / "test-ext-config.yml" assert backup_file.exists() assert backup_file.read_text() == "test: config" # ===== CommandRegistrar Tests ===== class TestCommandRegistrar: """Test CommandRegistrar command registration.""" def test_kiro_cli_agent_config_present(self): """Kiro CLI should be mapped to .kiro/prompts and legacy q removed.""" assert "kiro-cli" in CommandRegistrar.AGENT_CONFIGS assert CommandRegistrar.AGENT_CONFIGS["kiro-cli"]["dir"] == ".kiro/prompts" assert "q" not in CommandRegistrar.AGENT_CONFIGS def test_codex_agent_config_present(self): """Codex should be mapped to .agents/skills.""" assert "codex" in CommandRegistrar.AGENT_CONFIGS assert CommandRegistrar.AGENT_CONFIGS["codex"]["dir"] == ".agents/skills" assert CommandRegistrar.AGENT_CONFIGS["codex"]["extension"] == "/SKILL.md" def test_pi_agent_config_present(self): """Pi should be mapped to .pi/prompts.""" assert "pi" in CommandRegistrar.AGENT_CONFIGS cfg = CommandRegistrar.AGENT_CONFIGS["pi"] assert cfg["dir"] == ".pi/prompts" assert cfg["format"] == "markdown" assert cfg["args"] == "$ARGUMENTS" assert cfg["extension"] == ".md" def test_qwen_agent_config_is_markdown(self): """Qwen should use Markdown format with $ARGUMENTS (not TOML).""" assert "qwen" in CommandRegistrar.AGENT_CONFIGS cfg = CommandRegistrar.AGENT_CONFIGS["qwen"] assert cfg["dir"] == ".qwen/commands" assert cfg["format"] == "markdown" assert cfg["args"] == "$ARGUMENTS" assert cfg["extension"] == ".md" def test_parse_frontmatter_valid(self): """Test parsing valid YAML frontmatter.""" content = """--- description: "Test command" tools: - tool1 - tool2 --- # Command body $ARGUMENTS """ registrar = CommandRegistrar() frontmatter, body = registrar.parse_frontmatter(content) assert frontmatter["description"] == "Test command" assert frontmatter["tools"] == ["tool1", "tool2"] assert "Command body" in body assert "$ARGUMENTS" in body def test_parse_frontmatter_no_frontmatter(self): """Test parsing content without frontmatter.""" content = "# Just a command\n$ARGUMENTS" registrar = CommandRegistrar() frontmatter, body = registrar.parse_frontmatter(content) assert frontmatter == {} assert body == content def test_parse_frontmatter_non_mapping_returns_empty_dict(self): """Non-mapping YAML frontmatter should not crash downstream renderers.""" content = """--- - item1 - item2 --- # Command body """ registrar = CommandRegistrar() frontmatter, body = registrar.parse_frontmatter(content) assert frontmatter == {} assert "Command body" in body def test_render_frontmatter(self): """Test rendering frontmatter to YAML.""" frontmatter = { "description": "Test command", "tools": ["tool1", "tool2"] } registrar = CommandRegistrar() output = registrar.render_frontmatter(frontmatter) assert output.startswith("---\n") assert output.endswith("---\n") assert "description: Test command" in output def test_render_frontmatter_unicode(self): """Test rendering frontmatter preserves non-ASCII characters.""" frontmatter = { "description": "Prüfe Konformität der Implementierung" } registrar = CommandRegistrar() output = registrar.render_frontmatter(frontmatter) assert "Prüfe Konformität" in output assert "\\u" not in output def test_adjust_script_paths_does_not_mutate_input(self): """Path adjustments should not mutate caller-owned frontmatter dicts.""" from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar registrar = AgentCommandRegistrar() original = { "scripts": { "sh": "../../scripts/bash/setup-plan.sh {ARGS}", "ps": "../../scripts/powershell/setup-plan.ps1 {ARGS}", } } before = json.loads(json.dumps(original)) adjusted = registrar._adjust_script_paths(original) assert original == before assert adjusted["scripts"]["sh"] == ".specify/scripts/bash/setup-plan.sh {ARGS}" assert adjusted["scripts"]["ps"] == ".specify/scripts/powershell/setup-plan.ps1 {ARGS}" def test_adjust_script_paths_preserves_extension_local_paths(self): """Extension-local script paths should not be rewritten into .specify/.specify.""" from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar registrar = AgentCommandRegistrar() original = { "scripts": { "sh": ".specify/extensions/test-ext/scripts/setup.sh {ARGS}", "ps": "scripts/powershell/setup-plan.ps1 {ARGS}", } } adjusted = registrar._adjust_script_paths(original) assert adjusted["scripts"]["sh"] == ".specify/extensions/test-ext/scripts/setup.sh {ARGS}" assert adjusted["scripts"]["ps"] == ".specify/scripts/powershell/setup-plan.ps1 {ARGS}" def test_rewrite_project_relative_paths_preserves_extension_local_body_paths(self): """Body rewrites should preserve extension-local assets while fixing top-level refs.""" from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar body = ( "Read `.specify/extensions/test-ext/templates/spec.md`\n" "Run scripts/bash/setup-plan.sh\n" ) rewritten = AgentCommandRegistrar.rewrite_project_relative_paths(body) assert ".specify/extensions/test-ext/templates/spec.md" in rewritten assert ".specify/scripts/bash/setup-plan.sh" in rewritten def test_render_toml_command_handles_embedded_triple_double_quotes(self): """TOML renderer should stay valid when body includes triple double-quotes.""" from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar registrar = AgentCommandRegistrar() output = registrar.render_toml_command( {"description": "x"}, 'line1\n"""danger"""\nline2', "extension:test-ext", ) assert "prompt = '''" in output assert '"""danger"""' in output def test_render_toml_command_escapes_when_both_triple_quote_styles_exist(self): """If body has both triple quote styles, fall back to escaped basic string.""" from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar registrar = AgentCommandRegistrar() output = registrar.render_toml_command( {"description": "x"}, 'a """ b\nc \'\'\' d', "extension:test-ext", ) assert 'prompt = "' in output assert "\\n" in output assert "\\\"\\\"\\\"" in output def test_render_toml_command_preserves_multiline_description(self): """Multiline descriptions should render as parseable TOML with preserved semantics.""" from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar registrar = AgentCommandRegistrar() output = registrar.render_toml_command( {"description": "first line\nsecond line\n"}, "body", "extension:test-ext", ) parsed = tomllib.loads(output) assert parsed["description"] == "first line\nsecond line\n" def test_render_toml_command_preserves_backslashes_in_body(self): """A backslash in the body (e.g. a Windows path) must not break TOML. A multiline basic string ("\"\"\"") processes backslash escapes, so ``C:\\Users`` (``\\U``) would render as invalid TOML; the body must round-trip with backslashes intact. """ from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar registrar = AgentCommandRegistrar() output = registrar.render_toml_command( {"description": "x"}, r"Run C:\Users\dev\tool.exe then report.", "extension:test-ext", ) parsed = tomllib.loads(output) # must not raise assert parsed["prompt"].strip() == r"Run C:\Users\dev\tool.exe then report." def test_render_toml_command_handles_trailing_backslash(self): """A body ending in a backslash must round-trip without corruption.""" from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar registrar = AgentCommandRegistrar() output = registrar.render_toml_command( {"description": "x"}, "path ends with sep\\", "extension:test-ext", ) parsed = tomllib.loads(output) assert parsed["prompt"].strip() == "path ends with sep\\" def test_render_toml_command_backslash_with_both_triple_quotes_escapes(self): """Body with a backslash and both triple-quote styles → escaped basic string.""" from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar registrar = AgentCommandRegistrar() body = "a \\ b\nc \"\"\" d\ne ''' f" output = registrar.render_toml_command({"description": "x"}, body, "extension:test-ext") parsed = tomllib.loads(output) assert parsed["prompt"] == body def test_register_commands_for_claude(self, extension_dir, project_dir): """Test registering commands for Claude agent.""" # Create .claude directory claude_dir = project_dir / ".claude" / "skills" claude_dir.mkdir(parents=True) ExtensionManager(project_dir) # Initialize manager (side effects only) manifest = ExtensionManifest(extension_dir / "extension.yml") registrar = CommandRegistrar() registered = registrar.register_commands_for_claude( manifest, extension_dir, project_dir ) assert len(registered) == 1 assert "speckit.test-ext.hello" in registered # Check command file was created cmd_file = claude_dir / "speckit-test-ext-hello" / "SKILL.md" assert cmd_file.exists() content = cmd_file.read_text() assert "description: Test hello command" in content assert "test-ext" in content def test_command_with_aliases(self, project_dir, temp_dir): """Test registering a command with aliases.""" import yaml # Create extension with command alias ext_dir = temp_dir / "ext-alias" ext_dir.mkdir() manifest_data = { "schema_version": "1.0", "extension": { "id": "ext-alias", "name": "Extension with Alias", "version": "1.0.0", "description": "Test", }, "requires": { "speckit_version": ">=0.1.0", }, "provides": { "commands": [ { "name": "speckit.ext-alias.cmd", "file": "commands/cmd.md", "aliases": ["speckit.ext-alias.shortcut"], } ] }, } with open(ext_dir / "extension.yml", 'w') as f: yaml.dump(manifest_data, f) (ext_dir / "commands").mkdir() (ext_dir / "commands" / "cmd.md").write_text("---\ndescription: Test\n---\n\nTest") claude_dir = project_dir / ".claude" / "skills" claude_dir.mkdir(parents=True) manifest = ExtensionManifest(ext_dir / "extension.yml") registrar = CommandRegistrar() registered = registrar.register_commands_for_claude(manifest, ext_dir, project_dir) assert len(registered) == 2 assert "speckit.ext-alias.cmd" in registered assert "speckit.ext-alias.shortcut" in registered assert (claude_dir / "speckit-ext-alias-cmd" / "SKILL.md").exists() assert (claude_dir / "speckit-ext-alias-shortcut" / "SKILL.md").exists() def test_unregister_commands_for_codex_skills_uses_mapped_names(self, project_dir): """Codex skill cleanup should use the same mapped names as registration.""" skills_dir = project_dir / ".agents" / "skills" (skills_dir / "speckit-specify").mkdir(parents=True) (skills_dir / "speckit-specify" / "SKILL.md").write_text("body") (skills_dir / "speckit-shortcut").mkdir(parents=True) (skills_dir / "speckit-shortcut" / "SKILL.md").write_text("body") registrar = CommandRegistrar() registrar.unregister_commands( {"codex": ["speckit.specify", "speckit.shortcut"]}, project_dir, ) assert not (skills_dir / "speckit-specify" / "SKILL.md").exists() assert not (skills_dir / "speckit-shortcut" / "SKILL.md").exists() def test_unregister_commands_handles_legacy_dot_notated_files(self, project_dir): """Unregister should clean up both legacy dot-notated and new hyphenated files.""" # 1. Mock an agent that uses hyphenated/formatted names (e.g. Cline) from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar registrar = AgentCommandRegistrar() # We'll use "cline" since it has format_name assert "cline" in registrar.AGENT_CONFIGS cline_config = registrar.AGENT_CONFIGS["cline"] cline_dir = project_dir / cline_config["dir"] cline_dir.mkdir(parents=True, exist_ok=True) # 2. Create both legacy and new files # Command name: speckit.git.commit # Formatted name: speckit-git-commit cmd_name = "speckit.git.commit" formatted_name = "speckit-git-commit" legacy_file = cline_dir / f"{cmd_name}.md" formatted_file = cline_dir / f"{formatted_name}.md" legacy_file.write_text("legacy body") formatted_file.write_text("formatted body") assert legacy_file.exists() assert formatted_file.exists() # 3. Call unregister registrar.unregister_commands({"cline": [cmd_name]}, project_dir) # 4. Verify both are gone assert not legacy_file.exists(), "Legacy dot-notated file should be removed" assert ( not formatted_file.exists() ), "Formatted hyphenated file should be removed" def test_register_commands_for_all_agents_distinguishes_codex_from_amp(self, extension_dir, project_dir): """A Codex project under .agents/skills should not implicitly activate Amp.""" skills_dir = project_dir / ".agents" / "skills" skills_dir.mkdir(parents=True) manifest = ExtensionManifest(extension_dir / "extension.yml") registrar = CommandRegistrar() registered = registrar.register_commands_for_all_agents(manifest, extension_dir, project_dir) assert "codex" in registered assert "amp" not in registered assert not (project_dir / ".agents" / "commands").exists() def test_codex_skill_registration_writes_skill_frontmatter(self, extension_dir, project_dir): """Codex SKILL.md output should use skills-oriented frontmatter.""" skills_dir = project_dir / ".agents" / "skills" skills_dir.mkdir(parents=True) manifest = ExtensionManifest(extension_dir / "extension.yml") registrar = CommandRegistrar() registrar.register_commands_for_agent("codex", manifest, extension_dir, project_dir) skill_file = skills_dir / "speckit-test-ext-hello" / "SKILL.md" assert skill_file.exists() content = skill_file.read_text() assert "name: speckit-test-ext-hello" in content assert "description: Test hello command" in content assert "compatibility:" in content assert "metadata:" in content assert "source: test-ext:commands/hello.md" in content assert "