Skip to main content

File-based CLI routing for Python — build and organize command-line tooling by filesystem layout.

Project description

rad-cli

File-based CLI routing for Python — the SvelteKit pattern, for command-line tooling. Build apps by laying out .py files in a directory tree; the filesystem is the command grammar.

commands/
├── greet.py                   # my-app greet
├── users/
│   ├── list.py                # my-app users list
│   └── _name_.py              # my-app users <name>  (captures name)
└── _rest_.py                  # my-app <anything...>  (catches remaining args)

No decorators, no registration, no app object. A command is just a module with define() and execute(). Dependencies go through a punq.Container so tests can't accidentally hit production. --help is built in.


Table of contents


Install

uv pip install rad-cli

This gives you two things:

  1. The rad_cli Python library (importable).
  2. The rad-cli command-line tool (for scaffolding new projects).

Quick start

Scaffold a new project and run it:

rad-cli new my-app
cd my-app
uv sync
uv run my-app --help
uv run my-app hello
uv run my-app greet Alice --loud
uv run pytest

The scaffold produces a working project with three example commands (a plain one, a DI one, and a route-param one), matching tests, and a fully-wired __main__.py. Everything after this point in the README is reference: you don't need to read it front-to-back to start building.

Command anatomy

A command is a Python module in your commands/ tree with two required functions. It looks like this:

# commands/hello.py
from rad_cli import Def, RouteCtx


def define() -> Def:
    """Declare what this command accepts — description and flags."""
    return Def(description="Say hello.")


def execute(rt: RouteCtx) -> None:
    """Run the command."""
    print("Hello!")

define() returns a Def. That's the command's self-description — what --help reads, what the flag parser uses.

execute() does the work. Its signature can be either:

  • execute(rt: RouteCtx) — no dependencies.
  • execute(rt: RouteCtx, c: Container) — receives the DI container.

rad-cli inspects your signature and calls you with the right number of arguments. Commands that don't need DI don't have to participate in it.

Optional lifecycle hooks — any command can also declare:

  • setup(rt, c) — runs before execute. By convention, only used to register things in the container; not for side effects.
  • teardown(rt, c) — runs after execute in a finally block.

That's the whole contract. No base classes, no decorators, no registration — just module-level functions.

Help (--help)

Every rad-cli app gets --help for free by calling handle_help(...) in its main(). The scaffolded __main__.py already does this. Users can:

  • List all commandsmy-app --help
  • Detail by numeric IDmy-app --help 2
  • Detail by regexmy-app --help greet (single match → detail)
  • Filtered list by regexmy-app --help '^hello' (multiple matches)

Example output:

$ my-app --help
usage: my-app <command> [args...]

Use --help <id> or --help <pattern> to see detailed help.

Commands:
  1  greet <name>  Print a greeting for a given name, captured from the route as <name>.
  2  hello         Print a greeting.
  3  hello punq    Print a greeting composed by a container-resolved Greeter.

$ my-app --help greet
my-app greet <name>

  Description:
    Print a greeting for a given name, captured from the route as <name>.
    Example: ``my-app greet alice`` → ``Hello, alice.``. Add --loud to shout
    the greeting.

  Flags:
    --loud  Shout the greeting.

  File:   /path/to/commands/greet/_name_.py

Writing descriptions. define()'s description field is shown in both views. The list view shows the first sentence only; the detail view shows the whole thing, word-wrapped. So write a short summary sentence followed by any longer context:

return Def(
    description=(
        "Compact summary sentence for the list view. "
        "Follow-up sentences are included in the detail view only, "
        "giving room for examples and nuance."
    ),
)

Per-flag descriptions live on Flag(..., description=...).

Capturing input

Commands receive parsed input via rt.args. Three sources feed it: route params (captured from directory/file names), a positional "rest" overflow, and flags.

Route params

A directory or file named _name_ captures the matching segment as a param called name. The filesystem is the param grammar:

