Less stack-centric and location-centric model.

This commit is contained in:
2026-06-13 17:26:49 -07:00
parent 7bf98c17b7
commit 400e0b253b
33 changed files with 1112 additions and 408 deletions

View File

@@ -23,9 +23,23 @@ description fallthrough.
configuration (data dir, port, URLs) via env vars. Only castle programs (CLI, gateway)
know about castle internals.
## Creating Programs
## Programs: create new, or adopt existing
When creating a new service, tool, or frontend, follow the detailed guides:
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),
wherever it lives. No stack needed — castle detects sensible dev-verb commands
(pyproject→uv/ruff/pytest, Cargo.toml→cargo, etc.) or you declare them.
**Stacks vs programs** are decoupled: a stack seeds a new program's default
verb commands and scaffold, but a program stands on its own via its declared
`commands:` (and `source:`/`repo:`). A program may have no stack at all.
Stack guides (for writing *new* code, AI-facing):
- @docs/component-registry.md — Registry architecture, castle.yaml structure, lifecycle
- @docs/stacks/python-fastapi.md — FastAPI service patterns (config, routes, models, testing)
@@ -35,21 +49,23 @@ When creating a new service, tool, or frontend, follow the detailed guides:
### Quick start
```bash
# Daemon (python-fastapi)
# New daemon, scaffolded from a stack
castle create my-service --stack python-fastapi --description "Does something"
cd ~/.castle/code/my-service && uv sync
uv run my-service # starts on auto-assigned port
castle service enable my-service # register with systemd
castle gateway reload # update reverse proxy routes
cd /data/repos/my-service && uv sync
castle service enable my-service # register with systemd
castle gateway reload # update reverse proxy routes
# Tool (python-cli)
# New tool from a stack
castle create my-tool --stack python-cli --description "Does something"
cd ~/.castle/code/my-tool && uv sync
# Adopt an existing repo (no stack required)
castle add ~/projects/some-rust-tool
castle add https://github.com/me/widget.git --name widget
```
The `castle create` command scaffolds the project under `~/.castle/code/`,
generates a CLAUDE.md, and registers it in `castle.yaml` with
`source: code/<name>`.
`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 CLI
@@ -60,20 +76,26 @@ castle list # List all programs, services, and jobs
castle list --behavior daemon # Filter by behavior
castle list --stack python-cli # Filter by stack
castle info <name> # Show details (--json for machine-readable)
castle create <name> --stack python-fastapi # Scaffold new project
castle create <name> [--stack ...] # Scaffold new project (--stack optional → bare program)
castle add <path|git-url> [--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 test [project] # Run tests (one or all)
castle lint [project] # Run linter (one or all)
castle run <name> # Run service in foreground
castle build|test|lint|type-check|check [project] # Dev verbs (one or all)
castle install|uninstall [program] # Install/remove a program on PATH
castle run <name> # Run a program (declared run) or service in foreground
castle logs <name> [-f] [-n 50] # View service/job logs
castle tool list # List all tools
castle tool info <name> # Show tool details
castle gateway start|stop|reload|status # Manage Caddy reverse proxy
castle service enable|disable <name> # Manage individual systemd service
castle service status # Show all service statuses
castle services start|stop # Start/stop everything
```
**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).
## Infrastructure
Castle uses two roots, each overridable by an env var: `CASTLE_HOME` (config,

View File

@@ -1,5 +1,7 @@
# Castle
"Standing to author, run, govern, and maintain your own software"
A personal software platform. Castle manages independent services, tools, and frontends from a single CLI, with a unified gateway, systemd integration, and a web dashboard.
Historically, applications have been developed by third parties, distributed through app stores, and installed on user devices.
@@ -61,21 +63,23 @@ castle deploy my-app
```
castle list [--behavior B] [--stack S] [--json] List all programs, services, and jobs
castle info NAME [--json] Show program details
castle create NAME --stack STACK Scaffold a new project
castle build [NAME] Build projects (one or all)
castle test [NAME] Run tests (one or all)
castle lint [NAME] Run linter (one or all)
castle create NAME [--stack STACK] Scaffold a new project (bare if no stack)
castle add PATH|GIT-URL [--name N] Adopt an existing repo as a program
castle clone [NAME] Clone source for programs that declare repo:
castle build|test|lint|type-check|check [NAME] Dev verbs (one or all)
castle install|uninstall [NAME] Install/remove a program on PATH
castle deploy [NAME] Deploy to ~/.castle/ (spec -> runtime)
castle run NAME Run a service in the foreground
castle run NAME Run a program (declared run) or service in foreground
castle logs NAME [-f] [-n 50] View service/job logs
castle gateway start|stop|reload Manage Caddy reverse proxy
castle service enable|disable NAME Manage a systemd service
castle service status Show all service statuses
castle services start|stop Start/stop everything
castle tool list List all tools
castle tool info NAME Show tool details
```
Tools are programs with `behavior: tool` — list them with
`castle list --behavior tool`.
## Registry
`castle.yaml` lives at `~/.castle/castle.yaml` and is the single source of truth. It has four top-level sections:

View File

@@ -10,7 +10,6 @@ export function ProgramDetailPage() {
useEventStream()
const { name } = useParams<{ name: string }>()
const { data: component, isLoading, error, refetch } = useProgram(name ?? "")
const isTool = component?.behavior === "tool"
const [actionOutput, setActionOutput] = useState<ActionOutput | null>(null)
if (isLoading) {
@@ -51,53 +50,81 @@ export function ProgramDetailPage() {
<p className="text-sm text-[var(--muted)] -mt-4 mb-6">{component.description}</p>
)}
{isTool && (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-lg p-5 mb-6">
<h2 className="text-sm font-semibold text-[var(--muted)] uppercase tracking-wider mb-1">
Tool Info
</h2>
<p className="text-xs text-[var(--muted)] mb-4">
How this tool is packaged and what it depends on.
</p>
<div className="grid grid-cols-2 gap-x-6 gap-y-2 text-sm mb-4">
{component.source && (
<>
<span className="text-[var(--muted)]">Source</span>
<span className="font-mono">{component.source}</span>
</>
)}
{component.version && (
<>
<span className="text-[var(--muted)]">Version</span>
<span>{component.version}</span>
</>
)}
{component.runner && (
<>
<span className="text-[var(--muted)]">Runner</span>
<span>{runnerLabel(component.runner)}</span>
</>
)}
<span className="text-[var(--muted)]">Installed</span>
<span>{component.installed ? "Yes" : "No"}</span>
</div>
{component.system_dependencies.length > 0 && (
<div className="mb-4">
<span className="text-sm text-[var(--muted)] block mb-1">System Dependencies</span>
<div className="flex flex-wrap gap-1">
{component.system_dependencies.map((dep) => (
<span
key={dep}
className="text-xs px-2 py-0.5 rounded bg-amber-900/30 text-amber-400 border border-amber-800"
>
{dep}
</span>
))}
</div>
</div>
<div className="bg-[var(--card)] border border-[var(--border)] rounded-lg p-5 mb-6">
<h2 className="text-sm font-semibold text-[var(--muted)] uppercase tracking-wider mb-1">
Program Info
</h2>
<p className="text-xs text-[var(--muted)] mb-4">
Where the source lives and how castle works with it.
</p>
<div className="grid grid-cols-2 gap-x-6 gap-y-2 text-sm mb-4">
{component.source && (
<>
<span className="text-[var(--muted)]">Source</span>
<span className="font-mono break-all">{component.source}</span>
</>
)}
{component.repo && (
<>
<span className="text-[var(--muted)]">Repo</span>
<span className="font-mono break-all">
{component.repo}
{component.ref ? ` @ ${component.ref}` : ""}
</span>
</>
)}
{component.version && (
<>
<span className="text-[var(--muted)]">Version</span>
<span>{component.version}</span>
</>
)}
{component.runner && (
<>
<span className="text-[var(--muted)]">Runner</span>
<span>{runnerLabel(component.runner)}</span>
</>
)}
{component.installed !== null && (
<>
<span className="text-[var(--muted)]">Installed</span>
<span>{component.installed ? "Yes" : "No"}</span>
</>
)}
</div>
)}
{component.commands && Object.keys(component.commands).length > 0 && (
<div className="mb-4">
<span className="text-sm text-[var(--muted)] block mb-1">Commands</span>
<div className="space-y-1">
{Object.entries(component.commands).map(([verb, cmds]) => (
<div key={verb} className="flex gap-2 text-xs">
<span className="text-[var(--muted)] w-20 shrink-0">{verb}</span>
<span className="font-mono break-all">
{cmds.map((argv) => argv.join(" ")).join(" && ")}
</span>
</div>
))}
</div>
</div>
)}
{component.system_dependencies.length > 0 && (
<div className="mb-4">
<span className="text-sm text-[var(--muted)] block mb-1">System Dependencies</span>
<div className="flex flex-wrap gap-1">
{component.system_dependencies.map((dep) => (
<span
key={dep}
className="text-xs px-2 py-0.5 rounded bg-amber-900/30 text-amber-400 border border-amber-800"
>
{dep}
</span>
))}
</div>
</div>
)}
</div>
<ConfigPanel component={component} configSection="programs" onRefetch={refetch} />
</div>

View File

@@ -37,7 +37,6 @@ export function ScheduledDetailPage() {
backTo="/"
backLabel="Back to Jobs"
name={component.id}
behavior="tool"
stack={component.stack}
source={component.source}
>

View File

@@ -46,6 +46,9 @@ export interface ProgramSummary {
runner: string | null
version: string | null
source: string | null
repo: string | null
ref: string | null
commands: Record<string, string[][]> | null
system_dependencies: string[]
installed: boolean | null
actions: string[]
@@ -74,6 +77,9 @@ export interface ComponentSummary {
systemd: SystemdInfo | null
version: string | null
source: string | null
repo: string | null
ref: string | null
commands: Record<string, string[][]> | null
system_dependencies: string[]
schedule: string | null
installed: boolean | null

View File

@@ -27,6 +27,9 @@ class ComponentSummary(BaseModel):
systemd: SystemdInfo | None = None
version: str | None = None
source: str | None = None
repo: str | None = None
ref: str | None = None
commands: dict[str, list[list[str]]] | None = None
system_dependencies: list[str] = []
schedule: str | None = None
installed: bool | None = None
@@ -91,6 +94,9 @@ class ProgramSummary(BaseModel):
runner: str | None = None
version: str | None = None
source: str | None = None
repo: str | None = None
ref: str | None = None
commands: dict[str, list[list[str]]] | None = None
system_dependencies: list[str] = []
installed: bool | None = None
actions: list[str] = []

View File

@@ -66,7 +66,6 @@ def _deployed_to_summaries(registry: object, hostname: str) -> list[ComponentSum
def get_mesh_status(request: Request) -> MeshStatus:
"""Get the current state of the mesh coordination layer."""
mqtt_client = getattr(request.app.state, "mqtt_client", None)
mdns = getattr(request.app.state, "mdns", None)
peers = list(mesh_state.all_nodes(include_stale=True).keys())

View File

@@ -4,7 +4,7 @@ from __future__ import annotations
from fastapi import APIRouter, HTTPException, status
from castle_core.stacks import available_actions, get_handler
from castle_core.stacks import available_actions, run_action
from castle_api.config import get_config
@@ -27,7 +27,11 @@ _VALID_ACTIONS = {
@programs_router.post("/programs/{name}/{action}")
async def program_action(name: str, action: str) -> dict:
"""Run a lifecycle action on a program via its stack handler."""
"""Run a lifecycle action on a program.
Resolution-aware: a declared `commands:` entry overrides the stack default,
so a program with no stack can still be linted/tested/built/installed.
"""
if action not in _VALID_ACTIONS:
raise HTTPException(status_code=400, detail=f"Unknown action: {action}")
@@ -41,24 +45,14 @@ async def program_action(name: str, action: str) -> dict:
if not comp.source:
raise HTTPException(status_code=400, detail=f"'{name}' has no source directory")
actions = available_actions(comp)
if action not in actions:
if action not in available_actions(comp):
raise HTTPException(
status_code=400,
detail=f"Action '{action}' not available for '{name}' (stack: {comp.stack})",
detail=f"Action '{action}' not available for '{name}' "
f"(no declared command and no stack handler provides it)",
)
handler = get_handler(comp.stack)
if handler is None:
raise HTTPException(
status_code=400, detail=f"No handler for stack '{comp.stack}'"
)
# Map hyphenated action names to method names (type-check → type_check)
method_name = action.replace("-", "_")
method = getattr(handler, method_name)
result = await method(name, comp, config.root)
result = await run_action(action, name, comp, config.root)
if result.status != "ok":
raise HTTPException(status_code=500, detail=result.output or f"{action} failed")

View File

@@ -34,6 +34,19 @@ from castle_api.models import (
router = APIRouter(tags=["dashboard"])
def _declared_commands_dict(comp: ProgramSpec) -> dict[str, list[list[str]]] | None:
"""Serialize a program's declared verbs for the API (build + CommandsSpec)."""
out: dict[str, list[list[str]]] = {}
if comp.build and comp.build.commands:
out["build"] = comp.build.commands
if comp.commands is not None:
for verb in ("lint", "test", "type-check", "check", "run", "install", "uninstall"):
cmds = comp.commands.for_verb(verb)
if cmds:
out[verb] = cmds
return out or None
def _summary_from_deployed(name: str, deployed: object) -> ComponentSummary:
"""Build a ComponentSummary from a DeployedComponent."""
managed = deployed.managed
@@ -172,7 +185,7 @@ def _summary_from_program(name: str, comp: ProgramSpec, root: Path) -> Component
runner = "command"
installed: bool | None = None
if comp.source and comp.stack:
if comp.source and (comp.stack or comp.commands):
installed = shutil.which(name) is not None
return ComponentSummary(
@@ -184,6 +197,9 @@ def _summary_from_program(name: str, comp: ProgramSpec, root: Path) -> Component
runner=runner,
version=comp.version,
source=source,
repo=comp.repo,
ref=comp.ref,
commands=_declared_commands_dict(comp),
system_dependencies=comp.system_dependencies,
installed=installed,
)
@@ -323,7 +339,7 @@ def _program_from_spec(
runner = "command"
installed: bool | None = None
if comp.source and comp.stack:
if comp.source and (comp.stack or comp.commands):
installed = shutil.which(name) is not None
return ProgramSummary(
@@ -334,6 +350,9 @@ def _program_from_spec(
runner=runner,
version=comp.version,
source=source,
repo=comp.repo,
ref=comp.ref,
commands=_declared_commands_dict(comp),
system_dependencies=comp.system_dependencies,
installed=installed,
actions=available_actions(comp),

View File

@@ -36,6 +36,17 @@ def castle_root(tmp_path: Path) -> Generator[Path, None, None]:
"behavior": "tool",
"version": "2.0.0",
},
"wired-in": {
"description": "Adopted repo, no stack",
"source": "wired-in",
"behavior": "tool",
"repo": "https://github.com/someone/wired-in.git",
"commands": {
"lint": [["make", "lint"]],
"test": [["make", "test"]],
"run": [["./bin/wired-in"]],
},
},
},
"services": {
"test-svc": {

View File

@@ -0,0 +1,32 @@
"""Tests for the new per-program commands/repo fields on the programs API."""
from fastapi.testclient import TestClient
class TestProgramCommands:
def test_wired_in_program_surfaces_commands_and_repo(self, client: TestClient) -> None:
"""A stack-less adopted program exposes its declared commands + repo."""
resp = client.get("/programs")
assert resp.status_code == 200
progs = {p["id"]: p for p in resp.json()}
assert "wired-in" in progs
w = progs["wired-in"]
assert w["stack"] is None
assert w["repo"] == "https://github.com/someone/wired-in.git"
assert w["commands"]["lint"] == [["make", "lint"]]
assert w["commands"]["run"] == [["./bin/wired-in"]]
def test_wired_in_actions_resolved_from_commands(self, client: TestClient) -> None:
"""available actions come from declared commands when there's no stack."""
resp = client.get("/programs")
w = next(p for p in resp.json() if p["id"] == "wired-in")
# declared lint/test/run + the composite check (lint/test available)
assert set(w["actions"]) >= {"lint", "test", "run"}
assert "build" not in w["actions"] # not declared, no stack
def test_tools_via_behavior_filter(self, client: TestClient) -> None:
"""Tools are reached via /programs?behavior=tool (no dedicated /tools)."""
resp = client.get("/programs", params={"behavior": "tool"})
assert resp.status_code == 200
ids = [p["id"] for p in resp.json()]
assert "wired-in" in ids and "test-tool" in ids

View File

@@ -1,78 +0,0 @@
"""Tests for tools endpoints."""
from fastapi.testclient import TestClient
class TestToolsList:
"""GET /tools endpoint tests."""
def test_returns_flat_list(self, client: TestClient) -> None:
"""Returns tools as a flat sorted list."""
response = client.get("/tools")
assert response.status_code == 200
data = response.json()
assert isinstance(data, list)
ids = [t["id"] for t in data]
assert "test-tool" in ids
assert "test-tool-2" in ids
def test_sorted_alphabetically(self, client: TestClient) -> None:
"""Tools are sorted alphabetically by id."""
response = client.get("/tools")
data = response.json()
ids = [t["id"] for t in data]
assert ids == sorted(ids)
def test_tool_fields(self, client: TestClient) -> None:
"""Tool summary has expected fields."""
response = client.get("/tools")
data = response.json()
tool = next(t for t in data if t["id"] == "test-tool")
assert tool["description"] == "Test tool"
assert tool["source"].endswith("/test-tool")
assert tool["system_dependencies"] == ["pandoc"]
def test_installed_flag(self, client: TestClient) -> None:
"""Tool installed field reflects whether binary is on PATH."""
response = client.get("/tools")
data = response.json()
tool = next(t for t in data if t["id"] == "test-tool")
# test-tool binary won't be on PATH in test env
assert isinstance(tool["installed"], bool)
def test_service_excluded(self, client: TestClient) -> None:
"""Services without tool spec are not listed."""
response = client.get("/tools")
data = response.json()
all_ids = [t["id"] for t in data]
assert "test-svc" not in all_ids
class TestToolDetail:
"""GET /tools/{name} endpoint tests."""
def test_get_tool(self, client: TestClient) -> None:
"""Returns detail for a known tool."""
response = client.get("/tools/test-tool")
assert response.status_code == 200
data = response.json()
assert data["id"] == "test-tool"
assert data["source"].endswith("/test-tool")
assert data["system_dependencies"] == ["pandoc"]
def test_no_docs(self, client: TestClient) -> None:
"""Tool detail returns null docs (no .md files anymore)."""
response = client.get("/tools/test-tool")
assert response.status_code == 200
data = response.json()
assert data["docs"] is None
def test_not_found(self, client: TestClient) -> None:
"""Returns 404 for unknown component."""
response = client.get("/tools/nonexistent")
assert response.status_code == 404
def test_not_a_tool(self, client: TestClient) -> None:
"""Returns 404 for component that is not a tool."""
response = client.get("/tools/test-svc")
assert response.status_code == 404

View File

@@ -0,0 +1,134 @@
"""castle add — adopt an existing repo as a program (no scaffolding).
`castle create` makes new code from a stack. `castle add` adopts code that
already exists — a local path, or a git URL to clone. It detects sensible dev
verb commands so a non-castle project becomes usable without writing them by hand.
"""
from __future__ import annotations
import argparse
import tomllib
from pathlib import Path
from castle_cli.config import REPOS_DIR, load_config, save_config
from castle_cli.manifest import BuildSpec, CommandsSpec, ProgramSpec
def _is_git_url(s: str) -> bool:
return (
s.startswith(("http://", "https://", "git@", "ssh://"))
or s.endswith(".git")
)
def _detect(src: Path) -> tuple[str | None, dict[str, list[list[str]]], str]:
"""Detect (stack, commands, behavior) for a source dir.
Returns a stack name when the project fits a known one (so it inherits those
defaults), otherwise an explicit commands map. behavior defaults to 'tool'.
"""
stack: str | None = None
commands: dict[str, list[list[str]]] = {}
behavior = "tool"
pyproject = src / "pyproject.toml"
if pyproject.exists():
try:
data = tomllib.loads(pyproject.read_text())
except (OSError, tomllib.TOMLDecodeError):
data = {}
deps = " ".join(data.get("project", {}).get("dependencies", []))
if "fastapi" in deps or "uvicorn" in deps:
stack, behavior = "python-fastapi", "daemon"
else:
stack = "python-cli"
return stack, commands, behavior
if (src / "Cargo.toml").exists():
commands = {
"build": [["cargo", "build", "--release"]],
"test": [["cargo", "test"]],
"lint": [["cargo", "clippy"]],
"run": [["cargo", "run"]],
}
return None, commands, "tool"
if (src / "package.json").exists():
commands = {
"build": [["pnpm", "build"]],
"test": [["pnpm", "test"]],
"lint": [["pnpm", "lint"]],
}
return None, commands, "frontend"
if (src / "Makefile").exists() or (src / "makefile").exists():
commands = {"build": [["make"]], "test": [["make", "test"]]}
return None, commands, "tool"
return None, commands, "tool"
def run_add(args: argparse.Namespace) -> int:
"""Adopt an existing repo as a program."""
config = load_config()
target = args.target
repo_url: str | None = None
source: str | None = None
if _is_git_url(target):
repo_url = target
name = args.name or Path(target.rstrip("/")).name.removesuffix(".git")
# Default local clone location; cloned later via `castle clone`.
source = str(REPOS_DIR / name)
src_path = Path(source)
else:
src_path = Path(target).expanduser().resolve()
if not src_path.exists():
print(f"Error: path does not exist: {src_path}")
return 1
source = str(src_path)
name = args.name or src_path.name
if name in config.programs or name in config.services or name in config.jobs:
print(f"Error: '{name}' already exists in castle.yaml")
return 1
# Detect verbs from the working copy if we have one on disk.
stack: str | None = None
detected_commands: dict[str, list[list[str]]] = {}
behavior = "tool"
if src_path.exists():
stack, detected_commands, behavior = _detect(src_path)
prog = ProgramSpec(
id=name,
description=args.description or f"Adopted from {target}",
source=source,
stack=stack,
repo=repo_url,
behavior=behavior,
)
# `build` is declared via BuildSpec; every other verb via CommandsSpec.
if detected_commands:
build_cmds = detected_commands.pop("build", None)
if build_cmds:
prog.build = BuildSpec(commands=build_cmds)
if detected_commands:
prog.commands = CommandsSpec.model_validate(detected_commands)
config.programs[name] = prog
save_config(config)
print(f"Adopted '{name}' as a program.")
print(f" source: {source}")
if repo_url:
print(f" repo: {repo_url} (run 'castle clone {name}' to fetch it)")
if stack:
print(f" stack: {stack} (verbs inherited from stack defaults)")
elif detected_commands:
print(f" commands detected: {', '.join(sorted(detected_commands))}")
else:
print(" no stack/commands detected — declare verbs in castle.yaml as needed")
return 0

View File

@@ -0,0 +1,62 @@
"""castle clone — clone source for programs that declare a `repo:` URL.
Used to provision a fresh machine: every program with a `repo:` and a missing
local `source:` gets cloned. A program whose source already exists is skipped.
"""
from __future__ import annotations
import argparse
import subprocess
from pathlib import Path
from castle_cli.config import REPOS_DIR, load_config
def _clone_one(name: str, repo: str, source: str | None, ref: str | None) -> bool:
dest = Path(source) if source else REPOS_DIR / name
if dest.exists():
print(f" {name}: already present at {dest}, skipping")
return True
dest.parent.mkdir(parents=True, exist_ok=True)
cmd = ["git", "clone", repo, str(dest)]
print(f" {name}: cloning {repo}{dest}")
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
print(f" {name}: clone failed:\n{result.stderr}")
return False
if ref:
co = subprocess.run(
["git", "-C", str(dest), "checkout", ref], capture_output=True, text=True
)
if co.returncode != 0:
print(f" {name}: checkout {ref} failed:\n{co.stderr}")
return False
return True
def run_clone(args: argparse.Namespace) -> int:
config = load_config()
if getattr(args, "name", None):
if args.name not in config.programs:
print(f"Unknown program: {args.name}")
return 1
prog = config.programs[args.name]
if not prog.repo:
print(f"{args.name} has no repo: URL to clone from")
return 1
return 0 if _clone_one(args.name, prog.repo, prog.source, prog.ref) else 1
# Clone all programs that declare a repo: and lack a present source.
all_ok = True
cloned_any = False
for name, prog in config.programs.items():
if not prog.repo:
continue
cloned_any = True
if not _clone_one(name, prog.repo, prog.source, prog.ref):
all_ok = False
if not cloned_any:
print("No programs declare a repo: URL.")
return 0 if all_ok else 1

View File

@@ -3,15 +3,16 @@
from __future__ import annotations
import argparse
import subprocess
from castle_cli.config import load_config, save_config
from castle_cli.config import REPOS_DIR, load_config, save_config
from castle_cli.manifest import (
CaddySpec,
ProgramSpec,
ExposeSpec,
HttpExposeSpec,
HttpInternal,
ManageSpec,
ProgramSpec,
ProxySpec,
RunPython,
ServiceSpec,
@@ -43,21 +44,20 @@ def next_available_port(config: object) -> int:
def run_create(args: argparse.Namespace) -> int:
"""Create a new project."""
"""Create a new project (scaffolded from a stack, or a bare program)."""
config = load_config()
name = args.name
stack = args.stack
behavior = STACK_DEFAULTS.get(stack)
behavior = STACK_DEFAULTS.get(stack) if stack else None
if name in config.programs or name in config.services or name in config.jobs:
print(f"Error: '{name}' already exists in castle.yaml")
return 1
code_dir = config.root / "code"
code_dir.mkdir(exist_ok=True)
project_dir = code_dir / name
REPOS_DIR.mkdir(parents=True, exist_ok=True)
project_dir = REPOS_DIR / name
if project_dir.exists():
print(f"Error: directory 'code/{name}' already exists")
print(f"Error: directory already exists: {project_dir}")
return 1
# Determine port for daemons
@@ -65,24 +65,29 @@ def run_create(args: argparse.Namespace) -> int:
if behavior == "daemon" and port is None:
port = next_available_port(config)
# Package name: convert kebab-case to snake_case
package_name = name.replace("-", "_")
description = args.description or (f"A castle {stack} program" if stack else f"{name}")
# Scaffold the project files
scaffold_project(
project_dir=project_dir,
name=name,
package_name=package_name,
stack=stack,
description=args.description or f"A castle {stack} program",
port=port,
)
if stack:
scaffold_project(
project_dir=project_dir,
name=name,
package_name=package_name,
stack=stack,
description=description,
port=port,
)
else:
# Bare program: empty source tree, no scaffold; user declares commands later.
project_dir.mkdir(parents=True)
# Initialize a git repo for the new source.
subprocess.run(["git", "init", "-q", str(project_dir)], check=False)
# Build entries
config.programs[name] = ProgramSpec(
id=name,
description=args.description or f"A castle {stack} program",
source=f"code/{name}",
description=description,
source=str(project_dir),
stack=stack,
behavior=behavior,
)
@@ -103,16 +108,20 @@ def run_create(args: argparse.Namespace) -> int:
save_config(config)
print(f"Created {stack} program '{name}' at {project_dir}")
label = f"{stack} program" if stack else "bare program"
print(f"Created {label} '{name}' at {project_dir}")
if port:
print(f" Port: {port}")
print(" Registered in castle.yaml")
print("\nNext steps:")
print(f" cd ~/.castle/code/{name}")
print(" uv sync")
if behavior == "daemon":
print(f" uv run {name} # starts on port {port}")
print(f" castle deploy {name} # deploy to ~/.castle/")
print(f" castle test {name}")
print(f" cd {project_dir}")
if stack:
print(" uv sync")
if behavior == "daemon":
print(f" uv run {name} # starts on port {port}")
print(f" castle deploy {name}")
print(f" castle test {name}")
else:
print(" # add code, then declare commands: in castle.yaml")
return 0

View File

@@ -0,0 +1,81 @@
"""castle delete — remove a program/service/job from the registry.
Config-only by default (removes the castle.yaml entry; leaves source and any
installed binary in place). Use --source to also delete the source directory.
"""
from __future__ import annotations
import argparse
import shutil
from pathlib import Path
from castle_cli.config import load_config, save_config
def run_delete(args: argparse.Namespace) -> int:
config = load_config()
name = args.name
# 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
if not (in_programs or in_services or in_jobs):
print(f"Error: '{name}' not found in castle.yaml")
return 1
where = [s for s, present in
(("program", in_programs), ("service", in_services), ("job", in_jobs)) if present]
# Resolve source dir (from the program entry) for the optional --source removal.
source_dir: Path | None = None
if in_programs and config.programs[name].source:
source_dir = Path(config.programs[name].source)
print(f"Will remove '{name}' from castle.yaml ({', '.join(where)}).")
if args.source and source_dir:
print(f"Will ALSO delete source directory: {source_dir}")
# Confirm unless --yes.
if not args.yes:
prompt = f"Delete '{name}'? [y/N] "
try:
if input(prompt).strip().lower() not in ("y", "yes"):
print("Aborted.")
return 0
except EOFError:
print("Aborted (no input). Re-run with --yes to confirm non-interactively.")
return 1
# Remove registry entries.
if in_programs:
del config.programs[name]
if in_services:
del config.services[name]
if in_jobs:
del config.jobs[name]
save_config(config)
print(f"Removed '{name}' from castle.yaml ({', '.join(where)}).")
# Optional: delete the source directory.
if args.source and source_dir:
if source_dir.exists():
shutil.rmtree(source_dir)
print(f"Deleted source directory: {source_dir}")
else:
print(f"Source directory not found (already gone): {source_dir}")
# Warn about runtime artifacts we did NOT touch.
if in_services or in_jobs:
print(
f"\nNote: the systemd unit for '{name}' (if deployed) was NOT removed.\n"
f" Run: castle service disable {name}"
)
if shutil.which(name):
print(
f"\nNote: '{name}' is still installed on PATH. To remove it:\n"
f" castle uninstall {name}"
)
return 0

View File

@@ -1,19 +1,23 @@
"""castle build / castle test / castle lint - run dev commands across projects."""
"""castle build/test/lint/type-check/check/run/install/uninstall — dev verbs.
Verbs resolve per-program: a declared command (manifest `commands:` / `build:`)
overrides the stack default, falling back to the stack handler, else unavailable.
"""
from __future__ import annotations
import argparse
import asyncio
from castle_core.stacks import get_handler
from castle_core.stacks import is_available, run_action
from castle_cli.config import CastleConfig, load_config
def _run_action(config: CastleConfig, project_name: str, action: str) -> bool:
"""Run a stack action for a single project. Returns True on success."""
def _run_verb(config: CastleConfig, project_name: str, verb: str) -> bool:
"""Run a verb for a single program. Returns True on success."""
if project_name not in config.programs:
print(f"Unknown component: {project_name}")
print(f"Unknown program: {project_name}")
return False
comp = config.programs[project_name]
@@ -21,106 +25,69 @@ def _run_action(config: CastleConfig, project_name: str, action: str) -> bool:
print(f" {project_name}: no source directory, skipping")
return True
handler = get_handler(comp.stack)
if handler is None:
print(f" {project_name}: unsupported stack '{comp.stack}', skipping")
if not is_available(comp, verb):
print(f" {project_name}: '{verb}' not available (no declared command or stack), skipping")
return True
method_name = action.replace("-", "_")
method = getattr(handler, method_name, None)
if method is None:
print(f" {project_name}: action '{action}' not supported")
return False
print(f"\n{'' * 40}")
print(f" {action}: {project_name}")
print(f" {verb}: {project_name}")
print(f"{'' * 40}")
result = asyncio.run(method(project_name, comp, config.root))
result = asyncio.run(run_action(verb, project_name, comp, config.root))
if result.output:
print(result.output)
return result.status == "ok"
def run_test(args: argparse.Namespace) -> int:
"""Run tests for one or all projects."""
config = load_config()
if args.project:
success = _run_action(config, args.project, "test")
return 0 if success else 1
# Run all
def _run_verb_all(config: CastleConfig, verb: str) -> bool:
"""Run a verb across every program that supports it. Returns True if all pass."""
all_passed = True
ran_any = False
for name, comp in config.programs.items():
if not comp.source:
if not comp.source or not is_available(comp, verb):
continue
handler = get_handler(comp.stack)
if handler is None:
continue
# Skip projects without a tests directory (python) or test script (node)
source_dir = config.root / comp.source
if comp.stack in ("python-cli", "python-fastapi"):
if not (source_dir / "tests").exists():
continue
if not _run_action(config, name, "test"):
ran_any = True
if not _run_verb(config, name, verb):
all_passed = False
if not ran_any:
print(f"No programs support '{verb}'.")
return all_passed
if all_passed:
print("\nAll tests passed.")
else:
print("\nSome tests failed.")
return 0 if all_passed else 1
def run_verb(args: argparse.Namespace, verb: str) -> int:
"""Generic entry point for a dev verb (single project or all)."""
config = load_config()
if getattr(args, "project", None):
return 0 if _run_verb(config, args.project, 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
# Thin named wrappers wired from main.py.
def run_test(args: argparse.Namespace) -> int:
return run_verb(args, "test")
def run_lint(args: argparse.Namespace) -> int:
"""Run linter for one or all projects."""
config = load_config()
if args.project:
success = _run_action(config, args.project, "lint")
return 0 if success else 1
# Run all
all_passed = True
for name, comp in config.programs.items():
if not comp.source:
continue
handler = get_handler(comp.stack)
if handler is None:
continue
if not _run_action(config, name, "lint"):
all_passed = False
if all_passed:
print("\nAll lint checks passed.")
else:
print("\nSome lint checks failed.")
return 0 if all_passed else 1
return run_verb(args, "lint")
def run_build(args: argparse.Namespace) -> int:
"""Run build for one or all projects."""
config = load_config()
return run_verb(args, "build")
if args.project:
success = _run_action(config, args.project, "build")
return 0 if success else 1
# Run all buildable projects
all_passed = True
for name, comp in config.programs.items():
if not comp.source:
continue
handler = get_handler(comp.stack)
if handler is None:
continue
if not _run_action(config, name, "build"):
all_passed = False
def run_type_check(args: argparse.Namespace) -> int:
return run_verb(args, "type-check")
if all_passed:
print("\nAll builds succeeded.")
else:
print("\nSome builds failed.")
return 0 if all_passed else 1
def run_check(args: argparse.Namespace) -> int:
return run_verb(args, "check")
def run_install(args: argparse.Namespace) -> int:
return run_verb(args, "install")
def run_uninstall(args: argparse.Namespace) -> int:
return run_verb(args, "uninstall")

View File

@@ -1,39 +1,72 @@
"""castle run - run a component in the foreground."""
"""castle run - run a program or service in the foreground.
Unified: if the target is a program with a declared `run` command, run that in
the foreground (dev-run). Otherwise fall back to the deployed-service run from
the registry.
"""
from __future__ import annotations
import argparse
import os
import subprocess
from pathlib import Path
from castle_core.registry import REGISTRY_PATH, load_registry
from castle_cli.config import load_config
def _run_program(name: str, extra: list[str]) -> int | None:
"""Run a program's declared `run` command in the foreground.
Returns the exit code, or None if the program has no declared run command
(so the caller can fall back to the deployed-service path).
"""
config = load_config()
prog = config.programs.get(name)
if prog is None or prog.commands is None or prog.commands.run is None:
return None
if not prog.source:
print(f"Error: program '{name}' has no source directory.")
return 1
cwd = Path(prog.source)
cmds = prog.commands.run
rc = 0
for i, argv in enumerate(cmds):
# Append extra args to the final command in the sequence.
full = list(argv) + (extra if i == len(cmds) - 1 else [])
print(f"Running {name}: {' '.join(full)}")
rc = subprocess.run(full, cwd=cwd).returncode
if rc != 0:
break
return rc
def run_run(args: argparse.Namespace) -> int:
"""Run a component in the foreground using the registry."""
"""Run a program (declared run) or a deployed service in the foreground."""
name = args.name
extra_args = getattr(args, "extra", []) or []
# 1. Program with a declared run command.
prog_rc = _run_program(name, extra_args)
if prog_rc is not None:
return prog_rc
# 2. Deployed service from the registry.
if not REGISTRY_PATH.exists():
print("Error: no registry found. Run 'castle deploy' first.")
return 1
registry = load_registry()
name = args.name
if name not in registry.deployed:
print(f"Error: component '{name}' not found in registry.")
print("Run 'castle deploy' to update the registry.")
print(f"Error: '{name}' is not a runnable program or deployed service.")
print("Declare a `run` command in castle.yaml, or run 'castle deploy'.")
return 1
deployed = registry.deployed[name]
# Build command with any extra args
extra_args = getattr(args, "extra", []) or []
cmd = list(deployed.run_cmd) + extra_args
# Merge environment
env = dict(os.environ)
env.update(deployed.env)
# Run in foreground (no cwd — registry-based, no repo dependency)
print(f"Running {name}: {' '.join(cmd)}")
result = subprocess.run(cmd, env=env)
return result.returncode
return subprocess.run(cmd, env=env).returncode

View File

@@ -1,75 +0,0 @@
"""castle tool - manage tools."""
from __future__ import annotations
import argparse
from castle_cli.config import load_config
BOLD = "\033[1m"
RESET = "\033[0m"
DIM = "\033[2m"
CYAN = "\033[96m"
def run_tool(args: argparse.Namespace) -> int:
"""Manage tools."""
if not args.tool_command:
print("Usage: castle tool {list|info}")
return 1
if args.tool_command == "list":
return _tool_list()
elif args.tool_command == "info":
return _tool_info(args.name)
return 1
def _tool_list() -> int:
"""List all registered tools."""
config = load_config()
tools = {k: v for k, v in config.programs.items() if v.behavior == "tool"}
if not tools:
print("No tools registered.")
return 0
print(f"\n{BOLD}{CYAN}Tools{RESET}")
print(f"{CYAN}{'' * 40}{RESET}")
for name, manifest in sorted(tools.items()):
desc = manifest.description or ""
deps = ""
if manifest.system_dependencies:
deps = f" {DIM}[{', '.join(manifest.system_dependencies)}]{RESET}"
print(f" {BOLD}{name:<20}{RESET} {desc}{deps}")
print()
return 0
def _tool_info(name: str) -> int:
"""Show detailed info about a tool, including .md documentation."""
config = load_config()
if name not in config.programs:
print(f"Error: '{name}' not found")
return 1
manifest = config.programs[name]
if manifest.behavior != "tool":
print(f"Error: '{name}' is not a tool")
return 1
print(f"\n{BOLD}{name}{RESET}")
print(f"{'' * 40}")
if manifest.description:
print(f" {manifest.description}")
if manifest.version:
print(f" {BOLD}version{RESET}: {manifest.version}")
if manifest.source:
print(f" {BOLD}source{RESET}: {manifest.source}")
if manifest.system_dependencies:
print(f" {BOLD}requires{RESET}: {', '.join(manifest.system_dependencies)}")
print()
return 0

View File

@@ -2,14 +2,15 @@
from castle_core.config import * # noqa: F401, F403
from castle_core.config import ( # noqa: F401 — explicit re-exports for type checkers
ARTIFACTS_DIR,
CASTLE_HOME,
CODE_DIR,
ARTIFACTS_DIR,
SPECS_DIR,
CONTENT_DIR,
DATA_DIR,
SECRETS_DIR,
GENERATED_DIR,
REPOS_DIR,
SECRETS_DIR,
SPECS_DIR,
STATIC_DIR,
CastleConfig,
GatewayConfig,

View File

@@ -37,11 +37,30 @@ def build_parser() -> argparse.ArgumentParser:
create_parser.add_argument(
"--stack",
choices=["python-cli", "python-fastapi", "react-vite"],
required=True,
help="Development stack (determines scaffold template and default behavior)",
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)")
# 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")
# 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 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("project", help="Program, service, or job name")
@@ -59,6 +78,20 @@ def build_parser() -> argparse.ArgumentParser:
build_parser = subparsers.add_parser("build", help="Build projects")
build_parser.add_argument("project", nargs="?", help="Project 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)")
# 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)")
# 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 gateway
gateway_parser = subparsers.add_parser("gateway", help="Manage the Caddy gateway")
gateway_sub = gateway_parser.add_subparsers(dest="gateway_command")
@@ -99,24 +132,17 @@ def build_parser() -> argparse.ArgumentParser:
"-n", "--lines", type=int, default=50, help="Number of lines to show (default: 50)"
)
# castle run
run_parser = subparsers.add_parser("run", help="Run a service in the foreground")
run_parser.add_argument("name", help="Service name")
# 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 service"
"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)")
# castle tool
tool_parser = subparsers.add_parser("tool", help="Manage tools")
tool_sub = tool_parser.add_subparsers(dest="tool_command")
tool_sub.add_parser("list", help="List all tools")
tool_info_parser = tool_sub.add_parser("info", help="Show tool details")
tool_info_parser.add_argument("name", help="Tool name")
return parser
@@ -145,6 +171,21 @@ def main() -> int:
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 == "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
@@ -160,6 +201,26 @@ def main() -> int:
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
@@ -190,11 +251,6 @@ def main() -> int:
return run_run(args)
elif args.command == "tool":
from castle_cli.commands.tool import run_tool
return run_tool(args)
else:
parser.print_help()
return 1

View File

@@ -5,7 +5,7 @@ from castle_core.manifest import ( # noqa: F401 — explicit re-exports for typ
BuildSpec,
CaddySpec,
Capability,
ProgramSpec,
CommandsSpec,
DefaultsSpec,
EnvMap,
ExposeSpec,
@@ -14,6 +14,7 @@ from castle_core.manifest import ( # noqa: F401 — explicit re-exports for typ
HttpPublic,
JobSpec,
ManageSpec,
ProgramSpec,
ProxySpec,
ReadinessHttpGet,
RestartPolicy,

72
cli/tests/test_add.py Normal file
View File

@@ -0,0 +1,72 @@
"""Tests for castle add — adopting existing repos as programs."""
from __future__ import annotations
from argparse import Namespace
from pathlib import Path
from unittest.mock import patch
from castle_cli.config import load_config
def _run_add(castle_root: Path, **kwargs: object) -> object:
with (
patch("castle_cli.commands.add.load_config") as mock_load,
patch("castle_cli.commands.add.save_config"),
):
config = load_config(castle_root)
mock_load.return_value = config
from castle_cli.commands.add import run_add
args = Namespace(name=None, description="", **kwargs)
rc = run_add(args)
return rc, config
class TestAdd:
def test_adopt_python_path_assigns_stack(self, castle_root: Path, tmp_path: Path) -> None:
repo = tmp_path / "mytool"
repo.mkdir()
(repo / "pyproject.toml").write_text('[project]\nname = "mytool"\ndependencies = []\n')
rc, config = _run_add(castle_root, target=str(repo))
assert rc == 0
prog = config.programs["mytool"]
assert prog.stack == "python-cli" # detected
assert prog.source == str(repo.resolve())
def test_adopt_fastapi_detects_daemon(self, castle_root: Path, tmp_path: Path) -> None:
repo = tmp_path / "svc"
repo.mkdir()
(repo / "pyproject.toml").write_text(
'[project]\nname = "svc"\ndependencies = ["fastapi>=0.1"]\n'
)
rc, config = _run_add(castle_root, target=str(repo))
assert rc == 0
assert config.programs["svc"].stack == "python-fastapi"
assert config.programs["svc"].behavior == "daemon"
def test_adopt_rust_declares_commands(self, castle_root: Path, tmp_path: Path) -> None:
repo = tmp_path / "rusty"
repo.mkdir()
(repo / "Cargo.toml").write_text("[package]\nname = \"rusty\"\n")
rc, config = _run_add(castle_root, target=str(repo))
assert rc == 0
prog = config.programs["rusty"]
assert prog.stack is None # no castle stack for rust
# build lands in BuildSpec; other verbs in CommandsSpec
assert prog.build is not None
assert prog.build.commands == [["cargo", "build", "--release"]]
assert prog.commands is not None
assert prog.commands.run == [["cargo", "run"]]
def test_adopt_git_url_records_repo(self, castle_root: Path) -> None:
rc, config = _run_add(
castle_root, target="https://github.com/someone/widget.git"
)
assert rc == 0
prog = config.programs["widget"]
assert prog.repo == "https://github.com/someone/widget.git"
def test_missing_path_fails(self, castle_root: Path, tmp_path: Path) -> None:
rc, _ = _run_add(castle_root, target=str(tmp_path / "nope"))
assert rc == 1

View File

@@ -12,11 +12,13 @@ from castle_cli.config import load_config
class TestCreateCommand:
"""Tests for the create command."""
def test_create_service(self, castle_root: Path) -> None:
def test_create_service(self, castle_root: Path, tmp_path: Path) -> None:
"""Create a new service project."""
repos = tmp_path / "repos"
with (
patch("castle_cli.commands.create.load_config") as mock_load,
patch("castle_cli.commands.create.save_config") as mock_save,
patch("castle_cli.commands.create.REPOS_DIR", repos),
):
config = load_config(castle_root)
mock_load.return_value = config
@@ -32,7 +34,7 @@ class TestCreateCommand:
result = run_create(args)
assert result == 0
project_dir = castle_root / "code" / "my-api"
project_dir = repos / "my-api"
assert project_dir.exists()
assert (project_dir / "pyproject.toml").exists()
assert (project_dir / "src" / "my_api" / "main.py").exists()
@@ -49,11 +51,13 @@ class TestCreateCommand:
assert svc.component == "my-api"
mock_save.assert_called_once()
def test_create_tool(self, castle_root: Path) -> None:
def test_create_tool(self, castle_root: Path, tmp_path: Path) -> None:
"""Create a new tool project."""
repos = tmp_path / "repos"
with (
patch("castle_cli.commands.create.load_config") as mock_load,
patch("castle_cli.commands.create.save_config"),
patch("castle_cli.commands.create.REPOS_DIR", repos),
):
config = load_config(castle_root)
mock_load.return_value = config
@@ -64,7 +68,7 @@ class TestCreateCommand:
result = run_create(args)
assert result == 0
project_dir = castle_root / "code" / "my-tool2"
project_dir = repos / "my-tool2"
assert project_dir.exists()
assert (project_dir / "src" / "my_tool2" / "main.py").exists()
assert (project_dir / "CLAUDE.md").exists()
@@ -91,11 +95,12 @@ class TestCreateCommand:
assert result == 1
def test_create_auto_port(self, castle_root: Path) -> None:
def test_create_auto_port(self, castle_root: Path, tmp_path: Path) -> None:
"""Service creation auto-assigns next available port."""
with (
patch("castle_cli.commands.create.load_config") as mock_load,
patch("castle_cli.commands.create.save_config"),
patch("castle_cli.commands.create.REPOS_DIR", tmp_path / "repos"),
):
config = load_config(castle_root)
mock_load.return_value = config

67
cli/tests/test_delete.py Normal file
View File

@@ -0,0 +1,67 @@
"""Tests for castle delete."""
from __future__ import annotations
from argparse import Namespace
from pathlib import Path
from unittest.mock import patch
from castle_cli.config import load_config
def _run_delete(castle_root: Path, **kwargs: object) -> object:
with (
patch("castle_cli.commands.delete.load_config") as mock_load,
patch("castle_cli.commands.delete.save_config"),
):
config = load_config(castle_root)
mock_load.return_value = config
from castle_cli.commands.delete import run_delete
args = Namespace(source=False, yes=True, **kwargs)
rc = run_delete(args)
return rc, config
class TestDelete:
def test_delete_program(self, castle_root: Path) -> None:
rc, config = _run_delete(castle_root, name="test-tool")
assert rc == 0
assert "test-tool" not in config.programs
def test_delete_unknown_fails(self, castle_root: Path) -> None:
rc, _ = _run_delete(castle_root, name="does-not-exist")
assert rc == 1
def test_delete_source_removes_dir(self, castle_root: Path, tmp_path: Path) -> None:
# Point a program's source at a real temp dir, then delete with --source.
src = tmp_path / "victim"
src.mkdir()
(src / "file.txt").write_text("x")
with (
patch("castle_cli.commands.delete.load_config") as mock_load,
patch("castle_cli.commands.delete.save_config"),
):
config = load_config(castle_root)
config.programs["test-tool"].source = str(src)
mock_load.return_value = config
from castle_cli.commands.delete import run_delete
rc = run_delete(Namespace(name="test-tool", source=True, yes=True))
assert rc == 0
assert not src.exists()
def test_abort_without_yes_and_no_input(self, castle_root: Path) -> None:
# No --yes and no stdin → aborts safely (returns 1, leaves entry).
with (
patch("castle_cli.commands.delete.load_config") as mock_load,
patch("castle_cli.commands.delete.save_config"),
patch("builtins.input", side_effect=EOFError),
):
config = load_config(castle_root)
mock_load.return_value = config
from castle_cli.commands.delete import run_delete
rc = run_delete(Namespace(name="test-tool", source=False, yes=False))
assert rc == 1
assert "test-tool" in config.programs

View File

@@ -53,6 +53,19 @@ def _resolve_data_dir() -> Path:
return Path("/data/castle")
def _resolve_repos_dir() -> Path:
"""Resolve where program source repos live by default.
`castle create` scaffolds and `castle add` adopts repos under here. Programs
may also live anywhere (source: is an absolute path); this is just the default
home for new ones. Override with CASTLE_REPOS_DIR. Defaults to /data/repos.
"""
override = os.environ.get("CASTLE_REPOS_DIR")
if override:
return Path(override).expanduser().resolve()
return Path("/data/repos")
CASTLE_HOME = _resolve_castle_home()
CODE_DIR = CASTLE_HOME / "code"
ARTIFACTS_DIR = CASTLE_HOME / "artifacts"
@@ -60,6 +73,7 @@ SPECS_DIR = ARTIFACTS_DIR / "specs"
CONTENT_DIR = ARTIFACTS_DIR / "content"
DATA_DIR = _resolve_data_dir()
SECRETS_DIR = CASTLE_HOME / "secrets"
REPOS_DIR = _resolve_repos_dir()
# Backwards-compat aliases (used by existing imports)
GENERATED_DIR = SPECS_DIR

View File

@@ -5,7 +5,7 @@ from __future__ import annotations
from enum import Enum
from typing import Annotated, Literal, Union
from pydantic import BaseModel, Field, model_validator
from pydantic import BaseModel, ConfigDict, Field, model_validator
EnvMap = dict[str, str]
@@ -149,6 +149,35 @@ class BuildSpec(BaseModel):
outputs: list[str] = Field(default_factory=list)
# ---------------------
# Commands spec — per-program dev verb overrides
# ---------------------
class CommandsSpec(BaseModel):
"""Per-program dev verb commands. Each verb is a list of argv lists run in
sequence. A declared verb overrides the stack default; an absent verb falls
back to the program's stack handler (if any), else the verb is unavailable.
This generalizes BuildSpec.commands to the rest of the verb contract, which
is what lets a wired-in repo with no `stack` declare how it is linted/tested/run.
"""
model_config = ConfigDict(populate_by_name=True)
lint: list[list[str]] | None = None
test: list[list[str]] | None = None
type_check: list[list[str]] | None = Field(default=None, alias="type-check")
check: list[list[str]] | None = None
run: list[list[str]] | None = None
install: list[list[str]] | None = None
uninstall: list[list[str]] | None = None
def for_verb(self, verb: str) -> list[list[str]] | None:
"""Return the declared commands for a verb name (accepts 'type-check')."""
return getattr(self, verb.replace("-", "_"), None)
# ---------------------
# Capabilities
# ---------------------
@@ -184,6 +213,14 @@ class ProgramSpec(BaseModel):
source: str | None = None
stack: str | None = None
# Wiring in existing repos: clone from `repo` (git URL) at optional `ref`;
# `source` (when set) is the local working copy and takes precedence.
repo: str | None = None
ref: str | None = None
# Per-program dev verb overrides (declared verbs override the stack default).
commands: CommandsSpec | None = None
system_dependencies: list[str] = Field(default_factory=list)
install_extras: list[str] = Field(default_factory=list)
version: str | None = None

View File

@@ -12,10 +12,15 @@ from pathlib import Path
from castle_core.config import CONTENT_DIR
from castle_core.manifest import ProgramSpec
DEV_ACTIONS = ["build", "test", "lint", "type-check", "check"]
DEV_ACTIONS = ["build", "test", "lint", "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"}
# Verbs whose handler method name differs from the verb spelling.
_VERB_METHOD = {"type-check": "type_check"}
# User-local tool directories that may not be on the systemd service PATH.
_EXTRA_PATH_DIRS = [
Path.home() / ".local" / "share" / "pnpm",
@@ -253,11 +258,97 @@ def get_handler(stack: str | None) -> StackHandler | None:
return HANDLERS.get(stack)
def _declared_commands(comp: ProgramSpec, verb: str) -> list[list[str]] | None:
"""Declared argv-lists for a verb, or None.
`build` is declared via BuildSpec.commands; every other verb via CommandsSpec.
"""
if verb == "build":
if comp.build and comp.build.commands:
return comp.build.commands
return None
if comp.commands is not None:
return comp.commands.for_verb(verb)
return None
def _stack_provides(comp: ProgramSpec, verb: str) -> bool:
"""Whether the program's stack handler can run this verb."""
return bool(comp.source) and verb in _STACK_VERBS and get_handler(comp.stack) is not None
def is_available(comp: ProgramSpec, verb: str) -> bool:
"""Whether a verb can be run for a program (declared command or stack default)."""
if _declared_commands(comp, verb) is not None:
return True
if verb == "check":
return any(is_available(comp, sub) for sub in ("lint", "type-check", "test"))
return _stack_provides(comp, verb)
def available_actions(comp: ProgramSpec) -> list[str]:
"""Return the list of actions available for a program."""
"""Return the list of verbs available for a program (resolution-aware)."""
if not comp.source:
return []
return [verb for verb in ALL_ACTIONS if is_available(comp, verb)]
async def _run_declared(
name: str, verb: str, cmds: list[list[str]], src: Path
) -> ActionResult:
"""Run declared argv-lists in sequence; stop at the first failure."""
outputs: list[str] = []
for argv in cmds:
rc, output = await _run(argv, src)
outputs.append(output)
if rc != 0:
return ActionResult(component=name, action=verb, status="error", output="".join(outputs))
return ActionResult(component=name, action=verb, status="ok", output="".join(outputs))
async def run_action(verb: str, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
"""Resolve and run a verb: declared command → stack default → unavailable.
This is the single entry point callers should use; it replaces reaching for
get_handler(...).<method>(...) directly so the override logic stays in one place.
"""
# `check` is a composite that must respect per-verb overrides — unless the
# program declares its own `check`, run each available sub-verb via run_action.
if verb == "check" and _declared_commands(comp, "check") is None:
subs = [s for s in ("lint", "type-check", "test") if is_available(comp, s)]
if not subs:
return ActionResult(
component=name, action="check", status="error",
output="No checkable verbs available.",
)
for sub in subs:
result = await run_action(sub, name, comp, root)
if result.status != "ok":
return ActionResult(
component=name, action="check", status="error",
output=f"{sub} failed:\n{result.output}",
)
return ActionResult(component=name, action="check", status="ok")
# 1. Declared command overrides the stack default.
declared = _declared_commands(comp, verb)
if declared is not None:
try:
src = _source_dir(comp, root)
except ValueError:
return ActionResult(component=name, action=verb, status="error", output="No source directory")
return await _run_declared(name, verb, declared, src)
# 2. Stack default.
handler = get_handler(comp.stack)
if handler is None:
return []
return list(ALL_ACTIONS)
if handler is not None and verb in _STACK_VERBS:
method = getattr(handler, _VERB_METHOD.get(verb, verb), None)
if method is not None:
return await method(name, comp, root)
# 3. Unavailable.
return ActionResult(
component=name, action=verb, status="error",
output=f"Verb '{verb}' is not available for '{name}' "
f"(no declared command and no stack handler provides it).",
)

View File

@@ -0,0 +1,57 @@
"""Tests for per-program verb resolution (declared commands → stack → none)."""
from __future__ import annotations
from castle_core.manifest import ProgramSpec
from castle_core.stacks import _declared_commands, available_actions, is_available
class TestResolution:
def test_stack_only_program_unchanged(self) -> None:
"""A program with a stack and no commands resolves all stack verbs."""
p = ProgramSpec.model_validate({"source": "/tmp/x", "stack": "python-cli"})
actions = available_actions(p)
assert "build" in actions and "test" in actions and "lint" in actions
assert "install" in actions and "uninstall" in actions
# `run` is declared-only — a bare stack program does not expose it.
assert "run" not in actions
def test_wired_in_program_no_stack(self) -> None:
"""A program with no stack but declared commands resolves those verbs."""
p = ProgramSpec.model_validate(
{
"source": "/tmp/y",
"commands": {"lint": [["make", "lint"]], "test": [["make", "test"]], "run": [["./bin/y"]]},
}
)
actions = available_actions(p)
assert set(actions) >= {"lint", "test", "run"}
assert "build" not in actions # not declared, no stack
assert _declared_commands(p, "lint") == [["make", "lint"]]
def test_check_available_when_subverbs_are(self) -> None:
"""`check` is a composite; available when any of lint/type-check/test is."""
p = ProgramSpec.model_validate(
{"source": "/tmp/y", "commands": {"lint": [["ruff", "check", "."]]}}
)
assert is_available(p, "check")
def test_hybrid_override_one_verb(self) -> None:
"""A stack program can override a single verb; the rest fall back to stack."""
p = ProgramSpec.model_validate(
{"source": "/tmp/z", "stack": "python-cli", "commands": {"test": [["pytest", "-x"]]}}
)
assert _declared_commands(p, "test") == [["pytest", "-x"]]
assert _declared_commands(p, "build") is None # build still comes from the stack
def test_build_declared_via_buildspec(self) -> None:
"""`build` is declared through BuildSpec.commands, not CommandsSpec."""
p = ProgramSpec.model_validate(
{"source": "/tmp/w", "build": {"commands": [["make"]], "outputs": ["dist/"]}}
)
assert _declared_commands(p, "build") == [["make"]]
assert "build" in available_actions(p)
def test_no_source_no_actions(self) -> None:
p = ProgramSpec.model_validate({"description": "a library"})
assert available_actions(p) == []

View File

@@ -96,13 +96,43 @@ you create live under **`$CASTLE_HOME/code/`** and are recorded as
the git repo and use the `repo:` prefix. When `castle deploy` writes `castle.yaml`
back out, it rewrites absolute paths into these relative forms.
### `stack` — Development toolchain
### `stack` — Development toolchain (optional)
```yaml
stack: python-fastapi # or: python-cli, react-vite
stack: python-fastapi # or: python-cli, react-vite — OPTIONAL
```
Stacks define how programs get built, checked, and installed.
A stack provides **default** dev-verb commands (build/test/lint/type-check/…)
and a scaffold template for new code. It is **optional**: a program with no
stack works fine as long as it declares its own `commands:`. Stacks are a
creation-time convenience, not a runtime requirement.
### `commands` — Per-program dev verbs
```yaml
commands:
lint: [["ruff", "check", "."]]
test: [["pytest", "tests/"]]
run: [["./bin/my-tool", "--serve"]]
```
Each verb is a list of argv lists (run in sequence). A declared verb **overrides**
the stack default; an absent verb falls back to the stack handler (if any), else
the verb is unavailable. `build` is declared via `build:` (it also carries
`outputs:`); every other verb via `commands:`. This is what lets a wired-in repo
with no stack be linted/tested/run. Verb resolution lives in
`core/src/castle_core/stacks.py` (`run_action`, `available_actions`).
### `repo` / `ref` — Wiring in an existing repo
```yaml
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
machine. When `source:` points at an existing working copy, that takes
precedence. Use `castle add <path|url>` to register an existing repo as a program.
### `system_dependencies` — Required system packages
@@ -111,7 +141,7 @@ system_dependencies: [pandoc, poppler-utils]
```
System packages that must be installed for the program to work. Displayed
in `castle tool list` and the dashboard.
in `castle list --behavior tool` and the dashboard.
### `version` — Program version

View File

@@ -1,5 +1,12 @@
# 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
> --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
> declares its own `commands:`. See @docs/component-registry.md for `commands:`,
> `stack:` (optional), and `repo:`.
How to build CLI tools following Unix philosophy.
## Principles

View File

@@ -1,5 +1,12 @@
# 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
> --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
> this stack — it declares its own `commands:`. See @docs/component-registry.md
> for `commands:`, `stack:` (optional), and `repo:`.
How to build Python web APIs as castle service components. Based on the
patterns used in [wild-cloud/api](https://github.com/civilsociety-dev/wild-cloud)
and existing castle services (central-context, notification-bridge, event-bus).

View File

@@ -1,5 +1,12 @@
# 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
> --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
> stack — it declares its own `commands:`. See @docs/component-registry.md for
> `commands:`, `stack:` (optional), and `repo:`.
How to build, serve, and manage web frontends as castle components.
## Stack