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) configuration (data dir, port, URLs) via env vars. Only castle programs (CLI, gateway)
know about castle internals. 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/component-registry.md — Registry architecture, castle.yaml structure, lifecycle
- @docs/stacks/python-fastapi.md — FastAPI service patterns (config, routes, models, testing) - @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 ### Quick start
```bash ```bash
# Daemon (python-fastapi) # New daemon, scaffolded from a stack
castle create my-service --stack python-fastapi --description "Does something" castle create my-service --stack python-fastapi --description "Does something"
cd ~/.castle/code/my-service && uv sync cd /data/repos/my-service && uv sync
uv run my-service # starts on auto-assigned port castle service enable my-service # register with systemd
castle service enable my-service # register with systemd castle gateway reload # update reverse proxy routes
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" 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/`, `castle create` scaffolds under `/data/repos/` (override with `CASTLE_REPOS_DIR`)
generates a CLAUDE.md, and registers it in `castle.yaml` with and registers the program in `castle.yaml` with an absolute `source:`. `castle add`
`source: code/<name>`. registers an existing repo in place (or records its `repo:` URL for `castle clone`).
## Castle CLI ## Castle CLI
@@ -60,20 +76,26 @@ castle list # List all programs, services, and jobs
castle list --behavior daemon # Filter by behavior castle list --behavior daemon # Filter by behavior
castle list --stack python-cli # Filter by stack castle list --stack python-cli # Filter by stack
castle info <name> # Show details (--json for machine-readable) 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 deploy [name] # Deploy to runtime (registry + systemd + Caddyfile)
castle test [project] # Run tests (one or all) castle build|test|lint|type-check|check [project] # Dev verbs (one or all)
castle lint [project] # Run linter (one or all) castle install|uninstall [program] # Install/remove a program on PATH
castle run <name> # Run service in foreground castle run <name> # Run a program (declared run) or service in foreground
castle logs <name> [-f] [-n 50] # View service/job logs 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 gateway start|stop|reload|status # Manage Caddy reverse proxy
castle service enable|disable <name> # Manage individual systemd service castle service enable|disable <name> # Manage individual systemd service
castle service status # Show all service statuses castle service status # Show all service statuses
castle services start|stop # Start/stop everything 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 ## Infrastructure
Castle uses two roots, each overridable by an env var: `CASTLE_HOME` (config, Castle uses two roots, each overridable by an env var: `CASTLE_HOME` (config,

View File

@@ -1,5 +1,7 @@
# Castle # 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. 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. 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 list [--behavior B] [--stack S] [--json] List all programs, services, and jobs
castle info NAME [--json] Show program details castle info NAME [--json] Show program details
castle create NAME --stack STACK Scaffold a new project castle create NAME [--stack STACK] Scaffold a new project (bare if no stack)
castle build [NAME] Build projects (one or all) castle add PATH|GIT-URL [--name N] Adopt an existing repo as a program
castle test [NAME] Run tests (one or all) castle clone [NAME] Clone source for programs that declare repo:
castle lint [NAME] Run linter (one or all) 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 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 logs NAME [-f] [-n 50] View service/job logs
castle gateway start|stop|reload Manage Caddy reverse proxy castle gateway start|stop|reload Manage Caddy reverse proxy
castle service enable|disable NAME Manage a systemd service castle service enable|disable NAME Manage a systemd service
castle service status Show all service statuses castle service status Show all service statuses
castle services start|stop Start/stop everything 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 ## Registry
`castle.yaml` lives at `~/.castle/castle.yaml` and is the single source of truth. It has four top-level sections: `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() useEventStream()
const { name } = useParams<{ name: string }>() const { name } = useParams<{ name: string }>()
const { data: component, isLoading, error, refetch } = useProgram(name ?? "") const { data: component, isLoading, error, refetch } = useProgram(name ?? "")
const isTool = component?.behavior === "tool"
const [actionOutput, setActionOutput] = useState<ActionOutput | null>(null) const [actionOutput, setActionOutput] = useState<ActionOutput | null>(null)
if (isLoading) { if (isLoading) {
@@ -51,53 +50,81 @@ export function ProgramDetailPage() {
<p className="text-sm text-[var(--muted)] -mt-4 mb-6">{component.description}</p> <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">
<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">
<h2 className="text-sm font-semibold text-[var(--muted)] uppercase tracking-wider mb-1"> Program Info
Tool Info </h2>
</h2> <p className="text-xs text-[var(--muted)] mb-4">
<p className="text-xs text-[var(--muted)] mb-4"> Where the source lives and how castle works with it.
How this tool is packaged and what it depends on. </p>
</p> <div className="grid grid-cols-2 gap-x-6 gap-y-2 text-sm mb-4">
<div className="grid grid-cols-2 gap-x-6 gap-y-2 text-sm mb-4"> {component.source && (
{component.source && ( <>
<> <span className="text-[var(--muted)]">Source</span>
<span className="text-[var(--muted)]">Source</span> <span className="font-mono break-all">{component.source}</span>
<span className="font-mono">{component.source}</span> </>
</> )}
)} {component.repo && (
{component.version && ( <>
<> <span className="text-[var(--muted)]">Repo</span>
<span className="text-[var(--muted)]">Version</span> <span className="font-mono break-all">
<span>{component.version}</span> {component.repo}
</> {component.ref ? ` @ ${component.ref}` : ""}
)} </span>
{component.runner && ( </>
<> )}
<span className="text-[var(--muted)]">Runner</span> {component.version && (
<span>{runnerLabel(component.runner)}</span> <>
</> <span className="text-[var(--muted)]">Version</span>
)} <span>{component.version}</span>
<span className="text-[var(--muted)]">Installed</span> </>
<span>{component.installed ? "Yes" : "No"}</span> )}
</div> {component.runner && (
{component.system_dependencies.length > 0 && ( <>
<div className="mb-4"> <span className="text-[var(--muted)]">Runner</span>
<span className="text-sm text-[var(--muted)] block mb-1">System Dependencies</span> <span>{runnerLabel(component.runner)}</span>
<div className="flex flex-wrap gap-1"> </>
{component.system_dependencies.map((dep) => ( )}
<span {component.installed !== null && (
key={dep} <>
className="text-xs px-2 py-0.5 rounded bg-amber-900/30 text-amber-400 border border-amber-800" <span className="text-[var(--muted)]">Installed</span>
> <span>{component.installed ? "Yes" : "No"}</span>
{dep} </>
</span>
))}
</div>
</div>
)} )}
</div> </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} /> <ConfigPanel component={component} configSection="programs" onRefetch={refetch} />
</div> </div>

View File

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

View File

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

View File

@@ -27,6 +27,9 @@ class ComponentSummary(BaseModel):
systemd: SystemdInfo | None = None systemd: SystemdInfo | None = None
version: str | None = None version: str | None = None
source: 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] = [] system_dependencies: list[str] = []
schedule: str | None = None schedule: str | None = None
installed: bool | None = None installed: bool | None = None
@@ -91,6 +94,9 @@ class ProgramSummary(BaseModel):
runner: str | None = None runner: str | None = None
version: str | None = None version: str | None = None
source: 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] = [] system_dependencies: list[str] = []
installed: bool | None = None installed: bool | None = None
actions: list[str] = [] 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: def get_mesh_status(request: Request) -> MeshStatus:
"""Get the current state of the mesh coordination layer.""" """Get the current state of the mesh coordination layer."""
mqtt_client = getattr(request.app.state, "mqtt_client", None) 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()) 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 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 from castle_api.config import get_config
@@ -27,7 +27,11 @@ _VALID_ACTIONS = {
@programs_router.post("/programs/{name}/{action}") @programs_router.post("/programs/{name}/{action}")
async def program_action(name: str, action: str) -> dict: 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: if action not in _VALID_ACTIONS:
raise HTTPException(status_code=400, detail=f"Unknown action: {action}") 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: if not comp.source:
raise HTTPException(status_code=400, detail=f"'{name}' has no source directory") raise HTTPException(status_code=400, detail=f"'{name}' has no source directory")
actions = available_actions(comp) if action not in available_actions(comp):
if action not in actions:
raise HTTPException( raise HTTPException(
status_code=400, 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) result = await run_action(action, name, comp, config.root)
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)
if result.status != "ok": if result.status != "ok":
raise HTTPException(status_code=500, detail=result.output or f"{action} failed") 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"]) 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: def _summary_from_deployed(name: str, deployed: object) -> ComponentSummary:
"""Build a ComponentSummary from a DeployedComponent.""" """Build a ComponentSummary from a DeployedComponent."""
managed = deployed.managed managed = deployed.managed
@@ -172,7 +185,7 @@ def _summary_from_program(name: str, comp: ProgramSpec, root: Path) -> Component
runner = "command" runner = "command"
installed: bool | None = None 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 installed = shutil.which(name) is not None
return ComponentSummary( return ComponentSummary(
@@ -184,6 +197,9 @@ def _summary_from_program(name: str, comp: ProgramSpec, root: Path) -> Component
runner=runner, runner=runner,
version=comp.version, version=comp.version,
source=source, source=source,
repo=comp.repo,
ref=comp.ref,
commands=_declared_commands_dict(comp),
system_dependencies=comp.system_dependencies, system_dependencies=comp.system_dependencies,
installed=installed, installed=installed,
) )
@@ -323,7 +339,7 @@ def _program_from_spec(
runner = "command" runner = "command"
installed: bool | None = None 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 installed = shutil.which(name) is not None
return ProgramSummary( return ProgramSummary(
@@ -334,6 +350,9 @@ def _program_from_spec(
runner=runner, runner=runner,
version=comp.version, version=comp.version,
source=source, source=source,
repo=comp.repo,
ref=comp.ref,
commands=_declared_commands_dict(comp),
system_dependencies=comp.system_dependencies, system_dependencies=comp.system_dependencies,
installed=installed, installed=installed,
actions=available_actions(comp), actions=available_actions(comp),

View File

@@ -36,6 +36,17 @@ def castle_root(tmp_path: Path) -> Generator[Path, None, None]:
"behavior": "tool", "behavior": "tool",
"version": "2.0.0", "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": { "services": {
"test-svc": { "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 from __future__ import annotations
import argparse 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 ( from castle_cli.manifest import (
CaddySpec, CaddySpec,
ProgramSpec,
ExposeSpec, ExposeSpec,
HttpExposeSpec, HttpExposeSpec,
HttpInternal, HttpInternal,
ManageSpec, ManageSpec,
ProgramSpec,
ProxySpec, ProxySpec,
RunPython, RunPython,
ServiceSpec, ServiceSpec,
@@ -43,21 +44,20 @@ def next_available_port(config: object) -> int:
def run_create(args: argparse.Namespace) -> 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() config = load_config()
name = args.name name = args.name
stack = args.stack 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: if name in config.programs or name in config.services or name in config.jobs:
print(f"Error: '{name}' already exists in castle.yaml") print(f"Error: '{name}' already exists in castle.yaml")
return 1 return 1
code_dir = config.root / "code" REPOS_DIR.mkdir(parents=True, exist_ok=True)
code_dir.mkdir(exist_ok=True) project_dir = REPOS_DIR / name
project_dir = code_dir / name
if project_dir.exists(): if project_dir.exists():
print(f"Error: directory 'code/{name}' already exists") print(f"Error: directory already exists: {project_dir}")
return 1 return 1
# Determine port for daemons # Determine port for daemons
@@ -65,24 +65,29 @@ def run_create(args: argparse.Namespace) -> int:
if behavior == "daemon" and port is None: if behavior == "daemon" and port is None:
port = next_available_port(config) port = next_available_port(config)
# Package name: convert kebab-case to snake_case
package_name = name.replace("-", "_") package_name = name.replace("-", "_")
description = args.description or (f"A castle {stack} program" if stack else f"{name}")
# Scaffold the project files if stack:
scaffold_project( scaffold_project(
project_dir=project_dir, project_dir=project_dir,
name=name, name=name,
package_name=package_name, package_name=package_name,
stack=stack, stack=stack,
description=args.description or f"A castle {stack} program", description=description,
port=port, 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( config.programs[name] = ProgramSpec(
id=name, id=name,
description=args.description or f"A castle {stack} program", description=description,
source=f"code/{name}", source=str(project_dir),
stack=stack, stack=stack,
behavior=behavior, behavior=behavior,
) )
@@ -103,16 +108,20 @@ def run_create(args: argparse.Namespace) -> int:
save_config(config) 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: if port:
print(f" Port: {port}") print(f" Port: {port}")
print(" Registered in castle.yaml") print(" Registered in castle.yaml")
print("\nNext steps:") print("\nNext steps:")
print(f" cd ~/.castle/code/{name}") print(f" cd {project_dir}")
print(" uv sync") if stack:
if behavior == "daemon": print(" uv sync")
print(f" uv run {name} # starts on port {port}") if behavior == "daemon":
print(f" castle deploy {name} # deploy to ~/.castle/") print(f" uv run {name} # starts on port {port}")
print(f" castle test {name}") print(f" castle deploy {name}")
print(f" castle test {name}")
else:
print(" # add code, then declare commands: in castle.yaml")
return 0 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 from __future__ import annotations
import argparse import argparse
import asyncio 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 from castle_cli.config import CastleConfig, load_config
def _run_action(config: CastleConfig, project_name: str, action: str) -> bool: def _run_verb(config: CastleConfig, project_name: str, verb: str) -> bool:
"""Run a stack action for a single project. Returns True on success.""" """Run a verb for a single program. Returns True on success."""
if project_name not in config.programs: if project_name not in config.programs:
print(f"Unknown component: {project_name}") print(f"Unknown program: {project_name}")
return False return False
comp = config.programs[project_name] 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") print(f" {project_name}: no source directory, skipping")
return True return True
handler = get_handler(comp.stack) if not is_available(comp, verb):
if handler is None: print(f" {project_name}: '{verb}' not available (no declared command or stack), skipping")
print(f" {project_name}: unsupported stack '{comp.stack}', skipping")
return True 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"\n{'' * 40}")
print(f" {action}: {project_name}") print(f" {verb}: {project_name}")
print(f"{'' * 40}") 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: if result.output:
print(result.output) print(result.output)
return result.status == "ok" return result.status == "ok"
def run_test(args: argparse.Namespace) -> int: def _run_verb_all(config: CastleConfig, verb: str) -> bool:
"""Run tests for one or all projects.""" """Run a verb across every program that supports it. Returns True if all pass."""
config = load_config()
if args.project:
success = _run_action(config, args.project, "test")
return 0 if success else 1
# Run all
all_passed = True all_passed = True
ran_any = False
for name, comp in config.programs.items(): for name, comp in config.programs.items():
if not comp.source: if not comp.source or not is_available(comp, verb):
continue continue
handler = get_handler(comp.stack) ran_any = True
if handler is None: if not _run_verb(config, name, verb):
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"):
all_passed = False all_passed = False
if not ran_any:
print(f"No programs support '{verb}'.")
return all_passed
if all_passed:
print("\nAll tests passed.") def run_verb(args: argparse.Namespace, verb: str) -> int:
else: """Generic entry point for a dev verb (single project or all)."""
print("\nSome tests failed.") config = load_config()
return 0 if all_passed else 1 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: def run_lint(args: argparse.Namespace) -> int:
"""Run linter for one or all projects.""" return run_verb(args, "lint")
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
def run_build(args: argparse.Namespace) -> int: def run_build(args: argparse.Namespace) -> int:
"""Run build for one or all projects.""" return run_verb(args, "build")
config = load_config()
if args.project:
success = _run_action(config, args.project, "build")
return 0 if success else 1
# Run all buildable projects def run_type_check(args: argparse.Namespace) -> int:
all_passed = True return run_verb(args, "type-check")
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
if all_passed:
print("\nAll builds succeeded.") def run_check(args: argparse.Namespace) -> int:
else: return run_verb(args, "check")
print("\nSome builds failed.")
return 0 if all_passed else 1
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 from __future__ import annotations
import argparse import argparse
import os import os
import subprocess import subprocess
from pathlib import Path
from castle_core.registry import REGISTRY_PATH, load_registry 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: 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(): if not REGISTRY_PATH.exists():
print("Error: no registry found. Run 'castle deploy' first.") print("Error: no registry found. Run 'castle deploy' first.")
return 1 return 1
registry = load_registry() registry = load_registry()
name = args.name
if name not in registry.deployed: if name not in registry.deployed:
print(f"Error: component '{name}' not found in registry.") print(f"Error: '{name}' is not a runnable program or deployed service.")
print("Run 'castle deploy' to update the registry.") print("Declare a `run` command in castle.yaml, or run 'castle deploy'.")
return 1 return 1
deployed = registry.deployed[name] deployed = registry.deployed[name]
# Build command with any extra args
extra_args = getattr(args, "extra", []) or []
cmd = list(deployed.run_cmd) + extra_args cmd = list(deployed.run_cmd) + extra_args
# Merge environment
env = dict(os.environ) env = dict(os.environ)
env.update(deployed.env) env.update(deployed.env)
# Run in foreground (no cwd — registry-based, no repo dependency)
print(f"Running {name}: {' '.join(cmd)}") print(f"Running {name}: {' '.join(cmd)}")
result = subprocess.run(cmd, env=env) return subprocess.run(cmd, env=env).returncode
return result.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, F403
from castle_core.config import ( # noqa: F401 — explicit re-exports for type checkers from castle_core.config import ( # noqa: F401 — explicit re-exports for type checkers
ARTIFACTS_DIR,
CASTLE_HOME, CASTLE_HOME,
CODE_DIR, CODE_DIR,
ARTIFACTS_DIR,
SPECS_DIR,
CONTENT_DIR, CONTENT_DIR,
DATA_DIR, DATA_DIR,
SECRETS_DIR,
GENERATED_DIR, GENERATED_DIR,
REPOS_DIR,
SECRETS_DIR,
SPECS_DIR,
STATIC_DIR, STATIC_DIR,
CastleConfig, CastleConfig,
GatewayConfig, GatewayConfig,

View File

@@ -37,11 +37,30 @@ def build_parser() -> argparse.ArgumentParser:
create_parser.add_argument( create_parser.add_argument(
"--stack", "--stack",
choices=["python-cli", "python-fastapi", "react-vite"], choices=["python-cli", "python-fastapi", "react-vite"],
required=True, default=None,
help="Development stack (determines scaffold template and default behavior)", 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("--description", default="", help="Project description")
create_parser.add_argument("--port", type=int, help="Port number (daemons only)") 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 # castle info
info_parser = subparsers.add_parser("info", help="Show program details") info_parser = subparsers.add_parser("info", help="Show program details")
info_parser.add_argument("project", help="Program, service, or job name") info_parser.add_argument("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 = subparsers.add_parser("build", help="Build projects")
build_parser.add_argument("project", nargs="?", help="Project to build (default: all)") 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 # castle gateway
gateway_parser = subparsers.add_parser("gateway", help="Manage the Caddy gateway") gateway_parser = subparsers.add_parser("gateway", help="Manage the Caddy gateway")
gateway_sub = gateway_parser.add_subparsers(dest="gateway_command") 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)" "-n", "--lines", type=int, default=50, help="Number of lines to show (default: 50)"
) )
# castle run # castle run — run a program (declared run) or deployed service in the foreground
run_parser = subparsers.add_parser("run", help="Run a 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="Service name") run_parser.add_argument("name", help="Program or service name")
run_parser.add_argument( 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 # castle deploy
deploy_parser = subparsers.add_parser("deploy", help="Deploy to ~/.castle/ (spec → runtime)") 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)") 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 return parser
@@ -145,6 +171,21 @@ def main() -> int:
return run_create(args) 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": elif args.command == "test":
from castle_cli.commands.dev import run_test from castle_cli.commands.dev import run_test
@@ -160,6 +201,26 @@ def main() -> int:
return run_build(args) 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": elif args.command == "gateway":
from castle_cli.commands.gateway import run_gateway from castle_cli.commands.gateway import run_gateway
@@ -190,11 +251,6 @@ def main() -> int:
return run_run(args) return run_run(args)
elif args.command == "tool":
from castle_cli.commands.tool import run_tool
return run_tool(args)
else: else:
parser.print_help() parser.print_help()
return 1 return 1

View File

@@ -5,7 +5,7 @@ from castle_core.manifest import ( # noqa: F401 — explicit re-exports for typ
BuildSpec, BuildSpec,
CaddySpec, CaddySpec,
Capability, Capability,
ProgramSpec, CommandsSpec,
DefaultsSpec, DefaultsSpec,
EnvMap, EnvMap,
ExposeSpec, ExposeSpec,
@@ -14,6 +14,7 @@ from castle_core.manifest import ( # noqa: F401 — explicit re-exports for typ
HttpPublic, HttpPublic,
JobSpec, JobSpec,
ManageSpec, ManageSpec,
ProgramSpec,
ProxySpec, ProxySpec,
ReadinessHttpGet, ReadinessHttpGet,
RestartPolicy, 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: class TestCreateCommand:
"""Tests for the create command.""" """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.""" """Create a new service project."""
repos = tmp_path / "repos"
with ( with (
patch("castle_cli.commands.create.load_config") as mock_load, 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.save_config") as mock_save,
patch("castle_cli.commands.create.REPOS_DIR", repos),
): ):
config = load_config(castle_root) config = load_config(castle_root)
mock_load.return_value = config mock_load.return_value = config
@@ -32,7 +34,7 @@ class TestCreateCommand:
result = run_create(args) result = run_create(args)
assert result == 0 assert result == 0
project_dir = castle_root / "code" / "my-api" project_dir = repos / "my-api"
assert project_dir.exists() assert project_dir.exists()
assert (project_dir / "pyproject.toml").exists() assert (project_dir / "pyproject.toml").exists()
assert (project_dir / "src" / "my_api" / "main.py").exists() assert (project_dir / "src" / "my_api" / "main.py").exists()
@@ -49,11 +51,13 @@ class TestCreateCommand:
assert svc.component == "my-api" assert svc.component == "my-api"
mock_save.assert_called_once() 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.""" """Create a new tool project."""
repos = tmp_path / "repos"
with ( with (
patch("castle_cli.commands.create.load_config") as mock_load, patch("castle_cli.commands.create.load_config") as mock_load,
patch("castle_cli.commands.create.save_config"), patch("castle_cli.commands.create.save_config"),
patch("castle_cli.commands.create.REPOS_DIR", repos),
): ):
config = load_config(castle_root) config = load_config(castle_root)
mock_load.return_value = config mock_load.return_value = config
@@ -64,7 +68,7 @@ class TestCreateCommand:
result = run_create(args) result = run_create(args)
assert result == 0 assert result == 0
project_dir = castle_root / "code" / "my-tool2" project_dir = repos / "my-tool2"
assert project_dir.exists() assert project_dir.exists()
assert (project_dir / "src" / "my_tool2" / "main.py").exists() assert (project_dir / "src" / "my_tool2" / "main.py").exists()
assert (project_dir / "CLAUDE.md").exists() assert (project_dir / "CLAUDE.md").exists()
@@ -91,11 +95,12 @@ class TestCreateCommand:
assert result == 1 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.""" """Service creation auto-assigns next available port."""
with ( with (
patch("castle_cli.commands.create.load_config") as mock_load, patch("castle_cli.commands.create.load_config") as mock_load,
patch("castle_cli.commands.create.save_config"), patch("castle_cli.commands.create.save_config"),
patch("castle_cli.commands.create.REPOS_DIR", tmp_path / "repos"),
): ):
config = load_config(castle_root) config = load_config(castle_root)
mock_load.return_value = config 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") 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() CASTLE_HOME = _resolve_castle_home()
CODE_DIR = CASTLE_HOME / "code" CODE_DIR = CASTLE_HOME / "code"
ARTIFACTS_DIR = CASTLE_HOME / "artifacts" ARTIFACTS_DIR = CASTLE_HOME / "artifacts"
@@ -60,6 +73,7 @@ SPECS_DIR = ARTIFACTS_DIR / "specs"
CONTENT_DIR = ARTIFACTS_DIR / "content" CONTENT_DIR = ARTIFACTS_DIR / "content"
DATA_DIR = _resolve_data_dir() DATA_DIR = _resolve_data_dir()
SECRETS_DIR = CASTLE_HOME / "secrets" SECRETS_DIR = CASTLE_HOME / "secrets"
REPOS_DIR = _resolve_repos_dir()
# Backwards-compat aliases (used by existing imports) # Backwards-compat aliases (used by existing imports)
GENERATED_DIR = SPECS_DIR GENERATED_DIR = SPECS_DIR

View File

@@ -5,7 +5,7 @@ from __future__ import annotations
from enum import Enum from enum import Enum
from typing import Annotated, Literal, Union 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] EnvMap = dict[str, str]
@@ -149,6 +149,35 @@ class BuildSpec(BaseModel):
outputs: list[str] = Field(default_factory=list) 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 # Capabilities
# --------------------- # ---------------------
@@ -184,6 +213,14 @@ class ProgramSpec(BaseModel):
source: str | None = None source: str | None = None
stack: 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) system_dependencies: list[str] = Field(default_factory=list)
install_extras: list[str] = Field(default_factory=list) install_extras: list[str] = Field(default_factory=list)
version: str | None = None version: str | None = None

View File

@@ -12,10 +12,15 @@ from pathlib import Path
from castle_core.config import CONTENT_DIR from castle_core.config import CONTENT_DIR
from castle_core.manifest import ProgramSpec 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"] INSTALL_ACTIONS = ["install", "uninstall"]
ALL_ACTIONS = DEV_ACTIONS + INSTALL_ACTIONS 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. # User-local tool directories that may not be on the systemd service PATH.
_EXTRA_PATH_DIRS = [ _EXTRA_PATH_DIRS = [
Path.home() / ".local" / "share" / "pnpm", Path.home() / ".local" / "share" / "pnpm",
@@ -253,11 +258,97 @@ def get_handler(stack: str | None) -> StackHandler | None:
return HANDLERS.get(stack) 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]: 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: if not comp.source:
return [] 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) handler = get_handler(comp.stack)
if handler is None: if handler is not None and verb in _STACK_VERBS:
return [] method = getattr(handler, _VERB_METHOD.get(verb, verb), None)
return list(ALL_ACTIONS) 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` the git repo and use the `repo:` prefix. When `castle deploy` writes `castle.yaml`
back out, it rewrites absolute paths into these relative forms. back out, it rewrites absolute paths into these relative forms.
### `stack` — Development toolchain ### `stack` — Development toolchain (optional)
```yaml ```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 ### `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 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 ### `version` — Program version

View File

@@ -1,5 +1,12 @@
# Python Tools in Castle # 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. How to build CLI tools following Unix philosophy.
## Principles ## Principles

View File

@@ -1,5 +1,12 @@
# Web APIs in Castle # 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 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) patterns used in [wild-cloud/api](https://github.com/civilsociety-dev/wild-cloud)
and existing castle services (central-context, notification-bridge, event-bus). and existing castle services (central-context, notification-bridge, event-bus).

View File

@@ -1,5 +1,12 @@
# Web Frontends in Castle # 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. How to build, serve, and manage web frontends as castle components.
## Stack ## Stack