Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 31 additions & 1 deletion src/specify_cli/extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -626,12 +626,18 @@ class CommandRegistrar:
"args": "$ARGUMENTS",
"extension": ".agent.md",
},
"cursor": {
"cursor-agent": {
"dir": ".cursor/commands",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": ".md",
},
"codex": {
"dir": ".codex/prompts",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": ".md",
},
"qwen": {
"dir": ".qwen/commands",
"format": "toml",
Expand Down Expand Up @@ -710,6 +716,30 @@ class CommandRegistrar:
"args": "$ARGUMENTS",
"extension": ".md",
},
"agy": {
"dir": ".agent/workflows",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": ".md",
},
"vibe": {
"dir": ".vibe/prompts",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": ".md",
},
"generic": {
"dir": ".speckit/commands",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": ".md",
},
"q": {
"dir": ".amazonq/prompts",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": ".md",
},
}

@staticmethod
Expand Down
104 changes: 67 additions & 37 deletions tests/test_extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@

# ===== Fixtures =====


@pytest.fixture
def temp_dir():
"""Create a temporary directory for tests."""
Expand Down Expand Up @@ -87,8 +88,9 @@ def extension_dir(temp_dir, valid_manifest_data):

# Write manifest
import yaml

manifest_path = ext_dir / "extension.yml"
with open(manifest_path, 'w') as f:
with open(manifest_path, "w") as f:
yaml.dump(valid_manifest_data, f)

# Create commands directory
Expand Down Expand Up @@ -124,6 +126,7 @@ def project_dir(temp_dir):

# ===== ExtensionManifest Tests =====


class TestExtensionManifest:
"""Test ExtensionManifest validation and parsing."""

Expand All @@ -144,7 +147,7 @@ def test_missing_required_field(self, temp_dir):
import yaml

manifest_path = temp_dir / "extension.yml"
with open(manifest_path, 'w') as f:
with open(manifest_path, "w") as f:
yaml.dump({"schema_version": "1.0"}, f) # Missing 'extension'

with pytest.raises(ValidationError, match="Missing required field"):
Expand All @@ -157,7 +160,7 @@ def test_invalid_extension_id(self, temp_dir, valid_manifest_data):
valid_manifest_data["extension"]["id"] = "Invalid_ID" # Uppercase not allowed

manifest_path = temp_dir / "extension.yml"
with open(manifest_path, 'w') as f:
with open(manifest_path, "w") as f:
yaml.dump(valid_manifest_data, f)

with pytest.raises(ValidationError, match="Invalid extension ID"):
Expand All @@ -170,7 +173,7 @@ def test_invalid_version(self, temp_dir, valid_manifest_data):
valid_manifest_data["extension"]["version"] = "invalid"

manifest_path = temp_dir / "extension.yml"
with open(manifest_path, 'w') as f:
with open(manifest_path, "w") as f:
yaml.dump(valid_manifest_data, f)

with pytest.raises(ValidationError, match="Invalid version"):
Expand All @@ -183,7 +186,7 @@ def test_invalid_command_name(self, temp_dir, valid_manifest_data):
valid_manifest_data["provides"]["commands"][0]["name"] = "invalid-name"

manifest_path = temp_dir / "extension.yml"
with open(manifest_path, 'w') as f:
with open(manifest_path, "w") as f:
yaml.dump(valid_manifest_data, f)

with pytest.raises(ValidationError, match="Invalid command name"):
Expand All @@ -196,7 +199,7 @@ def test_no_commands(self, temp_dir, valid_manifest_data):
valid_manifest_data["provides"]["commands"] = []

manifest_path = temp_dir / "extension.yml"
with open(manifest_path, 'w') as f:
with open(manifest_path, "w") as f:
yaml.dump(valid_manifest_data, f)

with pytest.raises(ValidationError, match="must provide at least one command"):
Expand All @@ -214,6 +217,7 @@ def test_manifest_hash(self, extension_dir):

# ===== ExtensionRegistry Tests =====


class TestExtensionRegistry:
"""Test ExtensionRegistry operations."""

Expand Down Expand Up @@ -281,6 +285,7 @@ def test_registry_persistence(self, temp_dir):

# ===== ExtensionManager Tests =====


class TestExtensionManager:
"""Test ExtensionManager installation and removal."""

