Skip to content

The rtb-cli family

This repo is a four-crate family workspace sharing one version line — the CLI runtime of the toolkit. rtb-cli builds the application and dispatches commands; rtb-docs, rtb-update, and rtb-mcp each register a built-in command into the same link-time registry:

Crate Role
rtb-cli Entry-point crate every downstream tool's main() touches: Application::builder typestate, clap integration of BUILTIN_COMMANDS, tracing + miette wiring, signal binding, and the built-in command suite (version, doctor, init, config, credentials, telemetry).
rtb-docs Interactive docs browser (ratatui two-pane TUI), loopback docs serve HTML server, tantivy full-text search, and the streaming AI Q&A seam — all over a markdown tree carried by rtb-assets.
rtb-update Signature-verified self-update: select, download, verify (Ed25519 + SHA-256), atomically swap, roll back. Composes on rtb-forge.
rtb-mcp Model Context Protocol server (via the official rmcp SDK) that exposes mcp_exposed commands as MCP tools.

Part of the phpboyscout Rust toolkit; extracted from — and battle-tested by — rust-tool-base. The four crates are coupled by design and release in lockstep from this repo.

Overview

Downstream main() is a one-liner:

use rtb_cli::prelude::*;

#[tokio::main]
async fn main() -> miette::Result<()> {
    Application::builder()
        .metadata(ToolMetadata::builder().name("mytool").summary("a tool").build())
        .version(VersionInfo::from_env())
        .build()?
        .run()
        .await
}

A working reference example lives in the examples/minimal binary crate in the monorepo.

rtb-cli wires, in order:

  • Application::builder — a typestate assembler for ToolMetadata + VersionInfo (both required at compile time).
  • clap — materialises rtb_app::BUILTIN_COMMANDS into a subcommand tree, filtered by runtime Features, deduplicated by name.
  • tracing_subscriber — pretty fmt on TTY stderr, JSON otherwise.
  • The rtb_error hook pipeline — report handler, panic hook, tool-specific footer from ToolMetadata::help.
  • tokio::signalCtrl-C and (on Unix) SIGTERM cancel App.shutdown.

Design rationale

  • Hand-rolled typestate over bon::Builder. The Application builder needs custom validation at .build() (Features defaulting, App assembly) and type-level enforcement of required fields (metadata, version). Hand-rolled phantom markers (NoMetadata/HasMetadata, NoVersion/HasVersion) are clearer than fighting a macro.
  • clap only lives here. rtb-app stays clap-free so downstream tools that replace clap (argh, bpaf, …) can do so by substituting their own rtb-cli equivalent.
  • run_with_args for tests. Production code calls run() which reads std::env::args_os(). Tests call run_with_args(iter) so nothing touches process args.

Core types

Application + ApplicationBuilder

pub struct Application { /* App + sorted+deduped commands + hooks flag */ }

impl Application {
    pub const fn builder() -> ApplicationBuilder<NoMetadata, NoVersion>;
    pub async fn run(self) -> miette::Result<()>;
    pub async fn run_with_args<I, S>(self, args: I) -> miette::Result<()>
    where I: IntoIterator<Item = S>, S: Into<OsString> + Clone;
}

#[must_use]
pub struct ApplicationBuilder<M, V> { /* typestate */ }

impl ApplicationBuilder<NoMetadata, NoVersion> {
    pub const fn new() -> Self;
}

// metadata() is only callable on NoMetadata;
// version() is only callable on NoVersion;
// build() is only callable on HasMetadata + HasVersion.

Typestate enforcement is tested via two trybuild fixtures — omitting .metadata(…) or .version(…) is a compile error.

Wiring that runs at startup

Application::run_with_args installs, in order:

  1. rtb_error::hook::install_report_handler() — miette graphical renderer.
  2. rtb_error::hook::install_panic_hook() — panics render through the same pipeline.
  3. rtb_error::hook::install_with_footer(|| metadata.help.footer()) — if the tool has a help channel.
  4. runtime::install_tracing(LogFormat::auto()) — pretty fmt on TTY stderr, JSON otherwise. Idempotent via Once.
  5. runtime::bind_shutdown_signals(app.shutdown.clone()) — spawns a task that cancels the root token on Ctrl-C / SIGTERM.

ApplicationBuilder::install_hooks(false) opts tests out of the miette hook install (to avoid polluting test processes with a one-shot set-once hook).

HealthCheck, HealthReport, HealthStatus

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HealthStatus {
    Ok { summary: String },
    Warn { summary: String },
    Fail { summary: String },
}

#[async_trait::async_trait]
pub trait HealthCheck: Send + Sync + 'static {
    fn name(&self) -> &'static str;
    async fn check(&self, app: &App) -> HealthStatus;
}

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

