Add a command¶
Register a command with no arguments¶
A command is an impl Command plus a factory registered into BUILTIN_COMMANDS.
Nothing else — no list to edit, no builder call.
use rtb_app::app::App;
use rtb_app::command::{Command, CommandSpec, BUILTIN_COMMANDS};
use linkme::distributed_slice;
pub struct Deploy;
#[async_trait::async_trait]
impl Command for Deploy {
fn spec(&self) -> &CommandSpec {
static SPEC: CommandSpec = CommandSpec {
name: "deploy",
about: "Deploy the thing",
..CommandSpec::DEFAULT
};
&SPEC
}
async fn run(&self, _app: App) -> miette::Result<()> {
println!("deploying");
Ok(())
}
}
#[distributed_slice(BUILTIN_COMMANDS)]
fn __register_deploy() -> Box<dyn Command> {
Box::new(Deploy)
}
Always build CommandSpec with ..CommandSpec::DEFAULT so a future optional field
does not break the literal.
CommandSpec::feature stays None for a command of your own — the runtime
Feature enum covers the framework's built-ins, and leaving it None means the
command is always registered.
Two things that will not compile without help¶
linkme must be a direct dependency of your crate. The distributed_slice
attribute expands to ::linkme:: paths resolved in your crate root, so
re-exporting it is not enough — use rtb_app::linkme::distributed_slice; alone
fails with cannot find linkme in the crate root. Add it to Cargo.toml at the
same major version rtb-app uses:
The module needs #![allow(unsafe_code)] if your crate denies it, because the
macro emits a #[link_section] attribute. Scope the allow to the module holding the
registration, never the whole crate.
Give the command its own flags¶
Set subcommand_passthrough() to true, define a clap::Parser, and parse with
parse_passthrough:
use clap::Parser;
#[derive(Parser)]
#[command(name = "deploy")]
struct DeployArgs {
#[arg(long)]
region: Option<String>,
#[arg(long)]
force: bool,
}
#[async_trait::async_trait]
impl Command for Deploy {
fn spec(&self) -> &CommandSpec { /* as above */ }
fn subcommand_passthrough(&self) -> bool {
true
}
async fn run(&self, app: App) -> miette::Result<()> {
let args = rtb_cli::parse_passthrough::<DeployArgs>(&app)?;
println!("region={:?} force={}", args.region, args.force);
Ok(())
}
}
The framework captures every token after deploy and hands it to your parser
through App::trailing_args(). mytool deploy --help prints your help, because
the outer parser's auto-injected --help is disabled for a passthrough subtree.
Use parse_passthrough rather than reading std::env::args_os() yourself. It
is what makes the command testable through Application::run_with_args, and it
avoids the positional-slicing bug the built-in commands still carry — see
how arguments reach a command.
Render rows through render::output¶
If your command prints tabular data, read the mode and hand rows to
rtb_cli::render::output, which takes any slice of a type that is both Tabled and
Serialize:
use rtb_cli::render::{output, OutputMode};
let mode = OutputMode::from_args_os();
output(mode, &rows).map_err(|e| miette::miette!("render: {e}"))?;
OutputMode::from_args_os() scans the raw process arguments, so it finds the flag
wherever the user put it.
Tell users to write --output before your subcommand name. For a passthrough
command, clap consumes the global flag only while it appears before the first token
that starts trailing capture. All of these work:
mytool --output json deploy --region eu
mytool deploy --output json
mytool deploy --output=json --force
This one does not, because --region eu has already opened the trailing capture and
--output lands in it, where your parser rejects it as unknown:
rtb_cli::render::strip_global_output exists for commands that read
std::env::args_os() directly. A command using parse_passthrough does not need
it — the flag never reaches App::trailing_args() in the positions above.
Replace a built-in¶
Register a command with the same name. Application::build deduplicates by name,
keeping the last entry in slice order — which is the downstream crate's, not the
framework's:
static SPEC: CommandSpec = CommandSpec {
name: "version", // collides with rtb-cli's built-in
about: "Print version, our way",
feature: Some(rtb_app::features::Feature::Version),
..CommandSpec::DEFAULT
};
Keep the feature field matching the built-in you are replacing, or your
replacement will still appear when a tool disables that feature.
This only works reliably when exactly one of the colliding registrations is yours.
Linker slice order is not stable, so two third-party crates both registering
deploy is a coin toss.
Make docs, update and mcp appear¶
Adding rtb-docs, rtb-update or rtb-mcp to Cargo.toml is not enough. If
nothing in your binary references the crate, rustc does not link it, its
#[distributed_slice] registration never runs, and the command is silently absent
from --help.
Add a linking reference to main.rs:
You will know you have hit this because there is no error at all — just a command that is not there.
Check your work¶
$ mytool --help
Commands:
deploy Deploy the thing
...
$ mytool deploy --region eu --force
region=Some("eu") force=true
Related¶
- CLI reference — the tree your command joins.
- How arguments reach a command
- Expose a command over MCP