CLI: add a 'castle tool' lens + sweep for model alignment

The tools lens coding assistants need. 'castle tool list [--json]' lists tools
(programs with a manager:path deployment) surfacing each tool's actual EXECUTABLE
— resolved from pyproject [project.scripts], which can differ from the program
name (litellm-intent-router → intent-router) — plus description and install state.
'castle tool info <name> [--json]' details one; 'castle tool install|uninstall'.
--json is a clean machine-readable catalog for building context.

Sweep: main.py descriptions/help mention the tool lens; 'frontend'→'static' in
install help; add.py._detect drops the dead behavior return (add adopts source
only); stale comments/help fixed. Docs (CLAUDE.md/README/registry) document
'castle tool'. Tests: cli/tests/test_tool.py (list/info/json).

Suites: core 124, cli 29, castle-api 58.
This commit is contained in:
2026-07-01 13:21:05 -07:00
parent bcd040edfd
commit 416ec043fc
8 changed files with 286 additions and 32 deletions

View File

@@ -108,6 +108,11 @@ castle service logs <name> [-f] [-n 50]
castle job create <name> [--program P] --schedule "0 2 * * *" [--launcher ...] castle job create <name> [--program P] --schedule "0 2 * * *" [--launcher ...]
castle job <list|info|delete|deploy|enable|disable|start|stop|restart|logs> ... castle job <list|info|delete|deploy|enable|disable|start|stop|restart|logs> ...
# Tools — CLIs on your PATH (deployments with manager: path)
castle tool list [--json] # each tool's executable + description + install state
castle tool info <name> [--json] # a tool's executable, description, source, installed?
castle tool install|uninstall <name>
# Platform-wide (top-level) # Platform-wide (top-level)
castle list [--kind ...] [--stack ...] [--json] # all deployments (services, jobs, tools, statics) castle list [--kind ...] [--stack ...] [--json] # all deployments (services, jobs, tools, statics)
castle status # unified status castle status # unified status
@@ -119,15 +124,18 @@ castle gateway start|stop|reload|status # the Caddy gateway
Bringing everything online is the two honest steps `castle deploy && castle Bringing everything online is the two honest steps `castle deploy && castle
start` (apply config, then start) — there is no bundled `up`. start` (apply config, then start) — there is no bundled `up`.
`castle service` and `castle job` are **views** over the single deployment set, `castle service`, `castle job`, and `castle tool` are **views** over the single
filtered by derived kind (systemd-no-schedule → service, systemd+schedule → job). deployment set, filtered by derived kind (systemd-no-schedule → service,
Tools (`manager: path`) and statics (`manager: caddy`) are deployments too — systemd+schedule → job, `manager: path` → tool). `castle tool list --json` is the
reach them via `castle list --kind tool` / `--kind static`. machine-readable tool catalog for assistants — it surfaces each tool's actual
**executable** (which can differ from the program name — `litellm-intent-router`
installs `intent-router`), its description, and install state. Statics
(`manager: caddy`) surface via `castle list --kind static`.
**Dev verbs** resolve per-program: a declared `commands:` entry (or `build:`) **Dev verbs** resolve per-program: a declared `commands:` entry (or `build:`)
overrides the stack default, falling back to the program's stack handler, else overrides the stack default, falling back to the program's stack handler, else
the verb is unavailable. So a wired-in repo with **no `stack`** works as long as the verb is unavailable. So a wired-in repo with **no `stack`** works as long as
it declares its commands. Tools are reached via `castle list --kind tool`. it declares its commands. Tools are reached via `castle tool list`.
## Infrastructure ## Infrastructure

View File

