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

@@ -179,10 +179,11 @@ When enabled, the API publishes the node's registry to `castle/{hostname}/regist
|----------|-------------|
| `GET /health` | Health check |
| `GET /stream` | SSE stream (health, service-action, mesh events) |
| **Programs & Components** | |
| `GET /programs` | List all programs |
| **Programs** | |
| `GET /programs` | List all programs (`?behavior=tool\|daemon\|frontend` to filter) |
| `GET /programs/{name}` | Program detail |
| `GET /components` | Unified view of all components (`?include_remote=true` for cross-node) |
| `POST /programs/{name}/{action}` | Run a lifecycle action (build, test, lint, install, etc.) |
| `GET /components` | Unified view across nodes (`?include_remote=true` for cross-node) |
| `GET /components/{name}` | Component detail |
| **Services** | |
| `GET /services` | List all services with status |
@@ -206,10 +207,6 @@ When enabled, the API publishes the node's registry to `castle/{hostname}/regist
| `PUT /config/services/{name}` | Update a service entry |
| `PUT /config/jobs/{name}` | Update a job entry |
| `POST /config/apply` | Apply config changes (deploy + reload) |
| **Tools** | |
| `GET /tools` | List all tools |
| `GET /tools/{name}` | Tool detail |
| `POST /programs/{name}/{action}` | Run a program action (build, test, lint, install) |
| **Secrets** | |
| `GET /secrets` | List secrets |
| `GET /secrets/{name}` | Read a secret |

View File

@@ -1,10 +1,10 @@
import { Link } from "react-router-dom"
import { Terminal } from "lucide-react"
import type { ToolSummary } from "@/types"
import type { ProgramSummary } from "@/types"
import { runnerLabel } from "@/lib/labels"
interface ToolCardProps {
tool: ToolSummary
tool: ProgramSummary
}
export function ToolCard({ tool }: ToolCardProps) {

View File

@@ -1,6 +1,6 @@
import { useState } from "react"
import { useParams } from "react-router-dom"
import { useProgram, useEventStream, useToolDetail } from "@/services/api/hooks"
import { useProgram, useEventStream } from "@/services/api/hooks"
import { runnerLabel } from "@/lib/labels"
import { DetailHeader } from "@/components/detail/DetailHeader"
import { ConfigPanel } from "@/components/detail/ConfigPanel"
@@ -11,7 +11,6 @@ export function ComponentDetailPage() {
const { name } = useParams<{ name: string }>()
const { data: component, isLoading, error, refetch } = useProgram(name ?? "")
const isTool = component?.behavior === "tool"
const { data: toolDetail } = useToolDetail(isTool ? (name ?? "") : "")
const [actionOutput, setActionOutput] = useState<ActionOutput | null>(null)
if (isLoading) {
@@ -52,7 +51,7 @@ export function ComponentDetailPage() {
<p className="text-sm text-[var(--muted)] -mt-4 mb-6">{component.description}</p>
)}
{toolDetail && (
{isTool && (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-lg p-5 mb-6">
<h2 className="text-sm font-semibold text-[var(--muted)] uppercase tracking-wider mb-1">
Tool Info
@@ -61,32 +60,32 @@ export function ComponentDetailPage() {
How this tool is packaged and what it depends on.
</p>
<div className="grid grid-cols-2 gap-x-6 gap-y-2 text-sm mb-4">
{toolDetail.source && (
{component.source && (
<>
<span className="text-[var(--muted)]">Source</span>
<span className="font-mono">{toolDetail.source}</span>
<span className="font-mono">{component.source}</span>
</>
)}
{toolDetail.version && (
{component.version && (
<>
<span className="text-[var(--muted)]">Version</span>
<span>{toolDetail.version}</span>
<span>{component.version}</span>
</>
)}
{toolDetail.runner && (
{component.runner && (
<>
<span className="text-[var(--muted)]">Runner</span>
<span>{runnerLabel(toolDetail.runner)}</span>
<span>{runnerLabel(component.runner)}</span>
</>
)}
<span className="text-[var(--muted)]">Installed</span>
<span>{component.installed ? "Yes" : "No"}</span>
</div>
{toolDetail.system_dependencies.length > 0 && (
{component.system_dependencies.length > 0 && (
<div className="mb-4">
<span className="text-sm text-[var(--muted)] block mb-1">System Dependencies</span>
<div className="flex flex-wrap gap-1">
{toolDetail.system_dependencies.map((dep) => (
{component.system_dependencies.map((dep) => (
<span
key={dep}
className="text-xs px-2 py-0.5 rounded bg-amber-900/30 text-amber-400 border border-amber-800"
@@ -97,14 +96,6 @@ export function ComponentDetailPage() {
</div>
</div>
)}
{toolDetail.docs && (
<div>
<span className="text-sm text-[var(--muted)] block mb-1">Documentation</span>
<pre className="text-sm whitespace-pre-wrap bg-[var(--background)] rounded p-3 border border-[var(--border)]">
{toolDetail.docs}
</pre>
</div>
)}
</div>
)}

View File

@@ -1,10 +1,10 @@
import { Link } from "react-router-dom"
import { ArrowLeft } from "lucide-react"
import { useTools } from "@/services/api/hooks"
import { usePrograms } from "@/services/api/hooks"
import { ToolCard } from "@/components/ToolCard"
export function ToolsPage() {
const { data: tools, isLoading } = useTools()
const { data: tools, isLoading } = usePrograms("tool")
return (
<div className="max-w-6xl mx-auto px-6 py-8">

View File

@@ -16,8 +16,6 @@ import type {
MeshStatus,
NodeSummary,
NodeDetail,
ToolSummary,
ToolDetail,
} from "@/types"
// Legacy compat hook — used by ConfigEditorPage and ComponentRedirect
@@ -59,10 +57,11 @@ export function useJob(name: string) {
})
}
export function usePrograms() {
export function usePrograms(behavior?: string) {
const params = behavior ? `?behavior=${behavior}` : ""
return useQuery({
queryKey: ["programs"],
queryFn: () => apiClient.get<ProgramSummary[]>("/programs"),
queryKey: ["programs", behavior ?? "all"],
queryFn: () => apiClient.get<ProgramSummary[]>(`/programs${params}`),
})
}
@@ -187,21 +186,6 @@ export function useMeshStatus() {
})
}
export function useTools() {
return useQuery({
queryKey: ["tools"],
queryFn: () => apiClient.get<ToolSummary[]>("/tools"),
})
}
export function useToolDetail(name: string) {
return useQuery({
queryKey: ["tools", name],
queryFn: () => apiClient.get<ToolDetail>(`/tools/${name}`),
enabled: !!name,
})
}
export function useEventStream() {
const qc = useQueryClient()

View File

@@ -152,16 +152,4 @@ export interface MeshStatus {
peers: string[]
}
export interface ToolSummary {
id: string
description: string | null
source: string | null
version: string | null
runner: string | null
system_dependencies: string[]
installed: boolean
}
export interface ToolDetail extends ToolSummary {
docs: string | null
}

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
# ---------------------------------------------------------------------------