From a5dcb6b346b6c4a9760bdb3cf105cd7d8eba5c26 Mon Sep 17 00:00:00 2001 From: Paul Payne Date: Sun, 14 Jun 2026 11:57:58 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20CLI=20consistency=20pass=20=E2=80=94=20?= =?UTF-8?q?unified=20args,=20verbs,=20and=20status?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #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 ` — 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. --- CLAUDE.md | 14 +- cli/src/castle_cli/commands/dev.py | 18 ++- cli/src/castle_cli/commands/info.py | 2 +- cli/src/castle_cli/commands/list_cmd.py | 170 +++++++++--------------- cli/src/castle_cli/commands/service.py | 63 ++++++++- cli/src/castle_cli/main.py | 64 +++++++-- cli/tests/conftest.py | 4 + cli/tests/test_info.py | 12 +- cli/tests/test_list.py | 33 +++-- core/src/castle_core/stacks.py | 21 ++- 10 files changed, 245 insertions(+), 156 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 4e5fd5e..2cc35af 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -73,21 +73,23 @@ The CLI lives in `cli/` and is installed via `uv tool install --editable cli/`. ```bash castle list # List all programs, services, and jobs -castle list --behavior daemon # Filter by behavior +castle list --behavior daemon # Filter the program catalog by behavior castle list --stack python-cli # Filter by stack castle info # Show details (--json for machine-readable) +castle status # Unified status (services + jobs + programs) castle create [--stack ...] # Scaffold new project (--stack optional → bare program) castle add [--name ...] # Adopt an EXISTING repo as a program (detects verbs) castle clone [name] # Clone source for programs that declare repo: castle deploy [name] # Deploy to runtime (registry + systemd + Caddyfile) -castle build|test|lint|type-check|check [project] # Dev verbs (one or all) -castle install|uninstall [program] # Install/remove a program on PATH +castle up # Deploy + start everything (one-shot bring-up) +castle build|test|lint|format|type-check|check [name] # Dev verbs (one program or all) +castle install|uninstall [name] # Activate/deactivate (tool→PATH, service→systemd, frontend→served) castle run # Run a program (declared run) or service in foreground +castle restart # Restart a service or job castle logs [-f] [-n 50] # View service/job logs castle gateway start|stop|reload|status # Manage Caddy reverse proxy -castle service enable|disable # Manage individual systemd service -castle service status # Show all service statuses -castle services start|stop # Start/stop everything +castle service enable|disable # Manage an individual systemd service +castle services start|stop|status # Start/stop/inspect all services together ``` **Dev verbs** resolve per-program: a declared `commands:` entry (or `build:`) diff --git a/cli/src/castle_cli/commands/dev.py b/cli/src/castle_cli/commands/dev.py index 1aedbd2..8737581 100644 --- a/cli/src/castle_cli/commands/dev.py +++ b/cli/src/castle_cli/commands/dev.py @@ -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 diff --git a/cli/src/castle_cli/commands/info.py b/cli/src/castle_cli/commands/info.py index 18fff12..93b75f3 100644 --- a/cli/src/castle_cli/commands/info.py +++ b/cli/src/castle_cli/commands/info.py @@ -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) diff --git a/cli/src/castle_cli/commands/list_cmd.py b/cli/src/castle_cli/commands/list_cmd.py index 16ab1b6..ba60a38 100644 --- a/cli/src/castle_cli/commands/list_cmd.py +++ b/cli/src/castle_cli/commands/list_cmd.py @@ -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 diff --git a/cli/src/castle_cli/commands/service.py b/cli/src/castle_cli/commands/service.py index 86d3bc4..09365dd 100644 --- a/cli/src/castle_cli/commands/service.py +++ b/cli/src/castle_cli/commands/service.py @@ -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 diff --git a/cli/src/castle_cli/main.py b/cli/src/castle_cli/main.py index 0fd1c99..bae8130 100644 --- a/cli/src/castle_cli/main.py +++ b/cli/src/castle_cli/main.py @@ -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 diff --git a/cli/tests/conftest.py b/cli/tests/conftest.py index ac55b44..ceeb0d0 100644 --- a/cli/tests/conftest.py +++ b/cli/tests/conftest.py @@ -20,6 +20,10 @@ def castle_root(tmp_path: Path) -> Generator[Path, None, None]: "description": "Test tool", "behavior": "tool", }, + "test-daemon": { + "description": "Test daemon program", + "behavior": "daemon", + }, }, "services": { "test-svc": { diff --git a/cli/tests/test_info.py b/cli/tests/test_info.py index b9d2d7d..a46f778 100644 --- a/cli/tests/test_info.py +++ b/cli/tests/test_info.py @@ -20,7 +20,7 @@ class TestInfoCommand: from castle_cli.commands.info import run_info - result = run_info(Namespace(project="test-svc", json=False)) + result = run_info(Namespace(name="test-svc", json=False)) assert result == 0 output = capsys.readouterr().out @@ -37,7 +37,7 @@ class TestInfoCommand: from castle_cli.commands.info import run_info - result = run_info(Namespace(project="test-tool", json=False)) + result = run_info(Namespace(name="test-tool", json=False)) assert result == 0 output = capsys.readouterr().out @@ -53,7 +53,7 @@ class TestInfoCommand: from castle_cli.commands.info import run_info - result = run_info(Namespace(project="nope", json=False)) + result = run_info(Namespace(name="nope", json=False)) assert result == 1 output = capsys.readouterr().out @@ -68,7 +68,7 @@ class TestInfoCommand: from castle_cli.commands.info import run_info - result = run_info(Namespace(project="test-svc", json=True)) + result = run_info(Namespace(name="test-svc", json=True)) assert result == 0 output = capsys.readouterr().out @@ -85,7 +85,7 @@ class TestInfoCommand: from castle_cli.commands.info import run_info - result = run_info(Namespace(project="test-svc", json=False)) + result = run_info(Namespace(name="test-svc", json=False)) assert result == 0 output = capsys.readouterr().out @@ -100,7 +100,7 @@ class TestInfoCommand: from castle_cli.commands.info import run_info - result = run_info(Namespace(project="test-svc", json=False)) + result = run_info(Namespace(name="test-svc", json=False)) assert result == 0 output = capsys.readouterr().out diff --git a/cli/tests/test_list.py b/cli/tests/test_list.py index f915334..20e449b 100644 --- a/cli/tests/test_list.py +++ b/cli/tests/test_list.py @@ -28,7 +28,7 @@ class TestListCommand: assert "test-tool" in captured.out def test_list_filter_daemon(self, castle_root: Path, capsys: object) -> None: - """List filtered to daemons.""" + """--behavior filters the program catalog by its real behavior field.""" with patch("castle_cli.commands.list_cmd.load_config") as mock_load: from castle_cli.config import load_config @@ -38,8 +38,10 @@ class TestListCommand: assert result == 0 captured = capsys.readouterr() # type: ignore[attr-defined] - assert "test-svc" in captured.out + assert "test-daemon" in captured.out assert "test-tool" not in captured.out + # Services/jobs are deployment views, not behaviors — hidden under a filter. + assert "test-svc" not in captured.out def test_list_filter_tool(self, castle_root: Path, capsys: object) -> None: """List filtered to tools.""" @@ -55,22 +57,24 @@ class TestListCommand: assert "test-tool" in captured.out assert "test-svc" not in captured.out - def test_list_filter_job(self, castle_root: Path, capsys: object) -> None: - """List filtered to jobs (shown under tool behavior).""" + def test_list_jobs_are_deployments(self, castle_root: Path, capsys: object) -> None: + """Jobs are a deployment view: shown unfiltered, hidden under a behavior filter.""" with patch("castle_cli.commands.list_cmd.load_config") as mock_load: from castle_cli.config import load_config mock_load.return_value = load_config(castle_root) - args = Namespace(behavior="tool", stack=None, json=False) - result = run_list(args) - - assert result == 0 - captured = capsys.readouterr() # type: ignore[attr-defined] - assert "test-job" in captured.out - assert "test-svc" not in captured.out + # Unfiltered: the job appears. + run_list(Namespace(behavior=None, stack=None, json=False)) + assert "test-job" in capsys.readouterr().out # type: ignore[attr-defined] + # Behavior filter targets the catalog, so jobs drop out. + run_list(Namespace(behavior="tool", stack=None, json=False)) + out = capsys.readouterr().out # type: ignore[attr-defined] + assert "test-job" not in out + assert "test-svc" not in out + assert "test-tool" in out def test_list_json(self, castle_root: Path, capsys: object) -> None: - """List output as JSON.""" + """JSON output tags each entry with its kind; behavior lives on programs.""" with patch("castle_cli.commands.list_cmd.load_config") as mock_load: from castle_cli.config import load_config @@ -85,4 +89,7 @@ class TestListCommand: assert "test-svc" in names assert "test-tool" in names svc = next(p for p in data if p["name"] == "test-svc") - assert svc["behavior"] == "daemon" + assert svc["kind"] == "service" + tool = next(p for p in data if p["name"] == "test-tool") + assert tool["kind"] == "program" + assert tool["behavior"] == "tool" diff --git a/core/src/castle_core/stacks.py b/core/src/castle_core/stacks.py index 1e0323d..f6e9678 100644 --- a/core/src/castle_core/stacks.py +++ b/core/src/castle_core/stacks.py @@ -10,12 +10,12 @@ from pathlib import Path from castle_core.manifest import ProgramSpec -DEV_ACTIONS = ["build", "test", "lint", "type-check", "check", "run"] +DEV_ACTIONS = ["build", "test", "lint", "format", "type-check", "check", "run"] INSTALL_ACTIONS = ["install", "uninstall"] ALL_ACTIONS = DEV_ACTIONS + INSTALL_ACTIONS # Verbs a stack handler can provide (everything except `run`, which is declared-only). -_STACK_VERBS = {"build", "test", "lint", "type-check", "check", "install", "uninstall"} +_STACK_VERBS = {"build", "test", "lint", "format", "type-check", "check", "install", "uninstall"} # Verbs whose handler method name differs from the verb spelling. _VERB_METHOD = {"type-check": "type_check"} @@ -77,6 +77,9 @@ class StackHandler: async def lint(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult: raise NotImplementedError + async def format(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult: + raise NotImplementedError + async def type_check(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult: raise NotImplementedError @@ -133,6 +136,13 @@ class PythonHandler(StackHandler): program=name, action="lint", status="ok" if rc == 0 else "error", output=output ) + async def format(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult: + src = _source_dir(comp, root) + rc, output = await _run(["uv", "run", "ruff", "format", "."], src) + return ActionResult( + program=name, action="format", status="ok" if rc == 0 else "error", output=output + ) + async def type_check(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult: src = _source_dir(comp, root) rc, output = await _run(["uv", "run", "pyright"], src) @@ -190,6 +200,13 @@ class ReactViteHandler(StackHandler): program=name, action="lint", status="ok" if rc == 0 else "error", output=output ) + async def format(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult: + src = _source_dir(comp, root) + rc, output = await _run(["pnpm", "format"], src) + return ActionResult( + program=name, action="format", status="ok" if rc == 0 else "error", output=output + ) + async def type_check(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult: src = _source_dir(comp, root) rc, output = await _run(["pnpm", "type-check"], src)