commands/
├── users/
│   ├── _user_/                # captures "<user>" for everything below
│   │   ├── show.py            # my-app users <user> show
│   │   └── edit.py            # my-app users <user> edit
│   └── _user_.py              # my-app users <user>         (leaf)
└── greet/
    └── _name_.py              # my-app greet <name>

Access in execute:

def execute(rt: RouteCtx) -> None:
    name = rt.args.get_one("name")
    print(f"Hello, {name}!")

Explicit resolver names. If a param's name should differ from the resolver it routes to, use _param_as_resolver_/:

commands/
└── users/
    └── _user_/
        └── send/
            └── _target_as_user_.py    # my-app users <user> send <target>

Both params pass through the user resolver (if one is registered), but they're stored under distinct names (user and target) in rt.args.

Directory defaults: _index_.py

A file named _index_.py inside a directory is what runs when the user types the directory name with no further segments:

commands/
└── hello/
    ├── _index_.py          # my-app hello
    └── punq.py             # my-app hello punq

Without _index_.py, my-app hello would fail to route — there's no leaf file at that position. With it, my-app hello runs hello/_index_.py, while deeper paths like my-app hello punq route normally to their leaves.

_index_.py (single underscores) is distinct from Python's __init__.py (double underscores). Every directory in your tree still needs __init__.py to be a Python package — rad-cli excludes double-underscore ("dunder") files from routing entirely. Only the single-underscore form is user-facing.

The rest catch-all

A file named _rest_.py consumes remaining positional args at its level:

commands/
└── echo/
    └── _rest_.py              # my-app echo <anything...>
def execute(rt: RouteCtx) -> None:
    print(" ".join(rt.args.rest))

_rest_.py cannot coexist with any sibling param file or directory (it would be ambiguous which should capture the next token).

Flags

Everything from the first ---prefixed token onward is parsed as flags. Declare them in define():

from rad_cli import Def, Flag, RouteCtx


def define() -> Def:
    return Def(
        description="Demonstrate flag shapes.",
        flags=[
            Flag("verbose", type=bool),                           # boolean toggle
            Flag("name"),                                         # single value
            Flag("count", type=int, description="How many?"),     # typed single value
            Flag("tags", min_args=0, max_args=None),              # multi-value (0+)
            Flag("ids", min_args=1, max_args=None),               # multi-value (1+)
            Flag("pair", min_args=2, max_args=2),                 # exactly 2
        ],
    )


def execute(rt: RouteCtx) -> None:
    # Boolean — presence = True
    if rt.args.has("verbose"):
        ...

    # Single value — errors if missing (unless default_value)
    name = rt.args.get_one("name", default_value="world")

    # Multi-value — get the list
    tags = rt.args.get_list("tags", default_value=[])

    # First of possibly-many
    first_id = rt.args.get_first("ids", default_value=None)

Flag shapes supported on the command line:

  • --verbose — boolean presence.
  • --name alice — single value, space-separated.
  • --name=alice — single value, =-separated.
  • --tags a b c — multi-value (up to max_args, then next --flag wins).

Unknown flags raise ValueError — rad-cli rejects --foo if your command didn't declare it.

Dependency injection

rad-cli uses punq as its DI container. Your host state (paths, settings, clients, databases) goes through the container — not through globals, not through monkey-patched imports in tests, not through a base class you inherit. Or it doesn't. YOLO!

The contract

Your __main__.py builds a container and passes it to run_command:

from punq import Container
from rad_cli import RouteCtx, build_args, find_route, handle_help, load_command, run_command
from my_app import commands
from my_app.deps import Greeter, Database


def build_container() -> Container:
    """Register every dependency your commands can resolve."""
    c = Container()
    c.register(Greeter, instance=Greeter())
    c.register(Database, factory=lambda: Database.connect())
    return c


def main(argv: list[str] | None = None) -> int:
    # ... routing + help handling ...
    rt = RouteCtx(args=build_args(route, command))
    run_command(command, rt, build_container())
    return 0

Commands declare two-arg execute when they need dependencies:

