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
- Quick start
- Command anatomy
- Help (
--help) - Capturing input
- Dependency injection
- Testing
- Resolvers
- Philosophy
Install
uv pip install rad-cli
This gives you two things:
- The
rad_cliPython library (importable). - The
rad-clicommand-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 beforeexecute. By convention, only used to register things in the container; not for side effects.teardown(rt, c)— runs afterexecutein afinallyblock.
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 commands —
my-app --help - Detail by numeric ID —
my-app --help 2 - Detail by regex —
my-app --help greet(single match → detail) - Filtered list by regex —
my-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 tomax_args, then next--flagwins).
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 nameduserand the resolver name isuser. 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 namedtargetand the resolver isuser. 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
Metadata
Release files for rad-cli 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| rad_cli-0.1.0.tar.gz | 47.1 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| rad_cli-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 84.7 kB
Release files / rad_cli-0.1.0.tar.gz
| Download URL | rad_cli-0.1.0.tar.gz |
|---|---|
| Size | 47.1 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
b9b74d5b2389670c805a82bd0227dcb8087742965985781b425515ed60c70870
|
|
BLAKE2b-256 checksum How to use checksums |
d3de7e4d010f583c77d35bfeabad1476833b1980e39792ea7852c8bd451eabaa
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is 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}
|
Release files / rad_cli-0.1.0-py3-none-any.whl
| Download URL | rad_cli-0.1.0-py3-none-any.whl |
|---|---|
| Size | 37.6 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
c81b4fc2d5073cf4a65c48869198d572d8c0501b26e30660198497141d9188ba
|
|
BLAKE2b-256 checksum How to use checksums |
0154e5f44a284229197923a22d6a6d90b285e31f4df372f2be6795a6d0ac9368
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is 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}
|