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

@@ -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,