def execute(rt: RouteCtx, c: Container) -> None:
    greeter = c.resolve(Greeter)
    db = c.resolve(Database)
    ...

Define shared types outside command files

rad-cli's loader gives each command file a unique module name per load (to defeat Python's sys.modules cache and keep loads fresh). That means a class defined inside a command file becomes a new class object on every load — breaking DI by type identity. Define your types in a plain module like my_app/deps.py:

# my_app/deps.py
class Greeter:
    def greet(self, name: str) -> str:
        return f"Hello, {name}!"
# commands/hello/punq.py
from my_app.deps import Greeter           # same class object every load

def execute(rt, c):
    greeter = c.resolve(Greeter)
    ...

The scaffolded project's deps.py has a longer explanation in its docstring.

Testing

rad-cli ships rad_cli.testing with four helpers, matching the stages of a command's evolution. You can write tests at whichever level matches what you're verifying.

1. routes_to — does the file exist?

Asserts only that a command string routes somewhere (or nowhere). The target file can be empty. Useful for TDD from red:

from rad_cli import testing
from my_app import commands

def test_greet_routes():
    assert testing.routes_to("greet alice", commands) is not None

2. require_routes_to — does it route to the right file?

Same check, but asserts the exact file path relative to the commands directory:

def test_greet_goes_to_param_file():
    testing.require_routes_to(
        "greet alice",
        commands,
        expected="greet/_name_.py",
    )

3. parse — does define() work and flags parse?

Loads the command and runs the flag parser without executing:

def test_greet_accepts_loud_flag():
    result = testing.parse("greet alice --loud", commands)
    assert result.rt.args.get_one("name") == "alice"
    assert result.rt.args.has("loud")

4. execute — run the command with mocked dependencies

The full pipeline, DI included. execute() deliberately skips setup() and teardown() — tests must register their mocks in the container explicitly. This is the pit-of-success property: if you forget a mock, c.resolve() raises — you don't silently hit production.

import pytest
from punq import Container
from rad_cli import testing
from my_app import commands
from my_app.deps import Greeter


def test_greet_uses_mocked_greeter(capsys: pytest.CaptureFixture[str]) -> None:
    class FakeGreeter(Greeter):
        def greet(self, name: str) -> str:
            return f"[mock] {name}"

    c = Container()
    c.register(Greeter, instance=FakeGreeter())

    testing.execute("hello punq --name Alice", commands, container=c)
    assert capsys.readouterr().out == "[mock] Alice\n"

File I/O in commands

Commands that read or write files should use rt.cwd (a Path defaulted to Path.cwd()) rather than calling Path.cwd() or using bare relative paths. Then tests pass cwd=tmp_path:

def test_scaffolder_writes_files(tmp_path):
    testing.execute("new my-app", commands, cwd=tmp_path)
    assert (tmp_path / "my-app" / "pyproject.toml").exists()

No monkeypatch.chdir. No chance of the real filesystem sneaking in.

Resolvers

A resolver is a callback that turns a raw string from the command line into a domain object, at parse time. Each resolver is identified by a name — the _name_/ in a route param or the Flag(..., resolver="name") in a flag definition.

A resolver has one shape:

from typing import Any
from rad_cli import ResolveRequest


def resolve_user(req: ResolveRequest) -> Any:
    """Turn a username string into a User object."""
    return User.load(req.value)

You wire resolvers into the pipeline as a Callable[[ResolveRequest], Any] that dispatches by req.resolver:

def my_resolver(req: ResolveRequest):
    if req.resolver == "user":
        return resolve_user(req)
    if req.resolver == "project":
        return resolve_project(req)
    raise ValueError(f"unknown resolver: {req.resolver}")


# In main:
args = build_args(route, command, resolve=my_resolver)

Implicit vs. explicit resolver names

  • Implicit_user_/ means the param is named user and the resolver name is user. If a resolver exists for that name, it's used; if none exists, the raw string passes through.
  • Explicit_target_as_user_/ means the param is named target and the resolver is user. The resolver must exist for an explicit form; otherwise routing raises.
# Both resolve through the "user" resolver, stored under different names:
def execute(rt: RouteCtx) -> None:
    sender = rt.args.get_one("user", type=User)      # from _user_/
    recipient = rt.args.get_one("target", type=User)  # from _target_as_user_/

Resolvers work on flags too

Any flag can declare resolver=:

Flag("assignee", resolver="user")

When --assignee alice is parsed, alice runs through your resolver callback (with req.resolver == "user") and the resolved User lands in rt.args.

Testing with a mock resolver

Pass resolve=... to testing.execute or testing.parse:

def test_resolver_invoked():
    def fake(req):
        return f"FAKE({req.value})"

    result = testing.execute("greet alice", commands, resolve=fake)
    # The command sees "FAKE(alice)" wherever it reads "name"

If resolve is None (the default), raw strings pass through.

Philosophy

Filesystem-as-the-namespace. A .py file's path in commands/ is its route. No central registry, no @app.command() decorator, no routing table to keep in sync with reality. Add a file; it's routable. Delete a file; it's gone. When you clone a repo, you can read its command tree by running ls commands/.

DI over monkey-patching. Python's tradition of mock.patching imports is dangerous: miss one path and your test bleeds into real production side-effects. DI inverts the default — an unregistered dependency fails loud at .resolve() time. No silent production calls from tests. This is what pit-of-success testing means here.

Host-owned context. rad-cli owns RouteCtx (which carries args and cwd). Your host owns the Container and everything in it. You never implement a Protocol we define; you never inherit from a base class. RouteCtx and Container are the two things we hand to your command. The shape of the container — what you register in it, what types you use — is entirely yours.

Built for AI-built CLIs. rad-cli's home is haiv, a project about AI agents writing their own tools. When an agent needs to spawn a new subcommand, the minimum-friction form is "write a new file." That's what this framework optimizes for: a command surface an agent can extend without reading a manual, and that a parent orchestrator can verify with --help and test with rad_cli.testing.


Status

Alpha. The design is lifted from a production system (haiv) and is stable; the packaging and public API are still settling. Expect some churn before 1.0.

Inspiration

  • SvelteKit — file-based routing as a first-class primitive.
  • The haiv project — where this code grew up, supporting AI agents building their own tooling on the fly.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

rad_cli-0.1.0.tar.gz (47.1 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

rad_cli-0.1.0-py3-none-any.whl (37.6 kB view details)

Uploaded Python 3

File details

Details for the file rad_cli-0.1.0.tar.gz.

File metadata

  • Download URL: rad_cli-0.1.0.tar.gz
  • Upload date:
  • Size: 47.1 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.11.6 {"installer":{"name":"uv","version":"0.11.6","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Linux Mint","version":"22.3","id":"zena","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

File hashes

Hashes for rad_cli-0.1.0.tar.gz
Algorithm Hash digest
SHA256 b9b74d5b2389670c805a82bd0227dcb8087742965985781b425515ed60c70870
MD5 665654f83f2a85e33081a06b9a79abb0
BLAKE2b-256 d3de7e4d010f583c77d35bfeabad1476833b1980e39792ea7852c8bd451eabaa

See more details on using hashes here.

File details

Details for the file rad_cli-0.1.0-py3-none-any.whl.

File metadata

  • Download URL: rad_cli-0.1.0-py3-none-any.whl
  • Upload date:
  • Size: 37.6 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.11.6 {"installer":{"name":"uv","version":"0.11.6","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Linux Mint","version":"22.3","id":"zena","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

File hashes

Hashes for rad_cli-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 c81b4fc2d5073cf4a65c48869198d572d8c0501b26e30660198497141d9188ba
MD5 b127d80e925432194f436fe39372baf6
BLAKE2b-256 0154e5f44a284229197923a22d6a6d90b285e31f4df372f2be6795a6d0ac9368

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page