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 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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
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.
|
"""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
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
|
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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user