From 08c6f3fa839f6729605a444e8d1bb53b4fd67d9b Mon Sep 17 00:00:00 2001 From: Paul Payne Date: Sat, 21 Feb 2026 00:09:34 -0800 Subject: [PATCH] feat: Enhance tool management and documentation in Castle - Introduced category tools support in the `castle create` command. - Added detailed guides for creating components in CLAUDE.md. - Implemented new API endpoints for listing and retrieving tool details. - Updated component and tool models to include additional metadata. - Improved error handling and response structures in service actions. - Enhanced documentation for component registry and web APIs. --- CLAUDE.md | 106 +++--- cli/src/castle_cli/commands/create.py | 75 ++++- cli/src/castle_cli/commands/info.py | 9 + cli/src/castle_cli/commands/service.py | 1 + cli/src/castle_cli/commands/tool.py | 11 +- cli/src/castle_cli/main.py | 4 + cli/src/castle_cli/manifest.py | 4 +- cli/src/castle_cli/templates/scaffold.py | 93 ++++++ cli/tests/test_manifest.py | 5 +- dashboard-api/src/dashboard_api/main.py | 35 +- dashboard-api/src/dashboard_api/models.py | 31 ++ dashboard-api/src/dashboard_api/routes.py | 19 ++ dashboard-api/src/dashboard_api/services.py | 29 +- dashboard-api/src/dashboard_api/stream.py | 10 + dashboard-api/src/dashboard_api/tools.py | 177 ++++++++++ dashboard-api/tests/conftest.py | 13 + dashboard-api/tests/test_health.py | 2 +- dashboard-api/tests/test_tools.py | 105 ++++++ dashboard/src/components/ComponentCard.tsx | 3 +- dashboard/src/components/ComponentFields.tsx | 3 +- dashboard/src/components/ComponentTable.tsx | 333 +++++++++++++++++++ dashboard/src/components/RoleBadge.tsx | 2 + dashboard/src/components/ToolCard.tsx | 57 ++++ dashboard/src/lib/labels.ts | 31 ++ dashboard/src/pages/ComponentDetail.tsx | 71 +++- dashboard/src/pages/Dashboard.tsx | 34 +- dashboard/src/pages/Tools.tsx | 42 +++ dashboard/src/router/routes.tsx | 5 - dashboard/src/services/api/hooks.ts | 63 +++- dashboard/src/types/index.ts | 31 +- docs/component-registry.md | 310 +++++++++++++++++ docs/python-tools.md | 324 +++++++++--------- docs/web-apis.md | 3 + docs/web-frontends.md | 3 + 34 files changed, 1748 insertions(+), 296 deletions(-) create mode 100644 dashboard-api/src/dashboard_api/tools.py create mode 100644 dashboard-api/tests/test_tools.py create mode 100644 dashboard/src/components/ComponentTable.tsx create mode 100644 dashboard/src/components/ToolCard.tsx create mode 100644 dashboard/src/lib/labels.ts create mode 100644 dashboard/src/pages/Tools.tsx create mode 100644 docs/component-registry.md diff --git a/CLAUDE.md b/CLAUDE.md index adc0728..4b8c95a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,6 +14,37 @@ are **derived**, not labeled. configuration (data dir, port, URLs) via env vars. Only castle-components (CLI, gateway, event bus) know about castle internals. +## Creating Components + +When creating a new service, tool, or frontend, follow the detailed guides: + +- @docs/component-registry.md — Manifest architecture, castle.yaml structure, role derivation, lifecycle +- @docs/web-apis.md — FastAPI service patterns (config, routes, models, testing) +- @docs/python-tools.md — CLI tool patterns (argparse, stdin/stdout, piping, testing) +- @docs/web-frontends.md — React/Vite/TypeScript frontend patterns + +### Quick start + +```bash +# Service +castle create my-service --type service --description "Does something" +cd my-service && uv sync +uv run my-service # starts on auto-assigned port +castle service enable my-service # register with systemd +castle gateway reload # update reverse proxy routes + +# Standalone tool +castle create my-tool --type tool --description "Does something" +cd my-tool && uv sync + +# Category tool (adds to existing tools// package) +castle create my-tool --type tool --category document --description "Does something" +cd tools/document && uv sync +``` + +The `castle create` command scaffolds the project, generates a CLAUDE.md, +and registers it in `castle.yaml` as a `ComponentManifest`. + ## Castle CLI The CLI lives in `cli/` and is installed via `uv tool install --editable cli/`. @@ -28,59 +59,14 @@ castle lint [project] # Run linter (one or all) castle sync # Update submodules + uv sync all castle run # Run component in foreground castle logs [-f] [-n 50] # View component logs +castle tool list # List tools grouped by category +castle tool info # Show tool details + docs castle gateway start|stop|reload|status # Manage Caddy reverse proxy castle service enable|disable # Manage individual systemd service castle service status # Show all service statuses castle services start|stop # Start/stop everything -castle migrate # Convert castle.yaml to new format ``` -## Registry & Manifest Architecture - -`castle.yaml` at the repo root is the single source of truth. It uses a **manifest** -model (`cli/src/castle_cli/manifest.py`) where components declare capabilities: - -- **`run`**: How to start it (RunSpec: `python_uv_tool`, `command`, `container`, `node`, `remote`) -- **`expose`**: What it exposes (HTTP port, health endpoint) -- **`proxy`**: How to proxy it (Caddy path prefix) -- **`manage`**: How to manage it (systemd) -- **`install`**: How to install it (PATH shim) -- **`build`**: How to build it (commands, outputs) -- **`triggers`**: What triggers it (manual, schedule, event, request) - -**Roles are derived** from these declarations: -- `service` — has `expose.http` -- `tool` — has `install.path` or is fallback -- `worker` — has `manage.systemd` but no HTTP -- `job` — has schedule trigger -- `frontend` — has build outputs -- `containerized` — uses container runner -- `remote` — uses remote runner - -## Component Roles (replaces Project Types) - -| Role | Convention | Example | -|------|-----------|---------| -| **service** | FastAPI, pydantic-settings, lifespan, `/health` endpoint | central-context | -| **tool** | argparse, stdin/stdout, exit codes, Unix pipes | devbox-connect | -| **worker** | Systemd-managed, no HTTP | (none yet) | -| **job** | Scheduled task | (none yet) | -| **containerized** | Docker/Podman container | (none yet) | - -## Creating a New Project - -```bash -castle create my-service --type service --description "Does something" -cd my-service -uv sync -uv run my-service # starts on auto-assigned port -castle test my-service # run tests -castle service enable my-service # register with systemd -``` - -The `castle create` command scaffolds the project, generates a CLAUDE.md, and registers -it in `castle.yaml` as a `ComponentManifest`. - ## Infrastructure - **Gateway**: Caddy reverse proxy at port 9000, config generated from `castle.yaml` @@ -102,18 +88,6 @@ uv run ruff format . # Format Services also support: `uv run ` to start. -## Existing Components - -| Component | Roles | Port | Description | -|-----------|-------|------|-------------| -| central-context | service | 9001 | Content storage API (submodule) | -| notification-bridge | service | 9002 | Desktop notification forwarder (submodule) | -| devbox-connect | tool | — | SSH tunnel manager | -| mboxer | tool | — | MBOX to EML converter (submodule) | -| toolkit | tool | — | Personal utility scripts (submodule) | -| protonmail | tool | — | ProtonMail email sync via Bridge | -| event-bus | service | 9010 | Inter-service event bus | - ## Code Style - **Linting/formatting**: ruff — shared `ruff.toml` at repo root (100-char lines) @@ -121,11 +95,11 @@ Services also support: `uv run ` to start. - **Testing**: pytest, pytest-asyncio for async tests - **Python**: 3.13 for services, 3.11+ minimum for tools/libraries -## Agent Workflow +## Key Files -When creating a new service or tool: -1. `castle create --type ` — scaffold and register -2. Implement the project logic -3. `castle test ` — verify tests pass -4. `castle service enable ` — deploy as systemd service (services only) -5. `castle gateway reload` — update reverse proxy routes +- `castle.yaml` — Component registry (single source of truth) +- `cli/src/castle_cli/manifest.py` — Pydantic models (ComponentManifest, RunSpec, etc.) +- `cli/src/castle_cli/config.py` — Config loader (castle.yaml → CastleConfig) +- `cli/src/castle_cli/templates/scaffold.py` — Project scaffolding templates +- `cli/src/castle_cli/commands/service.py` — Systemd unit generation +- `ruff.toml` / `pyrightconfig.json` — Shared lint/type config diff --git a/cli/src/castle_cli/commands/create.py b/cli/src/castle_cli/commands/create.py index a7ad34f..8bf0d2c 100644 --- a/cli/src/castle_cli/commands/create.py +++ b/cli/src/castle_cli/commands/create.py @@ -17,8 +17,10 @@ from castle_cli.manifest import ( ProxySpec, RunPythonUvTool, SystemdSpec, + ToolSpec, + ToolType, ) -from castle_cli.templates.scaffold import scaffold_project +from castle_cli.templates.scaffold import scaffold_category_tool, scaffold_project def next_available_port(config: object) -> int: @@ -41,11 +43,16 @@ def run_create(args: argparse.Namespace) -> int: config = load_config() name = args.name proj_type = args.type + category = getattr(args, "category", None) if name in config.components: print(f"Error: component '{name}' already exists in castle.yaml") return 1 + # Category tool: add to existing category package + if proj_type == "tool" and category: + return _create_category_tool(config, name, args.description, category) + project_dir = config.root / name if project_dir.exists(): print(f"Error: directory '{name}' already exists") @@ -95,6 +102,10 @@ def run_create(args: argparse.Namespace) -> int: manifest = ComponentManifest( id=name, description=args.description or f"A castle {proj_type}", + tool=ToolSpec( + tool_type=ToolType.PYTHON_STANDALONE, + source=f"{name}/", + ), install=InstallSpec(path=PathInstallSpec(alias=name)), ) else: @@ -119,3 +130,65 @@ def run_create(args: argparse.Namespace) -> int: print(f" castle test {name}") return 0 + + +def _create_category_tool(config: object, name: str, description: str | None, category: str) -> int: + """Create a tool inside an existing category package.""" + category_dir = config.root / "tools" / category + if not category_dir.exists(): + print(f"Error: category directory 'tools/{category}/' does not exist") + print(" Create the category package first, then add tools to it.") + return 1 + + package_name = name.replace("-", "_") + desc = description or "A castle tool" + + # Scaffold the .py and .md files into the category package + scaffold_category_tool( + category_dir=category_dir, + tool_name=name, + package_name=package_name, + category=category, + description=desc, + ) + + # Append entry point to existing pyproject.toml + pyproject_path = category_dir / "pyproject.toml" + if pyproject_path.exists(): + content = pyproject_path.read_text() + entry_line = f'{name} = "{category}.{package_name}:main"' + if entry_line not in content: + # Find [project.scripts] section and append + if "[project.scripts]" in content: + content = content.replace( + "[project.scripts]", + f"[project.scripts]\n{entry_line}", + ) + pyproject_path.write_text(content) + print(f" Added entry point to tools/{category}/pyproject.toml") + else: + print(" Warning: could not find [project.scripts] in pyproject.toml") + + # Register in castle.yaml + manifest = ComponentManifest( + id=name, + description=desc, + tool=ToolSpec( + tool_type=ToolType.PYTHON_STANDALONE, + category=category, + source=f"tools/{category}/", + ), + install=InstallSpec(path=PathInstallSpec(alias=name)), + ) + config.components[name] = manifest + save_config(config) + + print(f"Created tool '{name}' in tools/{category}/") + print(f" Source: tools/{category}/src/{category}/{package_name}.py") + print(" Registered in castle.yaml") + print("\nNext steps:") + print(f" Edit tools/{category}/src/{category}/{package_name}.py") + print(f" cd tools/{category} && uv sync") + print(f" castle tool info {name}") + + return 0 diff --git a/cli/src/castle_cli/commands/info.py b/cli/src/castle_cli/commands/info.py index 1e44170..725bac9 100644 --- a/cli/src/castle_cli/commands/info.py +++ b/cli/src/castle_cli/commands/info.py @@ -82,6 +82,15 @@ def run_info(args: argparse.Namespace) -> int: pi = manifest.install.path print(f" {BOLD}install{RESET}: path" + (f" (alias: {pi.alias})" if pi.alias else "")) + # Tool + if manifest.tool: + t = manifest.tool + print(f" {BOLD}category{RESET}: {t.category or 'uncategorized'}") + if t.source: + print(f" {BOLD}source{RESET}: {t.source}") + if t.system_dependencies: + print(f" {BOLD}requires{RESET}: {', '.join(t.system_dependencies)}") + # Tags if manifest.tags: print(f" {BOLD}tags{RESET}: {', '.join(manifest.tags)}") diff --git a/cli/src/castle_cli/commands/service.py b/cli/src/castle_cli/commands/service.py index af85b25..b624d41 100644 --- a/cli/src/castle_cli/commands/service.py +++ b/cli/src/castle_cli/commands/service.py @@ -190,6 +190,7 @@ WorkingDirectory={working_dir} ExecStart={exec_start} {env_lines}Restart={restart} RestartSec={restart_sec} +SuccessExitStatus=143 """ if sd and sd.no_new_privileges: diff --git a/cli/src/castle_cli/commands/tool.py b/cli/src/castle_cli/commands/tool.py index 6d9bc6b..7467ca6 100644 --- a/cli/src/castle_cli/commands/tool.py +++ b/cli/src/castle_cli/commands/tool.py @@ -46,13 +46,11 @@ def _tool_list() -> int: print(f"\n{BOLD}{CYAN}{category}{RESET}") print(f"{CYAN}{'─' * 40}{RESET}") for name, manifest in sorted(items): - tt = manifest.tool.tool_type.value - ver = manifest.tool.version desc = manifest.description or "" deps = "" if manifest.tool.system_dependencies: deps = f" {DIM}[{', '.join(manifest.tool.system_dependencies)}]{RESET}" - print(f" {BOLD}{name:<20}{RESET} {ver:<6} {tt:<20} {desc}{deps}") + print(f" {BOLD}{name:<20}{RESET} {desc}{deps}") print() return 0 @@ -76,13 +74,10 @@ def _tool_info(name: str) -> int: print(f"{'─' * 40}") if manifest.description: print(f" {manifest.description}") - print(f" {BOLD}type{RESET}: {t.tool_type.value}") print(f" {BOLD}category{RESET}: {t.category or 'uncategorized'}") print(f" {BOLD}version{RESET}: {t.version}") if t.source: print(f" {BOLD}source{RESET}: {t.source}") - if t.entry_point: - print(f" {BOLD}entry{RESET}: {t.entry_point}") if t.system_dependencies: print(f" {BOLD}requires{RESET}: {', '.join(t.system_dependencies)}") @@ -106,7 +101,9 @@ def _tool_info(name: str) -> int: return 0 -def _find_md_for_tool(root: Path, source: str, tool_name: str, category: str | None = None) -> Path | None: +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 if source_path.is_file(): diff --git a/cli/src/castle_cli/main.py b/cli/src/castle_cli/main.py index ed0bfa3..363bc46 100644 --- a/cli/src/castle_cli/main.py +++ b/cli/src/castle_cli/main.py @@ -46,6 +46,10 @@ def build_parser() -> argparse.ArgumentParser: create_parser.add_argument( "--port", type=int, help="Port number (services only)" ) + create_parser.add_argument( + "--category", default=None, + help="Tool category (e.g. document, search, system)", + ) # castle info info_parser = subparsers.add_parser("info", help="Show component details") diff --git a/cli/src/castle_cli/manifest.py b/cli/src/castle_cli/manifest.py index 61fc1ab..2cfd2d3 100644 --- a/cli/src/castle_cli/manifest.py +++ b/cli/src/castle_cli/manifest.py @@ -170,17 +170,15 @@ class InstallSpec(BaseModel): class ToolType(str, Enum): - PYTHON_UV = "python_uv" PYTHON_STANDALONE = "python_standalone" SCRIPT = "script" class ToolSpec(BaseModel): - tool_type: ToolType = ToolType.PYTHON_UV + tool_type: ToolType = ToolType.PYTHON_STANDALONE category: str | None = None version: str = "1.0.0" source: str | None = None - entry_point: str | None = None system_dependencies: list[str] = Field(default_factory=list) diff --git a/cli/src/castle_cli/templates/scaffold.py b/cli/src/castle_cli/templates/scaffold.py index b4279e8..0bec345 100644 --- a/cli/src/castle_cli/templates/scaffold.py +++ b/cli/src/castle_cli/templates/scaffold.py @@ -5,6 +5,99 @@ from __future__ import annotations from pathlib import Path +def scaffold_category_tool( + category_dir: Path, + tool_name: str, + package_name: str, + category: str, + description: str, +) -> None: + """Scaffold a tool inside an existing category package. + + Creates the .py and .md files only — the category package + (pyproject.toml, tests, CLAUDE.md) already exists. + """ + src_dir = category_dir / "src" / category + + # tool .py file + _write( + src_dir / f"{package_name}.py", + f'''#!/usr/bin/env python3 +"""{tool_name}: {description} + +Usage: + {tool_name} [options] [input] + cat input.txt | {tool_name} + +Examples: + {tool_name} input.txt + {tool_name} input.txt -o output.txt + cat input.txt | {tool_name} > output.txt +""" + +import argparse +import sys + +__all__ = ["main"] + + +def main() -> int: + """Main entry point.""" + parser = argparse.ArgumentParser( + description="{description}", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument("input", nargs="?", help="Input file (default: stdin)") + parser.add_argument( + "-o", "--output", default=None, help="Output file (default: stdout)" + ) + args = parser.parse_args() + + if args.input: + with open(args.input) as f: + data = f.read() + else: + data = sys.stdin.read() + + # TODO: implement tool logic + result = data + + if args.output: + with open(args.output, "w") as f: + f.write(result) + else: + print(result, end="") + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) +''', + ) + + # tool .md documentation + _write( + src_dir / f"{package_name}.md", + f"""--- +name: {tool_name} +category: {category} +--- + +# {tool_name} + +{description} + +## Usage + +```bash +{tool_name} [options] [input] +cat input.txt | {tool_name} +``` +""", + ) + + def scaffold_project( project_dir: Path, name: str, diff --git a/cli/tests/test_manifest.py b/cli/tests/test_manifest.py index f3af523..877f898 100644 --- a/cli/tests/test_manifest.py +++ b/cli/tests/test_manifest.py @@ -97,10 +97,9 @@ class TestRoleDerivation: m = ComponentManifest( id="docx2md", tool=ToolSpec( - tool_type=ToolType.PYTHON_UV, + tool_type=ToolType.PYTHON_STANDALONE, category="document", - source="tools/document/docx2md.py", - entry_point="tools.document.docx2md:main", + source="tools/document/", ), ) assert Role.TOOL in m.roles diff --git a/dashboard-api/src/dashboard_api/main.py b/dashboard-api/src/dashboard_api/main.py index ee76552..fdb6b3c 100644 --- a/dashboard-api/src/dashboard_api/main.py +++ b/dashboard-api/src/dashboard_api/main.py @@ -19,16 +19,36 @@ from dashboard_api.logs import router as logs_router from dashboard_api.routes import router as dashboard_router from dashboard_api.secrets import router as secrets_router from dashboard_api.services import router as services_router -from dashboard_api.stream import health_poll_loop, subscribe, unsubscribe +from dashboard_api.stream import close_all_subscribers, health_poll_loop, subscribe, unsubscribe +from dashboard_api.tools import router as tools_router + +# Set by _watch_shutdown when uvicorn begins its shutdown sequence. +_shutting_down = False + + +async def _watch_shutdown(server: uvicorn.Server) -> None: + """Poll uvicorn's should_exit flag and close SSE subscribers promptly.""" + while not server.should_exit: + await asyncio.sleep(0.5) + global _shutting_down + _shutting_down = True + close_all_subscribers() @asynccontextmanager async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: """Application lifespan handler.""" + global _shutting_down + _shutting_down = False + await bus.start() poll_task = asyncio.create_task(health_poll_loop()) + yield + + _shutting_down = True poll_task.cancel() + close_all_subscribers() await bus.stop() @@ -52,6 +72,7 @@ app.include_router(events_router) app.include_router(logs_router) app.include_router(secrets_router) app.include_router(services_router) +app.include_router(tools_router) @app.get("/health") @@ -70,6 +91,8 @@ async def sse_stream() -> StreamingResponse: yield "event: connected\ndata: {}\n\n" while True: msg = await q.get() + if not msg: + break yield msg except asyncio.CancelledError: pass @@ -85,12 +108,20 @@ async def sse_stream() -> StreamingResponse: def run() -> None: """Run the application with uvicorn.""" - uvicorn.run( + config = uvicorn.Config( "dashboard_api.main:app", host=settings.host, port=settings.port, reload=False, ) + server = uvicorn.Server(config) + + async def serve_with_watcher() -> None: + watcher = asyncio.create_task(_watch_shutdown(server)) + await server.serve() + watcher.cancel() + + asyncio.run(serve_with_watcher()) if __name__ == "__main__": diff --git a/dashboard-api/src/dashboard_api/models.py b/dashboard-api/src/dashboard_api/models.py index 16c2ccc..98ceac9 100644 --- a/dashboard-api/src/dashboard_api/models.py +++ b/dashboard-api/src/dashboard_api/models.py @@ -17,6 +17,10 @@ class ComponentSummary(BaseModel): category: str | None = None version: str | None = None tool_type: str | None = None + source: str | None = None + system_dependencies: list[str] = [] + schedule: str | None = None + installed: bool | None = None class ComponentDetail(ComponentSummary): @@ -54,3 +58,30 @@ class ServiceActionResponse(BaseModel): component: str action: str status: str + + +class ToolSummary(BaseModel): + """Summary of a single tool.""" + + 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] = [] + 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.""" + + docs: str | None = None diff --git a/dashboard-api/src/dashboard_api/routes.py b/dashboard-api/src/dashboard_api/routes.py index 7ebd15d..4be4308 100644 --- a/dashboard-api/src/dashboard_api/routes.py +++ b/dashboard-api/src/dashboard_api/routes.py @@ -2,6 +2,8 @@ from __future__ import annotations +import shutil + from fastapi import APIRouter, HTTPException, status from castle_cli.config import load_config @@ -33,6 +35,19 @@ def _summary_from_manifest(name: str, manifest: object) -> ComponentSummary: manifest.manage and manifest.manage.systemd and manifest.manage.systemd.enable ) + # Extract cron schedule from first schedule trigger, if any + schedule = None + for t in manifest.triggers: + if t.type == "schedule": + schedule = t.cron + break + + # Check if tool is actually installed on PATH + installed: bool | None = None + if manifest.install and manifest.install.path: + alias = manifest.install.path.alias or name + installed = shutil.which(alias) is not None + return ComponentSummary( id=name, description=manifest.description, @@ -45,6 +60,10 @@ def _summary_from_manifest(name: str, manifest: object) -> ComponentSummary: category=manifest.tool.category if manifest.tool else None, 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, + installed=installed, ) diff --git a/dashboard-api/src/dashboard_api/services.py b/dashboard-api/src/dashboard_api/services.py index 7f629ff..35ae503 100644 --- a/dashboard-api/src/dashboard_api/services.py +++ b/dashboard-api/src/dashboard_api/services.py @@ -6,6 +6,7 @@ import asyncio import time from fastapi import APIRouter, HTTPException, status +from starlette.responses import JSONResponse from castle_cli.config import load_config @@ -17,6 +18,7 @@ from dashboard_api.stream import broadcast router = APIRouter(prefix="/services", tags=["services"]) UNIT_PREFIX = "castle-" +SELF_NAME = "dashboard-api" async def _systemctl(action: str, unit: str) -> tuple[bool, str]: @@ -77,10 +79,25 @@ async def _broadcast_health_with_override( }) -async def _do_action(name: str, action: str) -> dict: +async def _deferred_systemctl(action: str, unit: str, delay: float = 0.5) -> None: + """Run a systemctl action after a delay, allowing the HTTP response to flush.""" + await asyncio.sleep(delay) + await _systemctl(action, unit) + + +async def _do_action(name: str, action: str) -> JSONResponse: """Execute a systemctl action and broadcast updated health.""" _validate_managed(name) unit = f"{UNIT_PREFIX}{name}.service" + + # Self-restart: defer the systemctl call so the response can be sent first + if name == SELF_NAME and action in ("restart", "stop"): + asyncio.create_task(_deferred_systemctl(action, unit)) + return JSONResponse( + status_code=202, + content={"component": name, "action": action, "status": "accepted"}, + ) + ok, output = await _systemctl(action, unit) unit_status = await _get_unit_status(unit) @@ -90,22 +107,24 @@ async def _do_action(name: str, action: str) -> dict: # Broadcast immediately with systemd status as the source of truth await _broadcast_health_with_override(name, unit_status) - return {"component": name, "action": action, "status": unit_status} + return JSONResponse( + content={"component": name, "action": action, "status": unit_status}, + ) @router.post("/{name}/start") -async def start_service(name: str) -> dict: +async def start_service(name: str) -> JSONResponse: """Start a systemd-managed service.""" return await _do_action(name, "start") @router.post("/{name}/stop") -async def stop_service(name: str) -> dict: +async def stop_service(name: str) -> JSONResponse: """Stop a systemd-managed service.""" return await _do_action(name, "stop") @router.post("/{name}/restart") -async def restart_service(name: str) -> dict: +async def restart_service(name: str) -> JSONResponse: """Restart a systemd-managed service.""" return await _do_action(name, "restart") diff --git a/dashboard-api/src/dashboard_api/stream.py b/dashboard-api/src/dashboard_api/stream.py index ce64c47..2deedc6 100644 --- a/dashboard-api/src/dashboard_api/stream.py +++ b/dashboard-api/src/dashboard_api/stream.py @@ -33,6 +33,16 @@ def unsubscribe(q: asyncio.Queue[str]) -> None: pass +def close_all_subscribers() -> None: + """Unblock all SSE generators so they exit during shutdown.""" + for q in list(_subscribers): + try: + q.put_nowait("") + except asyncio.QueueFull: + pass + _subscribers.clear() + + async def broadcast(event_type: str, data: dict) -> None: """Send an event to all connected SSE clients.""" payload = f"event: {event_type}\ndata: {json.dumps(data)}\n\n" diff --git a/dashboard-api/src/dashboard_api/tools.py b/dashboard-api/src/dashboard_api/tools.py new file mode 100644 index 0000000..2367cbb --- /dev/null +++ b/dashboard-api/src/dashboard_api/tools.py @@ -0,0 +1,177 @@ +"""Tools router — browse and inspect tool components.""" + +from __future__ import annotations + +import asyncio +from pathlib import Path + +from fastapi import APIRouter, HTTPException, status + +from castle_cli.config import load_config +from castle_cli.manifest import ComponentManifest + +from dashboard_api.config import settings +from dashboard_api.models import ToolCategory, ToolDetail, ToolSummary + +router = APIRouter(tags=["tools"]) + + +def _tool_summary(name: str, manifest: ComponentManifest) -> 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) + 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, + system_dependencies=t.system_dependencies, + installed=installed, + ) + + +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 + 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("-", "_") + if category: + md = source_path / "src" / category / 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 category.""" + 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]] = {} + 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)) + + return [ + ToolCategory(name=cat, tools=sorted(items, key=lambda t: t.id)) + for cat, items in sorted(by_category.items()) + ] + + +@router.get("/tools/{name}", response_model=ToolDetail) +def get_tool(name: str) -> ToolDetail: + """Get detailed info for a single tool.""" + config = load_config(settings.castle_root) + + if name not in config.components: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Component '{name}' not found", + ) + + manifest = config.components[name] + if not manifest.tool: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"'{name}' is not a tool", + ) + + summary = _tool_summary(name, manifest) + docs: str | None = None + t = manifest.tool + if t.source: + md_path = _find_md_for_tool(config.root, t.source, name, t.category) + 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) + + +@router.post("/tools/{name}/install") +async def install_tool(name: str) -> dict: + """Install a tool to PATH via uv tool install.""" + config = load_config(settings.castle_root) + if name not in config.components: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"'{name}' not found") + + manifest = config.components[name] + if not manifest.tool or not manifest.tool.source: + raise HTTPException(status_code=400, detail=f"'{name}' has no tool source to install") + + source_dir = config.root / manifest.tool.source + if not (source_dir / "pyproject.toml").exists(): + raise HTTPException(status_code=400, detail=f"No pyproject.toml in {manifest.tool.source}") + + proc = await asyncio.create_subprocess_exec( + "uv", "tool", "install", "--editable", str(source_dir), "--force", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await proc.communicate() + output = (stdout or stderr or b"").decode().strip() + + if proc.returncode != 0: + raise HTTPException(status_code=500, detail=output or "Install failed") + + return {"component": name, "action": "install", "status": "ok"} + + +@router.post("/tools/{name}/uninstall") +async def uninstall_tool(name: str) -> dict: + """Uninstall a tool from PATH via uv tool uninstall.""" + config = load_config(settings.castle_root) + if name not in config.components: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"'{name}' not found") + + manifest = config.components[name] + if not manifest.tool or not manifest.tool.source: + raise HTTPException(status_code=400, detail=f"'{name}' has no tool source") + + # uv tool uninstall uses the package name from pyproject.toml + source_dir = config.root / manifest.tool.source + # Try to read the package name; fall back to the source dir name + pkg_name = source_dir.name + pyproject = source_dir / "pyproject.toml" + if pyproject.exists(): + import tomllib + with open(pyproject, "rb") as f: + data = tomllib.load(f) + pkg_name = data.get("project", {}).get("name", pkg_name) + + proc = await asyncio.create_subprocess_exec( + "uv", "tool", "uninstall", pkg_name, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await proc.communicate() + output = (stdout or stderr or b"").decode().strip() + + if proc.returncode != 0: + raise HTTPException(status_code=500, detail=output or "Uninstall failed") + + return {"component": name, "action": "uninstall", "status": "ok"} diff --git a/dashboard-api/tests/conftest.py b/dashboard-api/tests/conftest.py index 396c49f..7c50c67 100644 --- a/dashboard-api/tests/conftest.py +++ b/dashboard-api/tests/conftest.py @@ -37,6 +37,19 @@ def castle_root(tmp_path: Path) -> Generator[Path, None, None]: "test-tool": { "description": "Test tool", "install": {"path": {"alias": "test-tool"}}, + "tool": { + "category": "document", + "source": "tools/document", + "system_dependencies": ["pandoc"], + }, + }, + "test-tool-2": { + "description": "Another test tool", + "tool": { + "category": "utility", + "version": "2.0.0", + "tool_type": "script", + }, }, }, } diff --git a/dashboard-api/tests/test_health.py b/dashboard-api/tests/test_health.py index 8022e2f..c407d2d 100644 --- a/dashboard-api/tests/test_health.py +++ b/dashboard-api/tests/test_health.py @@ -72,6 +72,6 @@ class TestGateway: assert response.status_code == 200 data = response.json() assert data["port"] == 9000 - assert data["component_count"] == 2 + assert data["component_count"] == 3 assert data["service_count"] == 1 assert data["managed_count"] == 1 diff --git a/dashboard-api/tests/test_tools.py b/dashboard-api/tests/test_tools.py new file mode 100644 index 0000000..c5013a2 --- /dev/null +++ b/dashboard-api/tests/test_tools.py @@ -0,0 +1,105 @@ +"""Tests for tools endpoints.""" + +from pathlib import Path + +from fastapi.testclient import TestClient + + +class TestToolsList: + """GET /tools endpoint tests.""" + + def test_returns_grouped_tools(self, client: TestClient) -> None: + """Returns tools grouped by category.""" + 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 + + def test_categories_sorted(self, client: TestClient) -> None: + """Categories are sorted alphabetically.""" + response = client.get("/tools") + data = response.json() + names = [cat["name"] for cat in data] + assert names == sorted(names) + + def test_tool_fields(self, client: TestClient) -> None: + """Tool summary has expected fields.""" + response = client.get("/tools") + data = response.json() + doc_cat = next(c for c in data if c["name"] == "document") + tool = next(t for t in doc_cat["tools"] if t["id"] == "test-tool") + assert tool["description"] == "Test tool" + assert tool["category"] == "document" + assert tool["source"] == "tools/document" + assert tool["system_dependencies"] == ["pandoc"] + + def test_installed_flag(self, client: TestClient) -> None: + """Tool with install.path is marked as installed.""" + response = client.get("/tools") + data = response.json() + doc_cat = next(c for c in data if c["name"] == "document") + tool = next(t for t in doc_cat["tools"] 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_cat = next(c for c in data if c["name"] == "utility") + tool = next(t for t in util_cat["tools"] 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"]] + assert "test-svc" not in all_ids + + +class TestToolDetail: + """GET /tools/{name} endpoint tests.""" + + def test_get_tool(self, client: TestClient) -> None: + """Returns detail for a known tool.""" + response = client.get("/tools/test-tool") + assert response.status_code == 200 + data = response.json() + assert data["id"] == "test-tool" + assert data["category"] == "document" + 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 + 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.""" + response = client.get("/tools/test-tool") + assert response.status_code == 200 + data = response.json() + assert data["docs"] is None + + def test_not_found(self, client: TestClient) -> None: + """Returns 404 for unknown component.""" + response = client.get("/tools/nonexistent") + assert response.status_code == 404 + + def test_not_a_tool(self, client: TestClient) -> None: + """Returns 404 for component that is not a tool.""" + response = client.get("/tools/test-svc") + assert response.status_code == 404 diff --git a/dashboard/src/components/ComponentCard.tsx b/dashboard/src/components/ComponentCard.tsx index bbc316d..1b157f2 100644 --- a/dashboard/src/components/ComponentCard.tsx +++ b/dashboard/src/components/ComponentCard.tsx @@ -2,6 +2,7 @@ import { ExternalLink, Play, RefreshCw, Server, Square, Terminal } from "lucide- import { Link } from "react-router-dom" import type { ComponentSummary, HealthStatus } from "@/types" import { useServiceAction } from "@/services/api/hooks" +import { runnerLabel } from "@/lib/labels" import { HealthBadge } from "./HealthBadge" import { RoleBadge } from "./RoleBadge" @@ -56,7 +57,7 @@ export function ComponentCard({ component, health }: ComponentCardProps) { {component.runner && ( - {component.runner} + {runnerLabel(component.runner)} )} {component.proxy_path && ( diff --git a/dashboard/src/components/ComponentFields.tsx b/dashboard/src/components/ComponentFields.tsx index 03c2649..0b392ae 100644 --- a/dashboard/src/components/ComponentFields.tsx +++ b/dashboard/src/components/ComponentFields.tsx @@ -1,6 +1,7 @@ import { useMemo, useState } from "react" import { Check, Loader2, Save, Trash2 } from "lucide-react" import type { ComponentDetail } from "@/types" +import { runnerLabel } from "@/lib/labels" import { SecretsEditor } from "./SecretsEditor" interface ComponentFieldsProps { @@ -113,7 +114,7 @@ export function ComponentFields({ component, onSave, onDelete }: ComponentFields {runner && ( - {runner} + {runnerLabel(runner)} {(m.run as Record)?.tool && ( <> · {(m.run as Record).tool} )} diff --git a/dashboard/src/components/ComponentTable.tsx b/dashboard/src/components/ComponentTable.tsx new file mode 100644 index 0000000..e760027 --- /dev/null +++ b/dashboard/src/components/ComponentTable.tsx @@ -0,0 +1,333 @@ +import { useMemo, useState } from "react" +import { Link } from "react-router-dom" +import { ArrowDown, ArrowUp, ArrowUpDown, Download, Play, RefreshCw, Square, Trash2 } from "lucide-react" +import type { ComponentSummary, HealthStatus } from "@/types" +import { useServiceAction, useToolAction } from "@/services/api/hooks" +import { runnerLabel, toolTypeLabel } from "@/lib/labels" +import { HealthBadge } from "./HealthBadge" +import { RoleBadge } from "./RoleBadge" + +interface ComponentTableProps { + components: ComponentSummary[] + statuses: HealthStatus[] +} + +type SortKey = "id" | "role" | "runner" | "tool_type" | "category" | "schedule" | "port" | "status" +type SortDir = "asc" | "desc" + +function statusRank(s: HealthStatus | undefined, installed: boolean | null): number { + // Health takes priority for services + if (s) { + if (s.status === "down") return 0 + if (s.status === "up") return 3 + return 2 + } + // Installed state for tools + if (installed === false) return 1 + if (installed === true) return 3 + return 2 +} + +export function ComponentTable({ components, statuses }: ComponentTableProps) { + const statusMap = useMemo(() => new Map(statuses.map((s) => [s.id, s])), [statuses]) + const allRoles = useMemo(() => { + const set = new Set() + for (const c of components) for (const r of c.roles) set.add(r) + return Array.from(set).sort() + }, [components]) + + const [roleFilter, setRoleFilter] = useState(null) + const [search, setSearch] = useState("") + const [sortKey, setSortKey] = useState("id") + const [sortDir, setSortDir] = useState("asc") + + const toggleSort = (key: SortKey) => { + if (sortKey === key) { + setSortDir((d) => (d === "asc" ? "desc" : "asc")) + } else { + setSortKey(key) + setSortDir("asc") + } + } + + const filtered = useMemo(() => { + let list = components + if (roleFilter) { + list = list.filter((c) => c.roles.includes(roleFilter)) + } + if (search) { + const q = search.toLowerCase() + list = list.filter( + (c) => + c.id.toLowerCase().includes(q) || + (c.description?.toLowerCase().includes(q) ?? false), + ) + } + return list + }, [components, roleFilter, search]) + + const sorted = useMemo(() => { + const dir = sortDir === "asc" ? 1 : -1 + return [...filtered].sort((a, b) => { + switch (sortKey) { + case "id": + return dir * a.id.localeCompare(b.id) + case "role": + return dir * (a.roles[0] ?? "").localeCompare(b.roles[0] ?? "") + case "runner": + return dir * (a.runner ?? "").localeCompare(b.runner ?? "") + case "tool_type": + return dir * (a.tool_type ?? "").localeCompare(b.tool_type ?? "") + case "category": + return dir * (a.category ?? "").localeCompare(b.category ?? "") + case "schedule": + return dir * (a.schedule ?? "").localeCompare(b.schedule ?? "") + case "port": + return dir * ((a.port ?? 0) - (b.port ?? 0)) + case "status": + return dir * (statusRank(statusMap.get(a.id), a.installed) - statusRank(statusMap.get(b.id), b.installed)) + default: + return 0 + } + }) + }, [filtered, sortKey, sortDir, statusMap]) + + return ( +
+ {/* Filters */} +
+ setSearch(e.target.value)} + placeholder="Filter components..." + className="bg-black/30 border border-[var(--border)] rounded px-3 py-1.5 text-sm focus:outline-none focus:border-[var(--primary)] w-56" + /> +
+ + {allRoles.map((role) => ( + + ))} +
+
+ + {/* Table */} +
+ + + + + + + + + + + + + + + + {sorted.map((comp) => ( + + ))} + {sorted.length === 0 && ( + + + + )} + +
Actions
+ No components match. +
+
+
+ ) +} + +function SortHeader({ + label, + sortKey, + current, + dir, + onSort, +}: { + label: string + sortKey: SortKey + current: SortKey + dir: SortDir + onSort: (key: SortKey) => void +}) { + const active = current === sortKey + const Icon = active ? (dir === "asc" ? ArrowUp : ArrowDown) : ArrowUpDown + return ( + + + + ) +} + +function InstalledBadge({ installed }: { installed: boolean }) { + return installed ? ( + + + installed + + ) : ( + + + not installed + + ) +} + +function ComponentRow({ + component, + health, +}: { + component: ComponentSummary + health?: HealthStatus +}) { + const hasHttp = component.port != null + const isTool = component.installed !== null + const { mutate: serviceAction, isPending: servicePending } = useServiceAction() + const { mutate: toolAction, isPending: toolPending } = useToolAction() + const isDown = health?.status === "down" + + return ( + + + + {component.id} + + {component.description && ( +

+ {component.description} +

+ )} + + +
+ {component.roles.map((role) => ( + + ))} +
+ + + {component.runner ? runnerLabel(component.runner) : "—"} + + + {component.tool_type ? toolTypeLabel(component.tool_type) : "—"} + + + {component.category ?? "—"} + + + {component.schedule ?? "—"} + + + {component.port ?? "—"} + + + {health ? ( + + ) : hasHttp ? ( + + ) : isTool ? ( + + ) : ( + + )} + + + {component.managed ? ( +
+ {isDown && ( + + )} + + {!isDown && ( + + )} +
+ ) : isTool ? ( +
+ {component.installed ? ( + + ) : ( + + )} +
+ ) : ( + + )} + + + ) +} diff --git a/dashboard/src/components/RoleBadge.tsx b/dashboard/src/components/RoleBadge.tsx index 4fd8f89..63c5f7e 100644 --- a/dashboard/src/components/RoleBadge.tsx +++ b/dashboard/src/components/RoleBadge.tsx @@ -1,4 +1,5 @@ import { cn } from "@/lib/utils" +import { ROLE_DESCRIPTIONS } from "@/lib/labels" const roleColors: Record = { service: "bg-green-700 text-white", @@ -17,6 +18,7 @@ export function RoleBadge({ role }: { role: string }) { "inline-block text-[0.65rem] font-semibold uppercase px-1.5 py-0.5 rounded", roleColors[role] ?? "bg-gray-600 text-gray-200", )} + title={ROLE_DESCRIPTIONS[role]} > {role}
diff --git a/dashboard/src/components/ToolCard.tsx b/dashboard/src/components/ToolCard.tsx new file mode 100644 index 0000000..4d3ebea --- /dev/null +++ b/dashboard/src/components/ToolCard.tsx @@ -0,0 +1,57 @@ +import { Link } from "react-router-dom" +import { Terminal } from "lucide-react" +import type { ToolSummary } from "@/types" +import { runnerLabel } from "@/lib/labels" + +interface ToolCardProps { + tool: ToolSummary +} + +export function ToolCard({ tool }: ToolCardProps) { + return ( +
+
+ + {tool.id} + + {tool.installed && ( + + installed + + )} +
+ + {tool.description && ( +

{tool.description}

+ )} + +
+ {tool.runner && ( + + + {runnerLabel(tool.runner)} + + )} + {tool.version && ( + v{tool.version} + )} +
+ + {tool.system_dependencies.length > 0 && ( +
+ {tool.system_dependencies.map((dep) => ( + + {dep} + + ))} +
+ )} +
+ ) +} diff --git a/dashboard/src/lib/labels.ts b/dashboard/src/lib/labels.ts new file mode 100644 index 0000000..68cfaf4 --- /dev/null +++ b/dashboard/src/lib/labels.ts @@ -0,0 +1,31 @@ +export const RUNNER_LABELS: Record = { + python_uv_tool: "Python (uv)", + command: "Command", + python_module: "Python module", + container: "Container", + node: "Node.js", + remote: "Remote", +} + +export const TOOL_TYPE_LABELS: Record = { + python_standalone: "Python package", + script: "Shell script", +} + +export const ROLE_DESCRIPTIONS: Record = { + service: "Exposes HTTP endpoints", + tool: "CLI utility installed to PATH", + worker: "Background process (no HTTP)", + job: "Runs on a schedule", + frontend: "Built static assets", + remote: "Hosted externally", + containerized: "Runs in a container", +} + +export function runnerLabel(runner: string): string { + return RUNNER_LABELS[runner] ?? runner +} + +export function toolTypeLabel(toolType: string): string { + return TOOL_TYPE_LABELS[toolType] ?? toolType +} diff --git a/dashboard/src/pages/ComponentDetail.tsx b/dashboard/src/pages/ComponentDetail.tsx index 1f7f404..78488e2 100644 --- a/dashboard/src/pages/ComponentDetail.tsx +++ b/dashboard/src/pages/ComponentDetail.tsx @@ -3,7 +3,8 @@ import { useParams, Link, useNavigate } from "react-router-dom" import { ArrowLeft, Check, Play, RefreshCw, Square } from "lucide-react" import { useQueryClient } from "@tanstack/react-query" import { apiClient } from "@/services/api/client" -import { useComponent, useStatus, useServiceAction, useEventStream } from "@/services/api/hooks" +import { useComponent, useStatus, useServiceAction, useEventStream, useToolDetail } from "@/services/api/hooks" +import { runnerLabel, toolTypeLabel } from "@/lib/labels" import { ComponentFields } from "@/components/ComponentFields" import { HealthBadge } from "@/components/HealthBadge" import { LogViewer } from "@/components/LogViewer" @@ -19,6 +20,8 @@ export function ComponentDetailPage() { const { mutate, isPending } = useServiceAction() const health = statusResp?.statuses.find((s) => s.id === name) const isDown = health?.status === "down" + const isTool = component?.roles.includes("tool") ?? false + const { data: toolDetail } = useToolDetail(isTool ? (name ?? "") : "") const [message, setMessage] = useState<{ type: "ok" | "error"; text: string } | null>(null) const handleSave = async (compName: string, config: Record) => { @@ -130,6 +133,72 @@ export function ComponentDetailPage() { )} + {toolDetail && ( +
+

+ Tool Info +

+

+ How this tool is packaged and what it depends on. +

+
+ {toolDetail.category && ( + <> + Category + {toolDetail.category} + + )} + {toolDetail.source && ( + <> + Source + {toolDetail.source} + + )} + {toolDetail.version && ( + <> + Version + {toolDetail.version} + + )} + {toolDetail.tool_type && ( + <> + Type + {toolTypeLabel(toolDetail.tool_type)} + + )} + {toolDetail.runner && ( + <> + Runner + {runnerLabel(toolDetail.runner)} + + )} +
+ {toolDetail.system_dependencies.length > 0 && ( +
+ System Dependencies +
+ {toolDetail.system_dependencies.map((dep) => ( + + {dep} + + ))} +
+
+ )} + {toolDetail.docs && ( +
+ Documentation +
+                {toolDetail.docs}
+              
+
+ )} +
+ )} +

Configuration diff --git a/dashboard/src/pages/Dashboard.tsx b/dashboard/src/pages/Dashboard.tsx index 3067b89..018641b 100644 --- a/dashboard/src/pages/Dashboard.tsx +++ b/dashboard/src/pages/Dashboard.tsx @@ -1,6 +1,4 @@ -import { Link } from "react-router-dom" -import { Settings } from "lucide-react" -import { ComponentGrid } from "@/components/ComponentGrid" +import { ComponentTable } from "@/components/ComponentTable" import { useComponents, useStatus, useGateway, useEventStream } from "@/services/api/hooks" export function Dashboard() { @@ -11,30 +9,22 @@ export function Dashboard() { return (
-
-
-

Castle

-

- Personal software platform - {gateway && ( - - · {gateway.component_count} components · port {gateway.port} - - )} -

-
- - Config - +
+

Castle

+

+ Personal software platform + {gateway && ( + + · {gateway.component_count} components · port {gateway.port} + + )} +

{isLoading ? (

Loading components...

) : components ? ( - diff --git a/dashboard/src/pages/Tools.tsx b/dashboard/src/pages/Tools.tsx new file mode 100644 index 0000000..8f3761a --- /dev/null +++ b/dashboard/src/pages/Tools.tsx @@ -0,0 +1,42 @@ +import { Link } from "react-router-dom" +import { ArrowLeft } from "lucide-react" +import { useTools } from "@/services/api/hooks" +import { ToolCard } from "@/components/ToolCard" + +export function ToolsPage() { + const { data: categories, isLoading } = useTools() + + return ( +
+ + Back + + +

Tools

+

+ CLI utilities grouped by category. Each tool is installed to PATH via castle and run with uv. +

+ + {isLoading ? ( +

Loading tools...

+ ) : categories?.length ? ( +
+ {categories.map((cat) => ( +
+

+ {cat.name} +

+
+ {cat.tools.map((tool) => ( + + ))} +
+
+ ))} +
+ ) : ( +

No tools registered.

+ )} +
+ ) +} diff --git a/dashboard/src/router/routes.tsx b/dashboard/src/router/routes.tsx index 7ca838c..163f079 100644 --- a/dashboard/src/router/routes.tsx +++ b/dashboard/src/router/routes.tsx @@ -1,17 +1,12 @@ import { createBrowserRouter } from "react-router-dom" import { Dashboard } from "@/pages/Dashboard" import { ComponentDetailPage } from "@/pages/ComponentDetail" -import { ConfigEditorPage } from "@/pages/ConfigEditor" export const router = createBrowserRouter([ { path: "/", element: , }, - { - path: "/config", - element: , - }, { path: "/:name", element: , diff --git a/dashboard/src/services/api/hooks.ts b/dashboard/src/services/api/hooks.ts index 11a22c0..ce065e5 100644 --- a/dashboard/src/services/api/hooks.ts +++ b/dashboard/src/services/api/hooks.ts @@ -8,6 +8,8 @@ import type { GatewayInfo, ServiceActionResponse, SSEHealthEvent, + ToolCategory, + ToolDetail, } from "@/types" export function useComponents() { @@ -41,11 +43,66 @@ export function useGateway() { }) } +async function waitForApi(attempts = 20, interval = 1000): Promise { + for (let i = 0; i < attempts; i++) { + try { + await apiClient.get<{ status: string }>("/health") + return + } catch { + await new Promise((r) => setTimeout(r, interval)) + } + } +} + export function useServiceAction() { + const qc = useQueryClient() return useMutation({ - mutationFn: ({ name, action }: { name: string; action: string }) => - apiClient.post(`/services/${name}/${action}`), - // SSE health event handles the UI update; no need to refetch here + mutationFn: async ({ name, action }: { name: string; action: string }) => { + try { + return await apiClient.post(`/services/${name}/${action}`) + } catch (err) { + // Network error from self-restart killing the connection — expected + if (err instanceof TypeError) { + return { component: name, action, status: "accepted" } as ServiceActionResponse + } + throw err + } + }, + onSuccess: async (data) => { + if (data.status === "accepted") { + // API is restarting itself — poll until it's back, then refresh everything + await waitForApi() + qc.invalidateQueries() + } + }, + }) +} + +export function useToolAction() { + const qc = useQueryClient() + return useMutation({ + mutationFn: ({ name, action }: { name: string; action: "install" | "uninstall" }) => + apiClient.post<{ component: string; action: string; status: string }>( + `/tools/${name}/${action}`, + ), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["components"] }) + }, + }) +} + +export function useTools() { + return useQuery({ + queryKey: ["tools"], + queryFn: () => apiClient.get("/tools"), + }) +} + +export function useToolDetail(name: string) { + return useQuery({ + queryKey: ["tools", name], + queryFn: () => apiClient.get(`/tools/${name}`), + enabled: !!name, }) } diff --git a/dashboard/src/types/index.ts b/dashboard/src/types/index.ts index 419d375..00dd44d 100644 --- a/dashboard/src/types/index.ts +++ b/dashboard/src/types/index.ts @@ -7,6 +7,13 @@ export interface ComponentSummary { health_path: string | null proxy_path: string | null managed: boolean + category: string | null + version: string | null + tool_type: string | null + source: string | null + system_dependencies: string[] + schedule: string | null + installed: boolean | null } export interface ComponentDetail extends ComponentSummary { @@ -47,11 +54,23 @@ export interface SSEServiceActionEvent { status: string } -export interface ToolInfo { - command: string - description: string - category: string - version: string +export interface ToolSummary { + id: string + description: string | null + category: string | null + source: string | null + tool_type: string | null + version: string | null + runner: string | null system_dependencies: string[] - script: string + installed: boolean +} + +export interface ToolCategory { + name: string + tools: ToolSummary[] +} + +export interface ToolDetail extends ToolSummary { + docs: string | null } diff --git a/docs/component-registry.md b/docs/component-registry.md new file mode 100644 index 0000000..bd37ebb --- /dev/null +++ b/docs/component-registry.md @@ -0,0 +1,310 @@ +# Component Registry + +How castle tracks, configures, and manages components. This is the central +reference for `castle.yaml` structure and the manifest architecture. + +## castle.yaml + +The single source of truth for all components. Lives at the repo root. + +```yaml +gateway: + port: 9000 + +components: + my-service: + description: Does something useful + run: + runner: python_uv_tool + tool: my-service + cwd: my-service + env: + MY_SERVICE_DATA_DIR: /data/castle/my-service + MY_SERVICE_PORT: "9001" + expose: + http: + internal: { port: 9001 } + health_path: /health + proxy: + caddy: { path_prefix: /my-service } + manage: + systemd: {} +``` + +## Manifest blocks + +Each component declares **what it does** through these optional blocks: + +### `run` — How to start it + +Discriminated union on `runner`: + +| Runner | Use case | Key fields | +|--------|----------|------------| +| `python_uv_tool` | Python service/tool via uv | `tool`, `cwd`, `env` | +| `command` | Shell command | `argv`, `cwd`, `env` | +| `python_module` | Python -m invocation | `module`, `args`, `python` | +| `container` | Docker/Podman | `image`, `command`, `ports`, `volumes` | +| `node` | Node.js script | `script`, `package_manager` (npm/pnpm/yarn) | +| `remote` | External service | `base_url`, `health_url` | + +**Services** use `python_uv_tool`: +```yaml +run: + runner: python_uv_tool + tool: my-service # name in [project.scripts] + cwd: my-service # working directory relative to repo root + env: + MY_SERVICE_DATA_DIR: /data/castle/my-service + MY_SERVICE_PORT: "9001" +``` + +**Tools invoked by castle** (jobs, scheduled tasks) use `command`: +```yaml +run: + runner: command + argv: ["protonmail", "sync"] + cwd: protonmail + env: + PROTONMAIL_USERNAME: user@example.com +``` + +**Standalone tools** that users invoke directly often have no `run` block at +all — castle just installs them to PATH. + +### `expose` — What it exposes + +```yaml +expose: + http: + internal: + port: 9001 # Required for services + health_path: /health # Used by health polling +``` + +Having `expose.http` gives the component the **service** role. + +### `proxy` — How to proxy it + +```yaml +proxy: + caddy: + path_prefix: /my-service # Proxied at gateway:9000/my-service/ +``` + +Castle generates a Caddyfile from these entries. Only needed for services +accessible through the gateway. + +### `manage` — How to manage it + +```yaml +manage: + systemd: {} +``` + +Enables `castle service enable/disable` and `castle logs`. An empty `{}` +uses defaults (enable=true, restart=on-failure, restart_sec=5). + +Full options: +```yaml +manage: + systemd: + description: Custom unit description + restart: always # on-failure | always | no + restart_sec: 2 + no_new_privileges: true + after: [network.target, castle-other.service] + wanted_by: [default.target] +``` + +### `install` — How to install it + +```yaml +install: + path: + alias: my-tool # Command name in PATH +``` + +Creates a shim so the tool is available system-wide after +`uv tool install --editable .`. + +### `tool` — Tool metadata + +```yaml +tool: + tool_type: python_standalone # or "script" + category: document # Grouping for display + source: tools/document/ # Source directory + version: "1.0.0" + system_dependencies: [pandoc, poppler-utils] +``` + +This block provides metadata for `castle tool list` and the dashboard. +It's separate from `install` (which handles PATH registration) and `run` +(which handles execution). + +### `build` — How to build it + +```yaml +build: + commands: + - ["pnpm", "build"] + outputs: + - dist/ +``` + +Having build outputs gives the component the **frontend** role. + +### `triggers` — What triggers it + +```yaml +triggers: + - type: schedule + cron: "*/5 * * * *" + timezone: America/Los_Angeles # default +``` + +Having a schedule trigger gives the component the **job** role. +Castle generates a systemd .timer file alongside the .service unit. + +Other trigger types: `manual`, `event` (source + topic), `request` (protocol). + +### `env` with secrets + +Environment variables can reference secrets stored in `~/.castle/secrets/`: + +```yaml +run: + env: + API_KEY: ${secret:MY_API_KEY} +``` + +Castle resolves `${secret:NAME}` by reading `~/.castle/secrets/NAME`. +Never store secrets in castle.yaml or project directories. + +## Role derivation + +Roles are **computed** from manifest declarations, never set manually: + +| Role | Derived when | +|------|-------------| +| **service** | Has `expose.http` | +| **tool** | Has `install.path` or has `tool` spec (fallback) | +| **worker** | Has `manage.systemd` but no `expose.http` | +| **job** | Has trigger with `type: schedule` | +| **frontend** | Has `build` with outputs or commands | +| **containerized** | Runner is `container` | +| **remote** | Runner is `remote` | + +A component can have multiple roles. For example, `protonmail` is both a +**tool** (installed to PATH) and a **job** (runs on a cron schedule). + +## Registering a new component + +### Via `castle create` (recommended) + +```bash +# Service — scaffolds project, assigns port, registers in castle.yaml +castle create my-service --type service --description "Does something" + +# Standalone tool — scaffolds at repo root +castle create my-tool --type tool --description "Does something" + +# Category tool — adds to existing tools// package +castle create my-tool --type tool --category document --description "Does something" +``` + +### Manually + +Add an entry to the `components:` section of `castle.yaml`: + +```yaml +components: + my-tool: + description: Does something useful + tool: + tool_type: python_standalone + category: utilities + source: my-tool/ + install: + path: + alias: my-tool +``` + +## Lifecycle + +### Service lifecycle + +```bash +castle create my-service --type service # 1. Scaffold + register +cd my-service && uv sync # 2. Install deps +# ... implement ... +castle test my-service # 3. Run tests +castle service enable my-service # 4. Generate systemd unit, start +castle gateway reload # 5. Update Caddy routes +``` + +After `service enable`, the service starts automatically on boot and restarts +on failure. Manage with: + +```bash +castle logs my-service -f # Tail logs +castle run my-service # Run in foreground (for debugging) +castle service disable my-service # Stop and remove systemd unit +``` + +### Tool lifecycle + +```bash +castle create my-tool --type tool # 1. Scaffold + register +cd my-tool && uv sync # 2. Install deps +# ... implement ... +castle test my-tool # 3. Run tests +uv tool install --editable my-tool/ # 4. Install to PATH +``` + +### Job lifecycle + +Jobs are tools or services with a schedule trigger. They need both `run` +(so castle knows how to execute them) and `manage.systemd` (so systemd +handles the timer): + +```yaml +my-job: + description: Runs nightly + run: + runner: command + argv: ["my-job"] + triggers: + - type: schedule + cron: "0 2 * * *" + manage: + systemd: {} +``` + +`castle service enable my-job` generates both a `.service` (Type=oneshot) +and a `.timer` file. + +## Infrastructure paths + +| What | Where | +|------|-------| +| Component registry | `castle.yaml` (repo root) | +| Service data | `/data/castle//` | +| Secrets | `~/.castle/secrets/` | +| Generated Caddyfile | `~/.castle/generated/Caddyfile` | +| Systemd units | `~/.config/systemd/user/castle-*.service` | +| Systemd timers | `~/.config/systemd/user/castle-*.timer` | + +## Manifest models + +The Pydantic models live in `cli/src/castle_cli/manifest.py`. Key classes: + +- `ComponentManifest` — top-level model, has `roles` computed property +- `RunSpec` — discriminated union (RunPythonUvTool, RunCommand, etc.) +- `TriggerSpec` — union (TriggerSchedule, TriggerManual, TriggerEvent, TriggerRequest) +- `ExposeSpec`, `ProxySpec`, `ManageSpec`, `InstallSpec`, `ToolSpec`, `BuildSpec` +- `CaddySpec`, `SystemdSpec`, `HttpExposeSpec`, `HttpInternal` + +Config loading: `cli/src/castle_cli/config.py` — `load_config()` parses +castle.yaml into `CastleConfig` with typed `components` dict. diff --git a/docs/python-tools.md b/docs/python-tools.md index afe0299..4fcfbf0 100644 --- a/docs/python-tools.md +++ b/docs/python-tools.md @@ -1,7 +1,6 @@ # Python Tools in Castle -How to build CLI tools following Unix philosophy. Based on the patterns in the -[toolkit](https://github.com/payneio/toolkit) project. +How to build CLI tools following Unix philosophy. ## Principles @@ -18,88 +17,120 @@ How to build CLI tools following Unix philosophy. Based on the patterns in the | **CLI** | argparse | | **Package manager** | uv (never pip) | | **Build** | hatchling | -| **Testing** | unittest + mocking | -| **Linting** | ruff | -| **Type checking** | pyright | +| **Testing** | pytest | +| **Linting** | ruff (shared `ruff.toml` at repo root) | +| **Type checking** | pyright (shared `pyrightconfig.json` at repo root) | +| **Python** | 3.11+ minimum | -## Project layout +## Two kinds of tools -Tools live inside a single package with categories: +### Standalone tools + +Independent projects at the repo root with their own `pyproject.toml`: ``` -toolkit/ -├── tools/ -│ ├── document/ -│ │ ├── pdf2md.py # Implementation -│ │ ├── pdf2md.md # Docs + YAML frontmatter (single source of truth) -│ │ └── test_pdf2md.py # Tests alongside tool -│ ├── search/ -│ │ ├── search.py -│ │ ├── search.md -│ │ └── test_search.py -│ ├── system/ -│ │ ├── schedule.py -│ │ └── schedule.md -│ └── toolkit/ -│ ├── toolkit.py # Meta-tool for discovery/scaffolding -│ └── toolkit.md +my-tool/ +├── src/my_tool/ +│ ├── __init__.py +│ └── main.py # Entry point +├── tests/ +│ └── test_main.py ├── pyproject.toml -├── Makefile -└── README.md +└── CLAUDE.md ``` -Each tool is a `.py` + `.md` pair. The `.md` file has YAML frontmatter for -metadata — no separate config files needed. +Examples: `devbox-connect/`, `mboxer/`, `protonmail/` -## YAML frontmatter (.md file) +### Category tools -```yaml ---- -command: pdf2md -script: document/pdf2md.py -description: Convert PDF files to Markdown -version: 1.0.0 -category: document -system_dependencies: - - pandoc - - poppler-utils ---- +Multiple tools sharing a single package under `tools//`: -# pdf2md - -Converts PDF files to Markdown format... +``` +tools/document/ +├── src/document/ +│ ├── __init__.py +│ ├── pdf2md.py # Each tool is a module +│ ├── docx2md.py +│ ├── html2text.py +│ └── md2pdf.py +├── tests/ +├── pyproject.toml # One pyproject with multiple [project.scripts] +└── CLAUDE.md ``` -The toolkit management command discovers tools by scanning for these `.md` files. +Examples: `tools/document/`, `tools/search/`, `tools/system/` + +## Creating a new tool + +### Standalone + +```bash +castle create my-tool --type tool --description "Does something" +cd my-tool && uv sync +``` + +This scaffolds the project and registers it in `castle.yaml`. + +### Category tool + +```bash +castle create my-tool --type tool --category document --description "Does something" +cd tools/document && uv sync +``` + +This adds a `.py` file to the existing category package and updates its +`pyproject.toml` entry points. ## pyproject.toml +### Standalone tool + ```toml [project] -name = "toolkit" +name = "my-tool" version = "0.1.0" +description = "Does something useful" requires-python = ">=3.11" -dependencies = [ - "requests>=2.28.0", - "pyyaml>=6.0.0", -] +dependencies = [] [project.scripts] -pdf2md = "tools.document.pdf2md:main" -docx2md = "tools.document.docx2md:main" -search = "tools.search.search:main" -toolkit = "tools.toolkit.toolkit:main" +my-tool = "my_tool.main:main" [build-system] requires = ["hatchling"] build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] -packages = ["tools"] +packages = ["src/my_tool"] + +[dependency-groups] +dev = ["pytest>=7.0.0"] + +[tool.ruff.lint.isort] +known-first-party = ["my_tool"] ``` -Entry points follow `command = "tools..:main"`. After -`uv tool install --editable .`, all commands are in PATH. +### Category package + +```toml +[project] +name = "castle-document" +version = "0.1.0" +description = "Castle document conversion tools" +requires-python = ">=3.11" +dependencies = [] + +[project.scripts] +docx2md = "document.docx2md:main" +pdf2md = "document.pdf2md:main" +html2text = "document.html2text:main" +md2pdf = "document.md2pdf:main" + +[tool.hatch.build.targets.wheel] +packages = ["src/document"] +``` + +After `uv tool install --editable .`, all commands are in PATH. ## Tool implementation patterns @@ -113,8 +144,6 @@ to stdout. """ my-tool: Brief description -Detailed usage docs here. - Usage: my-tool [options] [FILE] cat input.txt | my-tool @@ -224,7 +253,7 @@ import sys def convert(input_file: str, output_file: str) -> int: try: - result = subprocess.run( + subprocess.run( ["pandoc", input_file, "-o", output_file], check=True, capture_output=True, @@ -245,24 +274,6 @@ def convert(input_file: str, output_file: str) -> int: Always use `check=True` and `capture_output=True` with subprocess. Handle `FileNotFoundError` for missing system dependencies. -### Tool with optional dependencies - -```python -tantivy_available = False -try: - import tantivy - tantivy_available = True -except ImportError: - tantivy = None - - -def check_tantivy() -> None: - if not tantivy_available: - print("Error: tantivy not found. Install with: uv add tantivy", - file=sys.stderr) - sys.exit(1) -``` - ## Error handling ```python @@ -301,119 +312,94 @@ for f in *.pdf; do pdf2md "$f" > "${f%.pdf}.md"; done cat doc.txt | text-extractor | jq .content ``` -When a tool writes to both a file and stdout, status messages must go to -stderr so they don't contaminate the pipe: - -```python -with open(output_file, "w") as f: - f.write(result) -print(result) # stdout (for piping) -print(f"Wrote to {output_file}", file=sys.stderr) # stderr (status) -``` - ## Testing -Tests live alongside the tool implementation, using unittest with mocking: +Tests use pytest. For standalone tools, test via subprocess to exercise the +real CLI interface: ```python -# tools/gpt/test_gpt.py -import unittest -from unittest.mock import patch, MagicMock -from io import StringIO - -from tools.gpt import gpt +import subprocess +import sys -class TestGPT(unittest.TestCase): - @patch("tools.gpt.gpt.get_api_key") - @patch("openai.OpenAI") - def test_generate(self, mock_openai, mock_key): - mock_key.return_value = "fake-key" - mock_client = MagicMock() - mock_openai.return_value = mock_client - mock_client.chat.completions.create.return_value = MagicMock( - choices=[MagicMock(message=MagicMock(content="response"))] +class TestCLI: + def test_version(self) -> None: + result = subprocess.run( + [sys.executable, "-m", "my_tool.main", "--version"], + capture_output=True, + text=True, ) + assert "my-tool" in result.stdout - result = gpt.generate_text("prompt", "gpt-4", 0.7, 500) - self.assertEqual(result, "response") + def test_stdin(self) -> None: + result = subprocess.run( + [sys.executable, "-m", "my_tool.main"], + input="hello\n", + capture_output=True, + text=True, + ) + assert result.returncode == 0 + assert "hello" in result.stdout - @patch("tools.gpt.gpt.generate_text") - def test_cli(self, mock_gen): - mock_gen.return_value = "output" - with patch("sys.argv", ["gpt", "prompt"]): - with patch("sys.stdout", new=StringIO()) as out: - gpt.main() - self.assertEqual(out.getvalue().strip(), "output") + def test_file_input(self, tmp_path) -> None: + input_file = tmp_path / "input.txt" + input_file.write_text("test data") + result = subprocess.run( + [sys.executable, "-m", "my_tool.main", str(input_file)], + capture_output=True, + text=True, + ) + assert result.returncode == 0 + assert "test data" in result.stdout ``` -Pattern: mock external dependencies (APIs, file I/O, subprocesses), test the -CLI by patching `sys.argv`. +For unit testing core logic, import the function directly and test it as a +pure function. -## Build and install - -```makefile -# Makefile -all: build install - -build: - uv sync - -install: - uv tool install --editable . - -check: - uv run ruff format . - uv run ruff check . --fix - uv run pyright - -test: - uv run pytest -v - -clean: - find . -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true -``` +## Commands ```bash -make all # Sync deps + install to PATH -make check # Format, lint, type-check -make test # Run tests +uv sync # Install deps +uv run my-tool --help # Run the tool +uv run pytest tests/ -v # Run tests +uv run ruff check . # Lint +uv run ruff format . # Format ``` -## Creating a new tool +## Registering in castle.yaml -Use the toolkit management command: - -```bash -toolkit create my-tool --description "Does something" --category document -``` - -This creates the `.py` template, `.md` with frontmatter, and updates -`pyproject.toml` with the entry point. - -Or manually: -1. Create `tools//my_tool.py` with the argparse pattern above -2. Create `tools//my_tool.md` with YAML frontmatter -3. Add entry to `pyproject.toml` under `[project.scripts]`: - ```toml - my-tool = "tools.category.my_tool:main" - ``` -4. Run `make install` to register in PATH - -## Registering in castle +Standalone tools: ```yaml -# castle.yaml components: - toolkit: - description: Personal utility scripts - run: - runner: command - argv: ["toolkit"] - cwd: toolkit + my-tool: + description: Does something useful + tool: + tool_type: python_standalone + category: utilities + source: my-tool/ install: - path: { alias: toolkit } + path: + alias: my-tool ``` -Tools with `install.path` get the `tool` role. They don't need `expose`, -`proxy`, or `manage` blocks. +Category tools get one entry per tool, all pointing to the same source: + +```yaml +components: + pdf2md: + description: Convert PDF files to Markdown + tool: + tool_type: python_standalone + category: document + source: tools/document/ + system_dependencies: [pandoc, poppler-utils] + install: + path: + alias: pdf2md +``` + +Tools with `install.path` get the **tool** role. They don't need `expose`, +`proxy`, or `manage` blocks unless castle also runs them (e.g., scheduled jobs). + +See @docs/component-registry.md for the full manifest reference. diff --git a/docs/web-apis.md b/docs/web-apis.md index d3530a9..442137a 100644 --- a/docs/web-apis.md +++ b/docs/web-apis.md @@ -422,3 +422,6 @@ uv run ruff format . # Format ```bash castle create my-service --type service --description "Does something useful" ``` + +See @docs/component-registry.md for manifest fields, role derivation, and +the full service lifecycle (enable, logs, gateway reload). diff --git a/docs/web-frontends.md b/docs/web-frontends.md index 1d45025..bd18476 100644 --- a/docs/web-frontends.md +++ b/docs/web-frontends.md @@ -156,6 +156,9 @@ components: This gives the component both the `frontend` role (from `build`) and the `service` role (from `expose.http`) during development. +See @docs/component-registry.md for the full manifest reference and role +derivation rules. + ## Serving with Caddy For production, serve the static `dist/` output directly from Caddy rather than