Skip to content

How arguments reach a command

A Command is an async fn(App) -> Result<()>. It never receives a clap::ArgMatches. That single decision is behind most of the argument-handling behaviour in this family, including the parts that look inconsistent.

Why a command does not receive parsed arguments

rtb-app defines the Command contract and does not depend on clap, so that rtb-cli can be swapped for an equivalent built on a different parser. A trait method taking &ArgMatches would nail the contract to clap and make that impossible.

For a simple command that is no loss: CommandSpec describes the name and help text, and the command needs nothing else. For a command with its own flags and sub-subcommands, something has to give.

Passthrough: the command owns its own subtree

Command::subcommand_passthrough() returning true changes what the framework builds for that subcommand. Instead of declaring flags it cannot know about, rtb-cli gives the subcommand one anonymous trailing argument:

sub = sub.arg(
    clap::Arg::new("rest")
        .num_args(0..)
        .value_parser(clap::value_parser!(std::ffi::OsString))
        .trailing_var_arg(true)
        .allow_hyphen_values(true),
);
sub = sub.disable_help_flag(true);

Every token after the command name is captured verbatim — flags, values, sub-subcommands, --help — and validated by nobody. OsString rather than String so a non-UTF-8 argument survives to the command's own parser. The auto-injected --help is disabled so that mytool docs --help reaches the inner parser and prints the docs help rather than the outer help screen.

The captured tokens are attached to the App the command receives, reachable through App::trailing_args().

parse_passthrough is the intended way to read them

#[derive(clap::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(), prepends the parser's declared name as a synthetic argv[0] so usage strings read correctly, and parses. On --help or --version it prints and exits 0, matching what Parser::parse does.

Because it never touches std::env::args_os(), a command written this way is fully driveable from Application::run_with_args — which is what makes it testable without spawning a subprocess.

Why the built-ins do not use it, and what that costs

Every built-in passthrough command predates parse_passthrough and still does this instead:

let mut args: Vec<OsString> = std::env::args_os().collect();
if args.len() >= 2 {
    args.drain(..2);
}
args.insert(0, OsString::from("config"));

Take the real process arguments, drop the first two on the assumption they are the binary name and the subcommand name, and put a synthetic name back.

That assumption is the whole problem. It is true for mytool config get /a. It is false the moment anything else appears before the subcommand name — which is exactly what the global --output flag does:

$ mytool --output json config show
error: unrecognized subcommand 'json'

--output and mytool were dropped, json was left behind, and the inner parser met a subcommand it had never heard of.

Three of the built-ins (config, credentials, telemetry) then call strip_global_output to remove a trailing --output from what is left, which is why the flag works after the subcommand name. The other three (docs, update, mcp) do not call it at all, which is why they reject --output outright.

So the flag's behaviour is not a designed matrix. It is the residue of two different workarounds applied to the same underlying assumption, and the limitations page records the resulting table.

Why --output is re-parsed rather than passed down

OutputMode::from_args_os() scans the process arguments for --output VALUE or --output=VALUE and falls back to Text for anything it cannot parse — including an unknown value like --output yaml, which the outer parser would have rejected.

The alternative would be to put the parsed mode on the App. That is a change to the rtb-app contract for the benefit of one flag, and it would still leave the positional-slicing bug above. Re-parsing was the smaller move; it is not the better one.

The rule of thumb

Write new commands with parse_passthrough. Put the global flag after the subcommand name. And when a passthrough command misbehaves with an unfamiliar argument order, check whether the first two tokens really were the binary and the subcommand.