Skip to content

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:

error[E0063]: missing fields `long_about` and `short` in initializer of `CommandSpec`

CommandSpec::DEFAULT is a const, so it works in a static initialiser.

BUILTIN_COMMANDS

#[distributed_slice]
pub static BUILTIN_COMMANDS: [fn() -> Box<dyn Command>];

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 Err aborts 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 return Ok.

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.