feat: Refactor tool management and update documentation for clarity

This commit is contained in:
2026-02-21 00:29:48 -08:00
parent 08c6f3fa83
commit 54ba2ccc62
17 changed files with 160 additions and 154 deletions

View File

@@ -3,6 +3,14 @@
from pydantic import BaseModel
class SystemdInfo(BaseModel):
"""Systemd unit information for a managed component."""
unit_name: str
unit_path: str
timer: bool = False
class ComponentSummary(BaseModel):
"""Summary of a single component."""
@@ -14,9 +22,8 @@ class ComponentSummary(BaseModel):
health_path: str | None = None
proxy_path: str | None = None
managed: bool = False
category: str | None = None
systemd: SystemdInfo | None = None
version: str | None = None
tool_type: str | None = None
source: str | None = None
system_dependencies: list[str] = []
schedule: str | None = None
@@ -65,9 +72,7 @@ class ToolSummary(BaseModel):
id: str
description: str | None = None
category: str | None = None
source: str | None = None
tool_type: str | None = None
version: str | None = None
runner: str | None = None
system_dependencies: list[str] = []

View File

@@ -3,6 +3,7 @@
from __future__ import annotations
import shutil
from pathlib import Path
from fastapi import APIRouter, HTTPException, status
@@ -15,12 +16,13 @@ from dashboard_api.models import (
ComponentSummary,
GatewayInfo,
StatusResponse,
SystemdInfo,
)
router = APIRouter(tags=["dashboard"])
def _summary_from_manifest(name: str, manifest: object) -> ComponentSummary:
def _summary_from_manifest(name: str, manifest: object, root: Path) -> ComponentSummary:
"""Build a ComponentSummary from a manifest."""
port = None
health_path = None
@@ -35,6 +37,18 @@ def _summary_from_manifest(name: str, manifest: object) -> ComponentSummary:
manifest.manage and manifest.manage.systemd and manifest.manage.systemd.enable
)
# Systemd info for managed components
systemd_info: SystemdInfo | None = None
if managed:
unit_name = f"castle-{name}.service"
unit_path = str(Path("~/.config/systemd/user") / unit_name)
has_timer = any(getattr(t, "type", None) == "schedule" for t in manifest.triggers)
systemd_info = SystemdInfo(
unit_name=unit_name,
unit_path=unit_path,
timer=has_timer,
)
# Extract cron schedule from first schedule trigger, if any
schedule = None
for t in manifest.triggers:
@@ -42,6 +56,15 @@ def _summary_from_manifest(name: str, manifest: object) -> ComponentSummary:
schedule = t.cron
break
# Infer runner — from run block or from tool source
runner = manifest.run.runner if manifest.run else None
if runner is None and manifest.tool and manifest.tool.source:
source_dir = root / manifest.tool.source
if (source_dir / "pyproject.toml").exists():
runner = "python_uv_tool"
elif source_dir.is_file():
runner = "command"
# Check if tool is actually installed on PATH
installed: bool | None = None
if manifest.install and manifest.install.path:
@@ -52,14 +75,13 @@ def _summary_from_manifest(name: str, manifest: object) -> ComponentSummary:
id=name,
description=manifest.description,
roles=[r.value for r in manifest.roles],
runner=manifest.run.runner if manifest.run else None,
runner=runner,
port=port,
health_path=health_path,
proxy_path=proxy_path,
managed=managed,
category=manifest.tool.category if manifest.tool else None,
systemd=systemd_info,
version=manifest.tool.version if manifest.tool else None,
tool_type=manifest.tool.tool_type.value if manifest.tool else None,
source=manifest.tool.source if manifest.tool else None,
system_dependencies=manifest.tool.system_dependencies if manifest.tool else [],
schedule=schedule,
@@ -72,7 +94,7 @@ def list_components() -> list[ComponentSummary]:
"""List all registered components."""
config = load_config(settings.castle_root)
return [
_summary_from_manifest(name, m)
_summary_from_manifest(name, m, config.root)
for name, m in config.components.items()
]
@@ -87,7 +109,7 @@ def get_component(name: str) -> ComponentDetail:
detail=f"Component '{name}' not found",
)
manifest = config.components[name]
summary = _summary_from_manifest(name, manifest)
summary = _summary_from_manifest(name, manifest, config.root)
raw = manifest.model_dump(mode="json", exclude_none=True)
return ComponentDetail(**summary.model_dump(), manifest=raw)

View File

@@ -16,19 +16,27 @@ from dashboard_api.models import ToolCategory, ToolDetail, ToolSummary
router = APIRouter(tags=["tools"])
def _tool_summary(name: str, manifest: ComponentManifest) -> ToolSummary:
def _tool_summary(name: str, manifest: ComponentManifest, root: Path | None = None) -> ToolSummary:
"""Build a ToolSummary from a manifest that has a tool spec."""
t = manifest.tool
assert t is not None
installed = bool(manifest.install and manifest.install.path and manifest.install.path.enable)
# Infer runner from run block or source directory
runner = manifest.run.runner if manifest.run else None
if runner is None and t.source and root:
source_dir = root / t.source
if (source_dir / "pyproject.toml").exists():
runner = "python_uv_tool"
elif source_dir.is_file():
runner = "command"
return ToolSummary(
id=name,
description=manifest.description,
category=t.category,
source=t.source,
tool_type=t.tool_type.value,
version=t.version,
runner=manifest.run.runner if manifest.run else None,
runner=runner,
system_dependencies=t.system_dependencies,
installed=installed,
)
@@ -38,7 +46,6 @@ def _find_md_for_tool(
root: Path,
source: str,
tool_name: str,
category: str | None = None,
) -> Path | None:
"""Find the .md documentation file for a tool source path."""
source_path = root / source
@@ -48,10 +55,10 @@ def _find_md_for_tool(
return md
elif source_path.is_dir():
py_name = tool_name.replace("-", "_")
if category:
md = source_path / "src" / category / f"{py_name}.md"
if md.exists():
return md
pkg_name = source_path.name
md = source_path / "src" / pkg_name / f"{py_name}.md"
if md.exists():
return md
return None
@@ -66,18 +73,23 @@ def _strip_frontmatter(content: str) -> str:
@router.get("/tools", response_model=list[ToolCategory])
def list_tools() -> list[ToolCategory]:
"""List tools grouped by category."""
"""List tools grouped by source directory."""
config = load_config(settings.castle_root)
tools = {k: v for k, v in config.components.items() if v.tool}
by_category: dict[str, list[ToolSummary]] = {}
by_group: dict[str, list[ToolSummary]] = {}
for name, manifest in tools.items():
cat = manifest.tool.category or "uncategorized" # type: ignore[union-attr]
by_category.setdefault(cat, []).append(_tool_summary(name, manifest))
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=cat, tools=sorted(items, key=lambda t: t.id))
for cat, items in sorted(by_category.items())
ToolCategory(name=group, tools=sorted(items, key=lambda t: t.id))
for group, items in sorted(by_group.items())
]
@@ -99,11 +111,11 @@ def get_tool(name: str) -> ToolDetail:
detail=f"'{name}' is not a tool",
)
summary = _tool_summary(name, manifest)
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, t.category)
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: