refactor: Remove dedicated /tools endpoints, use /programs?behavior=tool instead

Eliminates inconsistency where tools had their own filtered view but daemons and frontends didn't.

Changes:
- API: Removed tools router and ToolSummary/ToolDetail models. Added optional behavior query parameter to GET /programs for filtering by program type (tool, daemon, frontend).
- Frontend: Updated hooks to pass behavior param to usePrograms instead of separate useTools. Updated components to use ProgramSummary type. Removed useToolDetail hook.
- Docs: Updated API documentation to reflect program behavior filtering.
This commit is contained in:
2026-04-27 21:06:57 -07:00
parent 36d2d6073c
commit 2069e30353
10 changed files with 46 additions and 168 deletions

View File

@@ -25,7 +25,7 @@ from castle_api.stream import (
unsubscribe,
)
from castle_api.nodes import router as nodes_router
from castle_api.tools import programs_router, router as tools_router
from castle_api.tools import programs_router
logger = logging.getLogger(__name__)
@@ -120,7 +120,6 @@ app.include_router(logs_router)
app.include_router(nodes_router)
app.include_router(secrets_router)
app.include_router(services_router)
app.include_router(tools_router)
app.include_router(programs_router)

View File

@@ -174,21 +174,3 @@ class ServiceActionResponse(BaseModel):
component: str
action: str
status: str
class ToolSummary(BaseModel):
"""Summary of a single tool."""
id: str
description: str | None = None
source: str | None = None
version: str | None = None
runner: str | None = None
system_dependencies: list[str] = []
installed: bool = False
class ToolDetail(ToolSummary):
"""Full detail for a single tool, including documentation."""
docs: str | None = None

View File

@@ -73,7 +73,9 @@ def _summary_from_deployed(name: str, deployed: object) -> ComponentSummary:
)
def _summary_from_service(name: str, svc: ServiceSpec, config: object) -> ComponentSummary:
def _summary_from_service(
name: str, svc: ServiceSpec, config: object
) -> ComponentSummary:
"""Build a ComponentSummary from a ServiceSpec (non-deployed)."""
port = None
health_path = None
@@ -90,7 +92,9 @@ def _summary_from_service(name: str, svc: ServiceSpec, config: object) -> Compon
if managed:
unit_name = f"castle-{name}.service"
unit_path = str(Path("~/.config/systemd/user") / unit_name)
systemd_info = SystemdInfo(unit_name=unit_name, unit_path=unit_path, timer=False)
systemd_info = SystemdInfo(
unit_name=unit_name, unit_path=unit_path, timer=False
)
description = svc.description
source = None
@@ -226,9 +230,7 @@ def _service_from_deployed(name: str, deployed: object) -> ServiceSummary:
)
def _service_from_spec(
name: str, svc: ServiceSpec, config: object
) -> ServiceSummary:
def _service_from_spec(name: str, svc: ServiceSpec, config: object) -> ServiceSummary:
"""Build a ServiceSummary from a ServiceSpec."""
port = None
health_path = None
@@ -268,9 +270,7 @@ def _service_from_spec(
def _job_from_deployed(name: str, deployed: object) -> JobSummary:
"""Build a JobSummary from a DeployedComponent."""
systemd_info = (
_make_systemd_info(name, timer=True) if deployed.managed else None
)
systemd_info = _make_systemd_info(name, timer=True) if deployed.managed else None
return JobSummary(
id=name,
description=deployed.description,
@@ -553,8 +553,11 @@ def get_job(name: str) -> JobDetail:
@router.get("/programs", response_model=list[ProgramSummary], tags=["programs"])
def list_programs() -> list[ProgramSummary]:
"""List all programs from the software catalog (castle.yaml programs section)."""
def list_programs(behavior: str | None = None) -> list[ProgramSummary]:
"""List all programs from the software catalog (castle.yaml programs section).
Optionally filter by behavior: daemon, tool, or frontend.
"""
root = get_castle_root()
if not root:
return []
@@ -573,6 +576,8 @@ def list_programs() -> list[ProgramSummary]:
summary = _program_from_spec(name, comp, root, config)
if summary.behavior is None:
continue
if behavior and summary.behavior != behavior:
continue
summary.node = hostname
summaries.append(summary)
@@ -840,11 +845,16 @@ async def reload_gateway() -> dict[str, str]:
# Include remote registries for cross-node routing
remote_regs = {h: n.registry for h, n in mesh_state.all_nodes().items()}
caddyfile_path.write_text(
generate_caddyfile_from_registry(registry, remote_registries=remote_regs or None)
generate_caddyfile_from_registry(
registry, remote_registries=remote_regs or None
)
)
proc = await asyncio.create_subprocess_exec(
"systemctl", "--user", "reload", "castle-castle-gateway.service",
"systemctl",
"--user",
"reload",
"castle-castle-gateway.service",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)

View File

@@ -1,88 +1,15 @@
"""Tools router and program actions."""
"""Program action endpoints."""
from __future__ import annotations
import shutil
from pathlib import Path
from fastapi import APIRouter, HTTPException, status
from castle_core.manifest import ProgramSpec
from castle_core.stacks import available_actions, get_handler
from castle_api.config import get_config
from castle_api.models import ToolDetail, ToolSummary
router = APIRouter(tags=["tools"])
programs_router = APIRouter(tags=["programs"])
def _is_tool(comp: ProgramSpec) -> bool:
"""Check if a component is a tool."""
return comp.behavior == "tool"
def _tool_summary(
name: str, comp: ProgramSpec, root: Path | None = None
) -> ToolSummary:
"""Build a ToolSummary from a ProgramSpec that is a tool."""
installed = shutil.which(name) is not None
# Infer runner from source directory
runner = None
source = comp.source
if source:
source_dir = Path(source)
if (source_dir / "pyproject.toml").exists():
runner = "python"
elif source_dir.is_file():
runner = "command"
return ToolSummary(
id=name,
description=comp.description,
source=source,
version=comp.version,
runner=runner,
system_dependencies=comp.system_dependencies,
installed=installed,
)
@router.get("/tools", response_model=list[ToolSummary])
def list_tools() -> list[ToolSummary]:
"""List all registered tools (requires repo access)."""
config = get_config()
tools = {k: v for k, v in config.programs.items() if _is_tool(v)}
return sorted(
[_tool_summary(name, comp, config.root) for name, comp in tools.items()],
key=lambda t: t.id,
)
@router.get("/tools/{name}", response_model=ToolDetail)
def get_tool(name: str) -> ToolDetail:
"""Get detailed info for a single tool."""
config = get_config()
if name not in config.programs:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Component '{name}' not found",
)
comp = config.programs[name]
if not _is_tool(comp):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"'{name}' is not a tool",
)
summary = _tool_summary(name, comp, config.root)
return ToolDetail(**summary.model_dump())
# ---------------------------------------------------------------------------
# Unified program action endpoint
# ---------------------------------------------------------------------------