Files
wild-pc/cli/src/castle_cli/commands/list_cmd.py
Paul Payne 776e1235d7 castle list: add Tools and Static deployment sections
Tools and statics only appeared as labels in the Programs catalog; now they get
their own sections alongside Services and Jobs (deployment views over
config.deployments filtered by kind), in both the human and --json output.
2026-07-01 13:24:26 -07:00

273 lines
9.6 KiB
Python

"""castle list - the program catalog plus every deployment view (services, jobs,
tools, static)."""
from __future__ import annotations
import argparse
import json
import logging
from castle_core.manifest import kind_for
from castle_cli.config import load_config
log = logging.getLogger(__name__)
def _deployments_of_kind(config: object, kind: str) -> dict:
"""The deployments whose derived kind matches (a lens over config.deployments)."""
return {n: d for n, d in config.deployments.items() if kind_for(d) == kind}
# Terminal colors
BOLD = "\033[1m"
RESET = "\033[0m"
DIM = "\033[2m"
GREEN = "\033[92m"
RED = "\033[91m"
CYAN = "\033[96m"
MAGENTA = "\033[95m"
YELLOW = "\033[93m"
KIND_COLORS: dict[str, str] = {
"service": GREEN,
"job": MAGENTA,
"tool": CYAN,
"static": YELLOW,
"reference": DIM,
}
STACK_DISPLAY: dict[str, str] = {
"python-fastapi": "python-fastapi",
"python-cli": "python-cli",
"react-vite": "react-vite",
"supabase": "supabase",
"rust": "rust",
"go": "go",
"bash": "bash",
"container": "container",
"command": "command",
}
def _resolve_stack(config: object, name: str) -> str | None:
"""Resolve stack from program reference or direct program."""
# Check services for program ref
if name in config.services:
svc = config.services[name]
comp_name = svc.program
if comp_name and comp_name in config.programs:
return config.programs[comp_name].stack
# Check jobs for program ref
if name in config.jobs:
job = config.jobs[name]
comp_name = job.program
if comp_name and comp_name in config.programs:
return config.programs[comp_name].stack
# Direct program
if name in config.programs:
return config.programs[name].stack
return None
def run_list(args: argparse.Namespace) -> int:
"""List all programs, services, and jobs.
Two orthogonal axes: the **Programs** catalog (filtered by derived `kind`)
and the **Services**/**Jobs** deployment views. `--kind` filters the catalog
by a program's derived kind (service/job/tool/static/reference).
"""
from castle_core.lifecycle import is_active
config = load_config()
filter_kind = getattr(args, "kind", None)
filter_stack = getattr(args, "stack", None)
resource = getattr(args, "resource", None) # scope to one section, or all
if getattr(args, "json", False):
return _list_json(config, filter_kind, filter_stack)
def dot(name: str) -> str:
return f"{GREEN}{RESET}" if is_active(name, config) else f"{RED}{RESET}"
any_output = False
# A program's kinds are the kinds of its deployments (a program has no kind
# of its own). Sorted, de-duplicated.
def prog_kinds(name: str) -> list[str]:
return sorted({kind for _, kind in config.deployments_of(name)})
# Programs (the catalog) — filtered by a deployment kind + stack.
progs = (
{
name: comp
for name, comp in config.programs.items()
if (not filter_kind or filter_kind in prog_kinds(name))
and (not filter_stack or comp.stack == filter_stack)
}
if resource in (None, "program")
else {}
)
if progs:
any_output = True
print(f"\n{BOLD}{CYAN}Programs{RESET}")
print(f"{CYAN}{'' * 40}{RESET}")
for name, comp in progs.items():
kinds = prog_kinds(name)
kinds_str = "".join(
f" {KIND_COLORS.get(k, '')}{k}{RESET}" for k in kinds
)
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}{kinds_str}{stack_str}{desc}")
# Services + Jobs (deployment views) — independent of behavior, so only shown
# when no behavior filter is applied. Each gated by its own resource scope.
if not filter_kind and resource in (None, "service"):
services = _filter_by_stack(config.services, config, filter_stack)
if services:
any_output = True
color = KIND_COLORS["service"]
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}"
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" {dot(name)} {BOLD}{name}{RESET}{port_str}{stack_str}{desc}")
if not filter_kind and resource in (None, "job"):
jobs = _filter_by_stack(config.jobs, config, filter_stack)
if jobs:
any_output = True
print(f"\n{BOLD}{MAGENTA}Jobs{RESET}")
print(f"{MAGENTA}{'' * 40}{RESET}")
for name, job in jobs.items():
sched = f" {DIM}[{job.schedule}]{RESET}"
desc = f" {DIM}{job.description}{RESET}" if job.description else ""
print(f" {dot(name)} {BOLD}{name}{RESET}{sched}{desc}")
if not filter_kind and resource in (None, "tool"):
tools = _filter_by_stack(_deployments_of_kind(config, "tool"), config, filter_stack)
if tools:
any_output = True
color = KIND_COLORS["tool"]
print(f"\n{BOLD}{color}Tools{RESET}")
print(f"{color}{'' * 40}{RESET}")
for name, d in tools.items():
stack = _resolve_stack(config, name)
stack_str = f" {DIM}{stack}{RESET}" if stack else ""
desc = f" {DIM}{d.description}{RESET}" if d.description else ""
print(f" {dot(name)} {BOLD}{name}{RESET}{stack_str}{desc}")
if not filter_kind and resource in (None, "static"):
statics = _filter_by_stack(_deployments_of_kind(config, "static"), config, filter_stack)
if statics:
any_output = True
color = KIND_COLORS["static"]
print(f"\n{BOLD}{color}Static{RESET}")
print(f"{color}{'' * 40}{RESET}")
for name, d in statics.items():
sub = f" {DIM}{name}.<domain>{RESET}"
desc = f" {DIM}{d.description}{RESET}" if d.description else ""
print(f" {dot(name)} {BOLD}{name}{RESET}{sub}{desc}")
if not any_output:
print(f"No {resource or 'program'}s found.")
print()
return 0
def _filter_by_stack(
items: dict[str, object],
config: object,
filter_stack: str | None,
) -> dict[str, object]:
"""Filter items by stack if a filter is provided."""
if not filter_stack:
return items
return {
name: item
for name, item in items.items()
if _resolve_stack(config, name) == filter_stack
}
def _list_json(
config: object,
filter_kind: str | None,
filter_stack: str | None,
) -> int:
"""Output JSON: the program catalog (kind-filterable) plus deployments."""
from castle_core.lifecycle import is_active
output = []
# Programs (catalog) — a program's kinds are its deployments' kinds.
for name, comp in config.programs.items():
kinds = sorted({kind for _, kind in config.deployments_of(name)})
if filter_kind and filter_kind not in kinds:
continue
if filter_stack and comp.stack != filter_stack:
continue
entry: dict = {
"name": name,
"kinds": kinds,
"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 kind
if not filter_kind:
for name, svc in config.services.items():
stack = _resolve_stack(config, name)
if filter_stack and stack != filter_stack:
continue
entry = {"name": name, "kind": "service", "active": is_active(name, config)}
if stack:
entry["stack"] = stack
if svc.description:
entry["description"] = svc.description
if svc.expose and svc.expose.http:
entry["port"] = svc.expose.http.internal.port
output.append(entry)
for name, job in config.jobs.items():
stack = _resolve_stack(config, name)
if filter_stack and stack != filter_stack:
continue
entry = {
"name": name,
"kind": "job",
"active": is_active(name, config),
"schedule": job.schedule,
}
if stack:
entry["stack"] = stack
if job.description:
entry["description"] = job.description
output.append(entry)
for kind in ("tool", "static"):
for name, d in _deployments_of_kind(config, kind).items():
stack = _resolve_stack(config, name)
if filter_stack and stack != filter_stack:
continue
entry = {"name": name, "kind": kind, "active": is_active(name, config)}
if stack:
entry["stack"] = stack
if d.description:
entry["description"] = d.description
output.append(entry)
print(json.dumps(output, indent=2))
return 0