rtb-cli v0.1 — Application scaffolding and built-in commands¶
Status: IMPLEMENTED — closes the 0.1 workspace milestone. 10 unit
+ 5 BDD + 2 trybuild acceptance criteria all green.
Target crate: rtb-cli
Feeds: every downstream RTB-based tool.
Parent contract: §8 of the framework spec
and §3.5.
1. Motivation¶
rtb-cli is the entry point every downstream tool touches. It:
- wires
claptortb_app::command::BUILTIN_COMMANDS, - installs
tracing-subscriber,miettehooks (viartb-error), andtokiosignal handlers, - owns
Appconstruction so downstreammain()stays a one-liner, - provides built-in
version,config,doctor,initcommands and placeholders forupdate/docs/mcpuntil those crates ship.
rtb-cli is the first crate where user-facing UX lives. Its surface
goals: one-liner main(), typestate-enforced required metadata,
errors rendered uniformly through the edge hook installed by
rtb-error.
2. Scope boundaries¶
In scope for v0.1¶
Applicationstruct + typestate builder — required:metadata,version; optional: features, commands, tracing layers, assets.Application::run()async entry — returnsmiette::Result<()>. Parses CLI, filters commands by runtime Features, dispatches.- Tracing registry: pretty
fmton TTY, JSON off-TTY or when--log-format json;RUST_LOGrespected. - Error pipeline wiring:
rtb_error::hook::install_report_handler,install_panic_hook,install_with_footersourced fromToolMetadata::help.footer(). - Signal binding: tokio
ctrl_c+ UnixSIGTERMcancelApp::shutdown. - Built-in commands (registered via
linkmeslice): version— prints semver / commit / date / rustc target.doctor— runs registeredHealthChecktrait objects + reports.init— runs registeredInitialisertrait objects (wizard TBD → inline prompt for v0.1).config— (subtree)config show,config get <jsonpath>.- Feature-gated stubs for
update,docs,mcp: registered when their runtime Feature is enabled; theirrunreturnsrtb_error::Error::FeatureDisabled(...)pointing at the Cargo feature name. Real impls land with each crate's own v0.1.
Deferred¶
updatereal implementation (→rtb-updatev0.1).docsTUI browser (→rtb-docsv0.1).mcpserver (→rtb-mcpv0.1).config set,config schema,config validate— needs richer rtb-config API.- JSON envelope output (
--output json) — requires per-command DTO design; deferred to 0.2. #[rtb::command]attribute macro — commands in 0.1 are hand-writtenimpl Command for …with inlineCommandSpec. Macro lands once patterns stabilise.- Subcommand nesting beyond one level.
3. Public API¶
3.1 Crate root¶
pub mod application;
pub mod builtins;
pub mod health;
pub mod init;
pub mod runtime;
pub use application::Application;
pub use health::{HealthCheck, HealthReport, HealthStatus};
pub use init::Initialiser;
/// Re-exports for downstream tool `fn main()` convenience.
pub mod prelude {
pub use rtb_app::prelude::*;
pub use rtb_error::{Error as RtbError, Result as RtbResult};
pub use crate::application::Application;
}
3.2 Application + ApplicationBuilder¶
pub struct Application {
/* owns the constructed App, clap command tree, and a handle into
rtb_app's BUILTIN_COMMANDS filtered by Features */
}
impl Application {
/// Start a new builder. `metadata` and `version` are required
/// before `.build()` compiles — enforced by the `bon` typestate.
pub fn builder() -> ApplicationBuilder;
}
impl Application {
/// Parse `std::env::args_os()`, dispatch, return.
pub async fn run(self) -> miette::Result<()>;
/// Programmatic dispatch — for tests.
pub async fn run_with_args<I, S>(self, args: I) -> miette::Result<()>
where
I: IntoIterator<Item = S>,
S: Into<std::ffi::OsString> + Clone;
}
#[must_use]
pub struct ApplicationBuilder { /* typestate — generated by bon */ }
impl ApplicationBuilder {
pub fn metadata(self, m: ToolMetadata) -> Self; // required
pub fn version(self, v: VersionInfo) -> Self; // required
pub fn assets(self, a: Assets) -> Self; // optional
pub fn features(self, f: Features) -> Self; // optional (defaults)
pub fn install_hooks(self, yes: bool) -> Self; // optional (default true)
pub fn build(self) -> miette::Result<Application>;
}
Note: we do not use bon::Builder macro here because the builder
owns non-Default inputs and gains custom validation at build().
v0.1 uses a hand-rolled typestate pattern; bon may be revisited
later.
3.3 HealthCheck + reporting¶
#[async_trait::async_trait]
pub trait HealthCheck: Send + Sync + 'static {
fn name(&self) -> &'static str;
async fn check(&self, app: &App) -> HealthStatus;
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HealthStatus {
Ok { summary: String },
Warn { summary: String },
Fail { summary: String },
}
pub struct HealthReport {
pub entries: Vec<(&'static str, HealthStatus)>,
}
impl HealthReport {
pub fn is_ok(&self) -> bool;
pub fn render(&self) -> String;
}
Downstream tools register checks via a linkme distributed slice:
#[distributed_slice(rtb_cli::health::HEALTH_CHECKS)]
fn register() -> Box<dyn HealthCheck> { Box::new(MyCheck) }
Built-in checks for v0.1: config-present (warns if no user config
file loaded), no-literal-credentials (warns if any credential field
is a literal in config — placeholder hook; teeth added with
rtb-credentials v0.1).
3.4 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<()>;
}
Registered via #[distributed_slice(rtb_cli::init::INITIALISERS)].
init iterates the slice, prints what it's doing, and skips anything
is_configured already reports true for unless --force.
3.5 Built-in commands¶
All implement rtb_app::command::Command and register into
rtb_app::command::BUILTIN_COMMANDS:
version— always visible whenFeature::Versionenabled (default). Outputs semver, commit (if known), build date, rustc target triple.doctor— whenFeature::Doctorenabled. IteratesHEALTH_CHECKS, renders a summary table, exits non-zero if any check reportsFail.init— whenFeature::Initenabled. IteratesINITIALISERS.config show— whenFeature::Configenabled (default off for v0.1 — opt-in viaFeatures::builder().enable(Config)). Dumps the currently-resolved config as YAML.
Feature-gated placeholders (real impls in later crate versions):
update—Feature::Updateenabled by default; command returnsError::FeatureDisabled("update")in v0.1.docs—Feature::Docsenabled by default; stub.mcp—Feature::Mcpenabled by default; stub.
Downstream tools that don't want the stubs visible can
Features::builder().disable(Feature::Update)….
4. Acceptance criteria¶
4.1 Unit tests (T#)¶
- T1 —
Application::buildertypestate. Omitting.metadata(…)or.version(…)is a compile error. trybuild fixtures. - T2 —
ApplicationBuilder::build()returnsmiette::Result<Application>and succeeds for a minimal valid input. - T3 —
Application::run_with_args(["tool", "version"])prints the version info (captured via a custom tracing writer) and exits withOk(()). - T4 —
run_with_args(["tool", "version", "--commit"])prints the commit SHA if populated, or empty line otherwise. - T5 — Unknown subcommand returns
rtb_error::Error::CommandNotFound. - T6 — Disabled feature hides the command. Disabling
Feature::UpdateviaFeaturesomitsupdatefrom the clap tree; invokingupdatereturnsCommandNotFound. - T7 — Feature-disabled stub. With
Feature::Updateenabled but the realrtb-updatenot wired, invokingupdatereturnsError::FeatureDisabled("update"). - T8 —
doctoraggregatesHEALTH_CHECKS. Register a test check that returnsFail;doctorexits non-zero. - T9 —
inititeratesINITIALISERS. Register a test initialiser that records a call;inittriggers it. - T10 —
install_hooks(false)skips hook installation —miette::set_hooknot called (verified by counting a test sentinel). - T11 — signal wiring. Cancelling the shutdown token externally
causes
run_with_argsto surface a cancellation path cleanly (without crashing). Hard to assert without forking a thread — instead, assertApp::shutdownis a fresh token at start. - T12 —
config showdumps the merged config as YAML when theConfigfeature is enabled.
4.2 Gherkin scenarios (S#)¶
File: crates/rtb-cli/tests/features/cli.feature.
- S1 —
versionprints semver. - S2 — Unknown command emits a CommandNotFound diagnostic.
- S3 —
doctorwith all-OK checks exits zero and names every check. - S4 —
doctorwith a failing check exits non-zero. - S5 — Disabling the Update feature hides
updatefrom--help. - S6 — The Update placeholder returns a FeatureDisabled diagnostic pointing at the Cargo feature name.
- S7 —
initruns a registered initialiser. - S8 —
--helplists every enabled built-in.
4.3 Testing aids¶
- All tests use
Application::run_with_argsso nothing touchesstd::env::args. App::for_testingfrom rtb-app provides the context; we do not construct a realAppfromApplication::builderin unit tests (too much incidental setup). Instead, unit tests drive individual commandrunimpls directly.HEALTH_CHECKSandINITIALISERSuselinkme::distributed_slice; test binaries register fixtures at module scope.
5. Security & operational requirements¶
#![forbid(unsafe_code)].install_hooks(true)(default) idempotently installs the miette pipeline so subsequent errors render through the framework.run_with_argsnever logs secrets — redaction is deferred tortb-telemetry, but no command introduced here emits secrets.- No blocking operations on the async runtime — every built-in uses
tokio::task::spawn_blockingif it would block (none do in v0.1).
6. Non-goals (explicit)¶
- No macro attribute (
#[rtb::command]) yet. - No
--output jsonenvelope. - No subcommand-level middleware / middleware chains.
- No signal-specific behaviour beyond cancelling
shutdown(e.g.SIGHUPfor config reload is a v0.2 concern tied to rtb-config's deferred hot-reload).
7. Rollout plan¶
- Land spec + tests + impl in one
feat(cli)commit. examples/minimalupdates to useApplication::builder()— bundled into the same commit so the example stays live.- Subsequent v0.1 commits for
rtb-update,rtb-docs,rtb-mcpreplace their stub dispatch functions with real impls by simply registering a newCommandin their own#[distributed_slice]— no rtb-cli changes needed.
8. Open questions¶
- O1 — Should
Application::run_with_argsaccept anAsyncWritesink for test-capturing stdout? Or is a tracingwith_writerenough? Proposed: tracing for logs, and commands write their user-facing output viatracing::info!rather thanprintln!so tests can capture both uniformly.versionandconfig showfollow this pattern. - O2 —
doctorfailure exit code. Go Tool Base returns 1. Proposed: same. - O3 — Command name parameter to
Error::FeatureDisabled— currently&'static str. For v0.1 stubs we pass the Cargo feature name (e.g."update"). Leaning: keep&'static str. - O4 —
config showoutput format. YAML for v0.1 (matches the embedded-default format). JSON added when--output jsonlands.