feat: CLI consistency pass — unified args, verbs, and status

#1 Positional dest unified to `name` across info + dev verbs (was `project`).
#2 install/uninstall help now states the activate semantics (tool→PATH,
   service/job→systemd, frontend→served) rather than 'to PATH'.
#4 `service status` (a plural op) moved to `services status`; the singular
   `service` group is enable/disable only.
#5 `list --behavior` now filters the program *catalog* by its real behavior
   field. behavior (what a program is) and service/job (how it's deployed) are
   separate axes: `list` shows a Programs catalog plus Services/Jobs deployment
   views; a behavior filter scopes the catalog only.
#6 `list` uses lifecycle.is_active uniformly for the status dot (tool on PATH /
   service running / job timer / static frontend served) instead of registry
   presence.
#7 New `castle restart <name>` — restart a service (unit) or job (timer).
#8 New `castle status` — unified services + jobs + programs activation view.
#9 New `castle format [name]` dev verb (ruff format / pnpm format); wired into
   stacks DEV_ACTIONS + handlers.
#10 New `castle up` — deploy + start everything in one shot.

Docs: CLAUDE.md command reference refreshed. Tests updated to the two-axis
list model (+ a daemon-behavior program fixture). core 92 · cli 24 · api 52
green; ruff clean.
This commit is contained in:
2026-06-14 11:57:58 -07:00
parent 3f3b88f17b
commit a5dcb6b346
10 changed files with 245 additions and 156 deletions

View File

