Commands and the link-time registry¶
A command is an async fn(App) -> miette::Result<()> bundled with a small static
descriptor. Commands add themselves to a link-time registry, so nothing has to
maintain a list of them.
use rtb_app::command::{
Command, CommandSpec, BUILTIN_COMMANDS, BUILTIN_PRERUN_HOOKS, PreRunFuture, PreRunHook,
};
The Command trait¶
#[async_trait::async_trait]
pub trait Command: Send + Sync + 'static {
fn spec(&self) -> &CommandSpec;
async fn run(&self, app: App) -> miette::Result<()>;
fn subcommand_passthrough(&self) -> bool { false }
fn mcp_exposed(&self) -> bool { false }
fn mcp_input_schema(&self) -> Option<serde_json::Value> { None }
}
| Method | Required | Default | What it controls |
|---|---|---|---|
spec |
yes | — | The static descriptor. |
run |
yes | — | The work. app arrives by value; App::clone() is O(1) so fan out freely. |
subcommand_passthrough |
no | false |
true hands every argument after the command name straight through, unvalidated, for the command's own parser to deal with. |
mcp_exposed |
no | false |
true registers the command as an MCP tool. |
mcp_input_schema |
no | None |
JSON Schema for the arguments, shown to MCP clients. |
The three defaulted methods are additive by design: a new opt-in can be added in a minor release and existing implementations inherit a safe default rather than failing to compile.
Command is object-safe — Box<dyn Command> is exactly what the registry stores.
The async_trait attribute is what makes that possible, so an implementation needs
#[async_trait::async_trait] on its impl block and async-trait as a direct
dependency.
When to set subcommand_passthrough¶
Set it when the command owns its own clap subtree — docs list / show / browse /
serve, update check / run. The inner parser then produces its own help and error
messages instead of the outer parser rejecting arguments it does not recognise.
The arguments arrive on the App, not from the process environment: read them with
app.trailing_args() and re-parse them with your own parser. Reaching for
std::env::args_os() instead works in a real process and breaks under
Application::run_with_args, which is how these commands get driven in tests.
CommandSpec fields¶
#[derive(Debug, Clone, Copy)]
pub struct CommandSpec {
pub name: &'static str,
pub about: &'static str,
pub aliases: &'static [&'static str],
pub feature: Option<Feature>,
pub short: Option<char>,
pub long_about: Option<&'static str>,
}
| Field | Default in CommandSpec::DEFAULT |
Meaning |
|---|---|---|
name |
"" |
The subcommand as typed: mytool deploy. |
about |
"" |
One-line summary in --help. |
aliases |
&[] |
Alternative names accepted on the CLI, shown in help text. |
feature |
None |
Some(f) hides the command unless feature f is runtime-enabled. None means always visible. |
short |
None |
A single-character alias — mytool -d runs mytool deploy. |
long_about |
None |
Long help for the command's own --help. Falls back to about. |
Every field is 'static: commands are compile-time entities, and generating a
subcommand at runtime is not supported.
Build a spec with ..CommandSpec::DEFAULT¶
static SPEC: CommandSpec = CommandSpec {
name: "deploy",
about: "Deploy the thing",
..CommandSpec::DEFAULT
};
Use the struct-update form. A literal that names every field compiles today and
breaks the next time an optional field is added — which has already happened once,
when short and long_about arrived in 0.7.0. A four-field literal written against
an older release now fails with:
CommandSpec::DEFAULT is a const, so it works in a static initialiser.
BUILTIN_COMMANDS¶
A linkme distributed slice of factory functions,
populated at link time. Registering is one attribute:
#[distributed_slice(BUILTIN_COMMANDS)]
fn __register_deploy() -> Box<dyn Command> { Box::new(Deploy) }
Read it like any slice:
let names: Vec<&'static str> =
BUILTIN_COMMANDS.iter().map(|factory| factory().spec().name).collect();
Two contracts to respect:
- Factories must be cheap. Each is called to produce a fresh box; no I/O, no
allocation beyond the box. The work belongs in
run. - Order is not defined. Link-time registration order across crates is not deterministic, so nothing that depends on which entry comes first is safe.
The mechanics of getting linkme to resolve — including the case where you do not
want it as a direct dependency — are in
Register a command.
BUILTIN_PRERUN_HOOKS¶
pub type PreRunFuture =
std::pin::Pin<Box<dyn std::future::Future<Output = miette::Result<()>> + Send>>;
pub type PreRunHook = fn(App) -> PreRunFuture;
#[distributed_slice]
pub static BUILTIN_PRERUN_HOOKS: [PreRunHook];
A second distributed slice, for cross-cutting work that runs after parsing succeeds
and before the matched command executes. The self-update policy check is registered
this way, which is how rtb-cli stays decoupled from rtb-update.
The contract is stricter than for commands:
- A hook must be fast. It runs on the path of every single invocation.
- A hook must be order-independent. Registration order across crates is not deterministic, so a hook that assumes another has already run will work until it does not.
- A hook returning
Erraborts the run before the command executes. An advisory hook — one that should never stop the user getting their work done — has to swallow its own errors and returnOk.
PreRunHook is a plain fn pointer, not a closure, so a hook cannot capture state.
Anything it needs comes off the App it is handed.