https://github.com/caseymarquis/rad-cli
File-based CLI routing for Python — the SvelteKit pattern, for command-line tooling.
https://github.com/caseymarquis/rad-cli
cli dependency-injection file-based-routing framework python routing sveltekit
Last synced: about 8 hours ago
JSON representation
File-based CLI routing for Python — the SvelteKit pattern, for command-line tooling.
- Host: GitHub
- URL: https://github.com/caseymarquis/rad-cli
- Owner: caseymarquis
- License: mit
- Created: 2026-04-17T23:43:57.000Z (3 months ago)
- Default Branch: main
- Last Pushed: 2026-04-17T23:46:29.000Z (3 months ago)
- Last Synced: 2026-07-05T00:30:33.739Z (about 8 hours ago)
- Topics: cli, dependency-injection, file-based-routing, framework, python, routing, sveltekit
- Language: Python
- Size: 54.7 KB
- Stars: 0
- Watchers: 0
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
- License: LICENSE
Awesome Lists containing this project
README
# 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 (captures name)
└── _rest_.py # my-app (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](#install)
- [Quick start](#quick-start)
- [Command anatomy](#command-anatomy)
- [Help (`--help`)](#help---help)
- [Capturing input](#capturing-input)
- [Route params](#route-params)
- [Directory defaults: `_index_.py`](#directory-defaults-_index_py)
- [The rest catch-all](#the-rest-catch-all)
- [Flags](#flags)
- [Dependency injection](#dependency-injection)
- [Testing](#testing)
- [Resolvers](#resolvers)
- [Philosophy](#philosophy)
---
## Install
```bash
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:
```bash
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:
```python
# 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 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 [args...]
Use --help or --help to see detailed help.
Commands:
1 greet Print a greeting for a given name, captured from the route as .
2 hello Print a greeting.
3 hello punq Print a greeting composed by a container-resolved Greeter.
$ my-app --help greet
my-app greet
Description:
Print a greeting for a given name, captured from the route as .
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:
```python
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 "" for everything below
│ │ ├── show.py # my-app users show
│ │ └── edit.py # my-app users edit
│ └── _user_.py # my-app users (leaf)
└── greet/
└── _name_.py # my-app greet
```
Access in `execute`:
```python
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 send
```
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
```
```python
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()`:
```python
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](https://github.com/bobthemighty/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`:
```python
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:
```python
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`:
```python
# my_app/deps.py
class Greeter:
def greet(self, name: str) -> str:
return f"Hello, {name}!"
```
```python
# 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:
```python
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:
```python
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:
```python
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.
```python
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`:
```python
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:
```python
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`:
```python
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.
```python
# 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=`:
```python
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`:
```python
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.patch`ing
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][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](https://kit.svelte.dev/) — file-based routing as a
first-class primitive.
- The [haiv][haiv] project — where this code grew up, supporting AI
agents building their own tooling on the fly.
[haiv]: https://github.com/caseymarquis/haiv