Add local collections support with GUI, CLI, and publishing workflow
Features: - Local collection definitions stored in ~/.cmdforge/collections/ - CLI commands: create, show, add, remove, delete, publish, status - GUI Collections page with local and registry tabs - Collection publishing with tool resolution and visibility checks - New API endpoints: GET /api/v1/me, GET /api/v1/tools/.../approved, POST /api/v1/collections - RegistryClient methods: get_me(), has_approved_public_tool(), publish_collection() Implementation: - collection.py: Collection dataclass, resolve_tool_references(), classify_tool_reference(), ToolResolutionResult - collections_page.py: GUI with background workers for install/publish - collections_commands.py: Full CLI command implementations - registry/app.py: New authenticated endpoints with validation Tests: - test_collection.py: 27 unit tests for collection module - test_collection_api.py: 20 tests (8 client, 12 API with Flask skip) Documentation updated: README, CHANGELOG, CLAUDE.md, tests/README Co-Authored-By: Claude Opus 4.5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,298 @@
|
||||
"""Tests for collection.py - Collection definitions and management."""
|
||||
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from cmdforge.collection import (
|
||||
Collection, list_collections, get_collection, COLLECTIONS_DIR,
|
||||
classify_tool_reference, resolve_tool_references, ToolResolutionResult
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_collections_dir(tmp_path):
|
||||
"""Create a temporary collections directory."""
|
||||
with patch('cmdforge.collection.COLLECTIONS_DIR', tmp_path):
|
||||
yield tmp_path
|
||||
|
||||
|
||||
class TestCollection:
|
||||
"""Tests for Collection dataclass."""
|
||||
|
||||
def test_create_basic(self):
|
||||
coll = Collection(name="my-tools", display_name="My Tools")
|
||||
assert coll.name == "my-tools"
|
||||
assert coll.display_name == "My Tools"
|
||||
assert coll.description == ""
|
||||
assert coll.tools == []
|
||||
assert coll.pinned == {}
|
||||
assert coll.tags == []
|
||||
assert coll.published is False
|
||||
|
||||
def test_create_full(self):
|
||||
coll = Collection(
|
||||
name="dev-tools",
|
||||
display_name="Developer Tools",
|
||||
description="Essential tools for developers",
|
||||
maintainer="rob",
|
||||
tools=["summarize", "official/code-review"],
|
||||
pinned={"official/code-review": "^1.0.0"},
|
||||
tags=["dev", "productivity"],
|
||||
published=True,
|
||||
registry_name="dev-tools"
|
||||
)
|
||||
assert coll.name == "dev-tools"
|
||||
assert coll.maintainer == "rob"
|
||||
assert len(coll.tools) == 2
|
||||
assert coll.pinned["official/code-review"] == "^1.0.0"
|
||||
assert coll.published is True
|
||||
|
||||
def test_to_dict(self):
|
||||
coll = Collection(
|
||||
name="test-coll",
|
||||
display_name="Test Collection",
|
||||
description="Test",
|
||||
tools=["tool1"],
|
||||
tags=["test"]
|
||||
)
|
||||
d = coll.to_dict()
|
||||
assert d["name"] == "test-coll"
|
||||
assert d["display_name"] == "Test Collection"
|
||||
assert d["tools"] == ["tool1"]
|
||||
assert d["tags"] == ["test"]
|
||||
assert d["published"] is False
|
||||
|
||||
def test_save_and_load(self, temp_collections_dir):
|
||||
coll = Collection(
|
||||
name="my-coll",
|
||||
display_name="My Collection",
|
||||
description="A test collection",
|
||||
tools=["tool1", "owner/tool2"],
|
||||
pinned={"owner/tool2": "1.0.0"},
|
||||
tags=["test"]
|
||||
)
|
||||
coll.save()
|
||||
|
||||
# Verify file exists
|
||||
path = temp_collections_dir / "my-coll.yaml"
|
||||
assert path.exists()
|
||||
|
||||
# Load and verify
|
||||
loaded = Collection.load("my-coll")
|
||||
assert loaded is not None
|
||||
assert loaded.name == "my-coll"
|
||||
assert loaded.display_name == "My Collection"
|
||||
assert loaded.tools == ["tool1", "owner/tool2"]
|
||||
assert loaded.pinned == {"owner/tool2": "1.0.0"}
|
||||
|
||||
def test_load_nonexistent(self, temp_collections_dir):
|
||||
loaded = Collection.load("nonexistent")
|
||||
assert loaded is None
|
||||
|
||||
def test_load_empty_yaml(self, temp_collections_dir):
|
||||
# Create empty YAML file
|
||||
path = temp_collections_dir / "empty.yaml"
|
||||
path.write_text("")
|
||||
|
||||
loaded = Collection.load("empty")
|
||||
assert loaded is not None
|
||||
assert loaded.name == "empty"
|
||||
|
||||
def test_delete(self, temp_collections_dir):
|
||||
coll = Collection(name="to-delete", display_name="To Delete")
|
||||
coll.save()
|
||||
|
||||
path = temp_collections_dir / "to-delete.yaml"
|
||||
assert path.exists()
|
||||
|
||||
coll.delete()
|
||||
assert not path.exists()
|
||||
|
||||
def test_get_registry_url(self):
|
||||
with patch('cmdforge.collection.get_registry_url', return_value="https://cmdforge.brrd.tech/api/v1"):
|
||||
coll = Collection(name="my-coll", display_name="My Coll")
|
||||
url = coll.get_registry_url()
|
||||
assert url == "https://cmdforge.brrd.tech/collections/my-coll"
|
||||
|
||||
def test_get_registry_url_custom_base(self):
|
||||
with patch('cmdforge.collection.get_registry_url', return_value="https://custom.example.com/api/v1"):
|
||||
coll = Collection(name="my-coll", display_name="My Coll")
|
||||
url = coll.get_registry_url()
|
||||
assert url == "https://custom.example.com/collections/my-coll"
|
||||
|
||||
def test_get_registry_url_no_api_suffix(self):
|
||||
with patch('cmdforge.collection.get_registry_url', return_value="https://cmdforge.brrd.tech"):
|
||||
coll = Collection(name="my-coll", display_name="My Coll")
|
||||
url = coll.get_registry_url()
|
||||
assert url == "https://cmdforge.brrd.tech/collections/my-coll"
|
||||
|
||||
def test_get_registry_url_uses_registry_name(self):
|
||||
with patch('cmdforge.collection.get_registry_url', return_value="https://cmdforge.brrd.tech/api/v1"):
|
||||
coll = Collection(name="local-name", display_name="My Coll", registry_name="published-name")
|
||||
url = coll.get_registry_url()
|
||||
assert url == "https://cmdforge.brrd.tech/collections/published-name"
|
||||
|
||||
|
||||
class TestListCollections:
|
||||
"""Tests for list_collections function."""
|
||||
|
||||
def test_empty_dir(self, temp_collections_dir):
|
||||
result = list_collections()
|
||||
assert result == []
|
||||
|
||||
def test_with_collections(self, temp_collections_dir):
|
||||
# Create some collections
|
||||
Collection(name="alpha", display_name="Alpha").save()
|
||||
Collection(name="beta", display_name="Beta").save()
|
||||
Collection(name="gamma", display_name="Gamma").save()
|
||||
|
||||
result = list_collections()
|
||||
assert result == ["alpha", "beta", "gamma"] # Sorted
|
||||
|
||||
def test_ignores_non_yaml_files(self, temp_collections_dir):
|
||||
Collection(name="valid", display_name="Valid").save()
|
||||
(temp_collections_dir / "not-yaml.txt").write_text("ignored")
|
||||
|
||||
result = list_collections()
|
||||
assert result == ["valid"]
|
||||
|
||||
|
||||
class TestGetCollection:
|
||||
"""Tests for get_collection function."""
|
||||
|
||||
def test_existing(self, temp_collections_dir):
|
||||
Collection(name="test", display_name="Test", description="Desc").save()
|
||||
coll = get_collection("test")
|
||||
assert coll is not None
|
||||
assert coll.name == "test"
|
||||
assert coll.description == "Desc"
|
||||
|
||||
def test_nonexistent(self, temp_collections_dir):
|
||||
coll = get_collection("nonexistent")
|
||||
assert coll is None
|
||||
|
||||
|
||||
class TestClassifyToolReference:
|
||||
"""Tests for classify_tool_reference function."""
|
||||
|
||||
def test_local_tool(self):
|
||||
owner, name, is_local = classify_tool_reference("summarize")
|
||||
assert owner == ""
|
||||
assert name == "summarize"
|
||||
assert is_local is True
|
||||
|
||||
def test_registry_tool(self):
|
||||
owner, name, is_local = classify_tool_reference("official/summarize")
|
||||
assert owner == "official"
|
||||
assert name == "summarize"
|
||||
assert is_local is False
|
||||
|
||||
def test_registry_tool_nested_name(self):
|
||||
owner, name, is_local = classify_tool_reference("user/my-tool")
|
||||
assert owner == "user"
|
||||
assert name == "my-tool"
|
||||
assert is_local is False
|
||||
|
||||
|
||||
class TestResolveToolReferences:
|
||||
"""Tests for resolve_tool_references function."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_client(self):
|
||||
client = MagicMock()
|
||||
client.get_my_tool_status.side_effect = lambda name: {"status": "approved"}
|
||||
client.has_approved_public_tool.return_value = True
|
||||
return client
|
||||
|
||||
def test_all_registry_tools(self, mock_client):
|
||||
tools = ["official/tool1", "user/tool2"]
|
||||
pinned = {"official/tool1": "^1.0.0"}
|
||||
|
||||
result = resolve_tool_references(tools, pinned, "myuser", mock_client)
|
||||
|
||||
assert result.registry_refs == ["official/tool1", "user/tool2"]
|
||||
assert result.transformed_pinned == {"official/tool1": "^1.0.0"}
|
||||
assert result.local_unpublished == []
|
||||
|
||||
def test_local_tool_not_published(self, mock_client):
|
||||
from cmdforge.registry_client import RegistryError
|
||||
mock_client.get_my_tool_status.side_effect = RegistryError(
|
||||
code="TOOL_NOT_FOUND", message="Not found"
|
||||
)
|
||||
|
||||
with patch('cmdforge.collection._get_tool_visibility_from_yaml', return_value='public'):
|
||||
tools = ["my-local-tool"]
|
||||
result = resolve_tool_references(tools, {}, "myuser", mock_client)
|
||||
|
||||
assert "myuser/my-local-tool" in result.registry_refs
|
||||
assert "my-local-tool" in result.local_unpublished
|
||||
|
||||
def test_local_tool_published_approved(self, mock_client):
|
||||
mock_client.get_my_tool_status.return_value = {"status": "approved"}
|
||||
|
||||
with patch('cmdforge.collection._get_tool_visibility_from_yaml', return_value='public'):
|
||||
tools = ["my-tool"]
|
||||
result = resolve_tool_references(tools, {}, "myuser", mock_client)
|
||||
|
||||
assert "myuser/my-tool" in result.registry_refs
|
||||
assert result.local_unpublished == []
|
||||
assert ("my-tool", "approved", True) in result.local_published
|
||||
|
||||
def test_transforms_pinned_for_local_tools(self, mock_client):
|
||||
mock_client.get_my_tool_status.return_value = {"status": "approved"}
|
||||
|
||||
with patch('cmdforge.collection._get_tool_visibility_from_yaml', return_value='public'):
|
||||
tools = ["my-tool"]
|
||||
pinned = {"my-tool": "1.0.0"}
|
||||
result = resolve_tool_references(tools, pinned, "myuser", mock_client)
|
||||
|
||||
assert "myuser/my-tool" in result.transformed_pinned
|
||||
assert result.transformed_pinned["myuser/my-tool"] == "1.0.0"
|
||||
|
||||
def test_visibility_issues_detected(self, mock_client):
|
||||
with patch('cmdforge.collection._get_tool_visibility_from_yaml', return_value='private'):
|
||||
tools = ["private-tool"]
|
||||
result = resolve_tool_references(tools, {}, "myuser", mock_client)
|
||||
|
||||
assert ("private-tool", "private") in result.visibility_issues
|
||||
|
||||
def test_registry_tool_not_approved(self, mock_client):
|
||||
mock_client.has_approved_public_tool.return_value = False
|
||||
|
||||
tools = ["official/unapproved"]
|
||||
result = resolve_tool_references(tools, {}, "myuser", mock_client)
|
||||
|
||||
assert ("official/unapproved", "no approved public version") in result.registry_tool_issues
|
||||
|
||||
|
||||
class TestToolResolutionResult:
|
||||
"""Tests for ToolResolutionResult dataclass."""
|
||||
|
||||
def test_empty_result(self):
|
||||
result = ToolResolutionResult(
|
||||
registry_refs=[],
|
||||
transformed_pinned={},
|
||||
local_unpublished=[],
|
||||
local_published=[],
|
||||
visibility_issues=[],
|
||||
registry_tool_issues=[]
|
||||
)
|
||||
assert len(result.registry_refs) == 0
|
||||
assert len(result.local_unpublished) == 0
|
||||
|
||||
def test_with_data(self):
|
||||
result = ToolResolutionResult(
|
||||
registry_refs=["user/tool1", "user/tool2"],
|
||||
transformed_pinned={"user/tool1": "^1.0"},
|
||||
local_unpublished=["tool3"],
|
||||
local_published=[("tool1", "approved", True)],
|
||||
visibility_issues=[("tool4", "private")],
|
||||
registry_tool_issues=[("other/tool", "not found")]
|
||||
)
|
||||
assert len(result.registry_refs) == 2
|
||||
assert len(result.local_unpublished) == 1
|
||||
assert len(result.visibility_issues) == 1
|
||||
Reference in New Issue
Block a user