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:
--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.