Skip to content

Build a CLI tool on rtb-cli

By the end you'll have a binary called greet that answers --help, reports its own version, runs health checks, and carries one command you wrote yourself with its own flags.

Allow about twenty minutes. The first build pulls a fair amount of the toolkit and takes a few minutes on a cold cache; everything after that is seconds.

Before you start

You'll need:

  • Rust 1.82 or newer (rustc --version to check).
  • Network access for the first cargo build.

Nothing else. No config files, no keys, no accounts.

Create the project

cargo new greet
cd greet

Add the two dependencies you need for a minimal tool:

cargo add rtb-cli rtb-app tokio --features tokio/full
cargo add miette --features fancy

Write main()

Replace src/main.rs with this:

use rtb_cli::prelude::*;

#[tokio::main]
async fn main() -> std::process::ExitCode {
    let app = Application::builder()
        .metadata(
            ToolMetadata::builder()
                .name("greet")
                .summary("says hello, properly")
                .build(),
        )
        .version(rtb_app::version_info!())
        .build();

    match app {
        Ok(app) => app.run_and_exit().await,
        Err(report) => rtb_cli::report_to_exit_code(&report),
    }
}

Two things are required and enforced at compile time: .metadata(...) and .version(...). Leave either out and this will not compile — that is the builder's typestate doing its job, not a runtime check you might miss.

run_and_exit renders any error through the diagnostic pipeline and returns a process exit code. Build and run it:

cargo run -- --help
says hello, properly

Usage: greet [OPTIONS] <COMMAND>

Commands:
  config       Show, query, mutate, and validate the user config …
  credentials  Manage credential storage …
  doctor       Run diagnostic health checks
  init         Run first-time bootstrap and setup
  telemetry    Manage opt-in telemetry consent …
  version      Print tool version information
  help         Print this message or the help of the given subcommand(s)

Six commands you did not write. They registered themselves at link time.

Try the built-ins

cargo run -- version
greet 0.1.0
  target: x86_64-linux

There's no commit or built line because version_info!() reads only CARGO_PKG_VERSION; populating those needs a build script, which you don't need today.

cargo run -- doctor

That prints nothing and exits 0 — there are no health checks registered yet. Silence here means "nothing to check", not "all healthy".

One thing to know now rather than later: greet --version does not work.

cargo run -- --version
rtb::command_not_found

  x command not found: --version
  help: run `--help` to list available commands

rtb-cli doesn't declare a root --version flag; greet version is the supported spelling. It matters if you later enable self-update, which self-tests a staged binary by running --version on it.

Add a command of your own

A command is a struct implementing Command, plus a factory function registered into a link-time slice. Add two dependencies first:

cargo add async-trait clap --features clap/derive
cargo add linkme

linkme has to be a direct dependency even though rtb-app re-exports it — the registration macro expands to paths resolved in your own crate root.

Create src/hello.rs:

use clap::Parser;
use linkme::distributed_slice;
use rtb_app::app::App;
use rtb_app::command::{Command, CommandSpec, BUILTIN_COMMANDS};

#[derive(Parser)]
#[command(name = "hello")]
struct HelloArgs {
    /// Who to greet.
    #[arg(long, default_value = "world")]
    name: String,
    /// Shout it.
    #[arg(long)]
    loud: bool,
}

pub struct Hello;

#[async_trait::async_trait]
impl Command for Hello {
    fn spec(&self) -> &CommandSpec {
        static SPEC: CommandSpec =
            CommandSpec { name: "hello", about: "Greet someone", ..CommandSpec::DEFAULT };
        &SPEC
    }

    fn subcommand_passthrough(&self) -> bool {
        true
    }

    async fn run(&self, app: App) -> miette::Result<()> {
        let args = rtb_cli::parse_passthrough::<HelloArgs>(&app)?;
        let greeting = format!("hello, {}", args.name);
        if args.loud {
            println!("{}!", greeting.to_uppercase());
        } else {
            println!("{greeting}");
        }
        Ok(())
    }
}

#[distributed_slice(BUILTIN_COMMANDS)]
fn __register_hello() -> Box<dyn Command> {
    Box::new(Hello)
}

Then declare the module at the top of src/main.rs:

mod hello;

subcommand_passthrough is what lets the command own its own flags. The framework captures every token after hello and parse_passthrough hands them to your clap parser — so hello --help prints your help, not the outer one.

Run it:

cargo run -- hello --name Ada --loud
HELLO, ADA!

And check the command joined the tree:

cargo run -- --help

hello Greet someone now sits in the list, sorted alphabetically with the rest.

Add a health check

doctor printing nothing is a good sign that nothing is registered. Give it something to say. Create src/checks.rs:

use linkme::distributed_slice;
use rtb_app::app::App;
use rtb_cli::health::{HealthCheck, HealthStatus, HEALTH_CHECKS};

struct NameCheck;

#[async_trait::async_trait]
impl HealthCheck for NameCheck {
    fn name(&self) -> &'static str {
        "tool-name"
    }

    async fn check(&self, app: &App) -> HealthStatus {
        if app.metadata.name.is_empty() {
            HealthStatus::fail("tool name is empty")
        } else {
            HealthStatus::ok(format!("running as `{}`", app.metadata.name))
        }
    }
}

#[distributed_slice(HEALTH_CHECKS)]
fn register() -> Box<dyn HealthCheck> {
    Box::new(NameCheck)
}

Add mod checks; beside mod hello; in src/main.rs, then:

cargo run -- doctor
  [OK  ] tool-name: running as `greet`

A check returning HealthStatus::fail would print [FAIL] and make doctor exit non-zero, which is what makes it usable in CI. HealthStatus::warn prints [WARN] and leaves the exit code alone.

What you have

A binary with a self-assembling command tree: six built-ins, one command of your own with its own argument parser, and one health check — none of it wired through a central list.

Nothing here touched configuration, credentials, embedded documentation or self-update. Those are all opt-in, and each has its own guide.

Where to go next