From 5c6d8ec6f1163bfe0b9d4439f689289b459238c2 Mon Sep 17 00:00:00 2001 From: Paul Payne Date: Sun, 14 Jun 2026 16:25:34 -0700 Subject: [PATCH] refactor(cli): resource-first command surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Names collide across resource types (a program and a service can share a name), so the CLI no longer resolves bare names. Operations live under the resource they act on; platform lifecycle and the cross-resource overview stay top-level. castle program list|info|create|add|clone|delete|run|install|uninstall| build|test|lint|format|type-check|check castle service list|info|create|delete|deploy|enable|disable|start|stop|restart|logs castle job (create takes --schedule) castle gateway start|stop|reload|status castle list | status | deploy [name] # cross-cutting castle start | stop | restart # whole platform (systemd verbs) Key changes: - info/delete/list/run are resource-scoped (no more cross-section name match). - delete only removes the named resource's entry; program delete still blocks when a deployment references it. - per-resource start/stop/restart (systemctl on the unit/timer); top-level start/stop/restart act on the whole platform. - expose → 'service create' (more general: --program ref, --runner); gained 'job create' (the CLI never had one). - dropped up/down — bringing things online is the honest 'deploy && start'. Docs (CLAUDE.md, registry.md, stack guides) and user-facing messages updated. core 94 / cli 24 / api 52 green; ruff clean. Verified live: program/service/job list+info scope correctly, service create+scoped-delete round-trips. --- CLAUDE.md | 89 +-- cli/src/castle_cli/commands/delete.py | 22 +- cli/src/castle_cli/commands/deploy.py | 2 +- cli/src/castle_cli/commands/deploy_create.py | 119 ++++ cli/src/castle_cli/commands/expose.py | 99 ---- cli/src/castle_cli/commands/info.py | 12 +- cli/src/castle_cli/commands/list_cmd.py | 24 +- cli/src/castle_cli/commands/run_cmd.py | 24 +- cli/src/castle_cli/commands/service.py | 92 ++-- cli/src/castle_cli/main.py | 536 +++++++++---------- cli/tests/test_info.py | 2 +- core/src/castle_core/lifecycle.py | 2 +- docs/registry.md | 26 +- docs/stacks/python-cli.md | 6 +- docs/stacks/python-fastapi.md | 8 +- docs/stacks/react-vite.md | 4 +- 16 files changed, 556 insertions(+), 511 deletions(-) create mode 100644 cli/src/castle_cli/commands/deploy_create.py delete mode 100644 cli/src/castle_cli/commands/expose.py diff --git a/CLAUDE.md b/CLAUDE.md index 2cc35af..4b47fdc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,10 +28,10 @@ know about castle internals. A **program** is a source repo castle knows how to work with (dev verbs) and deploy. There are two ways to get one: -- **`castle create`** — scaffold *new* code from a **stack** (a creation-time - template). Stacks are guidance for how new code is written; they are NOT - required at runtime. -- **`castle add`** — adopt an *existing* repo (a local path or git URL), +- **`castle program create`** — scaffold *new* code from a **stack** (a + creation-time template). Stacks are guidance for how new code is written; they + are NOT required at runtime. +- **`castle program add`** — adopt an *existing* repo (a local path or git URL), wherever it lives. No stack needed — castle detects sensible dev-verb commands (pyproject→uv/ruff/pytest, Cargo.toml→cargo, etc.) or you declare them. @@ -50,53 +50,76 @@ Stack guides (for writing *new* code, AI-facing): ```bash # New daemon, scaffolded from a stack -castle create my-service --stack python-fastapi --description "Does something" +castle program create my-service --stack python-fastapi --description "Does something" cd /data/repos/my-service && uv sync -castle service enable my-service # register with systemd -castle gateway reload # update reverse proxy routes +castle service create my-service --program my-service --port 9001 # declare the service +castle service deploy my-service && castle service enable my-service # unit + start +castle gateway reload # update reverse proxy routes # New tool from a stack -castle create my-tool --stack python-cli --description "Does something" +castle program create my-tool --stack python-cli --description "Does something" # Adopt an existing repo (no stack required) -castle add ~/projects/some-rust-tool -castle add https://github.com/me/widget.git --name widget +castle program add ~/projects/some-rust-tool +castle program add https://github.com/me/widget.git --name widget ``` -`castle create` scaffolds under `/data/repos/` (override with `CASTLE_REPOS_DIR`) -and registers the program in `castle.yaml` with an absolute `source:`. `castle add` -registers an existing repo in place (or records its `repo:` URL for `castle clone`). +`castle program create` scaffolds under `/data/repos/` (override with +`CASTLE_REPOS_DIR`) and registers the program in `castle.yaml` with an absolute +`source:`. `castle program add` registers an existing repo in place (or records +its `repo:` URL for `castle program clone`). ## Castle CLI The CLI lives in `cli/` and is installed via `uv tool install --editable cli/`. +The CLI is **resource-first**: operations live under the resource they act on +(`program`, `service`, `job`, `gateway`). Names can collide across resource +types (a program and a service may share a name), so the resource is explicit. +Platform-wide lifecycle and the cross-resource overview are top-level. + ```bash -castle list # List all programs, services, and jobs -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 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 an individual systemd service -castle services start|stop|status # Start/stop/inspect all services together +# Programs — the software catalog +castle program list [--behavior daemon] [--stack python-cli] [--json] +castle program info [--json] +castle program create [--stack ...] [--description ...] # scaffold new +castle program add [--name ...] # adopt existing repo +castle program clone [name] # clone repo: source +castle program delete [--source] [-y] +castle program run [args...] # declared run command +castle program install|uninstall [name] # activate tools/frontends +castle program build|test|lint|format|type-check|check [name] # dev verbs + +# Services — long-running daemons +castle service list [--json] +castle service info [--json] +castle service create [--program P] [--port N] [--health ...] \ + [--path ...] [--host ...] [--port-env ...] [--runner ...] +castle service delete [-y] +castle service deploy # generate unit + route +castle service enable|disable # systemd enable/disable +castle service start|stop|restart # systemd lifecycle (one) +castle service logs [-f] [-n 50] + +# Jobs — scheduled tasks (same verbs; create takes --schedule) +castle job create [--program P] --schedule "0 2 * * *" [--runner ...] +castle job ... + +# Platform-wide (top-level) +castle list [--behavior ...] [--stack ...] [--json] # programs + services + jobs +castle status # unified status +castle deploy [name] # apply config → units + Caddyfile +castle start | stop | restart # all services (+ gateway) +castle gateway start|stop|reload|status # the Caddy gateway ``` +Bringing everything online is the two honest steps `castle deploy && castle +start` (apply config, then start) — there is no bundled `up`. + **Dev verbs** resolve per-program: a declared `commands:` entry (or `build:`) 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 -it declares its commands. Tools are reached via `castle list --behavior tool` -(the dedicated `castle tool` command was removed). +it declares its commands. Tools are reached via `castle program list --behavior tool`. ## Infrastructure diff --git a/cli/src/castle_cli/commands/delete.py b/cli/src/castle_cli/commands/delete.py index 29f14e2..3acea0c 100644 --- a/cli/src/castle_cli/commands/delete.py +++ b/cli/src/castle_cli/commands/delete.py @@ -16,13 +16,15 @@ from castle_cli.config import load_config, save_config def run_delete(args: argparse.Namespace) -> int: config = load_config() name = args.name + resource = getattr(args, "resource", None) # "program" | "service" | "job" - # Find every section the name appears in. - in_programs = name in config.programs - in_services = name in config.services - in_jobs = name in config.jobs + # Resolve which sections this delete touches (scoped to one resource). + in_programs = name in config.programs and resource in (None, "program") + in_services = name in config.services and resource in (None, "service") + in_jobs = name in config.jobs and resource in (None, "job") if not (in_programs or in_services or in_jobs): - print(f"Error: '{name}' not found in castle.yaml") + where = f" {resource}" if resource else "" + print(f"Error: no{where} '{name}' in castle.yaml") return 1 where = [s for s, present in @@ -32,8 +34,14 @@ def run_delete(args: argparse.Namespace) -> int: # named the same are removed in this call; any other referencing deployment # would be left dangling, so refuse. if in_programs: - dangling = [s for s, spec in config.services.items() if spec.program == name and s != name] - dangling += [j for j, spec in config.jobs.items() if spec.program == name and j != name] + dangling = [ + s for s, spec in config.services.items() + if spec.program == name and not (s == name and in_services) + ] + dangling += [ + j for j, spec in config.jobs.items() + if spec.program == name and not (j == name and in_jobs) + ] if dangling: print( "Error: programs with active jobs or services cannot be removed.\n" diff --git a/cli/src/castle_cli/commands/deploy.py b/cli/src/castle_cli/commands/deploy.py index 8619108..2b905ff 100644 --- a/cli/src/castle_cli/commands/deploy.py +++ b/cli/src/castle_cli/commands/deploy.py @@ -17,5 +17,5 @@ def run_deploy(args: argparse.Namespace) -> int: print(f"\nDeployed {result.deployed_count} item(s).") if result.deployed_count > 0: - print("Run 'castle services start' to start all services.") + print("Run 'castle start' to start all services.") return 0 diff --git a/cli/src/castle_cli/commands/deploy_create.py b/cli/src/castle_cli/commands/deploy_create.py new file mode 100644 index 0000000..db310f8 --- /dev/null +++ b/cli/src/castle_cli/commands/deploy_create.py @@ -0,0 +1,119 @@ +"""castle service create / castle job create — declare a deployment. + +A service or job can run anything (a castle program or not). `--program` +records a convenience reference for description fallthrough; the run target is +the console script (python) or argv (command) to execute. +""" + +from __future__ import annotations + +import argparse + +from castle_cli.config import load_config, save_config +from castle_cli.manifest import ( + CaddySpec, + ExposeSpec, + HttpExposeSpec, + HttpInternal, + JobSpec, + ManageSpec, + ProxySpec, + RunCommand, + RunPython, + ServiceSpec, + SystemdSpec, +) + + +def _run_spec(runner: str, target: str, name: str) -> RunPython | RunCommand: + if runner == "command": + return RunCommand(runner="command", argv=target.split() or [name]) + return RunPython(runner="python", program=target or name) + + +def _check_new(config: object, name: str, section: str) -> str | None: + """Return an error message if the name can't be created, else None.""" + existing = getattr(config, section) + if name in existing: + return f"Error: {section[:-1]} '{name}' already exists." + return None + + +def run_service_create(args: argparse.Namespace) -> int: + """Create a service entry in castle.yaml.""" + config = load_config() + name = args.name + if err := _check_new(config, name, "services"): + print(err) + return 1 + + run = _run_spec(args.runner, args.run or args.program or name, name) + + expose = None + proxy = None + if args.port is not None: + expose = ExposeSpec( + http=HttpExposeSpec( + internal=HttpInternal(port=args.port, port_env=args.port_env), + health_path=args.health, + ) + ) + if not args.no_proxy: + path = args.path + if args.path: + path = args.path if args.path.startswith("/") else f"/{args.path}" + elif args.host: + path = None + else: + path = f"/{name}" + proxy = ProxySpec(caddy=CaddySpec(path_prefix=path, host=args.host)) + + config.services[name] = ServiceSpec( + id=name, + program=args.program, + description=args.description or None, + run=run, + expose=expose, + proxy=proxy, + manage=ManageSpec(systemd=SystemdSpec()), + ) + save_config(config) + + print(f"Created service '{name}'.") + print(f" runs: {args.runner} ({args.run or args.program or name})") + if expose: + print(f" port: {args.port}" + (f" (env: {args.port_env})" if args.port_env else "")) + if proxy and proxy.caddy: + if proxy.caddy.path_prefix: + print(f" proxy: {proxy.caddy.path_prefix}") + if proxy.caddy.host: + print(f" host: {proxy.caddy.host}") + print(f"\nNext: castle service deploy {name} && castle service start {name}") + return 0 + + +def run_job_create(args: argparse.Namespace) -> int: + """Create a job entry in castle.yaml.""" + config = load_config() + name = args.name + if err := _check_new(config, name, "jobs"): + print(err) + return 1 + + run = _run_spec(args.runner, args.run or args.program or name, name) + + config.jobs[name] = JobSpec( + id=name, + program=args.program, + description=args.description or None, + run=run, + schedule=args.schedule, + manage=ManageSpec(systemd=SystemdSpec()), + ) + save_config(config) + + print(f"Created job '{name}'.") + print(f" runs: {args.runner} ({args.run or args.program or name})") + print(f" schedule: {args.schedule}") + print(f"\nNext: castle job deploy {name} && castle job enable {name}") + return 0 diff --git a/cli/src/castle_cli/commands/expose.py b/cli/src/castle_cli/commands/expose.py deleted file mode 100644 index cdae0bb..0000000 --- a/cli/src/castle_cli/commands/expose.py +++ /dev/null @@ -1,99 +0,0 @@ -"""castle expose — turn an existing program into a service. - -`castle add` adopts source as a program; `castle expose` declares how to *run* -that program as a long-running systemd service (port, health, proxy). It fills -the gap where adopting a daemon left you with a program but no way to run it. -""" - -from __future__ import annotations - -import argparse -from pathlib import Path - -from castle_cli.config import load_config, save_config -from castle_cli.manifest import ( - CaddySpec, - ExposeSpec, - HttpExposeSpec, - HttpInternal, - ManageSpec, - ProxySpec, - RunCommand, - RunPython, - ServiceSpec, - SystemdSpec, -) - - -def _is_python(program: object) -> bool: - """Whether the program runs as a python console script (uv-installed).""" - stack = getattr(program, "stack", None) - if stack and stack.startswith("python"): - return True - source = getattr(program, "source", None) - return bool(source and (Path(source) / "pyproject.toml").exists()) - - -def run_expose(args: argparse.Namespace) -> int: - """Create a service entry that runs an existing program.""" - config = load_config() - name = args.name - - program = config.programs.get(name) - if program is None: - print(f"Error: no program '{name}'. Adopt it first with 'castle add', then expose it.") - return 1 - if name in config.services or name in config.jobs: - print(f"Error: '{name}' is already a service or job.") - return 1 - - run_script = args.run or name - run = ( - RunPython(runner="python", program=run_script) - if _is_python(program) - else RunCommand(runner="command", argv=[run_script]) - ) - - expose = None - proxy = None - if args.port is not None: - expose = ExposeSpec( - http=HttpExposeSpec( - internal=HttpInternal(port=args.port, port_env=args.port_env), - health_path=args.health, - ) - ) - host = getattr(args, "host", None) - if not args.no_proxy: - # A path prefix, a hostname, or both. With a host but no explicit - # path, route by host only (root-based apps serve unchanged). - if args.path: - prefix = args.path if args.path.startswith("/") else "/" + args.path - elif host: - prefix = None - else: - prefix = f"/{name}" - proxy = ProxySpec(caddy=CaddySpec(path_prefix=prefix, host=host)) - - config.services[name] = ServiceSpec( - id=name, - program=name, - run=run, - expose=expose, - proxy=proxy, - manage=ManageSpec(systemd=SystemdSpec()), - ) - save_config(config) - - print(f"Exposed '{name}' as a service.") - print(f" run: {run.runner} ({run_script})") - if expose: - print(f" port: {args.port}" + (f" (env: {args.port_env})" if args.port_env else "")) - print(f" health: {args.health}") - if proxy and proxy.caddy: - if proxy.caddy.path_prefix: - print(f" proxy: {proxy.caddy.path_prefix}") - if proxy.caddy.host: - print(f" host: {proxy.caddy.host}") - print("\nNext: castle deploy " + name + " && castle service enable " + name) - return 0 diff --git a/cli/src/castle_cli/commands/info.py b/cli/src/castle_cli/commands/info.py index 93b75f3..83641d8 100644 --- a/cli/src/castle_cli/commands/info.py +++ b/cli/src/castle_cli/commands/info.py @@ -32,14 +32,16 @@ def run_info(args: argparse.Namespace) -> int: """Show detailed info for a program, service, or job.""" config = load_config() name = args.name + resource = getattr(args, "resource", None) - # Look up in all sections - program = config.programs.get(name) - service = config.services.get(name) - job = config.jobs.get(name) + # Look up in the requested section (or all, when unscoped). + program = config.programs.get(name) if resource in (None, "program") else None + service = config.services.get(name) if resource in (None, "service") else None + job = config.jobs.get(name) if resource in (None, "job") else None if not program and not service and not job: - print(f"Error: '{name}' not found in castle.yaml") + where = f" {resource}" if resource else "" + print(f"Error: no{where} '{name}' in castle.yaml") return 1 deployed = _load_deployed_program(name) diff --git a/cli/src/castle_cli/commands/list_cmd.py b/cli/src/castle_cli/commands/list_cmd.py index ba60a38..770e139 100644 --- a/cli/src/castle_cli/commands/list_cmd.py +++ b/cli/src/castle_cli/commands/list_cmd.py @@ -71,6 +71,7 @@ def run_list(args: argparse.Namespace) -> int: filter_behavior = getattr(args, "behavior", 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_behavior, filter_stack) @@ -81,12 +82,16 @@ def run_list(args: argparse.Namespace) -> int: any_output = False # 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) - } + 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 resource in (None, "program") + else {} + ) if progs: any_output = True print(f"\n{BOLD}{CYAN}Programs{RESET}") @@ -100,8 +105,8 @@ def run_list(args: argparse.Namespace) -> int: 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: + # when no behavior filter is applied. Each gated by its own resource scope. + if not filter_behavior and resource in (None, "service"): services = _filter_by_stack(config.services, config, filter_stack) if services: any_output = True @@ -117,6 +122,7 @@ def run_list(args: argparse.Namespace) -> int: 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_behavior and resource in (None, "job"): jobs = _filter_by_stack(config.jobs, config, filter_stack) if jobs: any_output = True @@ -128,7 +134,7 @@ def run_list(args: argparse.Namespace) -> int: print(f" {dot(name)} {BOLD}{name}{RESET}{sched}{desc}") if not any_output: - print("No programs found.") + print(f"No {resource or 'program'}s found.") print() return 0 diff --git a/cli/src/castle_cli/commands/run_cmd.py b/cli/src/castle_cli/commands/run_cmd.py index ce70513..a5ede82 100644 --- a/cli/src/castle_cli/commands/run_cmd.py +++ b/cli/src/castle_cli/commands/run_cmd.py @@ -44,24 +44,32 @@ def _run_program(name: str, extra: list[str]) -> int | None: def run_run(args: argparse.Namespace) -> int: - """Run a program (declared run) or a deployed service in the foreground.""" + """Run a program's declared run, or a deployed service, in the foreground. + + Scoped by `resource`: `program run` runs the program's declared `run`; + `service run` runs the deployed service's command from the registry. + """ name = args.name extra_args = getattr(args, "extra", []) or [] + resource = getattr(args, "resource", None) - # 1. Program with a declared run command. - prog_rc = _run_program(name, extra_args) - if prog_rc is not None: - return prog_rc + # Program: declared run command. + if resource in (None, "program"): + prog_rc = _run_program(name, extra_args) + if prog_rc is not None: + return prog_rc + if resource == "program": + print(f"Error: program '{name}' has no declared `run` command.") + return 1 - # 2. Deployed service from the registry. + # Service: deployed command from the registry. if not REGISTRY_PATH.exists(): print("Error: no registry found. Run 'castle deploy' first.") return 1 registry = load_registry() if name not in registry.deployed: - print(f"Error: '{name}' is not a runnable program or deployed service.") - print("Declare a `run` command in castle.yaml, or run 'castle deploy'.") + print(f"Error: '{name}' is not a deployed service. Run 'castle deploy' first.") return 1 deployed = registry.deployed[name] diff --git a/cli/src/castle_cli/commands/service.py b/cli/src/castle_cli/commands/service.py index 09365dd..67652ea 100644 --- a/cli/src/castle_cli/commands/service.py +++ b/cli/src/castle_cli/commands/service.py @@ -1,4 +1,4 @@ -"""castle service / castle services - manage systemd service units.""" +"""castle service / castle job — manage systemd service & timer units.""" from __future__ import annotations @@ -40,56 +40,73 @@ def _remove_unit(uname: str) -> None: subprocess.run(["systemctl", "--user", "daemon-reload"], check=False) -def run_service(args: argparse.Namespace) -> int: - """Manage individual services.""" - if not args.service_command: - print("Usage: castle service {enable|disable}") - return 1 +_PAST = {"start": "started", "stop": "stopped", "restart": "restarted"} + +def run_service_cmd(args: argparse.Namespace) -> int: + """`castle service `.""" + sub = args.service_command config = load_config() - - if args.service_command == "enable": + if sub == "enable": if getattr(args, "dry_run", False): return _service_dry_run(config, args.name) return _service_enable(config, args.name) - elif args.service_command == "disable": + if sub == "disable": return _service_disable(args.name) - + if sub in ("start", "stop", "restart"): + return _unit_action(config, args.name, sub, is_job=False) return 1 -def run_services(args: argparse.Namespace) -> int: - """Manage all services together.""" - if not args.services_command: - print("Usage: castle services {start|stop|status}") - return 1 - +def run_job_cmd(args: argparse.Namespace) -> int: + """`castle job ` (acts on the timer).""" + sub = args.job_command config = load_config() + if sub == "enable": + return _service_enable(config, args.name) # enable_service handles timers + if sub == "disable": + return _service_disable(args.name) + if sub in ("start", "stop", "restart"): + return _unit_action(config, args.name, sub, is_job=True) + return 1 - if args.services_command == "start": + +def run_platform(args: argparse.Namespace) -> int: + """Top-level `castle start|stop|restart` — the whole platform.""" + config = load_config() + action = args.command + if action == "start": return _services_start(config) - elif args.services_command == "stop": + if action == "stop": return _services_stop(config) - elif args.services_command == "status": - return _service_status(config) - + if action == "restart": + return _services_restart(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.") +def _unit_action(config: CastleConfig, name: str, action: str, is_job: bool) -> int: + """systemctl start/stop/restart one service (unit) or job (timer).""" + section = config.jobs if is_job else config.services + if name not in section: + print(f"Error: no {'job' if is_job else 'service'} '{name}'.") 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) + unit = timer_name(name) if is_job else unit_name(name) + result = subprocess.run(["systemctl", "--user", action, unit], check=False) if result.returncode != 0: - print(f"Error: failed to restart {unit}") + print(f"Error: failed to {action} {unit}") return 1 - print(f" {name}: restarted") + print(f" {name}: {_PAST[action]}") + return 0 + + +def _services_restart(config: CastleConfig) -> int: + """Restart every managed service and job unit.""" + for name in config.jobs: + subprocess.run(["systemctl", "--user", "restart", timer_name(name)], check=False) + print(f" {name}: restarted (timer)") + for name in config.services: + subprocess.run(["systemctl", "--user", "restart", unit_name(name)], check=False) + print(f" {name}: restarted") return 0 @@ -120,17 +137,6 @@ def run_status(args: argparse.Namespace) -> int: 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 d898d93..0de0903 100644 --- a/cli/src/castle_cli/main.py +++ b/cli/src/castle_cli/main.py @@ -1,4 +1,11 @@ -"""Castle CLI entry point.""" +"""Castle CLI entry point — resource-first command surface. + +Operations live under the resource they act on (`program`, `service`, `job`, +`gateway`); platform-wide lifecycle (`start`/`stop`/`restart`/`status`/`deploy`) +and the cross-resource `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 @@ -7,182 +14,244 @@ import sys from castle_cli import __version__ +DEV_VERBS = ["build", "test", "lint", "format", "type-check", "check"] + + +def _add_name(p: argparse.ArgumentParser, help: str = "Name", optional: bool = False) -> None: + p.add_argument("name", nargs="?" if optional else None, help=help) + + +def _build_program_group(subparsers: argparse._SubParsersAction) -> None: + prog = subparsers.add_parser("program", help="Manage programs (the software catalog)") + prog.set_defaults(resource="program") + sub = prog.add_subparsers(dest="program_command") + + p = sub.add_parser("list", help="List programs") + p.add_argument("--behavior", choices=["daemon", "tool", "frontend"], help="Filter by behavior") + p.add_argument("--stack", help="Filter by stack") + p.add_argument("--json", action="store_true", help="Output as JSON") + + p = sub.add_parser("info", help="Show program details") + _add_name(p, "Program name") + p.add_argument("--json", action="store_true", help="Output as JSON") + + p = sub.add_parser("create", help="Scaffold a new program") + _add_name(p, "Program name") + p.add_argument("--stack", choices=["python-cli", "python-fastapi", "react-vite"], default=None) + p.add_argument("--description", default="", help="Program description") + p.add_argument("--port", type=int, help="Port (daemons only)") + + 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("--name", help="Program name (default: dir/repo name)") + p.add_argument("--description", default="", help="Program description") + + p = sub.add_parser("clone", help="Clone source for programs with repo:") + _add_name(p, "Program to clone (default: all with repo:)", optional=True) + + p = sub.add_parser("delete", help="Remove a program from castle.yaml") + _add_name(p, "Program name") + p.add_argument("--source", action="store_true", help="Also delete the source directory") + p.add_argument("-y", "--yes", action="store_true", help="Skip confirmation") + + p = sub.add_parser("run", help="Run a program's declared run command") + _add_name(p, "Program name") + 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( + "name", nargs="?", help="Program (default: all)" + ) + sub.add_parser("uninstall", help="Deactivate a program").add_argument( + "name", nargs="?", help="Program" + ) + + for verb in DEV_VERBS: + p = sub.add_parser(verb, help=f"Run {verb}") + _add_name(p, "Program (default: all)", optional=True) + + +def _add_service_create(sub: argparse._SubParsersAction, kind: str) -> None: + p = sub.add_parser("create", help=f"Create a {kind} in castle.yaml") + _add_name(p, f"{kind.capitalize()} name") + p.add_argument("--program", help="Program this deployment runs (convenience ref)") + p.add_argument("--description", default="", help="Description") + p.add_argument("--run", help="Console script / command to run (default: --program or name)") + p.add_argument("--runner", choices=["python", "command"], default="python") + if kind == "service": + p.add_argument("--port", type=int, help="HTTP port") + p.add_argument("--health", default="/health", help="Health path (default: /health)") + p.add_argument("--path", help="Gateway proxy prefix (default: /)") + p.add_argument("--host", help="Route by hostname instead of a path prefix") + p.add_argument("--port-env", help="Env var the program reads for its port") + p.add_argument("--no-proxy", action="store_true", help="Don't add a gateway route") + else: + p.add_argument("--schedule", default="0 2 * * *", help="Cron schedule (default: 0 2 * * *)") + + +def _build_deployment_group(subparsers: argparse._SubParsersAction, kind: str) -> None: + """Build the `service` or `job` group (shared verb set).""" + grp = subparsers.add_parser(kind, help=f"Manage {kind}s") + grp.set_defaults(resource=kind) + sub = grp.add_subparsers(dest=f"{kind}_command") + + p = sub.add_parser("list", help=f"List {kind}s") + p.add_argument("--json", action="store_true", help="Output as JSON") + + p = sub.add_parser("info", help=f"Show {kind} details") + _add_name(p, f"{kind.capitalize()} name") + p.add_argument("--json", action="store_true", help="Output as JSON") + + _add_service_create(sub, kind) + + p = sub.add_parser("delete", help=f"Remove a {kind} from castle.yaml") + _add_name(p, f"{kind.capitalize()} name") + p.add_argument("-y", "--yes", action="store_true", help="Skip confirmation") + p.add_argument("--source", action="store_true", help=argparse.SUPPRESS) + + cap = f"{kind.capitalize()} name" + _add_name(sub.add_parser("deploy", help=f"Deploy this {kind} (unit + gateway)"), cap) + + p = sub.add_parser("enable", help=f"Enable and start the {kind}") + _add_name(p, cap) + if kind == "service": + p.add_argument("--dry-run", action="store_true", help="Print the unit without installing") + _add_name(sub.add_parser("disable", help=f"Stop and disable the {kind}"), cap) + _add_name(sub.add_parser("start", help=f"Start the {kind}"), cap) + _add_name(sub.add_parser("stop", help=f"Stop the {kind}"), cap) + _add_name(sub.add_parser("restart", help=f"Restart the {kind}"), cap) + + p = sub.add_parser("logs", help=f"View {kind} logs") + _add_name(p, f"{kind.capitalize()} name") + p.add_argument("-f", "--follow", action="store_true", help="Follow log output") + p.add_argument("-n", "--lines", type=int, default=50, help="Lines to show (default: 50)") + def build_parser() -> argparse.ArgumentParser: - """Build the argument parser.""" parser = argparse.ArgumentParser( prog="castle", - description="Castle platform CLI - manage projects, services, and infrastructure", + description="Castle platform CLI — programs, services, jobs, and infrastructure", ) parser.add_argument("--version", action="version", version=f"castle {__version__}") - subparsers = parser.add_subparsers(dest="command", help="Available commands") - # castle list - list_parser = subparsers.add_parser("list", help="List all programs, services, and jobs") - list_parser.add_argument( - "--behavior", - choices=["daemon", "tool", "frontend"], - help="Filter by behavior", - ) - list_parser.add_argument( - "--stack", - help="Filter by stack (e.g. python-cli, python-fastapi, react-vite)", - ) - list_parser.add_argument("--json", action="store_true", help="Output as JSON") + _build_program_group(subparsers) + _build_deployment_group(subparsers, "service") + _build_deployment_group(subparsers, "job") - # castle create - create_parser = subparsers.add_parser("create", help="Create a new project") - create_parser.add_argument("name", help="Project name") - create_parser.add_argument( - "--stack", - choices=["python-cli", "python-fastapi", "react-vite"], - default=None, - help="Development stack (scaffold template + default behavior). Omit for a bare program.", - ) - create_parser.add_argument("--description", default="", help="Project description") - create_parser.add_argument("--port", type=int, help="Port number (daemons only)") + # Gateway (infrastructure) + gw = subparsers.add_parser("gateway", help="Manage the Caddy gateway") + gw_sub = gw.add_subparsers(dest="gateway_command") + p = gw_sub.add_parser("start", help="Start the gateway") + p.add_argument("--dry-run", action="store_true") + gw_sub.add_parser("stop", help="Stop the gateway") + p = gw_sub.add_parser("reload", help="Reload gateway configuration") + p.add_argument("--dry-run", action="store_true") + gw_sub.add_parser("status", help="Show gateway status") - # castle add — adopt an existing repo as a program - add_parser = subparsers.add_parser("add", help="Adopt an existing repo (path or git URL)") - add_parser.add_argument("target", help="Local path to an existing repo, or a git URL to clone") - add_parser.add_argument("--name", help="Program name (default: directory/repo name)") - add_parser.add_argument("--description", default="", help="Program description") + # Platform-wide lifecycle (top-level) + subparsers.add_parser("start", help="Start all services and the gateway") + subparsers.add_parser("stop", help="Stop all services and the gateway") + subparsers.add_parser("restart", help="Restart all services and jobs") + subparsers.add_parser("status", help="Show status across the platform") + p = subparsers.add_parser("deploy", help="Apply config to runtime (units + Caddyfile)") + p.add_argument("name", nargs="?", help="Service/job to deploy (default: all)") - # castle clone — clone repos for programs that declare repo: - clone_parser = subparsers.add_parser("clone", help="Clone source for programs with repo:") - clone_parser.add_argument("name", nargs="?", help="Program to clone (default: all with repo:)") - - # castle expose — turn an existing program into a service - expose_parser = subparsers.add_parser("expose", help="Run an existing program as a service") - expose_parser.add_argument("name", help="Program to expose") - expose_parser.add_argument("--port", type=int, help="HTTP port the program binds") - expose_parser.add_argument("--health", default="/health", help="Health path (default: /health)") - expose_parser.add_argument("--path", help="Gateway proxy prefix (default: /)") - expose_parser.add_argument( - "--host", help="Route by hostname instead of a path prefix (e.g. lakehouse.civil.lan)" - ) - expose_parser.add_argument("--run", help="Console script / command to run (default: )") - expose_parser.add_argument( - "--port-env", help="Env var the program reads for its port (e.g. LAKEHOUSED_DAEMON_PORT)" - ) - expose_parser.add_argument( - "--no-proxy", action="store_true", help="Don't add a gateway route" - ) - - # castle delete — remove a program/service/job from the registry - delete_parser = subparsers.add_parser("delete", help="Remove a program/service/job") - delete_parser.add_argument("name", help="Program, service, or job name") - delete_parser.add_argument( - "--source", action="store_true", help="Also delete the source directory" - ) - delete_parser.add_argument("-y", "--yes", action="store_true", help="Skip confirmation") - - # castle info - info_parser = subparsers.add_parser("info", help="Show program details") - 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("name", nargs="?", help="Program to test (default: all)") - - # castle lint - lint_parser = subparsers.add_parser("lint", help="Run linter") - 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 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("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("name", nargs="?", help="Program to check (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") - gateway_sub = gateway_parser.add_subparsers(dest="gateway_command") - gw_start = gateway_sub.add_parser("start", help="Start the gateway") - gw_start.add_argument( - "--dry-run", action="store_true", help="Print generated config without applying" - ) - gateway_sub.add_parser("stop", help="Stop the gateway") - gw_reload = gateway_sub.add_parser("reload", help="Reload gateway configuration") - gw_reload.add_argument( - "--dry-run", action="store_true", help="Print generated config without applying" - ) - gateway_sub.add_parser("status", help="Show gateway status") - - # castle service (singular - manage individual services) - service_parser = subparsers.add_parser("service", help="Manage a service") - service_sub = service_parser.add_subparsers(dest="service_command") - enable_parser = service_sub.add_parser("enable", help="Enable and start a service") - enable_parser.add_argument("name", help="Service name") - enable_parser.add_argument( - "--dry-run", action="store_true", help="Print generated unit without installing" - ) - disable_parser = service_sub.add_parser("disable", help="Stop and disable a service") - disable_parser.add_argument("name", help="Service name") - - # 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") - logs_parser.add_argument("name", help="Service or job name") - logs_parser.add_argument("-f", "--follow", action="store_true", help="Follow log output") - logs_parser.add_argument( - "-n", "--lines", type=int, default=50, help="Number of lines to show (default: 50)" - ) - - # castle run — run a program (declared run) or deployed service in the foreground - run_parser = subparsers.add_parser("run", help="Run a program or service in the foreground") - run_parser.add_argument("name", help="Program or service name") - run_parser.add_argument( - "extra", nargs=argparse.REMAINDER, help="Extra arguments passed to the target" - ) - - # castle deploy - deploy_parser = subparsers.add_parser("deploy", help="Deploy to ~/.castle/ (spec → runtime)") - deploy_parser.add_argument("name", nargs="?", help="Service or job to deploy (default: all)") + # Cross-resource overview + p = subparsers.add_parser("list", help="List programs, services, and jobs") + p.add_argument("--behavior", choices=["daemon", "tool", "frontend"], help="Filter by behavior") + p.add_argument("--stack", help="Filter by stack") + p.add_argument("--json", action="store_true", help="Output as JSON") return parser +def _dispatch_program(args: argparse.Namespace) -> int: + sub = args.program_command + if not sub: + verbs = "list|info|create|add|clone|delete|run|install|uninstall|" + "|".join(DEV_VERBS) + print(f"Usage: castle program {{{verbs}}}") + return 1 + if sub == "list": + from castle_cli.commands.list_cmd import run_list + + return run_list(args) + if sub == "info": + from castle_cli.commands.info import run_info + + return run_info(args) + if sub == "create": + from castle_cli.commands.create import run_create + + return run_create(args) + if sub == "add": + from castle_cli.commands.add import run_add + + return run_add(args) + if sub == "clone": + from castle_cli.commands.clone import run_clone + + return run_clone(args) + if sub == "delete": + from castle_cli.commands.delete import run_delete + + return run_delete(args) + if sub == "run": + from castle_cli.commands.run_cmd import run_run + + return run_run(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) + if sub in DEV_VERBS: + from castle_cli.commands.dev import run_verb + + return run_verb(args, sub) + return 1 + + +def _dispatch_deployment(args: argparse.Namespace, kind: str) -> int: + sub = getattr(args, f"{kind}_command") + if not sub: + verbs = "list|info|create|delete|deploy|enable|disable|start|stop|restart|logs" + print(f"Usage: castle {kind} {{{verbs}}}") + return 1 + if sub == "list": + from castle_cli.commands.list_cmd import run_list + + return run_list(args) + if sub == "info": + from castle_cli.commands.info import run_info + + return run_info(args) + if sub == "create": + from castle_cli.commands.deploy_create import run_job_create, run_service_create + + return run_service_create(args) if kind == "service" else run_job_create(args) + if sub == "delete": + from castle_cli.commands.delete import run_delete + + return run_delete(args) + if sub == "deploy": + from castle_cli.commands.deploy import run_deploy + + return run_deploy(args) + if sub in ("enable", "disable", "start", "stop", "restart"): + from castle_cli.commands.service import run_job_cmd, run_service_cmd + + return run_service_cmd(args) if kind == "service" else run_job_cmd(args) + if sub == "logs": + from castle_cli.commands.logs import run_logs + + return run_logs(args) + return 1 + + def main() -> int: - """Main entry point.""" parser = build_parser() args = parser.parse_args() @@ -190,134 +259,37 @@ def main() -> int: parser.print_help() return 0 - # Import command handlers lazily to keep startup fast - if args.command == "list": + cmd = args.command + if cmd == "program": + return _dispatch_program(args) + if cmd in ("service", "job"): + return _dispatch_deployment(args, cmd) + if cmd == "gateway": + from castle_cli.commands.gateway import run_gateway + + return run_gateway(args) + if cmd in ("start", "stop", "restart"): + from castle_cli.commands.service import run_platform + + return run_platform(args) + if cmd == "status": + from castle_cli.commands.service import run_status + + return run_status(args) + if cmd == "deploy": + from castle_cli.commands.deploy import run_deploy + + return run_deploy(args) + if cmd == "list": from castle_cli.commands.list_cmd import run_list return run_list(args) - elif args.command == "info": - from castle_cli.commands.info import run_info - - return run_info(args) - - elif args.command == "create": - from castle_cli.commands.create import run_create - - return run_create(args) - - elif args.command == "add": - from castle_cli.commands.add import run_add - - return run_add(args) - - elif args.command == "clone": - from castle_cli.commands.clone import run_clone - - return run_clone(args) - - elif args.command == "expose": - from castle_cli.commands.expose import run_expose - - return run_expose(args) - - elif args.command == "delete": - from castle_cli.commands.delete import run_delete - - return run_delete(args) - - elif args.command == "test": - from castle_cli.commands.dev import run_test - - return run_test(args) - - elif args.command == "lint": - from castle_cli.commands.dev import run_lint - - 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 - - return run_build(args) - - elif args.command == "type-check": - from castle_cli.commands.dev import run_type_check - - return run_type_check(args) - - elif args.command == "check": - from castle_cli.commands.dev import run_check - - return run_check(args) - - elif args.command == "install": - from castle_cli.commands.dev import run_install - - return run_install(args) - - elif args.command == "uninstall": - from castle_cli.commands.dev import run_uninstall - - return run_uninstall(args) - - elif args.command == "gateway": - from castle_cli.commands.gateway import run_gateway - - return run_gateway(args) - - elif args.command == "service": - from castle_cli.commands.service import run_service - - return run_service(args) - - elif args.command == "services": - from castle_cli.commands.service import run_services - - 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 - - return run_logs(args) - - elif args.command == "deploy": - from castle_cli.commands.deploy import run_deploy - - return run_deploy(args) - - elif args.command == "run": - from castle_cli.commands.run_cmd import run_run - - return run_run(args) - - else: - parser.print_help() - return 1 + parser.print_help() + return 1 def cli() -> None: - """Entry point for the CLI.""" sys.exit(main()) diff --git a/cli/tests/test_info.py b/cli/tests/test_info.py index a46f778..6aafa2d 100644 --- a/cli/tests/test_info.py +++ b/cli/tests/test_info.py @@ -57,7 +57,7 @@ class TestInfoCommand: assert result == 1 output = capsys.readouterr().out - assert "not found" in output + assert "nope" in output def test_info_json_service(self, castle_root: Path, capsys) -> None: """--json produces valid JSON with service fields.""" diff --git a/core/src/castle_core/lifecycle.py b/core/src/castle_core/lifecycle.py index 57937cd..01e4a79 100644 --- a/core/src/castle_core/lifecycle.py +++ b/core/src/castle_core/lifecycle.py @@ -163,7 +163,7 @@ async def activate(name: str, config: CastleConfig, root: Path) -> ActionResult: "activate", "error", f"'{name}' is a daemon with no service. Run " - f"'castle expose {name} --port ' to deploy it as a service.", + f"'castle service create {name} --program {name} --port ' to deploy it.", ) # Tool: install to PATH. diff --git a/docs/registry.md b/docs/registry.md index a6df551..77e34e7 100644 --- a/docs/registry.md +++ b/docs/registry.md @@ -157,9 +157,9 @@ repo: https://github.com/me/widget.git ref: v2.1.0 # optional branch/tag/commit ``` -`repo` records a git URL so `castle clone` can provision the source on a fresh +`repo` records a git URL so `castle program clone` can provision the source on a fresh machine. When `source:` points at an existing working copy, that takes -precedence. Use `castle add ` to register an existing repo as a program. +precedence. Use `castle program add ` to register an existing repo as a program. ### `system_dependencies` — Required system packages @@ -240,7 +240,7 @@ manage: systemd: {} ``` -Enables `castle service enable/disable` and `castle logs`. An empty `{}` +Enables `castle service enable/disable` and `castle service logs`. An empty `{}` uses defaults (enable=true, restart=on-failure, restart_sec=2). Full options: @@ -292,7 +292,7 @@ semantics as services. Every program's source lives in one place — `~/.castle/code//`. It can arrive there a few ways: -1. **Scaffold a new one** with `castle create` — writes the project into +1. **Scaffold a new one** with `castle program create` — writes the project into `~/.castle/code//` and registers it in `castle.yaml` with `source: code/`. 2. **Clone an existing project** — `git clone ~/.castle/code/`, @@ -310,14 +310,14 @@ inside the castle git repo and are referenced with `source: repo:`. ## Registering a new program -### Via `castle create` (recommended) +### Via `castle program create` (recommended) ```bash # Service — scaffolds into ~/.castle/code/, assigns port, registers in castle.yaml -castle create my-service --stack python-fastapi --description "Does something" +castle program create my-service --stack python-fastapi --description "Does something" # Tool — scaffolds into ~/.castle/code/ -castle create my-tool --stack python-cli --description "Does something" +castle program create my-tool --stack python-cli --description "Does something" ``` ### Manually @@ -363,10 +363,10 @@ services: ### Service lifecycle ```bash -castle create my-service --stack python-fastapi # 1. Scaffold + register +castle program create my-service --stack python-fastapi # 1. Scaffold + register cd ~/.castle/code/my-service && uv sync # 2. Install deps # ... implement ... -castle test my-service # 3. Run tests +castle program test my-service # 3. Run tests castle service enable my-service # 4. Generate systemd unit, start castle gateway reload # 5. Update Caddy routes ``` @@ -376,17 +376,17 @@ on failure. Manage with: ```bash castle logs my-service -f # Tail logs -castle run my-service # Run in foreground (for debugging) +castle service run my-service # Run in foreground (for debugging) castle service disable my-service # Stop and remove systemd unit ``` ### Tool lifecycle ```bash -castle create my-tool --stack python-cli # 1. Scaffold + register +castle program create my-tool --stack python-cli # 1. Scaffold + register cd ~/.castle/code/my-tool && uv sync # 2. Install deps # ... implement ... -castle test my-tool # 3. Run tests +castle program test my-tool # 3. Run tests uv tool install --editable ~/.castle/code/my-tool/ # 4. Install to PATH ``` @@ -406,7 +406,7 @@ jobs: systemd: {} ``` -`castle service enable my-job` generates both a `.service` (Type=oneshot) +`castle job enable my-job` generates both a `.service` (Type=oneshot) and a `.timer` file. ## Infrastructure paths diff --git a/docs/stacks/python-cli.md b/docs/stacks/python-cli.md index 3fc33dc..868b8c1 100644 --- a/docs/stacks/python-cli.md +++ b/docs/stacks/python-cli.md @@ -1,9 +1,9 @@ # Python Tools in Castle > **This is a stack — creation-time guidance for writing _new_ CLI tools.** -> A stack is a template + conventions, not a runtime requirement. `castle create +> A stack is a template + conventions, not a runtime requirement. `castle program create > --stack python-cli` scaffolds from it and seeds the program's default dev-verb -> commands. An existing CLI adopted with `castle add` doesn't need this stack — it +> commands. An existing CLI adopted with `castle program add` doesn't need this stack — it > declares its own `commands:`. See @docs/registry.md for `commands:`, > `stack:` (optional), and `repo:`. @@ -49,7 +49,7 @@ Examples: `code/pdf2md/`, `code/gpt/`, `code/protonmail/` ## Creating a new tool ```bash -castle create my-tool --stack python-cli --description "Does something" +castle program create my-tool --stack python-cli --description "Does something" cd ~/.castle/code/my-tool && uv sync ``` diff --git a/docs/stacks/python-fastapi.md b/docs/stacks/python-fastapi.md index 125e9cd..c93a71b 100644 --- a/docs/stacks/python-fastapi.md +++ b/docs/stacks/python-fastapi.md @@ -1,9 +1,9 @@ # Web APIs in Castle > **This is a stack — creation-time guidance for writing _new_ FastAPI services.** -> A stack is a template + conventions, not a runtime requirement. `castle create +> A stack is a template + conventions, not a runtime requirement. `castle program create > --stack python-fastapi` scaffolds from it and seeds the program's default -> dev-verb commands. An existing service adopted with `castle add` doesn't need +> dev-verb commands. An existing service adopted with `castle program add` doesn't need > this stack — it declares its own `commands:`. See @docs/registry.md > for `commands:`, `stack:` (optional), and `repo:`. @@ -442,10 +442,10 @@ uv run ruff format . # Format ## Scaffolding -`castle create` generates all of this automatically: +`castle program create` generates all of this automatically: ```bash -castle create my-service --stack python-fastapi --description "Does something useful" +castle program create my-service --stack python-fastapi --description "Does something useful" ``` See @docs/registry.md for manifest fields, castle.yaml structure, diff --git a/docs/stacks/react-vite.md b/docs/stacks/react-vite.md index 1a7a992..04309c1 100644 --- a/docs/stacks/react-vite.md +++ b/docs/stacks/react-vite.md @@ -1,9 +1,9 @@ # Web Frontends in Castle > **This is a stack — creation-time guidance for writing _new_ frontends.** -> A stack is a template + conventions, not a runtime requirement. `castle create +> A stack is a template + conventions, not a runtime requirement. `castle program create > --stack react-vite` scaffolds from it and seeds the program's default dev-verb -> commands. An existing frontend adopted with `castle add` doesn't need this +> commands. An existing frontend adopted with `castle program add` doesn't need this > stack — it declares its own `commands:`. See @docs/registry.md for > `commands:`, `stack:` (optional), and `repo:`.