Preset-aware scaffolder codegen¶
Status: APPROVED — prerequisite bug fix surfaced while implementing
command-authoring-parity.
That spec threads richer metadata into generated commands; this spec
fixes the substrate it threads into, which is currently broken on the
cli preset.
Resolved decisions (2026-07-04)¶
Open questions were reviewed with MC and resolved as follows:
- Uniform passthrough shape — every cli-preset command is generated
as passthrough-with-inner-
Parser, even flagless. No "upgrade on first flag" surgery. - Rename + add partials —
command.rs.j2→command.minimal.rs.j2, addcommand.cli.rs.j2. Explicit selection bymanifest.preset; unknown preset is a hard error (no silent minimal fallback). - Adopt the "framework delivers passthrough args" model (was Q3
option D) — the framework already parses the trailing tokens and
discards them (
application.rs:72,_sub_matches). Instead, carry them onAppand expose aparse_passthroughhelper. Commands never re-readstd::env::args_os(). This is smaller than the original A/B helper options and makes passthrough commands testable viarun_with_args. - Defer AI codegen on cli —
generate command --prompt/--scripterrors clearly on the cli preset ("lands in a follow-up"). AI-on-cli is a fast-follow (see §8).
1. Problem¶
rtb generate command (and rtb generate flag) emit code that targets
one Command trait shape — the minimal preset's locally-defined
trait. On the cli preset the generated file does not compile, and
no test catches it.
Evidence:
- The shared partial
templates/_partials/command.rs.j2implements a trait withfn name(),fn about(),fn clap() -> ClapCommand,fn run(&self, _matches: &ArgMatches) -> Result<(), String>, anduse crate::{Command, BUILTIN_COMMANDS};. - The minimal preset defines exactly that trait + slice locally
(
templates/minimal/src/main.rs.j2), so the partial compiles there. - The cli preset uses the framework trait via
rtb::prelude::*(crates/rtb-app/src/command.rs):fn spec(&self) -> &CommandSpec+async fn run(&self, app: App). The generated impl provides the wrong methods and an incompatiblerunsignature, and the file fails to compile. generate commandnever branches onmanifest.preset(commands/generate/command.rsrenders the one partial unconditionally).- Same root cause for
generate flag: the framework builds a command's clap subtree fromCommandSpec(name/about/aliases) only (crates/rtb-cli/src/application.rs:350-401). A command gets no args unless it setssubcommand_passthrough() -> trueand re-parses the trailing tokens (thedocs/updatepattern). There is noclap()method to splice.arg(...)into — so the flag-marker model is minimal-only.
Net: scaffolded subcommands are a minimal-preset-only feature today,
silently. This blocks command-authoring-parity from being correct on the
cli preset and is a latent correctness bug regardless of that spec.
2. Scope¶
In scope:
- Make
generate commandandgenerate flagpreset-aware (branch onmanifest.preset). - Define and ship the cli-preset generated-command shape (framework trait) and a cli-preset flag-insertion strategy.
- Land the framework passthrough-args mechanism (§3.4) that the cli shape depends on.
- Add cli-preset E2E coverage that scaffolds, generates a command +
flag, and
cargo checks the result.
Out of scope (separate specs / fast-follows in §8):
--short/--aliases/--long/--argsmetadata and nested--parentsemantics — those layer on top of the shape defined here and remain incommand-authoring-parity.- Refactoring the existing
docs/updatebuilt-ins onto the new helper (fast-follow — the helper is additive; the built-ins keep working unchanged until then). - AI codegen (
--prompt/--script) on the cli preset (fast-follow). generate setting(writes into theAppConfigstruct marker region — preset-independent; unaffected).
3. Design¶
3.1 cli-preset generated-command shape¶
Emit a framework Command impl that is passthrough with an inner
#[derive(Parser)] args struct — mirroring the built-in docs/update
commands. The inner struct is the stable target generate flag inserts
into, and the future home of nested --parent (#[command(subcommand)]).
A new partial templates/_partials/command.cli.rs.j2 (the existing partial
is renamed to command.minimal.rs.j2; selection is explicit, no implicit
fallback) renders approximately:
//! `{{ tool_name }} {{ command_name }}` — {{ about }}.
use clap::Parser;
use rtb::prelude::*;
/// `{{ tool_name }} {{ command_name }}` subcommand.
pub struct {{ command_pascal }}Cmd;
/// Parsed arguments for `{{ command_name }}`.
#[derive(Debug, Parser)]
#[command(name = "{{ command_name }}", about = "{{ about }}")]
pub struct {{ command_pascal }}Args {
// rtb:args-begin
// rtb:args-end
}
#[async_trait::async_trait]
impl Command for {{ command_pascal }}Cmd {
fn spec(&self) -> &CommandSpec {
static SPEC: CommandSpec = CommandSpec {
name: "{{ command_name }}",
about: "{{ about }}",
aliases: &[],
feature: None,
};
&SPEC
}
fn subcommand_passthrough(&self) -> bool {
true
}
async fn run(&self, app: App) -> miette::Result<()> {
let args = rtb::cli::parse_passthrough::<{{ command_pascal }}Args>(&app)?;
// TODO: fill in the body.
let _ = args;
Ok(())
}
}
#[distributed_slice(BUILTIN_COMMANDS)]
fn __register_{{ command_snake }}() -> Box<dyn Command> {
Box::new({{ command_pascal }}Cmd)
}
The flag marker is // rtb:args-begin/-end inside the derive struct
(cli), distinct from the minimal preset's // rtb:flags-begin/-end
inside clap() — so the two insertion strategies never collide.
3.2 cli-preset generate flag¶
Insert a clap-derive struct field into the rtb:args-* region rather
than an .arg(...) builder call. FlagType maps to field types:
--type |
field type | attribute |
|---|---|---|
| string | Option<String> |
#[arg(long)] |
| bool | bool |
#[arg(long)] |
| int | Option<i64> |
#[arg(long)] |
| float | Option<f64> |
#[arg(long)] |
| string_slice | Vec<String> |
#[arg(long)] |
--shorthand → short = 'x'; --description → doc comment / help;
--required → non-Option + (string) required; --default →
default_value. Validation is unchanged (reuses validate.rs).
3.3 preset selection¶
generate command / generate flag read manifest.preset and select the
partial / insertion strategy. Unknown preset → clear error (no silent
minimal fallback — that fallback is the bug being fixed). The minimal path
is byte-for-byte unchanged.
3.4 framework passthrough-args mechanism (the core of this spec)¶
Today passthrough commands re-read std::env::args_os() and drain(..2)
because the framework never hands them the trailing tokens — even though it
already parses them and throws them away at application.rs:72
(_sub_matches). Fix that at the source:
Appcarries the trailing tokens. Addtrailing_args: Arc<[OsString]>(empty default inApp::new), a builder-styleApp::with_trailing_args(self, Vec<OsString>) -> Self(mirrors the existingApp::with_typed_config, app.rs:113), and aApp::trailing_args(&self) -> &[OsString]accessor. Additive — theCommand::run(&self, app)signature is unchanged, so every existing impl keeps compiling.- Dispatch populates it. In
run_with_args, extract therestvar-arg fromsub_matches, thenlet app = self.app.clone().with_trailing_args(rest);and pass that to the pre-run hooks andcmd.run(app). Non-passthrough commands have norestarg → empty slice → no behaviour change. - Detail: the
restarg (build_clap_tree, application.rs:386) has no explicitvalue_parserand so defaults toString. Setvalue_parser(clap::value_parser!(std::ffi::OsString))on it to preserve non-UTF-8 arguments (parity with the currentargs_ospath). rtb_cli::parse_passthrough::<T: clap::Parser>(app: &App) -> miette::Result<T>centralises the synthetic-arg0 +try_parse_from+ help/version short-circuit, readingapp.trailing_args()instead of global argv. It is the single home for the argv dance thatdocs/updatecurrently hand-roll.
Consequences: passthrough commands become testable via run_with_args
(trailing args flow through the parameter, not global state) — which they
are not today; and the drain count is always the outer bin+name pair, so no
depth bookkeeping is needed (nested --parent children live inside the
top-level command's own parser, never as separate registrations).
4. Testing (TDD)¶
- Unit: partial selection by
manifest.preset(incl. unknown-preset error); cli flag-field rendering perFlagType;rtb:args-*marker insertion (ordering, dedup, missing-marker error) mirroring the existinginsert_flagtests. - Unit (framework):
parse_passthrough— parses fromApp::trailing_args, returns help/version asOk(())-worthy short-circuit, preserves non-UTF-8 tokens;App::with_trailing_args/trailing_argsround-trip. - E2E (
assert_cmd, new cli-preset coverage):generate project --preset cli→generate command deploy→generate flag --command deploy region --type string→cargo checkpasses;deploy --helplists the flag. The existing minimal-preset E2E (generated_command_appears_in_tool_help) must stay green. - E2E:
generate command deploy --prompt "…"on a cli-preset tree errors with the deferral message (not a panic, not a broken file).
5. Interaction with command-authoring-parity¶
Once this lands, command-authoring-parity resumes with the substrate in
place: --aliases → CommandSpec.aliases (cli) / ClapCommand::aliases
(minimal); --short → short (cli field) / .short_flag (minimal);
--args/--long → the inner derive struct (cli) / clap() (minimal);
nested --parent → inner #[command(subcommand)] (cli) / main-loop
nesting (minimal). The new manifest commands: field (that spec) is
unaffected by this one.
6. Open questions¶
None outstanding — all resolved in the header block (2026-07-04).
7. Out of scope¶
- Metadata flags and nested-command semantics (
command-authoring-parity). - Any change to the framework
Command/CommandSpectrait surface (the passthrough-args mechanism is additive onApp, not the trait). generate setting(unaffected).
7a. Release-gated version pin¶
The cli preset pins rtb = { package = "rust-tool-base", version = "0.5.1" }
(crates.io), but the generated cli command calls rtb::cli::parse_passthrough,
which ships in the next release (0.6.x). So:
- The E2E test rewrites the generated
rtbdependency to a workspace path (a[patch.crates-io]cannot be used —0.6.0does not satisfy^0.5.1) so it validates against the code in this repo. - On the next
release-plzrun, bump the cli preset'srtbversion pin to the release that shipsparse_passthrough. This mirrors the existingrun_and_exitadoption note carried in2026-06-26-rtb-error-exit-code-attachment.md. Until then, a real cli-preset scaffold with a generated command needs the local/unreleased framework — acceptable, since the cli preset generated non-compiling code before this spec regardless.
8. Fast-follows (filed, not blocking)¶
- Refactor
docs+updateontoparse_passthrough— delete their hand-rolledargs_os()/drain(..2)blocks and add subcommand tests now thatrun_with_argscan drive them. Pure cleanup + coverage; the helper is additive so this is non-urgent. - AI codegen on the cli preset — teach
generate command --prompt/--scriptthe cli trait shape (async,miette::Result,app+ parsedargsin scope) and preset-aware prompting, replacing the deferral error from §4.