From 34e1231d5b6652658399c1b1abc817b52348f23e Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Wed, 19 Aug 2026 15:10:45 +0000 Subject: [PATCH 1/2] feat(derive): support clap no binary name --- PLAN.md | 7 ++++--- conformance/tests/program_identity.rs | 26 ++++++++++++++++++++++++++ derive/src/codegen.rs | 15 +++++++++++++++ derive/src/lib.rs | 4 ++-- derive/src/model.rs | 22 ++++++++++++++++++---- 5 files changed, 65 insertions(+), 9 deletions(-) diff --git a/PLAN.md b/PLAN.md index 81c0a236a..fd950e6c4 100644 --- a/PLAN.md +++ b/PLAN.md @@ -477,9 +477,10 @@ Groups are the opposite case: `Command::get_groups`, `ArgGroup::get_args` and (`Command::is_multicall_set`). Path components and a trailing `.exe` are stripped. **Not used by the fleet today**; clap's own applets and busybox-style binaries are the reason it exists. -- [ ] **`no_binary_name`** — parsing an argv that has no `argv[0]`. usage-argv - already takes argv without the program name; this is clap's setter that - skips stripping it. Out of scope until a fleet CLI needs it. +- [x] **`no_binary_name`** — parsing an argv that has no `argv[0]`. The + allocation-free `parse_from` primitive always has that contract, while + clap-shaped `try_parse_from` includes argv0 by default and honors + `#[command(no_binary_name)]` by routing directly to the primitive. - [ ] **Command parsing policy** — `arg_required_else_help`, `args_conflicts_with_subcommands`, `subcommand_negates_reqs`, `subcommand_precedence_over_arg`, `allow_missing_positional`, diff --git a/conformance/tests/program_identity.rs b/conformance/tests/program_identity.rs index bd6b09bf0..690f6c386 100644 --- a/conformance/tests/program_identity.rs +++ b/conformance/tests/program_identity.rs @@ -34,6 +34,13 @@ struct Busy { command: Option, } +#[derive(Cli)] +#[command(no_binary_name)] +struct WordsOnly { + #[usage(long)] + plain: bool, +} + #[derive(Subcommands)] enum BusyCommand { #[usage(alias = "r")] @@ -117,3 +124,22 @@ fn a_full_argv_helper_matches_clap_shaped_tests_and_multicall() { assert!(matches!(parsed.command, Some(BusyCommand::Run))); assert!(parsed.verbose, "root globals remain in scope for applets"); } + +#[test] +fn clap_shaped_parsing_honors_no_binary_name() { + use std::ffi::OsStr; + + let full = [OsStr::new("communique"), OsStr::new("--plain")]; + assert!( + Cli_::try_parse_from(&full) + .expect("argv0 is stripped") + .plain + ); + + let words = [OsStr::new("--plain")]; + assert!( + WordsOnly::try_parse_from(&words) + .expect("every word is parsed") + .plain + ); +} diff --git a/derive/src/codegen.rs b/derive/src/codegen.rs index 3f114811f..467d8457c 100644 --- a/derive/src/codegen.rs +++ b/derive/src/codegen.rs @@ -117,6 +117,7 @@ pub fn emit(cli: &Cli) -> TokenStream { let default_subcommand = option_str(cli.default_subcommand.as_deref()); let multicall = cli.multicall; + let no_binary_name = cli.no_binary_name; let usage = option_str(cli.usage.as_deref()); let restart_token = option_str(cli.restart_token.as_deref()); let mount = option_str(cli.mount.as_deref()); @@ -589,6 +590,20 @@ pub fn emit(cli: &Cli) -> TokenStream { Self::parse_from(__usage_words) } + /// Parse using clap's `try_parse_from` argv contract. + /// + /// Input includes argv0 by default. `#[command(no_binary_name)]` + /// opts into treating every supplied word as an argument. + pub fn try_parse_from<'v>( + argv: &'v [&'v ::std::ffi::OsStr], + ) -> ::std::result::Result> { + if #no_binary_name { + Self::parse_from(argv) + } else { + Self::parse_from_argv(argv) + } + } + /// Parse the process's own arguments. #completion diff --git a/derive/src/lib.rs b/derive/src/lib.rs index 2e1267a2d..ce5a5f848 100644 --- a/derive/src/lib.rs +++ b/derive/src/lib.rs @@ -316,7 +316,7 @@ mod crate_name; mod model; /// Compile a struct into a parser and a spec. See the [crate docs](crate). -#[proc_macro_derive(Cli, attributes(usage))] +#[proc_macro_derive(Cli, attributes(usage, command))] pub fn derive_cli(input: TokenStream) -> TokenStream { let input = parse_macro_input!(input as DeriveInput); let parsed = model::Cli::from_input(&input) @@ -335,7 +335,7 @@ pub fn derive_cli(input: TokenStream) -> TokenStream { /// tables and metadata as [`Cli`], minus the program-level parts a subcommand does /// not have — a name, a version, an entry point — plus the trait a parent uses to /// route events into it. -#[proc_macro_derive(Args, attributes(usage))] +#[proc_macro_derive(Args, attributes(usage, command))] pub fn derive_args(input: TokenStream) -> TokenStream { let input = parse_macro_input!(input as DeriveInput); // `restart_token` and `mount` are per-command and belong here; `default_subcommand` is diff --git a/derive/src/model.rs b/derive/src/model.rs index b11de74ec..620c99d6b 100644 --- a/derive/src/model.rs +++ b/derive/src/model.rs @@ -89,6 +89,8 @@ pub struct Cli { /// whole program. `parse()` rewrites the process's argv[0]; `parse_from` is /// unchanged, because the caller already decided the words. pub multicall: bool, + /// Whether clap-shaped `try_parse_from` input omits argv0. + pub no_binary_name: bool, /// Declared descriptions, for the case a doc comment cannot express: a long form that does /// not contain the short one. pub about_attr: Option, @@ -477,7 +479,7 @@ impl Cli { attr_span: input .attrs .iter() - .find(|a| a.path().is_ident("usage")) + .find(|a| a.path().is_ident("usage") || a.path().is_ident("command")) .map(|a| a.path().span()), version: None, runtime_version: None, @@ -486,6 +488,7 @@ impl Cli { unknown_flags: None, default_subcommand: None, multicall: false, + no_binary_name: false, about_attr: None, long_about_attr: None, before_help: None, @@ -579,6 +582,7 @@ impl Cli { cli.default_subcommand = Some(strip_dashes(&string_value(&meta)?)) } "multicall" => cli.multicall = flag_value(&meta)?, + "no_binary_name" => cli.no_binary_name = flag_value(&meta)?, "restart_token" => cli.restart_token = Some(string_value(&meta)?), "mount" => cli.mount = Some(string_value(&meta)?), "group" => cli.groups.push(group_decl(&meta)?), @@ -588,7 +592,7 @@ impl Cli { format!( "unknown option `{other}` on a struct; usage::Cli takes \ `name`, `bin`, `version`, `version_spec`, `usage`, `verbatim_doc_comment`, `unknown_flags`, \ - `default_subcommand`, `multicall`, `restart_token`, `mount` and \ + `default_subcommand`, `multicall`, `no_binary_name`, `restart_token`, `mount` and \ `group` here, and the description comes from the doc \ comment" ), @@ -720,6 +724,13 @@ impl Cli { declares it once for the whole program, not one per command", )); } + if self.no_binary_name { + return Err(self.misplaced( + ident, + "`no_binary_name` belongs on the root, where `#[derive(Cli)]` is: it \ + selects the input contract of the whole CLI's clap-shaped parser", + )); + } return Ok(()); } @@ -2524,9 +2535,12 @@ pub fn type_name(ty: &Type) -> String { } } -/// The `#[usage(...)]` attributes on an item. +/// The native `#[usage(...)]` or clap-compatible `#[command(...)]` +/// attributes on a command struct. fn attrs(attrs: &[Attribute]) -> impl Iterator { - attrs.iter().filter(|a| a.path().is_ident("usage")) + attrs + .iter() + .filter(|a| a.path().is_ident("usage") || a.path().is_ident("command")) } /// Value metadata accepts clap's `#[value(...)]` spelling so an enum can keep its From cc376e4e1246ec18fd655a465b251f52c8d65e46 Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Wed, 19 Aug 2026 15:17:36 +0000 Subject: [PATCH 2/2] refactor(derive): decouple argv slice lifetime --- derive/src/codegen.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/derive/src/codegen.rs b/derive/src/codegen.rs index 467d8457c..a567e5d30 100644 --- a/derive/src/codegen.rs +++ b/derive/src/codegen.rs @@ -595,7 +595,7 @@ pub fn emit(cli: &Cli) -> TokenStream { /// Input includes argv0 by default. `#[command(no_binary_name)]` /// opts into treating every supplied word as an argument. pub fn try_parse_from<'v>( - argv: &'v [&'v ::std::ffi::OsStr], + argv: &[&'v ::std::ffi::OsStr], ) -> ::std::result::Result> { if #no_binary_name { Self::parse_from(argv)