Removes tools from grouping.
This commit is contained in:
@@ -79,13 +79,6 @@ class ToolSummary(BaseModel):
|
||||
installed: bool = False
|
||||
|
||||
|
||||
class ToolCategory(BaseModel):
|
||||
"""Tools grouped by category."""
|
||||
|
||||
name: str
|
||||
tools: list[ToolSummary]
|
||||
|
||||
|
||||
class ToolDetail(ToolSummary):
|
||||
"""Full detail for a single tool, including documentation."""
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ from castle_cli.config import load_config
|
||||
from castle_cli.manifest import ComponentManifest
|
||||
|
||||
from castle_api.config import settings
|
||||
from castle_api.models import ToolCategory, ToolDetail, ToolSummary
|
||||
from castle_api.models import ToolDetail, ToolSummary
|
||||
|
||||
router = APIRouter(tags=["tools"])
|
||||
|
||||
@@ -42,55 +42,16 @@ def _tool_summary(name: str, manifest: ComponentManifest, root: Path | None = No
|
||||
)
|
||||
|
||||
|
||||
def _find_md_for_tool(
|
||||
root: Path,
|
||||
source: str,
|
||||
tool_name: str,
|
||||
) -> Path | None:
|
||||
"""Find the .md documentation file for a tool source path."""
|
||||
source_path = root / source
|
||||
if source_path.is_file():
|
||||
md = source_path.with_suffix(".md")
|
||||
if md.exists():
|
||||
return md
|
||||
elif source_path.is_dir():
|
||||
py_name = tool_name.replace("-", "_")
|
||||
pkg_name = source_path.name
|
||||
md = source_path / "src" / pkg_name / f"{py_name}.md"
|
||||
if md.exists():
|
||||
return md
|
||||
return None
|
||||
|
||||
|
||||
def _strip_frontmatter(content: str) -> str:
|
||||
"""Strip YAML frontmatter from markdown content."""
|
||||
if content.startswith("---\n"):
|
||||
end = content.find("\n---\n", 4)
|
||||
if end != -1:
|
||||
content = content[end + 5:]
|
||||
return content.strip()
|
||||
|
||||
|
||||
@router.get("/tools", response_model=list[ToolCategory])
|
||||
def list_tools() -> list[ToolCategory]:
|
||||
"""List tools grouped by source directory."""
|
||||
@router.get("/tools", response_model=list[ToolSummary])
|
||||
def list_tools() -> list[ToolSummary]:
|
||||
"""List all registered tools."""
|
||||
config = load_config(settings.castle_root)
|
||||
tools = {k: v for k, v in config.components.items() if v.tool}
|
||||
|
||||
by_group: dict[str, list[ToolSummary]] = {}
|
||||
for name, manifest in tools.items():
|
||||
t = manifest.tool
|
||||
assert t is not None
|
||||
if t.source:
|
||||
group = Path(t.source).name
|
||||
else:
|
||||
group = "standalone"
|
||||
by_group.setdefault(group, []).append(_tool_summary(name, manifest, config.root))
|
||||
|
||||
return [
|
||||
ToolCategory(name=group, tools=sorted(items, key=lambda t: t.id))
|
||||
for group, items in sorted(by_group.items())
|
||||
]
|
||||
return sorted(
|
||||
[_tool_summary(name, manifest, config.root) for name, manifest in tools.items()],
|
||||
key=lambda t: t.id,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/tools/{name}", response_model=ToolDetail)
|
||||
@@ -112,16 +73,7 @@ def get_tool(name: str) -> ToolDetail:
|
||||
)
|
||||
|
||||
summary = _tool_summary(name, manifest, config.root)
|
||||
docs: str | None = None
|
||||
t = manifest.tool
|
||||
if t.source:
|
||||
md_path = _find_md_for_tool(config.root, t.source, name)
|
||||
if md_path and md_path.exists():
|
||||
docs = _strip_frontmatter(md_path.read_text())
|
||||
if not docs:
|
||||
docs = None
|
||||
|
||||
return ToolDetail(**summary.model_dump(), docs=docs)
|
||||
return ToolDetail(**summary.model_dump())
|
||||
|
||||
|
||||
@router.post("/tools/{name}/install")
|
||||
|
||||
@@ -38,14 +38,14 @@ def castle_root(tmp_path: Path) -> Generator[Path, None, None]:
|
||||
"description": "Test tool",
|
||||
"install": {"path": {"alias": "test-tool"}},
|
||||
"tool": {
|
||||
"source": "tools/document",
|
||||
"source": "test-tool",
|
||||
"system_dependencies": ["pandoc"],
|
||||
},
|
||||
},
|
||||
"test-tool-2": {
|
||||
"description": "Another test tool",
|
||||
"tool": {
|
||||
"source": "tools/utility",
|
||||
"source": "test-tool-2",
|
||||
"version": "2.0.0",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -8,56 +8,51 @@ from fastapi.testclient import TestClient
|
||||
class TestToolsList:
|
||||
"""GET /tools endpoint tests."""
|
||||
|
||||
def test_returns_grouped_tools(self, client: TestClient) -> None:
|
||||
"""Returns tools grouped by source directory name."""
|
||||
def test_returns_flat_list(self, client: TestClient) -> None:
|
||||
"""Returns tools as a flat sorted list."""
|
||||
response = client.get("/tools")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
names = [cat["name"] for cat in data]
|
||||
assert "document" in names
|
||||
assert "utility" in names
|
||||
assert isinstance(data, list)
|
||||
ids = [t["id"] for t in data]
|
||||
assert "test-tool" in ids
|
||||
assert "test-tool-2" in ids
|
||||
|
||||
def test_groups_sorted(self, client: TestClient) -> None:
|
||||
"""Groups are sorted alphabetically."""
|
||||
def test_sorted_alphabetically(self, client: TestClient) -> None:
|
||||
"""Tools are sorted alphabetically by id."""
|
||||
response = client.get("/tools")
|
||||
data = response.json()
|
||||
names = [cat["name"] for cat in data]
|
||||
assert names == sorted(names)
|
||||
ids = [t["id"] for t in data]
|
||||
assert ids == sorted(ids)
|
||||
|
||||
def test_tool_fields(self, client: TestClient) -> None:
|
||||
"""Tool summary has expected fields."""
|
||||
response = client.get("/tools")
|
||||
data = response.json()
|
||||
doc_group = next(c for c in data if c["name"] == "document")
|
||||
tool = next(t for t in doc_group["tools"] if t["id"] == "test-tool")
|
||||
tool = next(t for t in data if t["id"] == "test-tool")
|
||||
assert tool["description"] == "Test tool"
|
||||
assert tool["source"] == "tools/document"
|
||||
assert tool["source"] == "test-tool"
|
||||
assert tool["system_dependencies"] == ["pandoc"]
|
||||
# tool_type and category should not be present
|
||||
assert "tool_type" not in tool
|
||||
assert "category" not in tool
|
||||
|
||||
def test_installed_flag(self, client: TestClient) -> None:
|
||||
"""Tool with install.path is marked as installed."""
|
||||
response = client.get("/tools")
|
||||
data = response.json()
|
||||
doc_group = next(c for c in data if c["name"] == "document")
|
||||
tool = next(t for t in doc_group["tools"] if t["id"] == "test-tool")
|
||||
tool = next(t for t in data if t["id"] == "test-tool")
|
||||
assert tool["installed"] is True
|
||||
|
||||
def test_not_installed_flag(self, client: TestClient) -> None:
|
||||
"""Tool without install.path is not marked as installed."""
|
||||
response = client.get("/tools")
|
||||
data = response.json()
|
||||
util_group = next(c for c in data if c["name"] == "utility")
|
||||
tool = next(t for t in util_group["tools"] if t["id"] == "test-tool-2")
|
||||
tool = next(t for t in data if t["id"] == "test-tool-2")
|
||||
assert tool["installed"] is False
|
||||
|
||||
def test_service_excluded(self, client: TestClient) -> None:
|
||||
"""Services without tool spec are not listed."""
|
||||
response = client.get("/tools")
|
||||
data = response.json()
|
||||
all_ids = [t["id"] for cat in data for t in cat["tools"]]
|
||||
all_ids = [t["id"] for t in data]
|
||||
assert "test-svc" not in all_ids
|
||||
|
||||
|
||||
@@ -70,27 +65,11 @@ class TestToolDetail:
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["id"] == "test-tool"
|
||||
assert data["source"] == "tools/document"
|
||||
assert data["source"] == "test-tool"
|
||||
assert data["system_dependencies"] == ["pandoc"]
|
||||
|
||||
def test_docs_from_file(self, client: TestClient, castle_root: Path) -> None:
|
||||
"""Reads documentation from .md file."""
|
||||
# Create doc file matching the source lookup (src/<dir_name>/<tool>.md)
|
||||
doc_dir = castle_root / "tools" / "document" / "src" / "document"
|
||||
doc_dir.mkdir(parents=True)
|
||||
(doc_dir / "test_tool.md").write_text(
|
||||
"---\ntitle: Test\n---\n\n# Test Tool\n\nUsage info here."
|
||||
)
|
||||
response = client.get("/tools/test-tool")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["docs"] is not None
|
||||
assert "# Test Tool" in data["docs"]
|
||||
# Frontmatter should be stripped
|
||||
assert "---" not in data["docs"]
|
||||
|
||||
def test_no_docs(self, client: TestClient) -> None:
|
||||
"""Tool with no doc file returns null docs."""
|
||||
"""Tool detail returns null docs (no .md files anymore)."""
|
||||
response = client.get("/tools/test-tool")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
Reference in New Issue
Block a user