pub struct HealthReport { pub entries: Vec<(&'static str, HealthStatus)> }

impl HealthReport {
    pub fn is_ok(&self) -> bool;
    pub fn render(&self) -> String;
}

Downstream crates register checks via #[distributed_slice(HEALTH_CHECKS)]. The doctor subcommand iterates and reports.

Initialiser

#[async_trait::async_trait]
pub trait Initialiser: Send + Sync + 'static {
    fn name(&self) -> &'static str;
    async fn is_configured(&self, app: &App) -> bool;
    async fn configure(&self, app: &App) -> miette::Result<()>;
}

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

The init subcommand iterates, skipping already-configured entries.

Built-in commands

Every built-in registers into rtb_app::BUILTIN_COMMANDS via #[distributed_slice]. Application::build filters them by the runtime Features set.

Subcommand Feature Behaviour
version Version Prints name/semver/commit/date + target triple.
doctor Doctor Runs HEALTH_CHECKS; exits non-zero if any Fail.
init Init Iterates INITIALISERS; skips already-configured.
config Config Subcommands show (default) / get / set / schema / validate. Schema-aware paths light up when the host tool calls Application::builder().config<C>(...)show renders the merged typed value, schema prints the JSON Schema for C, validate validates the merged value (or --config-file <PATH>) against it, set validates post-write before persisting. The untyped path operates against the canonical user-file <config_dir>/<tool>/config.yaml.
update Update Registered by rtb-update. Subcommands check / run.
docs Docs Registered by rtb-docs. Subcommands list / show / browse / serve / ask.
mcp Mcp Registered by rtb-mcp. Subcommands serve / list.
credentials Credentials Subcommands list / add / remove / test / doctor. Backed by App::credentials_provider and rtb-credentials's Resolver / KeyringStore.
telemetry Telemetry Subcommands status / enable / disable / reset. Backed by rtb_telemetry::consent (file at <config_dir>/<tool>/consent.toml). enable refuses under CI=true.

Replacing a built-in

Downstream crates override any built-in command by registering a Command with the same name. Application::build deduplicates keeping the last entry in slice order, so a downstream tool can ship its own version (or any other) command and the framework's default falls away:

use rtb_app::command::{BUILTIN_COMMANDS, Command, CommandSpec};
use linkme::distributed_slice;

pub struct MyUpdate;

#[async_trait::async_trait]
impl Command for MyUpdate {
    fn spec(&self) -> &CommandSpec {
        static SPEC: CommandSpec = CommandSpec {
            name: "update",   // collides with rtb-update; dedup picks the later entry
            about: "Run the real update flow",
            feature: Some(rtb_app::features::Feature::Update),
            ..CommandSpec::DEFAULT
        };
        &SPEC
    }
    async fn run(&self, _app: App) -> miette::Result<()> { /* ... */ }
}

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

Passthrough subcommands (since 0.6.0)

A command that owns its own clap subtree sets Command::subcommand_passthrough() -> true. The outer parser then captures every token after <name> and stashes it on the App; the command re-parses those with its own clap::Parser via parse_passthrough:

use clap::Parser;

#[derive(Parser)]
struct DeployArgs {
    #[arg(long)]
    region: Option<String>,
}

async fn run(&self, app: App) -> miette::Result<()> {
    let args = rtb_cli::parse_passthrough::<DeployArgs>(&app)?;
    // …
}

parse_passthrough reads App::trailing_args() (populated by dispatch from the outer parse — the framework already parsed those tokens), so the command never touches std::env::args_os() and is fully driveable from run_with_args in tests. --help/--version print and exit 0, matching clap's own Parser::parse. This is the shape rtb generate command scaffolds on the cli preset.

The built-in docs/update commands still re-read std::env::args_os() directly; migrating them onto parse_passthrough is a tracked fast-follow.

Output rendering — --output text|json (since 0.4.0)

A global --output text|json flag is declared once at the root of the clap tree with Arg::global(true) and propagates to every subcommand. Both forms parse identically:

mytool --output json subcommand
mytool subcommand --output json

Subcommands that print structured data honour the flag through the rtb_cli::render module:

use rtb_cli::{OutputMode, render};

let mode = OutputMode::from_args_os();   // re-parse for passthrough subtrees
render::output(mode, &rows)?;             // tabled for Text, JSON for Json

render::output wraps rtb_tui::render_table (text) and rtb_tui::render_json (JSON, pretty-printed). Subcommands that own their own clap subtree (subcommand_passthrough = true) re-parse the flag from std::env::args_os() via OutputMode::from_args_os — same pattern those subcommands use for their other args.

Subcommands without structured output (init, update run, mcp serve) silently ignore the flag.

Testing

  • Unit tests (tests/unit.rs) + tests/output_flag.rs.
  • Gherkin scenarios (tests/features/cli.feature) driven by cucumber (tests/bdd.rs).
  • Two trybuild fixtures — typestate enforcement for .metadata and .version (run isolated; see .config/nextest.toml — nextest is required).

Design record