Expand Down Expand Up @@ -309,7 +314,7 @@ def test_install_from_directory(self, extension_dir, project_dir):
manifest = manager.install_from_directory(
extension_dir,
"0.1.0",
register_commands=False # Skip command registration for now
register_commands=False, # Skip command registration for now
)

assert manifest.id == "test-ext"
Expand All @@ -330,7 +335,9 @@ def test_install_duplicate(self, extension_dir, project_dir):

# Try to install again
with pytest.raises(ExtensionError, match="already installed"):
manager.install_from_directory(extension_dir, "0.1.0", register_commands=False)
manager.install_from_directory(
extension_dir, "0.1.0", register_commands=False
)

def test_remove_extension(self, extension_dir, project_dir):
"""Test removing an installed extension."""
Expand Down Expand Up @@ -399,14 +406,15 @@ def test_config_backup_on_remove(self, extension_dir, project_dir):

# ===== 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."""
"""Kiro CLI should be mapped to .kiro/prompts and q should be present."""
assert "kiro-cli" in CommandRegistrar.AGENT_CONFIGS
assert CommandRegistrar.AGENT_CONFIGS["kiro-cli"]["dir"] == ".kiro/prompts"
assert "q" not in CommandRegistrar.AGENT_CONFIGS
assert "q" in CommandRegistrar.AGENT_CONFIGS

def test_parse_frontmatter_valid(self):
"""Test parsing valid YAML frontmatter."""
Expand Down Expand Up @@ -440,10 +448,7 @@ def test_parse_frontmatter_no_frontmatter(self):

def test_render_frontmatter(self):
"""Test rendering frontmatter to YAML."""
frontmatter = {
"description": "Test command",
"tools": ["tool1", "tool2"]
}
frontmatter = {"description": "Test command", "tools": ["tool1", "tool2"]}

registrar = CommandRegistrar()
output = registrar.render_frontmatter(frontmatter)
Expand All @@ -463,9 +468,7 @@ def test_register_commands_for_claude(self, extension_dir, project_dir):

registrar = CommandRegistrar()
registered = registrar.register_commands_for_claude(
manifest,
extension_dir,
project_dir
manifest, extension_dir, project_dir
)

assert len(registered) == 1
Expand Down Expand Up @@ -510,18 +513,22 @@ def test_command_with_aliases(self, project_dir, temp_dir):
},
}

with open(ext_dir / "extension.yml", 'w') as f:
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")
(ext_dir / "commands" / "cmd.md").write_text(
"---\ndescription: Test\n---\n\nTest"
)

claude_dir = project_dir / ".claude" / "commands"
claude_dir.mkdir(parents=True)

manifest = ExtensionManifest(ext_dir / "extension.yml")
registrar = CommandRegistrar()
registered = registrar.register_commands_for_claude(manifest, ext_dir, project_dir)
registered = registrar.register_commands_for_claude(
manifest, ext_dir, project_dir
)

assert len(registered) == 2
assert "speckit.alias.cmd" in registered
Expand Down Expand Up @@ -570,7 +577,9 @@ def test_copilot_companion_prompt_created(self, extension_dir, project_dir):
)

# Verify companion .prompt.md file exists
prompt_file = project_dir / ".github" / "prompts" / "speckit.test.hello.prompt.md"
prompt_file = (
project_dir / ".github" / "prompts" / "speckit.test.hello.prompt.md"
)
assert prompt_file.exists()

# Verify content has correct agent frontmatter
Expand Down Expand Up @@ -647,6 +656,7 @@ def test_non_copilot_agent_no_companion_file(self, extension_dir, project_dir):

# ===== Utility Function Tests =====


class TestVersionSatisfies:
"""Test version_satisfies utility function."""

Expand Down Expand Up @@ -675,6 +685,7 @@ def test_version_satisfies_invalid(self):

# ===== Integration Tests =====


class TestIntegration:
"""Integration tests for complete workflows."""

Expand All @@ -686,11 +697,7 @@ def test_full_install_and_remove_workflow(self, extension_dir, project_dir):
manager = ExtensionManager(project_dir)

# Install
manager.install_from_directory(
extension_dir,
"0.1.0",
register_commands=True
)
manager.install_from_directory(extension_dir, "0.1.0", register_commands=True)

