rust-tool-base 0.10.1

Rust Tool Base — the batteries-included CLI application framework. Umbrella crate re-exporting the full public API.
docs.rs failed to build rust-tool-base-0.10.1
Please check the build logs for more information.
See Builds for ideas on how to fix a failed build, or Metadata for how to configure docs.rs builds.
If you believe this is docs.rs' fault, open an issue.

Rust Tool Base (RTB)

A batteries-included application framework for building Rust CLI tools: idiomatic, composable and AI-ready.

Status: pre-1.0. The crates are implemented and published, but the public API is not frozen, so expect breaking changes within 0.x. The authoritative contract for each subsystem lives in 0008-rust-tool-base.

RTB is a Rust sibling of Go Tool Base (GTB). It is not a line-for-line port. Go Tool Base leans on Go's idioms (context.Context, functional options, package-level init(), interface{}-keyed containers); RTB leans on Rust's (? + miette, typestate builders, linkme distributed slices, strongly-typed serde config, tokio structured concurrency).

What RTB gives you

  • Opinionated application scaffolding. Application::builder() is a typestate assembler that wires clap, figment, tracing, miette, tokio and a signal-bound CancellationToken in one call.
  • Strongly-typed layered config. Env vars > user files > embedded defaults, materialised into your serde::Deserialize struct. Hot-reload (the hot-reload feature of rtb-config) uses notify and arc-swap, with a watch::Receiver for observers.
  • Embedded assets with overrides. rtb-assets layers files compiled in with rust-embed, directories on disk and in-memory maps behind one read-only Assets API. A read takes the file from the last-registered layer that has it; YAML and JSON can instead be deep-merged across every layer (RFC 7396 merge-patch).
  • Diagnostic errors, not error handlers. Every crate derives thiserror::Error + miette::Diagnostic; main() returns miette::Result<()> and a framework-installed hook renders them with labels, help, and severity.
  • Self-update. self-replace plus minisign-signed releases (the prehashed ED variant, ed25519(BLAKE2b-512(archive))), verified with minisign-verify. That is the crate cargo-binstall uses, so one signature serves both. The staged binary is self-tested before an atomic swap.
  • Release sources and git. rtb-forge reads releases from GitHub, GitLab, Bitbucket, Gitea, Codeberg or a bare HTTPS URL, which is where rtb update looks for a new version, and its Repo type runs git operations on gix.
  • AI. genai covers OpenAI, Gemini, Ollama and OpenAI-compatible endpoints; Anthropic goes through a direct reqwest client so prompt caching, extended thinking and citations work. Structured output is validated with jsonschema.
  • MCP. Built on the official rmcp SDK. A command opts in by overriding Command::mcp_exposed() to return true, and can describe its arguments to MCP clients through Command::mcp_input_schema(). mcp serve (stdio by default) and mcp list ship as built-in subcommands.
  • TUI docs browser. ratatui with tui-markdown, plus streaming AI Q&A.
  • Credentials. keyring wrapped in a CredentialStore trait, with secrecy::SecretString end-to-end, zeroed on drop.
  • Telemetry. Opt-in, salted machine-uid, pluggable sinks (noop, file, HTTP, OTLP).

Workspace layout

Only the umbrella crate and the rtb binary live here. Every other crate is a standalone module of the phpboyscout Rust toolkit, consumed here as an ordinary dependency:

  • Foundations: rtb-error, rtb-redact, rtb-config, rtb-assets, rtb-app, rtb-tui, rtb-credentials, rtb-telemetry, rtb-test-support, rtb-chat (formerly rtb-ai), rtb-forge (formerly rtb-vcs).
  • CLI family: rtb-cli, rtb-update, rtb-docs, rtb-mcp, in phpboyscout/rust/cli.
rust-tool-base/
├── Cargo.toml                      # workspace root
├── crates/
│   ├── rtb/                        # umbrella crate (public API)
│   └── rtb-cli-bin/                # the `rtb` scaffolder binary
├── examples/
│   └── minimal/                    # smoke-test tool
├── docs/                           # user-facing documentation
│                                   # (specs live in the project wiki)
├── .gitlab-ci.yml                  # CI + the tagged `dist` release stage
├── deny.toml                       # cargo-deny policy
├── rustfmt.toml
├── rust-toolchain.toml             # pinned stable
├── justfile                        # common tasks
├── LICENSE                         # MIT
└── SECURITY.md

Quick start

A working end-to-end example lives at examples/minimal. Run it with:

$ cargo run -p rtb-example-minimal -- version
$ cargo run -p rtb-example-minimal -- greet
$ cargo run -p rtb-example-minimal -- doctor
$ cargo run -p rtb-example-minimal -- --help

Anatomy

A minimal RTB-based tool has three parts: (1) a custom Command implementation, (2) a linkme registration that inserts it into the framework's command slice, and (3) an Application::builder() call in main.

use async_trait::async_trait;
use linkme::distributed_slice;
use rtb::core::app::App;
use rtb::core::command::{BUILTIN_COMMANDS, Command, CommandSpec};
use rtb::prelude::*;

// 1. Implement the Command trait.
struct Greet;

#[async_trait]
impl Command for Greet {
    fn spec(&self) -> &CommandSpec {
        static SPEC: CommandSpec = CommandSpec {
            name: "greet",
            about: "Print a friendly greeting",
            aliases: &["hi"],
            ..CommandSpec::DEFAULT
        };
        &SPEC
    }

    async fn run(&self, app: App) -> miette::Result<()> {
        println!("hello from {}", app.metadata.name);
        Ok(())
    }
}

// 2. Register into the framework's command slice at link time.
#[distributed_slice(BUILTIN_COMMANDS)]
fn register_greet() -> Box<dyn Command> { Box::new(Greet) }

// 3. Wire the Application.
#[tokio::main]
async fn main() -> miette::Result<()> {
    rtb::cli::Application::builder()
        .metadata(
            ToolMetadata::builder()
                .name("mytool")
                .summary("my CLI tool")
                .build(),
        )
        .version(version_info!())
        .build()?
        .run()
        .await
}

version_info!() has to be a macro: it expands env!("CARGO_PKG_VERSION") at your call site, so it reports your tool's version. A function couldn't: its env! would expand inside the framework and report the framework's version instead.

Downstream tools need the framework, tokio, miette, async-trait and linkme as direct dependencies. The framework publishes as rust-tool-base and the examples import it as rtb:

[dependencies]
rtb = { package = "rust-tool-base", version = "0.9" }

linkme must be direct because its #[distributed_slice] attribute expands to ::linkme::… paths.

Development

just ci              # fmt, clippy, rustdoc, tests, cargo-deny, coverage
just check           # cargo check, all targets, default features
just rtb -- --help   # run the scaffolder CLI

just pre-push is the merge-request gate without coverage. AGENTS.md explains why it needs cargo-nextest.

License

MIT. See LICENSE.