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 --versionto check). - Network access for the first
cargo build.
Nothing else. No config files, no keys, no accounts.
Create the project¶
Add the two dependencies you need for a minimal tool:
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:
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¶
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.
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.
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:
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:
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:
And check the command joined the tree:
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:
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¶
- Add a command — replacing a built-in, rendering
JSON, and making the
docs,updateandmcpcommands appear. - CLI reference — the flags and exit codes of everything you just ran.
- How the application is assembled — why registration works the way it does.