Skip to content

Build your first tool

By the end of this you'll have a small Rust CLI called greeter that you scaffolded rather than typed, with a greet subcommand, a --name flag, and a config field — and it will print hello, Ada! when you run it.

Allow about fifteen minutes, most of which is the first cargo build.

What you need first

  • Rust 1.82 or newer. That's the minimum the workspace declares.
  • The rtb binary. Install it with cargo install rtb-cli-bin. It's a separate install from the framework crate on purpose — a tool built on RTB never pulls the scaffolder's templates into its own binary.
  • A directory you don't mind creating a project in.

You don't need an AI provider or an API key. Everything here runs from the templates baked into the binary, so it works offline.

Check the binary is on your path — note that it's a subcommand, not a flag, so rtb --version won't work:

$ rtb version
rtb 0.9.0
  target: x86_64-linux

Scaffold the project

rtb generate project renders a whole tool from a preset. Run it from the directory you want the project to appear in — it creates ./greeter for you:

$ rtb generate project --name greeter --description "A greeting tool" --non-interactive
Created `greeter` (6 files written to greeter)

--non-interactive skips the wizard, which means every required field has to be a flag; drop it and rtb will prompt you for anything you left out. The name has to be lowercase with hyphens — ^[a-z][a-z0-9-]{0,63}$ — so greeter is fine and Greeter isn't.

You now have six files and a git repository:

greeter/
├── .git/
├── .gitignore
├── .rtb/manifest.yaml
├── Cargo.toml
├── README.md
└── src/
    ├── commands/mod.rs
    └── main.rs

That default preset is minimal. It depends on clap and linkme and nothing else, which is why it builds without fetching the framework. The other bundled preset, cli, builds on the full framework and inherits commands like version, doctor and config — worth switching to later, but it's more moving parts than a first run needs.

.rtb/manifest.yaml is the important one. It records which preset built the tree and a hash of every file the generator owns, and every command below reads it. That's also why the rest of this tutorial runs from inside the project directory:

$ cd greeter

Add a subcommand

$ rtb generate command greet --about "Print a greeting"
Created `src/commands/greet.rs` and registered in `src/commands/mod.rs`

Two things happened: a new file, and a pub mod greet; line inserted between the // rtb:commands-begin and // rtb:commands-end markers in src/commands/mod.rs. Those markers matter — they're the region the scaffolder owns, and anything inside them survives a later regeneration.

Command names are single-segment only. rtb generate command kube/ctx fails with "nested command paths are not yet supported", so build nested trees by hand for now.

Add a flag to it

$ rtb generate flag --command greet name --type string --description "Who to greet"
Added `--name` to `src/commands/greet.rs`

Pass the flag's long name without the leading dashes — name, not --name. Passing --name there is rejected rather than quietly stripped.

--type takes string, bool, int, float or string_slice. The argument lands between that file's // rtb:flags-begin / -end markers, as a normal clap builder call:

// rtb:flags-begin
.arg(
    clap::Arg::new("name")
        .long("name")
        .help("Who to greet"),
)
// rtb:flags-end

Add a setting

A flag is something the user types this run. A setting is something they configure once — it lives on the tool's AppConfig struct rather than on a command:

$ rtb generate setting greeting --type string --default hello
Added `greeting: String` to AppConfig

Keep setting names to a single lowercase word. The help text says snake_case, but the validator behind it only allows ^[a-z][a-z0-9-]{0,63}$: default_name is rejected outright, and default-name is accepted and then renders a field name that isn't valid Rust. greeting is safe.

Look at src/main.rs and you'll see the field, plus a comment recording the default:

// rtb:settings-begin
// default: hello
pub greeting: String,
// rtb:settings-end

That comment is documentation, not behaviour — at runtime the field still takes String::default(), an empty string. Wire a real default with #[serde(default = "…")] when you need one.

Run what you have

$ cargo run -- --help
A greeting tool

Usage: greeter [COMMAND]

Commands:
  greet  Print a greeting
  help   Print this message or the help of the given subcommand(s)

Options:
      --version  Print version information
  -h, --help     Print help

The first build fetches and compiles clap, so give it a minute. Your subcommand and its flag are both there:

$ cargo run -- greet --help
Print a greeting

Usage: greeter greet [OPTIONS]

Options:
      --name <name>  Who to greet
  -h, --help         Print help

And running it tells you exactly where you've got to:

$ cargo run -- greet --name Ada
`greeter greet` is scaffolded but not yet implemented

Fill in the body

The scaffolder writes structure, not behaviour. Open src/commands/greet.rs and replace the run body:

fn run(&self, matches: &ArgMatches) -> Result<(), String> {
    let name = matches.get_one::<String>("name").map_or("world", String::as_str);
    println!("hello, {name}!");
    Ok(())
}
$ cargo run -- greet --name Ada
hello, Ada!
$ cargo run -- greet
hello, world!

That's a working tool.

Check that regeneration leaves your work alone

rtb regenerate project re-renders the preset and reconciles it against your tree. Run it now, with your hand-written body in place:

$ rtb regenerate project
Wrote 0 files; 0 protected skipped; 0 edited skipped; 0 stale (diff only)

Nothing was touched, and src/commands/greet.rs still says hello, Ada!. Two mechanisms did that. Files the generator owns are compared against the hashes in the manifest, so an edited file is skipped rather than clobbered. And for files it does refresh, the interiors of the // rtb:* marker regions are copied off disk into the new render, so your registrations and settings come across.

One thing to know before you rely on it: regeneration prints file names, never a diff, and --dry-run doesn't preview what it would write. Commit before you regenerate and read git diff afterwards — that's the real preview.

Where to go next