diff --git a/CLAUDE.md b/CLAUDE.md index 1412e50..06fb5b1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -108,6 +108,11 @@ castle service logs [-f] [-n 50] castle job create [--program P] --schedule "0 2 * * *" [--launcher ...] castle job ... +# Tools — CLIs on your PATH (deployments with manager: path) +castle tool list [--json] # each tool's executable + description + install state +castle tool info [--json] # a tool's executable, description, source, installed? +castle tool install|uninstall + # 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 diff --git a/README.md b/README.md index 2319431..0194cea 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/cli/src/castle_cli/commands/add.py b/cli/src/castle_cli/commands/add.py index 948ffd4..115a3c7 100644 --- a/cli/src/castle_cli/commands/add.py +++ b/cli/src/castle_cli/commands/add.py @@ -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, diff --git a/cli/src/castle_cli/commands/service.py b/cli/src/castle_cli/commands/service.py index 119db1d..c60d81a 100644 --- a/cli/src/castle_cli/commands/service.py +++ b/cli/src/castle_cli/commands/service.py @@ -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() diff --git a/cli/src/castle_cli/commands/tool.py b/cli/src/castle_cli/commands/tool.py new file mode 100644 index 0000000..4a11b57 --- /dev/null +++ b/cli/src/castle_cli/commands/tool.py @@ -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 diff --git a/cli/src/castle_cli/main.py b/cli/src/castle_cli/main.py index 9a5e3b8..f27e2d3 100644 --- a/cli/src/castle_cli/main.py +++ b/cli/src/castle_cli/main.py @@ -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 diff --git a/cli/tests/test_tool.py b/cli/tests/test_tool.py new file mode 100644 index 0000000..9ef97a5 --- /dev/null +++ b/cli/tests/test_tool.py @@ -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 diff --git a/docs/registry.md b/docs/registry.md index 381712e..12c0ae5 100644 --- a/docs/registry.md +++ b/docs/registry.md @@ -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