# Verify installation
assert manager.registry.is_installed("test-ext")
Expand All @@ -707,8 +714,7 @@ def test_full_install_and_remove_workflow(self, extension_dir, project_dir):
registered_commands = metadata["registered_commands"]
# Check that the command is registered for at least one agent
assert any(
"speckit.test.hello" in cmds
for cmds in registered_commands.values()
"speckit.test.hello" in cmds for cmds in registered_commands.values()
)

# Remove
Expand All @@ -734,7 +740,9 @@ def test_copilot_cleanup_removes_prompt_files(self, extension_dir, project_dir):

# Verify files exist before cleanup
agent_file = agents_dir / "speckit.test.hello.agent.md"
prompt_file = project_dir / ".github" / "prompts" / "speckit.test.hello.prompt.md"
prompt_file = (
project_dir / ".github" / "prompts" / "speckit.test.hello.prompt.md"
)
assert agent_file.exists()
assert prompt_file.exists()

Expand Down Expand Up @@ -773,17 +781,23 @@ def test_multiple_extensions(self, temp_dir, project_dir):
},
}

with open(ext_dir / "extension.yml", 'w') as f:
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---\nTest")
(ext_dir / "commands" / "cmd.md").write_text(
"---\ndescription: Test\n---\nTest"
)

manager = ExtensionManager(project_dir)

# Install both
manager.install_from_directory(temp_dir / "ext1", "0.1.0", register_commands=False)
manager.install_from_directory(temp_dir / "ext2", "0.1.0", register_commands=False)
manager.install_from_directory(
temp_dir / "ext1", "0.1.0", register_commands=False
)
manager.install_from_directory(
temp_dir / "ext2", "0.1.0", register_commands=False
)

# Verify both installed
installed = manager.list_installed()
Expand Down Expand Up @@ -1235,6 +1249,7 @@ def test_clear_cache(self, temp_dir):

# ===== CatalogEntry Tests =====


class TestCatalogEntry:
"""Test CatalogEntry dataclass."""

Expand All @@ -1254,6 +1269,7 @@ def test_catalog_entry_creation(self):

# ===== Catalog Stack Tests =====


class TestCatalogStack:
"""Test multi-catalog stack support."""

Expand Down Expand Up @@ -1421,7 +1437,9 @@ def test_load_catalog_config_missing_file(self, temp_dir):
project_dir = self._make_project(temp_dir)
catalog = ExtensionCatalog(project_dir)

result = catalog._load_catalog_config(project_dir / ".specify" / "nonexistent.yml")
result = catalog._load_catalog_config(
project_dir / ".specify" / "nonexistent.yml"
)
assert result is None

def test_load_catalog_config_localhost_allowed(self, temp_dir):
Expand Down Expand Up @@ -1487,13 +1505,20 @@ def test_merge_conflict_higher_priority_wins(self, temp_dir):
catalog.cache_dir.mkdir(parents=True, exist_ok=True)
catalog.cache_file.write_text(json.dumps(primary_data))
catalog.cache_metadata_file.write_text(
json.dumps({"cached_at": datetime.now(timezone.utc).isoformat(), "catalog_url": "http://test.com"})
json.dumps(
{
"cached_at": datetime.now(timezone.utc).isoformat(),
"catalog_url": "http://test.com",
}
)
)

# Write secondary cache (URL-hash-based) with jira v1.0.0 (should lose)
import hashlib

url_hash = hashlib.sha256(ExtensionCatalog.COMMUNITY_CATALOG_URL.encode()).hexdigest()[:16]
url_hash = hashlib.sha256(
ExtensionCatalog.COMMUNITY_CATALOG_URL.encode()
).hexdigest()[:16]
secondary_cache = catalog.cache_dir / f"catalog-{url_hash}.json"
secondary_meta = catalog.cache_dir / f"catalog-{url_hash}-metadata.json"
secondary_data = {
Expand All @@ -1515,7 +1540,12 @@ def test_merge_conflict_higher_priority_wins(self, temp_dir):
}
secondary_cache.write_text(json.dumps(secondary_data))
secondary_meta.write_text(
json.dumps({"cached_at": datetime.now(timezone.utc).isoformat(), "catalog_url": ExtensionCatalog.COMMUNITY_CATALOG_URL})
json.dumps(
{
"cached_at": datetime.now(timezone.utc).isoformat(),
"catalog_url": ExtensionCatalog.COMMUNITY_CATALOG_URL,
}
)
)

results = catalog.search()
Expand Down
Loading