@@ -75,7 +75,8 @@ castle services start|stop Start/stop everything
``` ```
Tools are deployments with `manager: path` (derived **kind: tool**) — list them Tools are deployments with `manager: path` (derived **kind: tool**) — list them
with `castle list --kind tool`. with `castle tool list` (add `--json` for the machine-readable catalog with each
tool's executable, description, and install state).
## Registry ## Registry

View File

@@ -22,15 +22,14 @@ def _is_git_url(s: str) -> bool:
) )
def _detect(src: Path) -> tuple[str | None, dict[str, list[list[str]]], str]: def _detect(src: Path) -> tuple[str | None, dict[str, list[list[str]]]]:
"""Detect (stack, commands, behavior) for a source dir. """Detect (stack, commands) for a source dir.
Returns a stack name when the project fits a known one (so it inherits those Returns a stack name when the project fits a known one (so it inherits those
defaults), otherwise an explicit commands map. behavior defaults to 'tool'. defaults), otherwise an explicit commands map. `add` adopts source only; the
deployment (and thus kind) is declared separately, so no kind is inferred here.
""" """
stack: str | None = None
commands: dict[str, list[list[str]]] = {} commands: dict[str, list[list[str]]] = {}
behavior = "tool"
pyproject = src / "pyproject.toml" pyproject = src / "pyproject.toml"
if pyproject.exists(): if pyproject.exists():
@@ -39,11 +38,8 @@ def _detect(src: Path) -> tuple[str | None, dict[str, list[list[str]]], str]:
except (OSError, tomllib.TOMLDecodeError): except (OSError, tomllib.TOMLDecodeError):
data = {} data = {}
deps = " ".join(data.get("project", {}).get("dependencies", [])) deps = " ".join(data.get("project", {}).get("dependencies", []))
if "fastapi" in deps or "uvicorn" in deps: stack = "python-fastapi" if ("fastapi" in deps or "uvicorn" in deps) else "python-cli"
stack, behavior = "python-fastapi", "daemon" return stack, commands
else:
stack = "python-cli"
return stack, commands, behavior
if (src / "Cargo.toml").exists(): if (src / "Cargo.toml").exists():
commands = { commands = {
@@ -52,7 +48,7 @@ def _detect(src: Path) -> tuple[str | None, dict[str, list[list[str]]], str]:
"lint": [["cargo", "clippy"]], "lint": [["cargo", "clippy"]],
"run": [["cargo", "run"]], "run": [["cargo", "run"]],
} }
return None, commands, "tool" return None, commands
if (src / "package.json").exists(): if (src / "package.json").exists():
commands = { commands = {
@@ -60,13 +56,13 @@ def _detect(src: Path) -> tuple[str | None, dict[str, list[list[str]]], str]:
"test": [["pnpm", "test"]], "test": [["pnpm", "test"]],
"lint": [["pnpm", "lint"]], "lint": [["pnpm", "lint"]],
} }
return None, commands, "frontend" return None, commands
if (src / "Makefile").exists() or (src / "makefile").exists(): if (src / "Makefile").exists() or (src / "makefile").exists():
commands = {"build": [["make"]], "test": [["make", "test"]]} commands = {"build": [["make"]], "test": [["make", "test"]]}
return None, commands, "tool" return None, commands
return None, commands, "tool" return None, commands
def run_add(args: argparse.Namespace) -> int: def run_add(args: argparse.Namespace) -> int:
@@ -101,7 +97,7 @@ def run_add(args: argparse.Namespace) -> int:
stack: str | None = None stack: str | None = None
detected_commands: dict[str, list[list[str]]] = {} detected_commands: dict[str, list[list[str]]] = {}
if src_path.exists(): if src_path.exists():
stack, detected_commands, _ = _detect(src_path) stack, detected_commands = _detect(src_path)
prog = ProgramSpec( prog = ProgramSpec(
id=name, id=name,

View File

@@ -170,7 +170,7 @@ def run_status(args: argparse.Namespace) -> int:
# Services + jobs (deployment state); the gateway appears here as a service. # Services + jobs (deployment state); the gateway appears here as a service.
_service_status(config) _service_status(config)
# Programs (catalog activation: tools on PATH, static frontends served) # Programs (catalog activation: tools on PATH, statics served by the gateway)
catalog = { catalog = {
n: c n: c
for n, c in config.programs.items() for n, c in config.programs.items()

View File

@@ -0,0 +1,144 @@
"""castle tool — the tools lens (programs installed on PATH).
A *tool* is a program with a `path` (manager) deployment: a CLI on your PATH.
This lens is what coding assistants use to discover what's available — so the
listing surfaces the *executable* to invoke (which can differ from the program
name, e.g. `litellm-intent-router` installs `intent-router`), the description,
and whether it's installed. `--json` gives a machine-readable context payload.
"""
from __future__ import annotations
import argparse
import json
import tomllib
from pathlib import Path
from castle_cli.config import load_config
BOLD = "\033[1m"
DIM = "\033[2m"
RESET = "\033[0m"
GREEN = "\033[92m"
GREY = "\033[90m"
CYAN = "\033[96m"
def _is_tool(config: object, name: str) -> bool:
return any(kind == "tool" for _, kind in config.deployments_of(name))
def _tool_programs(config: object) -> dict:
"""Programs with a tool (path) deployment, name-sorted."""
return {
name: comp
for name, comp in sorted(config.programs.items())
if _is_tool(config, name)
}
def _executables(comp: object) -> list[str]:
"""The console scripts a tool exposes, from its pyproject `[project.scripts]`.
This is the command(s) to actually run — the source of truth even when the
tool isn't installed. Falls back to the program name when none are declared
(e.g. a non-python tool).
"""
src = getattr(comp, "source", None)
if src:
pyproject = Path(src) / "pyproject.toml"
if pyproject.exists():
try:
data = tomllib.loads(pyproject.read_text())
scripts = data.get("project", {}).get("scripts", {})
if scripts:
return sorted(scripts.keys())
except (OSError, tomllib.TOMLDecodeError):
pass
return [comp.id]
def _tool_record(config: object, name: str, comp: object, installed: bool) -> dict:
return {
"name": name,
"executables": _executables(comp),
"description": comp.description,
"installed": installed,
"stack": comp.stack,
"source": comp.source,
"system_dependencies": comp.system_dependencies,
}
def run_tool_list(args: argparse.Namespace) -> int:
"""List tools (programs on PATH) with their executable + description."""
from castle_core.lifecycle import tool_installed
config = load_config()
tools = _tool_programs(config)
records = [
_tool_record(config, name, comp, tool_installed(name))
for name, comp in tools.items()
]
if getattr(args, "json", False):
print(json.dumps(records, indent=2))
return 0
if not records:
print("No tools.")
return 0
print(f"\n{BOLD}Tools{RESET}")
print("" * 60)
width = max(len(r["name"]) for r in records)
for r in records:
dot = f"{GREEN}{RESET}" if r["installed"] else f"{GREY}{RESET}"
exes = ", ".join(r["executables"])
exe_str = f" {CYAN}{exes}{RESET}" if exes and exes != [r["name"]] else ""
# Only show the executable when it differs from the name (the useful case).
if r["executables"] == [r["name"]]:
exe_str = ""
desc = f" {DIM}{r['description']}{RESET}" if r["description"] else ""
print(f" {dot} {BOLD}{r['name']:<{width}}{RESET}{exe_str}{desc}")
print()
return 0
def run_tool_info(args: argparse.Namespace) -> int:
"""Show one tool's details — executable, description, install state, source."""
from castle_core.lifecycle import tool_installed
config = load_config()
name = args.name
if name not in config.programs or not _is_tool(config, name):
print(f"Error: no tool '{name}'.")
return 1
comp = config.programs[name]
installed = tool_installed(name)
record = _tool_record(config, name, comp, installed)
if getattr(args, "json", False):
print(json.dumps(record, indent=2))
return 0
exes = record["executables"]
print(f"\n{BOLD}{name}{RESET}")
print("" * 40)
if comp.description:
print(f" {BOLD}description{RESET}: {comp.description}")
print(f" {BOLD}run{RESET}: {', '.join(exes)}")
print(f" {BOLD}installed{RESET}: {'yes' if installed else 'no'}")
if comp.source:
print(f" {BOLD}source{RESET}: {comp.source}")
if comp.stack:
print(f" {BOLD}stack{RESET}: {comp.stack}")
if comp.system_dependencies:
print(f" {BOLD}requires{RESET}: {', '.join(comp.system_dependencies)}")
if not installed:
print(f"\n {DIM}install with: castle tool install {name}{RESET}")
else:
print(f"\n {DIM}run `{exes[0]} --help` for arguments{RESET}")
print()
return 0

View File

@@ -1,10 +1,11 @@
"""Castle CLI entry point — resource-first command surface. """Castle CLI entry point — resource-first command surface.
Operations live under the resource they act on (`program`, `service`, `job`, Operations live under the resource they act on. `program` is the catalog;
`gateway`); platform-wide lifecycle (`start`/`stop`/`restart`/`status`/`deploy`) `service`, `job`, and `tool` are deployment lenses (systemd services, scheduled
and the cross-resource `list` are top-level. Names can collide across resource timers, and PATH-installed CLIs); `gateway` is infrastructure. Platform-wide
types (a program and a service may share a name), so the resource is always lifecycle (`start`/`stop`/`restart`/`status`/`deploy`) and the cross-resource
explicit. `list` are top-level. Names can collide across resource types (a program and a
service may share a name), so the resource is always explicit.
""" """
from __future__ import annotations from __future__ import annotations
@@ -41,7 +42,7 @@ def _build_program_group(subparsers: argparse._SubParsersAction) -> None:
_add_name(p, "Program name") _add_name(p, "Program name")
p.add_argument("--stack", choices=available_stacks(), default=None) p.add_argument("--stack", choices=available_stacks(), default=None)
p.add_argument("--description", default="", help="Program description") p.add_argument("--description", default="", help="Program description")
p.add_argument("--port", type=int, help="Port (daemons only)") p.add_argument("--port", type=int, help="Port (service deployments only)")
p = sub.add_parser("add", help="Adopt an existing repo (path or git URL)") p = sub.add_parser("add", help="Adopt an existing repo (path or git URL)")
p.add_argument("target", help="Local path or git URL") p.add_argument("target", help="Local path or git URL")
@@ -60,7 +61,7 @@ def _build_program_group(subparsers: argparse._SubParsersAction) -> None:
_add_name(p, "Program name") _add_name(p, "Program name")
p.add_argument("extra", nargs=argparse.REMAINDER, help="Extra args passed to the program") p.add_argument("extra", nargs=argparse.REMAINDER, help="Extra args passed to the program")
sub.add_parser("install", help="Activate a program (tool→PATH, frontend→served)").add_argument( sub.add_parser("install", help="Activate a program (tool→PATH, static→served)").add_argument(
"name", nargs="?", help="Program (default: all)" "name", nargs="?", help="Program (default: all)"
) )
sub.add_parser("uninstall", help="Deactivate a program").add_argument( sub.add_parser("uninstall", help="Deactivate a program").add_argument(
@@ -72,6 +73,23 @@ def _build_program_group(subparsers: argparse._SubParsersAction) -> None:
_add_name(p, "Program (default: all)", optional=True) _add_name(p, "Program (default: all)", optional=True)
def _build_tool_group(subparsers: argparse._SubParsersAction) -> None:
"""The `tool` lens — programs installed on PATH (path deployments)."""
grp = subparsers.add_parser("tool", help="Tools on your PATH (the tools lens)")
grp.set_defaults(resource="tool")
sub = grp.add_subparsers(dest="tool_command")
p = sub.add_parser("list", help="List tools with their executable + description")
p.add_argument("--json", action="store_true", help="Machine-readable output")
p = sub.add_parser("info", help="Show a tool's executable, description, install state")
_add_name(p, "Tool name")
p.add_argument("--json", action="store_true", help="Machine-readable output")
sub.add_parser("install", help="Install a tool on PATH").add_argument("name", help="Tool name")
sub.add_parser("uninstall", help="Remove a tool from PATH").add_argument("name", help="Tool name")
def _add_service_create(sub: argparse._SubParsersAction, kind: str) -> None: def _add_service_create(sub: argparse._SubParsersAction, kind: str) -> None:
p = sub.add_parser("create", help=f"Create a {kind} in castle.yaml") p = sub.add_parser("create", help=f"Create a {kind} in castle.yaml")
_add_name(p, f"{kind.capitalize()} name") _add_name(p, f"{kind.capitalize()} name")
@@ -138,7 +156,7 @@ def _build_deployment_group(subparsers: argparse._SubParsersAction, kind: str) -
def build_parser() -> argparse.ArgumentParser: def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
prog="castle", prog="castle",
description="Castle platform CLI — programs, services, jobs, and infrastructure", description="Castle platform CLI — programs, deployments (services, jobs, tools), and infrastructure",
) )
parser.add_argument("--version", action="version", version=f"castle {__version__}") parser.add_argument("--version", action="version", version=f"castle {__version__}")
subparsers = parser.add_subparsers(dest="command", help="Available commands") subparsers = parser.add_subparsers(dest="command", help="Available commands")
@@ -146,6 +164,7 @@ def build_parser() -> argparse.ArgumentParser:
_build_program_group(subparsers) _build_program_group(subparsers)
_build_deployment_group(subparsers, "service") _build_deployment_group(subparsers, "service")
_build_deployment_group(subparsers, "job") _build_deployment_group(subparsers, "job")
_build_tool_group(subparsers)
# Gateway (infrastructure) # Gateway (infrastructure)
gw = subparsers.add_parser("gateway", help="Manage the Caddy gateway") gw = subparsers.add_parser("gateway", help="Manage the Caddy gateway")
@@ -166,7 +185,7 @@ def build_parser() -> argparse.ArgumentParser:
p.add_argument("name", nargs="?", help="Service/job to deploy (default: all)") p.add_argument("name", nargs="?", help="Service/job to deploy (default: all)")
# Cross-resource overview # Cross-resource overview
p = subparsers.add_parser("list", help="List programs, services, and jobs") p = subparsers.add_parser("list", help="List programs, services, jobs, and tools")
p.add_argument("--kind", choices=["service", "job", "tool", "static", "reference"], help="Filter by derived kind") p.add_argument("--kind", choices=["service", "job", "tool", "static", "reference"], help="Filter by derived kind")
p.add_argument("--stack", help="Filter by stack") p.add_argument("--stack", help="Filter by stack")
p.add_argument("--json", action="store_true", help="Output as JSON") p.add_argument("--json", action="store_true", help="Output as JSON")
@@ -223,6 +242,30 @@ def _dispatch_program(args: argparse.Namespace) -> int:
return 1 return 1
def _dispatch_tool(args: argparse.Namespace) -> int:
sub = args.tool_command
if not sub:
print("Usage: castle tool {list|info|install|uninstall}")
return 1
if sub == "list":
from castle_cli.commands.tool import run_tool_list
return run_tool_list(args)
if sub == "info":
from castle_cli.commands.tool import run_tool_info
return run_tool_info(args)
if sub == "install":
from castle_cli.commands.dev import run_install
return run_install(args)
if sub == "uninstall":
from castle_cli.commands.dev import run_uninstall
return run_uninstall(args)
return 1
def _dispatch_deployment(args: argparse.Namespace, kind: str) -> int: def _dispatch_deployment(args: argparse.Namespace, kind: str) -> int:
sub = getattr(args, f"{kind}_command") sub = getattr(args, f"{kind}_command")
if not sub: if not sub:
@@ -273,6 +316,8 @@ def main() -> int:
return _dispatch_program(args) return _dispatch_program(args)
if cmd in ("service", "job"): if cmd in ("service", "job"):
return _dispatch_deployment(args, cmd) return _dispatch_deployment(args, cmd)
if cmd == "tool":
return _dispatch_tool(args)
if cmd == "gateway": if cmd == "gateway":
from castle_cli.commands.gateway import run_gateway from castle_cli.commands.gateway import run_gateway

60
cli/tests/test_tool.py Normal file
View File

@@ -0,0 +1,60 @@
"""Tests for the `castle tool` lens."""
from __future__ import annotations
import json
from argparse import Namespace
from pathlib import Path
from unittest.mock import patch
def _config(castle_root: Path):
from castle_cli.config import load_config
return load_config(castle_root)
class TestToolList:
def test_list_includes_tool(self, castle_root: Path, capsys: object) -> None:
with patch("castle_cli.commands.tool.load_config", return_value=_config(castle_root)):
from castle_cli.commands.tool import run_tool_list
rc = run_tool_list(Namespace(json=False))
assert rc == 0
out = capsys.readouterr().out # type: ignore[attr-defined]
assert "test-tool" in out
# test-svc is a service (systemd), not a tool — must not appear.
assert "test-svc" not in out
def test_list_json_payload(self, castle_root: Path, capsys: object) -> None:
with patch("castle_cli.commands.tool.load_config", return_value=_config(castle_root)):
from castle_cli.commands.tool import run_tool_list
rc = run_tool_list(Namespace(json=True))
assert rc == 0
data = json.loads(capsys.readouterr().out) # type: ignore[attr-defined]
tool = next(t for t in data if t["name"] == "test-tool")
# The coding-assistant context payload.
for key in ("name", "executables", "description", "installed", "source"):
assert key in tool
assert isinstance(tool["executables"], list) and tool["executables"]
class TestToolInfo:
def test_info_json(self, castle_root: Path, capsys: object) -> None:
with patch("castle_cli.commands.tool.load_config", return_value=_config(castle_root)):
from castle_cli.commands.tool import run_tool_info
rc = run_tool_info(Namespace(name="test-tool", json=True))
assert rc == 0
data = json.loads(capsys.readouterr().out) # type: ignore[attr-defined]
assert data["name"] == "test-tool"
assert data["executables"]
def test_info_rejects_non_tool(self, castle_root: Path, capsys: object) -> None:
# test-svc is a service deployment, not a tool.
with patch("castle_cli.commands.tool.load_config", return_value=_config(castle_root)):
from castle_cli.commands.tool import run_tool_info
rc = run_tool_info(Namespace(name="test-svc", json=False))
assert rc == 1

View File

@@ -195,7 +195,7 @@ system_dependencies: [pandoc, poppler-utils]
``` ```
System packages that must be installed for the program to work. Displayed System packages that must be installed for the program to work. Displayed
in `castle list --kind tool` and the dashboard. in `castle tool list` / `castle tool info` and the dashboard.
### `version` — Program version ### `version` — Program version