Files
wild-pc/docs/stacks/python-cli.md
Paul Payne 05b28cb584 Rename Castle -> Wild PC across the repo
Repo-side rename only (Phases 1-3 of the migration plan); the live box
(~/.castle, systemd units, /data/castle, domains) is a separate cutover.

- Slug `castle` -> `wildpc`: CLI command, module names (wildpc_core/cli/api),
  dist names, entry point `wildpc = wildpc_cli.main:main`.
- Identifiers: CastleConfig/NATSClient/DirError/MDNS -> Wildpc*.
- Env/constants: CASTLE_* -> WILDPC_*; ~/.castle -> ~/.wildpc, castle.yaml ->
  wildpc.yaml, /data/castle -> /data/wildpc.
- Systemd UNIT_PREFIX castle- -> wildpc-; own programs castle-api/gateway/etc.
- Display prose "Castle" -> "Wild PC" in docs, agent-guide files, README, frontend.
- Package dirs and bootstrap yaml renamed via git mv; lockfiles regenerated;
  redundant nested uv.lock files dropped (workspace root lock is authoritative).

Tests: core 273, cli 47, wildpc-api 120 all pass. Frontend type-checks + builds.
Fixed a stale test fixture (secret_env_path kind arg) broken pre-rename.
2026-07-18 22:55:08 -07:00

8.5 KiB

Python Tools in Wild PC

This is a stack — creation-time guidance for writing new CLI tools. A stack is a template + conventions, not a runtime requirement. wildpc program create --stack python-cli scaffolds from it and seeds the program's default dev-verb commands. An existing CLI adopted with wildpc program 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 /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

wildpc 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 wildpc.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.