@@ -55,10 +55,10 @@ def _run_verb_all(config: CastleConfig, verb: str) -> bool:
def run_verb(args: argparse.Namespace, verb: str) -> int:
"""Generic entry point for a dev verb (single project or all)."""
"""Generic entry point for a dev verb (single program or all)."""
config = load_config()
if getattr(args, "project", None):
return 0 if _run_verb(config, args.project, verb) else 1
if getattr(args, "name", None):
return 0 if _run_verb(config, args.name, verb) else 1
ok = _run_verb_all(config, verb)
print(f"\n{'All ' + verb + ' passed.' if ok else 'Some ' + verb + ' failed.'}")
return 0 if ok else 1
@@ -73,6 +73,10 @@ def run_lint(args: argparse.Namespace) -> int:
return run_verb(args, "lint")
def run_format(args: argparse.Namespace) -> int:
return run_verb(args, "format")
def run_build(args: argparse.Namespace) -> int:
return run_verb(args, "build")
@@ -105,8 +109,8 @@ def _lifecycle(config: CastleConfig, name: str, deactivate: bool) -> bool:
def run_install(args: argparse.Namespace) -> int:
"""Activate (install/deploy/serve) a program — verb word kept, meaning unified."""
config = load_config()
if getattr(args, "project", None):
return 0 if _lifecycle(config, args.project, deactivate=False) else 1
if getattr(args, "name", None):
return 0 if _lifecycle(config, args.name, deactivate=False) else 1
ok = all(
_lifecycle(config, name, deactivate=False)
for name, comp in config.programs.items()
@@ -118,7 +122,7 @@ def run_install(args: argparse.Namespace) -> int:
def run_uninstall(args: argparse.Namespace) -> int:
"""Deactivate (uninstall/stop/unpublish) a program."""
config = load_config()
if getattr(args, "project", None):
return 0 if _lifecycle(config, args.project, deactivate=True) else 1
if getattr(args, "name", None):
return 0 if _lifecycle(config, args.name, deactivate=True) else 1
print("Refusing to deactivate all programs; name a target.")
return 1

View File

@@ -31,7 +31,7 @@ def _load_deployed_program(name: str) -> object | None:
def run_info(args: argparse.Namespace) -> int:
"""Show detailed info for a program, service, or job."""
config = load_config()
name = args.project
name = args.name
# Look up in all sections
program = config.programs.get(name)

View File

@@ -38,17 +38,6 @@ STACK_DISPLAY: dict[str, str] = {
}
def _load_deployed() -> dict[str, object] | None:
"""Try to load deployed state from registry, return None if unavailable."""
try:
from castle_core.registry import load_registry
registry = load_registry()
return registry.deployed
except (FileNotFoundError, ValueError):
return None
def _resolve_stack(config: object, name: str) -> str | None:
"""Resolve stack from program reference or direct program."""
# Check services for program ref
@@ -70,97 +59,77 @@ def _resolve_stack(config: object, name: str) -> str | None:
def run_list(args: argparse.Namespace) -> int:
"""List all programs, services, and jobs."""
"""List all programs, services, and jobs.
Two orthogonal axes: the **Programs** catalog (filtered by real `behavior`)
and the **Services**/**Jobs** deployment views. `--behavior` filters the
catalog only — it's a property of a program, not of a deployment.
"""
from castle_core.lifecycle import is_active
config = load_config()
deployed = _load_deployed()
filter_behavior = getattr(args, "behavior", None)
filter_stack = getattr(args, "stack", None)
if getattr(args, "json", False):
return _list_json(config, deployed, filter_behavior, filter_stack)
return _list_json(config, filter_behavior, filter_stack)
def dot(name: str) -> str:
return f"{GREEN}{RESET}" if is_active(name, config) else f"{RED}{RESET}"
any_output = False
# Daemons (services)
if not filter_behavior or filter_behavior == "daemon":
# Programs (the catalog) — filtered by real behavior + stack
progs = {
name: comp
for name, comp in config.programs.items()
if (not filter_behavior or comp.behavior == filter_behavior)
and (not filter_stack or comp.stack == filter_stack)
}
if progs:
any_output = True
print(f"\n{BOLD}{CYAN}Programs{RESET}")
print(f"{CYAN}{'' * 40}{RESET}")
for name, comp in progs.items():
behavior = comp.behavior or "program"
bcolor = BEHAVIOR_COLORS.get(behavior, "")
behavior_str = f" {bcolor}{behavior}{RESET}"
stack_str = f" {DIM}{comp.stack}{RESET}" if comp.stack else ""
desc = f" {DIM}{comp.description}{RESET}" if comp.description else ""
print(f" {dot(name)} {BOLD}{name}{RESET}{behavior_str}{stack_str}{desc}")
# Services + Jobs (deployment views) — independent of behavior, so only shown
# when no behavior filter is applied.
if not filter_behavior:
services = _filter_by_stack(config.services, config, filter_stack)
if services:
any_output = True
color = BEHAVIOR_COLORS["daemon"]
print(f"\n{BOLD}{color}Daemons{RESET}")
print(f"\n{BOLD}{color}Services{RESET}")
print(f"{color}{'' * 40}{RESET}")
for name, svc in services.items():
port_str = ""
if svc.expose and svc.expose.http:
port_str = f" :{svc.expose.http.internal.port}"
if deployed is not None:
status = f"{GREEN}{RESET}" if name in deployed else f"{RED}{RESET}"
else:
status = f"{DIM}?{RESET}"
stack = _resolve_stack(config, name)
stack_str = f" {DIM}{stack}{RESET}" if stack else ""
desc = f" {DIM}{svc.description}{RESET}" if svc.description else ""
print(f" {status} {BOLD}{name}{RESET}{port_str}{stack_str}{desc}")
print(f" {dot(name)} {BOLD}{name}{RESET}{port_str}{stack_str}{desc}")
# Scheduled (jobs)
if not filter_behavior or filter_behavior == "tool":
jobs = _filter_by_stack(config.jobs, config, filter_stack)
if jobs:
any_output = True
color = MAGENTA
print(f"\n{BOLD}{color}Scheduled{RESET}")
print(f"{color}{'' * 40}{RESET}")
print(f"\n{BOLD}{MAGENTA}Jobs{RESET}")
print(f"{MAGENTA}{'' * 40}{RESET}")
for name, job in jobs.items():
if deployed is not None:
status = f"{GREEN}{RESET}" if name in deployed else f"{RED}{RESET}"
else:
status = f"{DIM}?{RESET}"
desc = f" {DIM}{job.description}{RESET}" if job.description else ""
sched = f" {DIM}[{job.schedule}]{RESET}"
print(f" {status} {BOLD}{name}{RESET}{sched}{desc}")
# Programs (tools, frontends, etc.)
show_tools = not filter_behavior or filter_behavior == "tool"
show_frontends = not filter_behavior or filter_behavior == "frontend"
if show_tools or show_frontends:
# Collect non-daemon programs
comps: dict[str, tuple[str, str | None, str | None]] = {}
if show_tools:
for name, comp in config.tools.items():
if filter_stack and comp.stack != filter_stack:
continue
comps[name] = ("tool", comp.stack, comp.description)
if show_frontends:
for name, comp in config.frontends.items():
if filter_stack and comp.stack != filter_stack:
continue
if name not in comps:
comps[name] = ("frontend", comp.stack, comp.description)
if comps:
any_output = True
color = CYAN
print(f"\n{BOLD}{color}Programs{RESET}")
print(f"{color}{'' * 40}{RESET}")
for name, (behavior, stack, description) in comps.items():
stack_str = f" {DIM}{stack}{RESET}" if stack else ""
behavior_str = f" {behavior}"
desc = f" {DIM}{description}{RESET}" if description else ""
print(f" {BOLD}{name}{RESET}{stack_str}{behavior_str}{desc}")
desc = f" {DIM}{job.description}{RESET}" if job.description else ""
print(f" {dot(name)} {BOLD}{name}{RESET}{sched}{desc}")
if not any_output:
print("No programs found.")
if deployed is None:
print(f"\n{DIM}(no registry — run 'castle deploy' to generate){RESET}")
print()
return 0
@@ -182,23 +151,39 @@ def _filter_by_stack(
def _list_json(
config: object,
deployed: dict | None,
filter_behavior: str | None,
filter_stack: str | None,
) -> int:
"""Output JSON list of all entries."""
"""Output JSON: the program catalog (behavior-filterable) plus deployments."""
from castle_core.lifecycle import is_active
output = []
if not filter_behavior or filter_behavior == "daemon":
# Programs (catalog) — filtered by real behavior + stack
for name, comp in config.programs.items():
if filter_behavior and comp.behavior != filter_behavior:
continue
if filter_stack and comp.stack != filter_stack:
continue
entry: dict = {
"name": name,
"kind": "program",
"behavior": comp.behavior,
"active": is_active(name, config),
}
if comp.stack:
entry["stack"] = comp.stack
if comp.description:
entry["description"] = comp.description
output.append(entry)
# Services + Jobs (deployments) — only when not filtering by behavior
if not filter_behavior:
for name, svc in config.services.items():
stack = _resolve_stack(config, name)
if filter_stack and stack != filter_stack:
continue
entry: dict = {
"name": name,
"behavior": "daemon",
"deployed": deployed is not None and name in deployed,
}
entry = {"name": name, "kind": "service", "active": is_active(name, config)}
if stack:
entry["stack"] = stack
if svc.description:
@@ -207,15 +192,14 @@ def _list_json(
entry["port"] = svc.expose.http.internal.port
output.append(entry)
if not filter_behavior or filter_behavior == "tool":
for name, job in config.jobs.items():
stack = _resolve_stack(config, name)
if filter_stack and stack != filter_stack:
continue
entry = {
"name": name,
"behavior": "tool",
"deployed": deployed is not None and name in deployed,
"kind": "job",
"active": is_active(name, config),
"schedule": job.schedule,
}
if stack:
@@ -224,27 +208,5 @@ def _list_json(
entry["description"] = job.description
output.append(entry)
if not filter_behavior or filter_behavior == "tool":
for name, comp in config.tools.items():
if filter_stack and comp.stack != filter_stack:
continue
entry = {"name": name, "behavior": "tool"}
if comp.stack:
entry["stack"] = comp.stack
if comp.description:
entry["description"] = comp.description
output.append(entry)
if not filter_behavior or filter_behavior == "frontend":
for name, comp in config.frontends.items():
if filter_stack and comp.stack != filter_stack:
continue
entry = {"name": name, "behavior": "frontend"}
if comp.stack:
entry["stack"] = comp.stack
if comp.description:
entry["description"] = comp.description
output.append(entry)
print(json.dumps(output, indent=2))
return 0

View File

@@ -43,7 +43,7 @@ def _remove_unit(uname: str) -> None:
def run_service(args: argparse.Namespace) -> int:
"""Manage individual services."""
if not args.service_command:
print("Usage: castle service {enable|disable|status}")
print("Usage: castle service {enable|disable}")
return 1
config = load_config()
@@ -54,8 +54,6 @@ def run_service(args: argparse.Namespace) -> int:
return _service_enable(config, args.name)
elif args.service_command == "disable":
return _service_disable(args.name)
elif args.service_command == "status":
return _service_status(config)
return 1
@@ -63,7 +61,7 @@ def run_service(args: argparse.Namespace) -> int:
def run_services(args: argparse.Namespace) -> int:
"""Manage all services together."""
if not args.services_command:
print("Usage: castle services {start|stop}")
print("Usage: castle services {start|stop|status}")
return 1
config = load_config()
@@ -72,10 +70,67 @@ def run_services(args: argparse.Namespace) -> int:
return _services_start(config)
elif args.services_command == "stop":
return _services_stop(config)
elif args.services_command == "status":
return _service_status(config)
return 1
def run_restart(args: argparse.Namespace) -> int:
"""Restart a single deployed service or job."""
config = load_config()
name = args.name
if name not in config.services and name not in config.jobs:
print(f"Error: '{name}' is not a known service or job.")
return 1
# Jobs are driven by their timer; services by the service unit.
unit = timer_name(name) if name in config.jobs else unit_name(name)
result = subprocess.run(["systemctl", "--user", "restart", unit], check=False)
if result.returncode != 0:
print(f"Error: failed to restart {unit}")
return 1
print(f" {name}: restarted")
return 0
def run_status(args: argparse.Namespace) -> int:
"""Unified status across the platform: services + jobs + programs."""
from castle_core.lifecycle import is_active
config = load_config()
# Services + jobs (deployment state); the gateway appears here as a service.
_service_status(config)
# Programs (catalog activation: tools on PATH, static frontends served)
catalog = {
n: c
for n, c in config.programs.items()
if n not in config.services and n not in config.jobs
}
if catalog:
print(f"{'' * 50}")
print("Programs")
for name, comp in catalog.items():
on = is_active(name, config)
color = "\033[92m" if on else "\033[90m"
label = "active" if on else "inactive"
print(f" {color}{label:10s}\033[0m {name} ({comp.behavior or 'program'})")
print()
return 0
def run_up(args: argparse.Namespace) -> int:
"""Bring everything online: deploy from castle.yaml, then start all services."""
from castle_cli.commands.deploy import run_deploy
config = load_config()
print("Deploying from castle.yaml...")
run_deploy(argparse.Namespace(name=None))
print("\nStarting services and gateway...")
return _services_start(config)
def _service_enable(config: CastleConfig, name: str) -> int:
"""Enable and start a single service (or timer for scheduled jobs)."""
from castle_core.lifecycle import enable_service

View File

@@ -63,34 +63,42 @@ def build_parser() -> argparse.ArgumentParser:
# castle info
info_parser = subparsers.add_parser("info", help="Show program details")
info_parser.add_argument("project", help="Program, service, or job name")
info_parser.add_argument("name", help="Program, service, or job name")
info_parser.add_argument("--json", action="store_true", help="Output as JSON")
# castle test
test_parser = subparsers.add_parser("test", help="Run tests")
test_parser.add_argument("project", nargs="?", help="Project to test (default: all)")
test_parser.add_argument("name", nargs="?", help="Program to test (default: all)")
# castle lint
lint_parser = subparsers.add_parser("lint", help="Run linter")
lint_parser.add_argument("project", nargs="?", help="Project to lint (default: all)")
lint_parser.add_argument("name", nargs="?", help="Program to lint (default: all)")
# castle format
format_parser = subparsers.add_parser("format", help="Format source code")
format_parser.add_argument("name", nargs="?", help="Program to format (default: all)")
# castle build
build_parser = subparsers.add_parser("build", help="Build projects")
build_parser.add_argument("project", nargs="?", help="Project to build (default: all)")
build_parser = subparsers.add_parser("build", help="Build programs")
build_parser.add_argument("name", nargs="?", help="Program to build (default: all)")
# castle type-check
tc_parser = subparsers.add_parser("type-check", help="Run type checker")
tc_parser.add_argument("project", nargs="?", help="Project to type-check (default: all)")
tc_parser.add_argument("name", nargs="?", help="Program to type-check (default: all)")
# castle check (composite: lint + type-check + test)
check_parser = subparsers.add_parser("check", help="Run lint + type-check + test")
check_parser.add_argument("project", nargs="?", help="Project to check (default: all)")
check_parser.add_argument("name", nargs="?", help="Program to check (default: all)")
# castle install / uninstall
install_parser = subparsers.add_parser("install", help="Install a program to PATH")
install_parser.add_argument("project", nargs="?", help="Program to install (default: all)")
uninstall_parser = subparsers.add_parser("uninstall", help="Uninstall a program")
uninstall_parser.add_argument("project", nargs="?", help="Program to uninstall (default: all)")
# castle install / uninstall — activate / deactivate a program in its mode
install_parser = subparsers.add_parser(
"install", help="Activate a program (tool→PATH, service/job→systemd, frontend→served)"
)
install_parser.add_argument("name", nargs="?", help="Program to activate (default: all)")
uninstall_parser = subparsers.add_parser(
"uninstall", help="Deactivate a program (reverse of install)"
)
uninstall_parser.add_argument("name", nargs="?", help="Program to deactivate")
# castle gateway
gateway_parser = subparsers.add_parser("gateway", help="Manage the Caddy gateway")
@@ -116,13 +124,23 @@ def build_parser() -> argparse.ArgumentParser:
)
disable_parser = service_sub.add_parser("disable", help="Stop and disable a service")
disable_parser.add_argument("name", help="Service name")
service_sub.add_parser("status", help="Show status of all services")
# castle services (plural - manage all services)
services_parser = subparsers.add_parser("services", help="Manage all services together")
services_sub = services_parser.add_subparsers(dest="services_command")
services_sub.add_parser("start", help="Start all services and gateway")
services_sub.add_parser("stop", help="Stop all services and gateway")
services_sub.add_parser("status", help="Show status of all services and jobs")
# castle restart — restart a single deployed service or job
restart_parser = subparsers.add_parser("restart", help="Restart a service or job")
restart_parser.add_argument("name", help="Service or job name")
# castle status — unified status (gateway + services + jobs + programs)
subparsers.add_parser("status", help="Show overall status across the platform")
# castle up — bring everything online (deploy + start all services)
subparsers.add_parser("up", help="Deploy and start all services and the gateway")
# castle logs
logs_parser = subparsers.add_parser("logs", help="View service/job logs")
@@ -196,6 +214,11 @@ def main() -> int:
return run_lint(args)
elif args.command == "format":
from castle_cli.commands.dev import run_format
return run_format(args)
elif args.command == "build":
from castle_cli.commands.dev import run_build
@@ -236,6 +259,21 @@ def main() -> int:
return run_services(args)
elif args.command == "restart":
from castle_cli.commands.service import run_restart
return run_restart(args)
elif args.command == "status":
from castle_cli.commands.service import run_status
return run_status(args)
elif args.command == "up":
from castle_cli.commands.service import run_up
return run_up(args)
elif args.command == "logs":
from castle_cli.commands.logs import run_logs