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
- 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
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
b9b74d5b2389670c805a82bd0227dcb8087742965985781b425515ed60c70870
|
|
| MD5 |
665654f83f2a85e33081a06b9a79abb0
|
|
| BLAKE2b-256 |
d3de7e4d010f583c77d35bfeabad1476833b1980e39792ea7852c8bd451eabaa
|
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
c81b4fc2d5073cf4a65c48869198d572d8c0501b26e30660198497141d9188ba
|
|
| MD5 |
b127d80e925432194f436fe39372baf6
|
|
| BLAKE2b-256 |
0154e5f44a284229197923a22d6a6d90b285e31f4df372f2be6795a6d0ac9368
|