Metadata-Version: 2.4
Name: rad-cli
Version: 0.1.0
Summary: File-based CLI routing for Python — build and organize command-line tooling by filesystem layout.
Project-URL: Homepage, https://github.com/caseymarquis/rad-cli
Project-URL: Repository, https://github.com/caseymarquis/rad-cli
Author: Casey Marquis
License-Expression: MIT
License-File: LICENSE
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
Requires-Python: >=3.12
Requires-Dist: punq>=0.7.0
Description-Content-Type: text/markdown

# 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](#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 <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:

```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 "<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`:

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

```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
