Skip to content

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:

[dependencies]
linkme = "0.3"

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:

mytool deploy --region eu --output json
error: unexpected argument '--output' found

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:

use rtb_docs as _;
use rtb_mcp as _;
use rtb_update as _;

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