rtb-cli¶
rtb-cli is the entry-point crate every downstream tool's main()
touches. It wires:
Application::builder— a typestate assembler forToolMetadata+VersionInfo(both required at compile time).clap— materialisesrtb_app::BUILTIN_COMMANDSinto a subcommand tree, filtered by runtimeFeatures, deduplicated by name.tracing_subscriber— pretty fmt on TTY stderr, JSON otherwise.- The
rtb_errorhook pipeline — report handler, panic hook, tool-specific footer fromToolMetadata::help. tokio::signal—Ctrl-Cand (on Unix)SIGTERMcancelApp.shutdown.
Plus the built-in command suite: version,
doctor, init, config, and feature-gated stubs for update,
docs, mcp.
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.
Design rationale¶
- Hand-rolled typestate over
bon::Builder. TheApplicationbuilder 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-appstays clap-free so downstream tools that replace clap (argh, bpaf, …) can do so by substituting their ownrtb-cliequivalent. run_with_argsfor tests. Production code callsrun()which readsstd::env::args_os(). Tests callrun_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:
rtb_error::hook::install_report_handler()— miette graphical renderer.rtb_error::hook::install_panic_hook()— panics render through the same pipeline.rtb_error::hook::install_with_footer(|| metadata.help.footer())— if the tool has a help channel.runtime::install_tracing(LogFormat::auto())— pretty fmt on TTY stderr, JSON otherwise. Idempotent viaOnce.runtime::bind_shutdown_signals(app.shutdown.clone())— spawns a task that cancels the root token onCtrl-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 |
v0.4 — moved into the default-on set. Subcommands show (default) / get / set / schema / validate. Schema-aware paths (since 0.4.1) 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 v0.4 untyped path operates against the canonical user-file <config_dir>/<tool>/config.yaml (override via --config-file PATH). |
update |
Update |
Registered by rtb-update v0.1. Subcommands check / run. |
docs |
Docs |
Registered by rtb-docs v0.1. Subcommands list / show / browse / serve / ask. |
mcp |
Mcp |
Registered by rtb-mcp v0.1. Subcommands serve / list. |
credentials |
Credentials |
v0.4. Subcommands list / add / remove / test / doctor. Backed by App::credentials_provider and rtb-credentials's Resolver / KeyringStore. |
telemetry |
Telemetry |
v0.4 — moved into the default-on set. 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",
aliases: &[],
feature: Some(rtb_app::features::Feature::Update),
};
&SPEC
}
async fn run(&self, _app: App) -> miette::Result<()> { /* ... */ }
}
#[distributed_slice(BUILTIN_COMMANDS)]
fn __register_update() -> Box<dyn Command> { Box::new(MyUpdate) }
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:
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.
API surface¶
| Item | Kind | Since |
|---|---|---|
Application, ApplicationBuilder<M, V> |
structs | 0.1.0 |
ApplicationBuilder::{metadata, version, assets, features, install_hooks, build} |
methods | 0.1.0 |
ApplicationBuilder::credentials_from<T: CredentialBearing> |
method | 0.4.0 |
Application::{run, run_with_args} |
async methods | 0.1.0 |
HealthCheck, HealthStatus, HealthReport |
trait + types | 0.1.0 |
HEALTH_CHECKS, INITIALISERS |
linkme distributed slices |
0.1.0 |
Initialiser |
trait | 0.1.0 |
runtime::{install_tracing, bind_shutdown_signals, LogFormat} |
module | 0.1.0 |
builtins::{VersionCmd, DoctorCmd, InitCmd} |
structs | 0.1.0 |
render::{OutputMode, output, strip_global_output} |
enum + fn + helper | 0.4.0 |
Global --output text\|json flag |
clap arg | 0.4.0 |
credentials::CredentialsCmd (registered) |
struct | 0.4.0 |
telemetry::TelemetryCmd (registered) |
struct | 0.4.0 |
config_cmd::ConfigCmd (registered) |
struct | 0.4.0 |
ApplicationBuilder::config<C> |
builder step (typed-config wiring) | 0.4.1 |
prelude |
module (re-exports) | 0.1.0 |
Deferred to later versions¶
#[rtb::command]attribute macro for less-boilerplate command authoring — once patterns stabilise.--output jsonoutput envelope — needs per-command DTO design.Shipped at v0.4 (untyped fallback) and lit up with schema-aware behaviour at v0.4.1 — see v0.4.1 scope addendum.config set/config schema/config validate— waits on richerrtb-configAPI.Shipped at v0.4.telemetry enable/disable/status/reset— waits onrtb-telemetryv0.2.
Consumers¶
Every downstream RTB-based tool is a consumer. The
examples/minimal
crate is the shipped reference.
Testing¶
17 acceptance criteria across:
- 10 unit tests (
tests/unit.rs) — T1–T13 (some subsumed). - 5 Gherkin scenarios (
tests/features/cli.feature) — S1/S2/S5/S6/S7. - 2 trybuild fixtures — typestate enforcement for
.metadataand.version.
Spec and status¶
- Status:
IMPLEMENTEDsince 0.1.0. - Spec:
docs/development/specs/2026-04-22-rtb-cli-v0.1.md. - Source:
crates/rtb-cli/.
Related¶
- rtb-app —
App,Command,BUILTIN_COMMANDS. - rtb-error — diagnostic pipeline that
run_with_argsinstalls. - rtb-test-support — test-side
Appconstruction.