""" 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 tempfile import shutil import tomllib from pathlib import Path from datetime import datetime, timezone from tests.conftest import strip_ansi from specify_cli.extensions import ( CatalogEntry, CORE_COMMAND_NAMES, ExtensionManifest, ExtensionRegistry, ExtensionManager, CommandRegistrar, HookExecutor, ExtensionCatalog, ExtensionError, ValidationError, CompatibilityError, normalize_priority, version_satisfies, ) # ===== 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 # ===== 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_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_invalid_command_name(self, temp_dir, valid_manifest_data): """Test manifest with invalid command name format.""" 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) def test_no_commands(self, temp_dir, valid_manifest_data): """Test manifest with no commands provided.""" import yaml valid_manifest_data["provides"]["commands"] = [] 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"): ExtensionManifest(manifest_path) 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_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_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_rejects_alias_without_extension_namespace(self, temp_dir, project_dir): """Install should reject legacy short aliases that can shadow core commands.""" 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) with pytest.raises(ValidationError, match="Invalid alias 'speckit.shortcut'"): manager.install_from_directory(ext_dir, "0.1.0", register_commands=False) 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_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_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 "