{"id":51422201,"url":"https://github.com/caseymarquis/rad-cli","last_synced_at":"2026-07-05T00:30:36.470Z","repository":{"id":361305940,"uuid":"1213941970","full_name":"caseymarquis/rad-cli","owner":"caseymarquis","description":"File-based CLI routing for Python — the SvelteKit pattern, for command-line tooling.","archived":false,"fork":false,"pushed_at":"2026-04-17T23:46:29.000Z","size":56,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-07-05T00:30:33.739Z","etag":null,"topics":["cli","dependency-injection","file-based-routing","framework","python","routing","sveltekit"],"latest_commit_sha":null,"homepage":null,"language":"Python","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/caseymarquis.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-04-17T23:43:57.000Z","updated_at":"2026-04-17T23:46:31.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/caseymarquis/rad-cli","commit_stats":null,"previous_names":["caseymarquis/rad-cli"],"tags_count":1,"template":false,"template_full_name":null,"purl":"pkg:github/caseymarquis/rad-cli","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/caseymarquis%2Frad-cli","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/caseymarquis%2Frad-cli/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/caseymarquis%2Frad-cli/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/caseymarquis%2Frad-cli/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/caseymarquis","download_url":"https://codeload.github.com/caseymarquis/rad-cli/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/caseymarquis%2Frad-cli/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":35140188,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-07-04T02:00:05.987Z","response_time":113,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["cli","dependency-injection","file-based-routing","framework","python","routing","sveltekit"],"created_at":"2026-07-05T00:30:35.548Z","updated_at":"2026-07-05T00:30:36.405Z","avatar_url":"https://github.com/caseymarquis.png","language":"Python","funding_links":[],"categories":[],"sub_categories":[],"readme":"# rad-cli\n\nFile-based CLI routing for Python — the SvelteKit pattern, for command-line\ntooling. Build apps by laying out `.py` files in a directory tree; the\nfilesystem *is* the command grammar.\n\n```\ncommands/\n├── greet.py                   # my-app greet\n├── users/\n│   ├── list.py                # my-app users list\n│   └── _name_.py              # my-app users \u003cname\u003e  (captures name)\n└── _rest_.py                  # my-app \u003canything...\u003e  (catches remaining args)\n```\n\nNo decorators, no registration, no app object. A command is just a module\nwith `define()` and `execute()`. Dependencies go through a `punq.Container`\nso tests can't accidentally hit production. `--help` is built in.\n\n---\n\n## Table of contents\n\n- [Install](#install)\n- [Quick start](#quick-start)\n- [Command anatomy](#command-anatomy)\n- [Help (`--help`)](#help---help)\n- [Capturing input](#capturing-input)\n  - [Route params](#route-params)\n  - [Directory defaults: `_index_.py`](#directory-defaults-_index_py)\n  - [The rest catch-all](#the-rest-catch-all)\n  - [Flags](#flags)\n- [Dependency injection](#dependency-injection)\n- [Testing](#testing)\n- [Resolvers](#resolvers)\n- [Philosophy](#philosophy)\n\n---\n\n## Install\n\n```bash\nuv pip install rad-cli\n```\n\nThis gives you two things:\n\n1. The `rad_cli` Python library (importable).\n2. The `rad-cli` command-line tool (for scaffolding new projects).\n\n## Quick start\n\nScaffold a new project and run it:\n\n```bash\nrad-cli new my-app\ncd my-app\nuv sync\nuv run my-app --help\nuv run my-app hello\nuv run my-app greet Alice --loud\nuv run pytest\n```\n\nThe scaffold produces a working project with three example commands\n(a plain one, a DI one, and a route-param one), matching tests, and a\nfully-wired `__main__.py`. Everything after this point in the README\nis reference: you don't need to read it front-to-back to start building.\n\n## Command anatomy\n\nA command is a Python module in your `commands/` tree with two required\nfunctions. It looks like this:\n\n```python\n# commands/hello.py\nfrom rad_cli import Def, RouteCtx\n\n\ndef define() -\u003e Def:\n    \"\"\"Declare what this command accepts — description and flags.\"\"\"\n    return Def(description=\"Say hello.\")\n\n\ndef execute(rt: RouteCtx) -\u003e None:\n    \"\"\"Run the command.\"\"\"\n    print(\"Hello!\")\n```\n\n**`define()`** returns a `Def`. That's the command's self-description —\nwhat `--help` reads, what the flag parser uses.\n\n**`execute()`** does the work. Its signature can be either:\n\n- `execute(rt: RouteCtx)` — no dependencies.\n- `execute(rt: RouteCtx, c: Container)` — receives the DI container.\n\nrad-cli inspects your signature and calls you with the right number of\narguments. Commands that don't need DI don't have to participate in it.\n\n**Optional lifecycle hooks** — any command can also declare:\n\n- `setup(rt, c)` — runs before `execute`. By convention, only used to\n  register things in the container; not for side effects.\n- `teardown(rt, c)` — runs after `execute` in a `finally` block.\n\nThat's the whole contract. No base classes, no decorators, no\nregistration — just module-level functions.\n\n## Help (`--help`)\n\nEvery rad-cli app gets `--help` for free by calling `handle_help(...)`\nin its `main()`. The scaffolded `__main__.py` already does this. Users\ncan:\n\n- **List all commands** — `my-app --help`\n- **Detail by numeric ID** — `my-app --help 2`\n- **Detail by regex** — `my-app --help greet` (single match → detail)\n- **Filtered list by regex** — `my-app --help '^hello'` (multiple matches)\n\nExample output:\n\n```\n$ my-app --help\nusage: my-app \u003ccommand\u003e [args...]\n\nUse --help \u003cid\u003e or --help \u003cpattern\u003e to see detailed help.\n\nCommands:\n  1  greet \u003cname\u003e  Print a greeting for a given name, captured from the route as \u003cname\u003e.\n  2  hello         Print a greeting.\n  3  hello punq    Print a greeting composed by a container-resolved Greeter.\n\n$ my-app --help greet\nmy-app greet \u003cname\u003e\n\n  Description:\n    Print a greeting for a given name, captured from the route as \u003cname\u003e.\n    Example: ``my-app greet alice`` → ``Hello, alice.``. Add --loud to shout\n    the greeting.\n\n  Flags:\n    --loud  Shout the greeting.\n\n  File:   /path/to/commands/greet/_name_.py\n```\n\n**Writing descriptions.** `define()`'s `description` field is shown in\nboth views. The **list view shows the first sentence only**; the **detail\nview shows the whole thing**, word-wrapped. So write a short summary\nsentence followed by any longer context:\n\n```python\nreturn Def(\n    description=(\n        \"Compact summary sentence for the list view. \"\n        \"Follow-up sentences are included in the detail view only, \"\n        \"giving room for examples and nuance.\"\n    ),\n)\n```\n\nPer-flag descriptions live on `Flag(..., description=...)`.\n\n## Capturing input\n\nCommands receive parsed input via `rt.args`. Three sources feed it:\nroute params (captured from directory/file names), a positional \"rest\"\noverflow, and flags.\n\n### Route params\n\nA directory or file named `_name_` captures the matching segment as a\nparam called `name`. The filesystem *is* the param grammar:\n\n```\ncommands/\n├── users/\n│   ├── _user_/                # captures \"\u003cuser\u003e\" for everything below\n│   │   ├── show.py            # my-app users \u003cuser\u003e show\n│   │   └── edit.py            # my-app users \u003cuser\u003e edit\n│   └── _user_.py              # my-app users \u003cuser\u003e         (leaf)\n└── greet/\n    └── _name_.py              # my-app greet \u003cname\u003e\n```\n\nAccess in `execute`:\n\n```python\ndef execute(rt: RouteCtx) -\u003e None:\n    name = rt.args.get_one(\"name\")\n    print(f\"Hello, {name}!\")\n```\n\n**Explicit resolver names.** If a param's name should differ from the\nresolver it routes to, use `_param_as_resolver_/`:\n\n```\ncommands/\n└── users/\n    └── _user_/\n        └── send/\n            └── _target_as_user_.py    # my-app users \u003cuser\u003e send \u003ctarget\u003e\n```\n\nBoth params pass through the `user` resolver (if one is registered), but\nthey're stored under distinct names (`user` and `target`) in `rt.args`.\n\n### Directory defaults: `_index_.py`\n\nA file named `_index_.py` inside a directory is what runs when the user\ntypes the directory name with no further segments:\n\n```\ncommands/\n└── hello/\n    ├── _index_.py          # my-app hello\n    └── punq.py             # my-app hello punq\n```\n\nWithout `_index_.py`, `my-app hello` would fail to route — there's no\nleaf file at that position. With it, `my-app hello` runs\n`hello/_index_.py`, while deeper paths like `my-app hello punq` route\nnormally to their leaves.\n\n`_index_.py` (single underscores) is distinct from Python's\n`__init__.py` (double underscores). Every directory in your tree still\nneeds `__init__.py` to be a Python package — rad-cli excludes\ndouble-underscore (\"dunder\") files from routing entirely. Only the\nsingle-underscore form is user-facing.\n\n### The rest catch-all\n\nA file named `_rest_.py` consumes remaining positional args at its level:\n\n```\ncommands/\n└── echo/\n    └── _rest_.py              # my-app echo \u003canything...\u003e\n```\n\n```python\ndef execute(rt: RouteCtx) -\u003e None:\n    print(\" \".join(rt.args.rest))\n```\n\n`_rest_.py` cannot coexist with any sibling param file or directory (it\nwould be ambiguous which should capture the next token).\n\n### Flags\n\nEverything from the first `--`-prefixed token onward is parsed as flags.\nDeclare them in `define()`:\n\n```python\nfrom rad_cli import Def, Flag, RouteCtx\n\n\ndef define() -\u003e Def:\n    return Def(\n        description=\"Demonstrate flag shapes.\",\n        flags=[\n            Flag(\"verbose\", type=bool),                           # boolean toggle\n            Flag(\"name\"),                                         # single value\n            Flag(\"count\", type=int, description=\"How many?\"),     # typed single value\n            Flag(\"tags\", min_args=0, max_args=None),              # multi-value (0+)\n            Flag(\"ids\", min_args=1, max_args=None),               # multi-value (1+)\n            Flag(\"pair\", min_args=2, max_args=2),                 # exactly 2\n        ],\n    )\n\n\ndef execute(rt: RouteCtx) -\u003e None:\n    # Boolean — presence = True\n    if rt.args.has(\"verbose\"):\n        ...\n\n    # Single value — errors if missing (unless default_value)\n    name = rt.args.get_one(\"name\", default_value=\"world\")\n\n    # Multi-value — get the list\n    tags = rt.args.get_list(\"tags\", default_value=[])\n\n    # First of possibly-many\n    first_id = rt.args.get_first(\"ids\", default_value=None)\n```\n\nFlag shapes supported on the command line:\n\n- `--verbose` — boolean presence.\n- `--name alice` — single value, space-separated.\n- `--name=alice` — single value, `=`-separated.\n- `--tags a b c` — multi-value (up to `max_args`, then next `--flag` wins).\n\nUnknown flags raise `ValueError` — rad-cli rejects `--foo` if your command\ndidn't declare it.\n\n## Dependency injection\n\nrad-cli uses [punq](https://github.com/bobthemighty/punq) as its DI\ncontainer. **Your host state (paths, settings, clients, databases) goes\nthrough the container** — not through globals, not through monkey-patched\nimports in tests, not through a base class you inherit. Or it doesn't. YOLO!\n\n### The contract\n\nYour `__main__.py` builds a container and passes it to `run_command`:\n\n```python\nfrom punq import Container\nfrom rad_cli import RouteCtx, build_args, find_route, handle_help, load_command, run_command\nfrom my_app import commands\nfrom my_app.deps import Greeter, Database\n\n\ndef build_container() -\u003e Container:\n    \"\"\"Register every dependency your commands can resolve.\"\"\"\n    c = Container()\n    c.register(Greeter, instance=Greeter())\n    c.register(Database, factory=lambda: Database.connect())\n    return c\n\n\ndef main(argv: list[str] | None = None) -\u003e int:\n    # ... routing + help handling ...\n    rt = RouteCtx(args=build_args(route, command))\n    run_command(command, rt, build_container())\n    return 0\n```\n\nCommands declare two-arg `execute` when they need dependencies:\n\n```python\ndef execute(rt: RouteCtx, c: Container) -\u003e None:\n    greeter = c.resolve(Greeter)\n    db = c.resolve(Database)\n    ...\n```\n\n### Define shared types outside command files\n\nrad-cli's loader gives each command file a unique module name per load\n(to defeat Python's `sys.modules` cache and keep loads fresh). That\nmeans **a class defined inside a command file becomes a new class object\non every load** — breaking DI by type identity. Define your types in a\nplain module like `my_app/deps.py`:\n\n```python\n# my_app/deps.py\nclass Greeter:\n    def greet(self, name: str) -\u003e str:\n        return f\"Hello, {name}!\"\n```\n\n```python\n# commands/hello/punq.py\nfrom my_app.deps import Greeter           # same class object every load\n\ndef execute(rt, c):\n    greeter = c.resolve(Greeter)\n    ...\n```\n\nThe scaffolded project's `deps.py` has a longer explanation in its\ndocstring.\n\n## Testing\n\nrad-cli ships `rad_cli.testing` with four helpers, matching the\nstages of a command's evolution. You can write tests at whichever level\nmatches what you're verifying.\n\n### 1. `routes_to` — does the file exist?\n\nAsserts only that a command string routes somewhere (or nowhere). The\ntarget file can be empty. Useful for TDD from red:\n\n```python\nfrom rad_cli import testing\nfrom my_app import commands\n\ndef test_greet_routes():\n    assert testing.routes_to(\"greet alice\", commands) is not None\n```\n\n### 2. `require_routes_to` — does it route to the *right* file?\n\nSame check, but asserts the exact file path relative to the commands\ndirectory:\n\n```python\ndef test_greet_goes_to_param_file():\n    testing.require_routes_to(\n        \"greet alice\",\n        commands,\n        expected=\"greet/_name_.py\",\n    )\n```\n\n### 3. `parse` — does `define()` work and flags parse?\n\nLoads the command and runs the flag parser without executing:\n\n```python\ndef test_greet_accepts_loud_flag():\n    result = testing.parse(\"greet alice --loud\", commands)\n    assert result.rt.args.get_one(\"name\") == \"alice\"\n    assert result.rt.args.has(\"loud\")\n```\n\n### 4. `execute` — run the command with mocked dependencies\n\nThe full pipeline, DI included. **`execute()` deliberately skips\n`setup()` and `teardown()`** — tests must register their mocks in the\ncontainer explicitly. This is the pit-of-success property: if you forget\na mock, `c.resolve()` raises — you don't silently hit production.\n\n```python\nimport pytest\nfrom punq import Container\nfrom rad_cli import testing\nfrom my_app import commands\nfrom my_app.deps import Greeter\n\n\ndef test_greet_uses_mocked_greeter(capsys: pytest.CaptureFixture[str]) -\u003e None:\n    class FakeGreeter(Greeter):\n        def greet(self, name: str) -\u003e str:\n            return f\"[mock] {name}\"\n\n    c = Container()\n    c.register(Greeter, instance=FakeGreeter())\n\n    testing.execute(\"hello punq --name Alice\", commands, container=c)\n    assert capsys.readouterr().out == \"[mock] Alice\\n\"\n```\n\n### File I/O in commands\n\nCommands that read or write files should use `rt.cwd` (a `Path`\ndefaulted to `Path.cwd()`) rather than calling `Path.cwd()` or using\nbare relative paths. Then tests pass `cwd=tmp_path`:\n\n```python\ndef test_scaffolder_writes_files(tmp_path):\n    testing.execute(\"new my-app\", commands, cwd=tmp_path)\n    assert (tmp_path / \"my-app\" / \"pyproject.toml\").exists()\n```\n\nNo `monkeypatch.chdir`. No chance of the real filesystem sneaking in.\n\n## Resolvers\n\nA **resolver** is a callback that turns a raw string from the command\nline into a domain object, at parse time. Each resolver is identified by\na name — the `_name_/` in a route param or the `Flag(..., resolver=\"name\")`\nin a flag definition.\n\nA resolver has one shape:\n\n```python\nfrom typing import Any\nfrom rad_cli import ResolveRequest\n\n\ndef resolve_user(req: ResolveRequest) -\u003e Any:\n    \"\"\"Turn a username string into a User object.\"\"\"\n    return User.load(req.value)\n```\n\nYou wire resolvers into the pipeline as a `Callable[[ResolveRequest], Any]`\nthat dispatches by `req.resolver`:\n\n```python\ndef my_resolver(req: ResolveRequest):\n    if req.resolver == \"user\":\n        return resolve_user(req)\n    if req.resolver == \"project\":\n        return resolve_project(req)\n    raise ValueError(f\"unknown resolver: {req.resolver}\")\n\n\n# In main:\nargs = build_args(route, command, resolve=my_resolver)\n```\n\n### Implicit vs. explicit resolver names\n\n- **Implicit** — `_user_/` means the param is named `user` *and* the\n  resolver name is `user`. If a resolver exists for that name, it's used;\n  if none exists, the raw string passes through.\n- **Explicit** — `_target_as_user_/` means the param is named `target`\n  and the resolver is `user`. The resolver *must* exist for an explicit\n  form; otherwise routing raises.\n\n```python\n# Both resolve through the \"user\" resolver, stored under different names:\ndef execute(rt: RouteCtx) -\u003e None:\n    sender = rt.args.get_one(\"user\", type=User)      # from _user_/\n    recipient = rt.args.get_one(\"target\", type=User)  # from _target_as_user_/\n```\n\n### Resolvers work on flags too\n\nAny flag can declare `resolver=`:\n\n```python\nFlag(\"assignee\", resolver=\"user\")\n```\n\nWhen `--assignee alice` is parsed, `alice` runs through your resolver\ncallback (with `req.resolver == \"user\"`) and the resolved `User` lands\nin `rt.args`.\n\n### Testing with a mock resolver\n\nPass `resolve=...` to `testing.execute` or `testing.parse`:\n\n```python\ndef test_resolver_invoked():\n    def fake(req):\n        return f\"FAKE({req.value})\"\n\n    result = testing.execute(\"greet alice\", commands, resolve=fake)\n    # The command sees \"FAKE(alice)\" wherever it reads \"name\"\n```\n\nIf `resolve` is `None` (the default), raw strings pass through.\n\n## Philosophy\n\n**Filesystem-as-the-namespace.** A `.py` file's path in `commands/`\n*is* its route. No central registry, no `@app.command()` decorator, no\nrouting table to keep in sync with reality. Add a file; it's routable.\nDelete a file; it's gone. When you clone a repo, you can read its command\ntree by running `ls commands/`.\n\n**DI over monkey-patching.** Python's tradition of `mock.patch`ing\nimports is dangerous: miss one path and your test bleeds into real\nproduction side-effects. DI inverts the default — an unregistered\ndependency fails loud at `.resolve()` time. No silent production calls\nfrom tests. This is what `pit-of-success testing` means here.\n\n**Host-owned context.** rad-cli owns `RouteCtx` (which carries `args`\nand `cwd`). Your host owns the `Container` and everything in it. You\nnever implement a Protocol we define; you never inherit from a base\nclass. `RouteCtx` and `Container` are the two things we hand to your\ncommand. The shape of the container — what you register in it, what\ntypes you use — is entirely yours.\n\n**Built for AI-built CLIs.** rad-cli's home is [haiv][haiv], a project\nabout AI agents writing their own tools. When an agent needs to spawn a\nnew subcommand, the minimum-friction form is \"write a new file.\" That's\nwhat this framework optimizes for: a command surface an agent can extend\nwithout reading a manual, and that a parent orchestrator can verify with\n`--help` and test with `rad_cli.testing`.\n\n---\n\n## Status\n\nAlpha. The design is lifted from a production system (haiv) and is\nstable; the packaging and public API are still settling. Expect some\nchurn before 1.0.\n\n## Inspiration\n\n- [SvelteKit](https://kit.svelte.dev/) — file-based routing as a\n  first-class primitive.\n- The [haiv][haiv] project — where this code grew up, supporting AI\n  agents building their own tooling on the fly.\n\n[haiv]: https://github.com/caseymarquis/haiv\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fcaseymarquis%2Frad-cli","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fcaseymarquis%2Frad-cli","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fcaseymarquis%2Frad-cli/lists"}