Create a beet CLI
SkillDev toolsCreate a beet CLI, ie a CliServer router whose commands are routes, flags are request params and --help is middleware, serializable to a beet.json scene. Use when building or extending a CLI on beet.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the Create a beet CLI skill
What this skill tells your AI
The instructions your AI receives, as published by mrchantey/beet in .agents/skills/create-cli/SKILL.md and read by ahel’s review.
A beet CLI is just a router: a [CliServer] reads the process args as a [Request], the [router] dispatches it to a child route, and the route's response is written to stdout. There is no bespoke arg-parsing layer — a command is a route, its flags are request params, and --help is router middleware.
The canonical example is the beet CLI itself: the beet binary (crates/beet-cli/src/main.rs) is a bare scene runner that loads its routes from a beet.json, and the default_cli example (crates/beet-cli/examples/default_cli.rs) builds that scene and serializes it. Read them alongside this skill; everything below is drawn from them.
1. The app
A CLI is a [CliServer] router whose routes are the commands. The commands are reflectable route actions (#[action(route = "...")]), so the whole tree can be serialized to a beet.json scene and reloaded by the beet runner:
use beet::prelude::*;
use beet_cli::prelude::*;
/// The default `beet` CLI: a CliServer router wiring every built-in command.
fn default_cli(world: &mut World) -> Entity {
let entity = world.spawn((CliServer::default(), default_router())).id();
world.entity_mut(entity).with_children(|parent| {
parent.spawn(BuildWasm);
parent.spawn(ExportPdf);
parent.spawn(RunWasm);
});
entity
}
Each command is spawned as a bare marker: its #[action(route = "...")] attribute requires the PathPartial + dispatch components, so the route is self-describing and round-trips through world serde with no extra wiring.
CliServerturnsbeet <args>into a request and streams the response to stdout, mapping a non-OK status to a non-zero exit code.default_router()bundles the route lookup plus theRequestLogger,HelpHandlerandNavigateHandlermiddleware and the default app routes; spawn your own routes aschildrenof the same entity.- Each command marker is one command. The
routepath is matched against the args, iebeet build-wasmhits thebuild-wasmroute.
2. A command
A command is an #[action(route = "...")] async fn taking [RequestParts] and returning Result<String>. The string is the response body. Derive Reflect + #[reflect(Component)] so the route serializes into a beet.json scene.
#[action(route = "qrcode")]
#[derive(Component, Reflect)]
#[reflect(Component)]
#[require(ParamsPartial = ParamsPartial::new::<QrCodeParams>())]
pub async fn QrCode(parts: RequestParts) -> Result<String> {
let params = parts.params().parse_reflect::<QrCodeParams>()?;
let output = params.output.as_deref().unwrap_or("qrcode.png");
// ..
Ok(format!("wrote qr code to {output}"))
}
3. Params: parse, never hand-roll
Define a Reflect struct for the flags. Do NOT pull values out one by one with parts.get_param("..") — that skips validation, defaults, and the help listing. Instead parse the whole struct in one call:
/// Request params for the [`QrCode`] command, surfaced in `--help`.
#[derive(Reflect, Default)]
#[reflect(Default)]
struct QrCodeParams {
/// The text/url to encode.
#[reflect(@RequiredField)]
input: String,
/// The output file path, defaults to `qrcode.png`.
output: Option<String>,
}
// BAD — manual, unvalidated, invisible to --help
let input = parts.get_param("input").ok_or_else(|| bevyhow!(".."))?;
// GOOD — one typed parse
let params = parts.params().parse_reflect::<QrCodeParams>()?;
Rules for the params struct:
- Always derive
Defaultand add#[reflect(Default)]. Missing flags fall back to the struct'sDefault, so parsing partial args just works. Without itparse_reflectcannot build the struct from an incomplete arg set. - Field names are snake_case; the CLI flag is the kebab-case form, ie
out_dir↔--out-dir. The normalisation is automatic. bool→ a flag (--release).Option<T>→ optional. A bare field → uses the structDefaultwhen absent.- Mark a field required with
#[reflect(@RequiredField)]; parsing errors if it is missing. Prefer this over a manual presence check. - For a default that is not the type's own default (ie
adefaults to2), give the struct a customDefaultimpl and keep the field bare:#[derive(Reflect)] #[reflect(Default)] struct CallAddParams { a: i32, b: i32 } impl Default for CallAddParams { fn default() -> Self { Self { a: 2, b: 3 } } } - Supported field types:
bool,String,Option<String>,Vec<String>, the numeric primitives, and nested/newtype structs.Option<numeric>is not supported — use a bare numeric with a customDefaultinstead.
4. --help is free
#[require(ParamsPartial = ParamsPartial::new::<QrCodeParams>())] registers the param metadata on the route. The router's HelpHandler intercepts --help, walks the [RouteTree], and renders the available routes and their params. Doc comments on the params fields become the flag descriptions, so document them. beet --help lists everything; beet qrcode --help scopes to that subtree.
5. Greedy routes and forwarding args
A trailing *name segment captures the rest of the args greedily, eg the run-wasm/*args cargo runner. To rebuild a forwardable arg vector from the request use [RequestParts::unparse_cli_args] — it returns every path segment as a positional followed by params as --key/--key=value:
#[action(route = "run-wasm/*args")]
#[derive(Component, Reflect)]
#[reflect(Component)]
pub async fn RunWasm(parts: RequestParts) -> Result<String> {
// rebuilds `[run-wasm, <binary>, ..forwarded]`; skip the command segment
// consumed by the route, pop the binary, forward the rest to the module.
let mut args = parts.unparse_cli_args().into_iter().skip(1);
let exe_path = args
.next()
.ok_or_else(|| bevyhow!("usage: beet run-wasm <binary-path> [args..]"))?;
run_wasm(Path::new(&exe_path), args.collect()).await?;
Ok(String::new())
}
6. Output and content negotiation
The body is rendered per the --accept header (default: ansi-term, then text, markdown, json). A plain Ok(String) prints as text; a scene route (eg render_action::async_route) renders through the beet_ui pipeline, so --accept=text/html yields HTML and the default yields styled terminal output. Rendering requires RouterPlugin (pulled in by ClientAppPlugin), which registers the charcell render pipeline.
7. Shipping the CLI as a scene
The beet binary is a scene runner: on startup it loads beet.json from the cwd, and that scene supplies the CliServer and its routes. The default_cli example builds the route tree and serializes it via WorldSerdeSaver; the runner reloads it with WorldSerdeLoader, reconstructing each command's behaviour from its require hooks. The runner must register_type every command (see CliCommandsPlugin) so the markers deserialize. The scene management (loading, marking, watching, reloading) lives in crates/beet-cli/src/scene_management.
Reference
crates/beet-cli/src/main.rs— the scene runnercrates/beet-cli/examples/default_cli.rs— build + serialize the CLI scenecrates/beet-cli/src/scene_management— load/watch/reloadbeet.jsoncrates/beet-cli/src/commands/qrcode.rs— params +parse_reflectcrates/beet-cli/src/commands/run_wasm.rs— greedy route +unparse_cli_argsexamples/rsx_site/src/server.rs— a router serving typed pages, markdown content and a server action, with the backend selected by build feature
Signals
- GitHub stars
- 134
- Forks
- 8
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
create-cli- Source
- github.com/mrchantey/beet