Replace the conflated `runner` axis with two orthogonal ones: `manager`
(systemd|caddy|path|none) — who supervises/realizes a deployment — and, for
systemd only, a nested `launcher` (python|command|container|compose|node) — how
the process starts. ServiceSpec and JobSpec collapse into one manager-
discriminated DeploymentSpec union (Systemd/Caddy/Path/Remote); the services/
and jobs/ config dirs collapse into one deployments/ dir. The human "kind"
(service|job|tool|static|reference) is fully derived (kind_for), never stored —
the frontend kind is renamed static. behavior is gone.
- core: DeploymentSpec union + LaunchSpec + kind_for; legacy-aware loader
normalizes old runner shapes; CastleConfig.deployments with derived
services/jobs/tools views; registry.Deployment carries manager/launcher/kind.
- cli: service/job/tool as filtered views + a deployment group; --behavior→--kind,
create --runner→--launcher; lifecycle dispatches over config.deployments.
- castle-api: /deployments primary with /services,/jobs as views; summaries
derive kind; PUT/DELETE /config/deployments/{name} (services/jobs aliased).
- app: KindBadge, frontend→static everywhere, pick-a-kind creation wizard,
per-kind config editors.
- docs: single deployments/ layout, manager/launcher, static kind throughout.
Live migration verified byte-identical: regenerated Caddyfile and every unit
ExecStart line unchanged, so nothing restarted. Suites: core 124, cli 25,
castle-api 55; dashboard build + type-check clean.
8.5 KiB
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 program create --stack python-cliscaffolds from it and seeds the program's default dev-verb commands. An existing CLI adopted withcastle program adddoesn't need this stack — it declares its owncommands:. See @docs/registry.md forcommands:,stack:(optional), andrepo:.
How to build CLI tools following Unix philosophy.
Principles
- Each tool does one thing well
- Read from stdin or file argument, write to stdout
- Compose via pipes:
pdf2md doc.pdf | gpt "summarize this" - Status messages go to stderr (don't interfere with piping)
- Exit 0 on success, 1 on error
Stack
| Layer | Choice |
|---|---|
| CLI | argparse |
| Package manager | uv (never pip) |
| Build | hatchling |
| Testing | pytest |
| Linting | ruff (shared ruff.toml at repo root) |
| Type checking | pyright (shared pyrightconfig.json at repo root) |
| Python | 3.11+ minimum |
Project layout
Each tool is an independent project under /data/repos/ with its own pyproject.toml:
/data/repos/my-tool/
├── src/my_tool/
│ ├── __init__.py
│ └── main.py # Entry point
├── tests/
│ └── test_main.py
├── pyproject.toml
└── CLAUDE.md
Examples: code/pdf2md/, code/gpt/, code/protonmail/
Creating a new tool
castle program create my-tool --stack python-cli --description "Does something"
cd /data/repos/my-tool && uv sync
This scaffolds the project and registers it in castle.yaml.
pyproject.toml
[project]
name = "my-tool"
version = "0.1.0"
description = "Does something useful"
requires-python = ">=3.11"
dependencies = []
[project.scripts]
my-tool = "my_tool.main:main"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/my_tool"]
[dependency-groups]
dev = ["pytest>=7.0.0"]
[tool.ruff.lint.isort]
known-first-party = ["my_tool"]
After uv tool install --editable ., the command is in PATH.
Tool implementation patterns
Simple tool: stdin/stdout
The most common pattern. Read from a file argument or stdin, process, write to stdout.
#!/usr/bin/env python3
"""
my-tool: Brief description
Usage:
my-tool [options] [FILE]
cat input.txt | my-tool
Examples:
my-tool input.txt
my-tool input.txt -o output.txt
cat input.txt | my-tool > output.txt
"""
import argparse
import sys
def process(data: str) -> str:
"""Core logic — pure function, easy to test."""
return data.upper()
def main() -> int:
parser = argparse.ArgumentParser(
description="Brief description",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument("input", nargs="?", help="Input file (default: stdin)")
parser.add_argument("-o", "--output", help="Output file (default: stdout)")
parser.add_argument("--version", action="version", version="my-tool 1.0.0")
args = parser.parse_args()
# Read
if args.input:
with open(args.input) as f:
data = f.read()
else:
data = sys.stdin.read()
# Process
result = process(data)
# Write
if args.output:
with open(args.output, "w") as f:
f.write(result)
print(f"Wrote to {args.output}", file=sys.stderr)
else:
print(result, end="")
return 0
if __name__ == "__main__":
sys.exit(main())
Tool with subcommands
For complex tools with multiple operations:
def cmd_init(args: argparse.Namespace) -> int:
"""Initialize a collection."""
directory = args.directory or "."
# ...
print(f"Initialized in {directory}")
return 0
def cmd_query(args: argparse.Namespace) -> int:
"""Search a collection."""
# ...
return 0
def main() -> int:
parser = argparse.ArgumentParser(description="Manage collections")
parser.add_argument("--version", action="version", version="1.0.0")
subparsers = parser.add_subparsers(dest="command")
init_p = subparsers.add_parser("init", help="Initialize")
init_p.add_argument("directory", nargs="?")
init_p.add_argument("--name", help="Collection name")
init_p.add_argument("--force", action="store_true")
query_p = subparsers.add_parser("query", help="Search")
query_p.add_argument("query", help="Search query")
query_p.add_argument("--limit", type=int, default=20)
args = parser.parse_args()
if args.command == "init":
return cmd_init(args)
elif args.command == "query":
return cmd_query(args)
else:
parser.print_help()
return 1
Tool with external processes
When wrapping system commands:
import subprocess
import sys
def convert(input_file: str, output_file: str) -> int:
try:
subprocess.run(
["pandoc", input_file, "-o", output_file],
check=True,
capture_output=True,
text=True,
)
return 0
except subprocess.CalledProcessError as e:
print(f"Error: {e}", file=sys.stderr)
if e.stderr:
print(e.stderr, file=sys.stderr)
return 1
except FileNotFoundError:
print("Error: pandoc not found. Install with: apt install pandoc",
file=sys.stderr)
return 1
Always use check=True and capture_output=True with subprocess. Handle
FileNotFoundError for missing system dependencies.
Error handling
def main() -> int:
try:
result = do_work()
print(result)
return 0
except SpecificError as e:
print(f"Error: {e}", file=sys.stderr)
return 1
except FileNotFoundError as e:
print(f"Error: file not found: {e}", file=sys.stderr)
return 1
Rules:
- Normal output goes to stdout (enables piping)
- Error messages go to stderr
- Status/progress messages go to stderr
- Return 0 for success, 1 for error
- Entry point:
sys.exit(main())
Piping
Tools compose naturally via Unix pipes:
# Convert and summarize
pdf2md paper.pdf | gpt "summarize this"
# Process a batch
for f in *.pdf; do pdf2md "$f" > "${f%.pdf}.md"; done
# Chain extractors
cat doc.txt | text-extractor | jq .content
Testing
Tests use pytest. For standalone tools, test via subprocess to exercise the real CLI interface:
import subprocess
import sys
class TestCLI:
def test_version(self) -> None:
result = subprocess.run(
[sys.executable, "-m", "my_tool.main", "--version"],
capture_output=True,
text=True,
)
assert "my-tool" in result.stdout
def test_stdin(self) -> None:
result = subprocess.run(
[sys.executable, "-m", "my_tool.main"],
input="hello\n",
capture_output=True,
text=True,
)
assert result.returncode == 0
assert "hello" in result.stdout
def test_file_input(self, tmp_path) -> None:
input_file = tmp_path / "input.txt"
input_file.write_text("test data")
result = subprocess.run(
[sys.executable, "-m", "my_tool.main", str(input_file)],
capture_output=True,
text=True,
)
assert result.returncode == 0
assert "test data" in result.stdout
For unit testing core logic, import the function directly and test it as a pure function.
Commands
uv sync # Install deps
uv run my-tool --help # Run the tool
uv run pytest tests/ -v # Run tests
uv run ruff check . # Lint
uv run ruff format . # Format
Registering in the registry
# programs/my-tool.yaml
description: Does something useful
source: /data/repos/my-tool
stack: python-cli
# deployments/my-tool.yaml (manager: path → kind: tool)
program: my-tool
manager: path
Tools with system dependencies declare them directly on the program:
# programs/pdf2md.yaml
description: Convert PDF files to Markdown
source: /data/repos/pdf2md
stack: python-cli
system_dependencies: [pandoc, poppler-utils]
A tool is a programs/<name>.yaml entry plus a deployments/<name>.yaml with
manager: path (derived kind: tool). If a tool also runs on a schedule, add
a second deployment with manager: systemd + schedule (derived kind:
job) referencing the same program.
See @docs/registry.md for the full registry reference.