Files
wild-pc/docs/stacks/python-cli.md
Paul Payne 3f3b88f17b refactor: Align vocabulary — component → program / deployment
Canonical terms (see docs/registry.md glossary):
- program: any catalog entry (tool/daemon/frontend) — the software
- service / job: how a program is deployed (systemd .service / .timer)
- deployment: umbrella for the unified service+job+program view

Changes:
- core: ServiceSpec/JobSpec component: → program: (component accepted as
  back-compat validation_alias); DeployedComponent → Deployment.
- api: ComponentSummary/Detail → DeploymentSummary/Detail; GET /components
  → /deployments; GatewayRoute.component → program; GatewayInfo
  .component_count → deployment_count; ServiceActionResponse/action keys
  component → program.
- app: matching type renames, /components → /deployments hook, route
  /component/:name → /deployment/:name, GatewayRoute.program.
- docs: component-registry.md → registry.md (+ canonical glossary);
  CLAUDE.md endpoint list refreshed (drop removed /tools, add typed
  program/service/job + config routes).

Tests: core 92, cli 24, api 52 green; app build clean; ruff clean.
2026-06-14 11:05:22 -07:00

8.3 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 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/registry.md for commands:, stack: (optional), and repo:.

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 ~/.castle/code/ with its own pyproject.toml:

~/.castle/code/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 create my-tool --stack python-cli --description "Does something"
cd ~/.castle/code/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 castle.yaml

programs:
  my-tool:
    description: Does something useful
    source: code/my-tool
    stack: python-cli
    behavior: tool

Tools with system dependencies declare them directly on the program:

programs:
  pdf2md:
    description: Convert PDF files to Markdown
    source: code/pdf2md
    stack: python-cli
    behavior: tool
    system_dependencies: [pandoc, poppler-utils]

Tools live in the programs: section. If a tool also runs on a schedule, add a separate entry in the jobs: section referencing the component.

See @docs/registry.md for the full registry reference.