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:
18
CLAUDE.md
18
CLAUDE.md
@@ -108,6 +108,11 @@ castle service logs <name> [-f] [-n 50]
|
||||
castle job create <name> [--program P] --schedule "0 2 * * *" [--launcher ...]
|
||||
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)
|
||||
castle list [--kind ...] [--stack ...] [--json] # all deployments (services, jobs, tools, statics)
|
||||
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
|
||||
start` (apply config, then start) — there is no bundled `up`.
|
||||
|
||||
`castle service` and `castle job` are **views** over the single deployment set,
|
||||
filtered by derived kind (systemd-no-schedule → service, systemd+schedule → job).
|
||||
Tools (`manager: path`) and statics (`manager: caddy`) are deployments too —
|
||||
reach them via `castle list --kind tool` / `--kind static`.
|
||||
`castle service`, `castle job`, and `castle tool` are **views** over the single
|
||||
deployment set, filtered by derived kind (systemd-no-schedule → service,
|
||||
systemd+schedule → job, `manager: path` → tool). `castle tool list --json` is the
|
||||
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:`)
|
||||
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
|
||||
it declares its commands. Tools are reached via `castle list --kind tool`.
|
||||
it declares its commands. Tools are reached via `castle tool list`.
|
||||
|
||||
## Infrastructure
|
||||
|
||||
|
||||
@@ -75,7 +75,8 @@ castle services start|stop Start/stop everything
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -22,15 +22,14 @@ def _is_git_url(s: str) -> bool:
|
||||
)
|
||||
|
||||
|
||||
def _detect(src: Path) -> tuple[str | None, dict[str, list[list[str]]], str]:
|
||||
"""Detect (stack, commands, behavior) for a source dir.
|
||||
def _detect(src: Path) -> tuple[str | None, dict[str, list[list[str]]]]:
|
||||
"""Detect (stack, commands) for a source dir.
|
||||
|
||||
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]]] = {}
|
||||
behavior = "tool"
|
||||
|
||||
pyproject = src / "pyproject.toml"
|
||||
if pyproject.exists():
|
||||
@@ -39,11 +38,8 @@ def _detect(src: Path) -> tuple[str | None, dict[str, list[list[str]]], str]:
|
||||
except (OSError, tomllib.TOMLDecodeError):
|
||||
data = {}
|
||||
deps = " ".join(data.get("project", {}).get("dependencies", []))
|
||||
if "fastapi" in deps or "uvicorn" in deps:
|
||||
stack, behavior = "python-fastapi", "daemon"
|
||||
else:
|
||||
stack = "python-cli"
|
||||
return stack, commands, behavior
|
||||
stack = "python-fastapi" if ("fastapi" in deps or "uvicorn" in deps) else "python-cli"
|
||||
return stack, commands
|
||||
|
||||
if (src / "Cargo.toml").exists():
|
||||
commands = {
|
||||
@@ -52,7 +48,7 @@ def _detect(src: Path) -> tuple[str | None, dict[str, list[list[str]]], str]:
|
||||
"lint": [["cargo", "clippy"]],
|
||||
"run": [["cargo", "run"]],
|
||||
}
|
||||
return None, commands, "tool"
|
||||
return None, commands
|
||||
|
||||
if (src / "package.json").exists():
|
||||
commands = {
|
||||
@@ -60,13 +56,13 @@ def _detect(src: Path) -> tuple[str | None, dict[str, list[list[str]]], str]:
|
||||
"test": [["pnpm", "test"]],
|
||||
"lint": [["pnpm", "lint"]],
|
||||
}
|
||||
return None, commands, "frontend"
|
||||
return None, commands
|
||||
|
||||
if (src / "Makefile").exists() or (src / "makefile").exists():
|
||||
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:
|
||||
@@ -101,7 +97,7 @@ def run_add(args: argparse.Namespace) -> int:
|
||||
stack: str | None = None
|
||||
detected_commands: dict[str, list[list[str]]] = {}
|
||||
if src_path.exists():
|
||||
stack, detected_commands, _ = _detect(src_path)
|
||||
stack, detected_commands = _detect(src_path)
|
||||
|
||||
prog = ProgramSpec(
|
||||
id=name,
|
||||
|
||||
@@ -170,7 +170,7 @@ def run_status(args: argparse.Namespace) -> int:
|
||||
# Services + jobs (deployment state); the gateway appears here as a service.
|
||||
_service_status(config)
|
||||
|
||||
# Programs (catalog activation: tools on PATH, static frontends served)
|
||||
# Programs (catalog activation: tools on PATH, statics served by the gateway)
|
||||
catalog = {
|
||||
n: c
|
||||
for n, c in config.programs.items()
|
||||
|
||||
144
cli/src/castle_cli/commands/tool.py
Normal file
144
cli/src/castle_cli/commands/tool.py
Normal 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
|
||||
@@ -1,10 +1,11 @@
|
||||
"""Castle CLI entry point — resource-first command surface.
|
||||
|
||||
Operations live under the resource they act on (`program`, `service`, `job`,
|
||||
`gateway`); platform-wide lifecycle (`start`/`stop`/`restart`/`status`/`deploy`)
|
||||
and the cross-resource `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.
|
||||
Operations live under the resource they act on. `program` is the catalog;
|
||||
`service`, `job`, and `tool` are deployment lenses (systemd services, scheduled
|
||||
timers, and PATH-installed CLIs); `gateway` is infrastructure. Platform-wide
|
||||
lifecycle (`start`/`stop`/`restart`/`status`/`deploy`) and the cross-resource
|
||||
`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
|
||||
@@ -41,7 +42,7 @@ def _build_program_group(subparsers: argparse._SubParsersAction) -> None:
|
||||
_add_name(p, "Program name")
|
||||
p.add_argument("--stack", choices=available_stacks(), default=None)
|
||||
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.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")
|
||||
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)"
|
||||
)
|
||||
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)
|
||||
|
||||
|
||||
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:
|
||||
p = sub.add_parser("create", help=f"Create a {kind} in castle.yaml")
|
||||
_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:
|
||||
parser = argparse.ArgumentParser(
|
||||
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__}")
|
||||
subparsers = parser.add_subparsers(dest="command", help="Available commands")
|
||||
@@ -146,6 +164,7 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
_build_program_group(subparsers)
|
||||
_build_deployment_group(subparsers, "service")
|
||||
_build_deployment_group(subparsers, "job")
|
||||
_build_tool_group(subparsers)
|
||||
|
||||
# Gateway (infrastructure)
|
||||
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)")
|
||||
|
||||
# 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("--stack", help="Filter by stack")
|
||||
p.add_argument("--json", action="store_true", help="Output as JSON")
|
||||
@@ -223,6 +242,30 @@ def _dispatch_program(args: argparse.Namespace) -> int:
|
||||
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:
|
||||
sub = getattr(args, f"{kind}_command")
|
||||
if not sub:
|
||||
@@ -273,6 +316,8 @@ def main() -> int:
|
||||
return _dispatch_program(args)
|
||||
if cmd in ("service", "job"):
|
||||
return _dispatch_deployment(args, cmd)
|
||||
if cmd == "tool":
|
||||
return _dispatch_tool(args)
|
||||
if cmd == "gateway":
|
||||
from castle_cli.commands.gateway import run_gateway
|
||||
|
||||
|
||||
60
cli/tests/test_tool.py
Normal file
60
cli/tests/test_tool.py
Normal 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
|
||||
@@ -195,7 +195,7 @@ system_dependencies: [pandoc, poppler-utils]
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user