Skip to content

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:

  1. Uniform passthrough shape — every cli-preset command is generated as passthrough-with-inner-Parser, even flagless. No "upgrade on first flag" surgery.
  2. Rename + add partialscommand.rs.j2command.minimal.rs.j2, add command.cli.rs.j2. Explicit selection by manifest.preset; unknown preset is a hard error (no silent minimal fallback).
  3. 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 on App and expose a parse_passthrough helper. Commands never re-read std::env::args_os(). This is smaller than the original A/B helper options and makes passthrough commands testable via run_with_args.
  4. Defer AI codegen on cligenerate command --prompt/--script errors 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.j2 implements a trait with fn name(), fn about(), fn clap() -> ClapCommand, fn run(&self, _matches: &ArgMatches) -> Result<(), String>, and use 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 incompatible run signature, and the file fails to compile.
  • generate command never branches on manifest.preset (commands/generate/command.rs renders the one partial unconditionally).
  • Same root cause for generate flag: the framework builds a command's clap subtree from CommandSpec (name/about/aliases) only (crates/rtb-cli/src/application.rs:350-401). A command gets no args unless it sets subcommand_passthrough() -> true and re-parses the trailing tokens (the docs/update pattern). There is no clap() 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 command and generate flag preset-aware (branch on manifest.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/--args metadata and nested --parent semantics — those layer on top of the shape defined here and remain in command-authoring-parity.
  • Refactoring the existing docs/update built-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 the AppConfig struct 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)]

--shorthandshort = 'x'; --description → doc comment / help; --required → non-Option + (string) required; --defaultdefault_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:

  1. App carries the trailing tokens. Add trailing_args: Arc<[OsString]> (empty default in App::new), a builder-style App::with_trailing_args(self, Vec<OsString>) -> Self (mirrors the existing App::with_typed_config, app.rs:113), and a App::trailing_args(&self) -> &[OsString] accessor. Additive — the Command::run(&self, app) signature is unchanged, so every existing impl keeps compiling.
  2. Dispatch populates it. In run_with_args, extract the rest var-arg from sub_matches, then let app = self.app.clone().with_trailing_args(rest); and pass that to the pre-run hooks and cmd.run(app). Non-passthrough commands have no rest arg → empty slice → no behaviour change.
  3. Detail: the rest arg (build_clap_tree, application.rs:386) has no explicit value_parser and so defaults to String. Set value_parser(clap::value_parser!(std::ffi::OsString)) on it to preserve non-UTF-8 arguments (parity with the current args_os path).
  4. rtb_cli::parse_passthrough::<T: clap::Parser>(app: &App) -> miette::Result<T> centralises the synthetic-arg0 + try_parse_from + help/version short-circuit, reading app.trailing_args() instead of global argv. It is the single home for the argv dance that docs/update currently 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 per FlagType; rtb:args-* marker insertion (ordering, dedup, missing-marker error) mirroring the existing insert_flag tests.
  • Unit (framework): parse_passthrough — parses from App::trailing_args, returns help/version as Ok(())-worthy short-circuit, preserves non-UTF-8 tokens; App::with_trailing_args/trailing_args round-trip.
  • E2E (assert_cmd, new cli-preset coverage): generate project --preset cligenerate command deploygenerate flag --command deploy region --type stringcargo check passes; deploy --help lists 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: --aliasesCommandSpec.aliases (cli) / ClapCommand::aliases (minimal); --shortshort (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/CommandSpec trait surface (the passthrough-args mechanism is additive on App, 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 rtb dependency to a workspace path (a [patch.crates-io] cannot be used — 0.6.0 does not satisfy ^0.5.1) so it validates against the code in this repo.
  • On the next release-plz run, bump the cli preset's rtb version pin to the release that ships parse_passthrough. This mirrors the existing run_and_exit adoption note carried in 2026-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)

  1. Refactor docs + update onto parse_passthrough — delete their hand-rolled args_os()/drain(..2) blocks and add subcommand tests now that run_with_args can drive them. Pure cleanup + coverage; the helper is additive so this is non-urgent.
  2. AI codegen on the cli preset — teach generate command --prompt/--script the cli trait shape (async, miette::Result, app + parsed args in scope) and preset-aware prompting, replacing the deferral error from §4.