From 0955ac1fe2b92a37f2c70aa4c89f50769eef847b Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Thu, 13 Aug 2026 18:19:49 +0000 Subject: [PATCH 1/6] feat(argv): render `--help` too, byte-identical to usage-lib's MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wider layout: help aligned into a column and wrapped to `COLUMNS`, long descriptions preferred over short, annotations each on their own line, and the text a command puts around the rest of the page. All 211 of mise's commands match usage-lib byte for byte, in both forms. The last cause was the largest: `before_help`/`after_help` and their long forms did not exist in the metadata at all, and **115 of mise's commands carry their Examples section in `after_long_help`** — so every one of those pages was missing the part a reader came for. Now four fields on `CommandMeta`, four attributes on the derive, written into the emitted KDL, and carried by the generator. Four smaller ones, each found by reading the reference's output rather than its template: - No blank line after an entry whose help wrapped. The template asks for one and its whitespace trimming eats it before it reaches the page. - The first line of an indented description is indented even when it is *empty*, and later blank lines are not — because the reference writes the indent literally and indents the rest with a filter that skips blanks. - A long form that opens with a blank line does not open with its short form. Trimming before the comparison hid that, and `plugins ls-remote` says nothing on its first line. - A text that ends with a break has a blank line at the end, which `lines()` does not report. Co-Authored-By: Claude Opus 5 --- PLAN.md | 23 +-- argv/src/help.rs | 240 ++++++++++++++++++++++++ argv/src/spec.rs | 26 +++ benches/gate/tests/help.rs | 44 ++++- benches/shadows/mise/src/lib.rs | 315 +++++++++++++++++++++++++++++++- derive/src/codegen.rs | 16 ++ derive/src/model.rs | 15 ++ xtask/src/shadow.rs | 17 +- 8 files changed, 676 insertions(+), 20 deletions(-) diff --git a/PLAN.md b/PLAN.md index 0a2389c8f..801a15932 100644 --- a/PLAN.md +++ b/PLAN.md @@ -265,21 +265,14 @@ tasks --usage"`, so task names are meant to come from running that. usage-argv d ### Then: what a CLI framework has to have -- [ ] **Help rendering** — `-h` is done and matches usage-lib byte for byte across all 211 of - mise's commands: the usage line, the about, the commands list, arguments and flags grouped - by heading, examples, hidden-item filtering. `--help`'s wider layout, which aligns help - into a column and wraps to `COLUMNS`, is next, then the `--help`/`-h` wiring in the derive. - Holding it to parity found six more things a spec could say that the derive could not — - value names, required collections, `var` on a count, the spec's own `about`, `hide` on a - command, and help text whose line breaks matter — plus a bug in usage-lib, which printed - everything marked `hide`. Then three more, found while starting on `--help`: a variant's short - description was hiding the struct's long one, which is the shape every generated CLI has; - a doc comment's lines were trimmed one by one, flattening every indented example in help; - and a program could not describe itself twice over, since a comment's long form always - contains its short one where a spec keeps the two independent. `--help`'s own layout is - written and not yet at parity — 123 of 211 pages differ, each remaining cause a metadata - path where the shadow's description is not the spec's — so it is held back rather than - shipped wrong. +- [x] **Help rendering** — `-h` and `--help` both match usage-lib byte for byte across all 211 + of mise's commands: the usage line, the descriptions, commands, arguments and flags grouped + by heading, examples, the surrounding text, hidden-item filtering, and for the long form the + column alignment and wrapping to `COLUMNS`. What is left is the wiring — `--help`/`-h` on + every command, `Error::Help`, and a real `help` subcommand. Holding it to parity found ten + things a spec could say that the derive could not, plus two bugs in usage-lib's own + renderer; the last of them was `after_long_help`, which 115 of mise's commands use to carry + their Examples section, so a page without it was missing what a reader came for. - [ ] **Completions, self-contained** — ` completion ` emits the script; a hidden ` complete-word` serves requests from the binary's own embedded spec. Same dispatch shape usage-cli uses today, without requiring diff --git a/argv/src/help.rs b/argv/src/help.rs index bff7255ce..5c764dae5 100644 --- a/argv/src/help.rs +++ b/argv/src/help.rs @@ -385,3 +385,243 @@ fn examples_section(out: &mut String, meta: &CommandMeta<'_>) { let _ = writeln!(out, " $ {}", example.code); } } + +/// The width help is wrapped to, from `COLUMNS`. +/// +/// usage-lib reads the same variable and falls back to the same 80, so the two agree about +/// where a line ends whatever the terminal says. +fn terminal_width() -> usize { + std::env::var("COLUMNS") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(80) +} + +/// Everything `--help` prints. +/// +/// The same content as [`short_help`] through a wider layout: help is aligned into a column and +/// wrapped, the long form of each description is preferred over the short one, and the +/// annotations — choices, environment, default — each get their own line. +/// +/// An entry whose help contains a line break is laid out as a block instead, its text indented +/// under the usage rather than beside it, because there is no column that keeps a line the +/// author already broke readable. +pub fn long_help(spec: &Spec<'_>, path: &[&str], meta: &CommandMeta<'_>) -> String { + let width = terminal_width(); + let mut out = String::new(); + + if let Some(version) = spec.version { + let name = if spec.name.is_empty() { + spec.bin.unwrap_or_default() + } else { + spec.name + }; + let _ = writeln!(out, "{name} {version}"); + } + if let Some(about) = spec.long_about.or(spec.about) { + let _ = writeln!(out, "{about}\n"); + } + let _ = writeln!(out, "Usage: {}", usage_line(path, meta)); + + long_commands_section(&mut out, &path[1.min(path.len())..], meta); + + // One column width per section, over its visible entries — the same two the reference + // computes, and separately, so a long flag does not push the arguments out. + let args: Vec<&ArgMeta<'_>> = meta.args.iter().filter(|a| !a.hide).collect(); + let arg_col = args + .iter() + .map(|a| arg_usage(a).chars().count()) + .max() + .unwrap_or(0); + groups_section( + &mut out, + "Arguments", + args.iter().copied(), + |a| a.help_heading, + |out, a| { + let text = a.long_help.or(a.help); + entry(out, &arg_usage(a), text, arg_col, width); + long_annotations(out, a.choices, a.env, a.default); + }, + ); + + let flags: Vec<&FlagMeta<'_>> = meta.flags.iter().filter(|f| !f.hide).collect(); + let flag_col = flags + .iter() + .map(|f| display_usage(f).chars().count()) + .max() + .unwrap_or(0); + groups_section( + &mut out, + "Flags", + flags.iter().copied(), + |f| f.help_heading, + |out, f| { + let text = f.long_help.or(f.help); + entry(out, &display_usage(f), text, flag_col, width); + long_annotations(out, f.choices, f.env, &[]); + }, + ); + + if !meta.examples.is_empty() { + let _ = writeln!(out, "\nExamples:"); + for example in meta.examples { + if let Some(header) = example.header { + let _ = writeln!(out, " {header}:"); + } + let _ = writeln!(out, " $ {}", example.code); + if let Some(help) = example.help { + let _ = writeln!(out, " {help}"); + } + } + } + + // mise puts an Examples section here on 115 commands, which is why a page without it is + // missing the part a reader came for. + if let Some(after) = meta.after_long_help.or(meta.after_help) { + let _ = writeln!(out, "\n{after}"); + } + + let trimmed = out.trim(); + let mut done = String::with_capacity(trimmed.len() + 1); + done.push_str(trimmed); + done.push('\n'); + done +} + +/// Write text with every line indented, leaving blank lines blank. +/// +/// An indented empty line would be trailing whitespace, which the reference does not emit and +/// a diff would show as a line that is not empty. +fn write_indented(out: &mut String, text: &str, indent: usize) { + let pad = " ".repeat(indent); + for (i, line) in text.lines().enumerate() { + // The first line is always indented, even when it is empty, and later blank lines are + // left blank. That is not a choice: the reference writes the indent literally before the + // text and indents the *rest* with a filter that skips blanks, so an opening empty line + // comes out as whitespace and a later one does not. + if i == 0 || !line.trim().is_empty() { + let _ = writeln!(out, "{pad}{line}"); + } else { + out.push('\n'); + } + } + // A text that ends with a break has a blank line at the end, and `lines()` does not report + // it. The reference writes the text verbatim, so the blank is part of what it prints. + if text.ends_with('\n') { + out.push('\n'); + } +} + +/// One entry: its usage, and its help either beside it or beneath it. +fn entry(out: &mut String, usage: &str, help: Option<&str>, col: usize, width: usize) { + let Some(help) = help.filter(|h| !h.trim().is_empty()) else { + let _ = writeln!(out, " {usage}"); + return; + }; + + // The column layout only works for text that has not been broken already, and only when + // there is room left for it to say anything. + let indent = 2 + col + 2; + let room = width.saturating_sub(indent); + if help.contains('\n') || room < 10 { + let _ = writeln!(out, " {usage}"); + write_indented(out, help, 4); + return; + } + + let lines = wrap(help, room); + let _ = writeln!(out, " {usage: Vec { + let mut lines = Vec::new(); + for paragraph in text.split('\n') { + if paragraph.is_empty() { + lines.push(String::new()); + continue; + } + let mut line = String::new(); + for word in paragraph.split_whitespace() { + let word_width = word.chars().count(); + if !line.is_empty() && line.chars().count() + 1 + word_width > width { + lines.push(std::mem::take(&mut line)); + } + if !line.is_empty() { + line.push(' '); + } + line.push_str(word); + } + if !line.is_empty() { + lines.push(line); + } + } + if lines.is_empty() { + lines.push(String::new()); + } + lines +} + +/// The annotations, each on its own line as the wider layout puts them. +fn long_annotations(out: &mut String, choices: &[&str], env: Option<&str>, default: &[&str]) { + if !choices.is_empty() { + let _ = writeln!(out, " [possible values: {}]", choices.join(", ")); + } + if let Some(env) = env { + let _ = writeln!(out, " [env: {env}]"); + } + if !default.is_empty() { + let _ = writeln!(out, " (default: {})", default.join(", ")); + } +} + +/// The commands list, with each command's help beneath its usage. +fn long_commands_section(out: &mut String, path: &[&str], meta: &CommandMeta<'_>) { + let visible: Vec<&&CommandMeta<'_>> = meta.subcommands.iter().filter(|c| !c.hide).collect(); + if visible.is_empty() { + return; + } + let _ = writeln!(out, "\nCommands:"); + + let mut lines: Vec<(String, &&CommandMeta<'_>)> = visible + .iter() + .map(|sub| { + let mut sub_path: Vec<&str> = path.to_vec(); + sub_path.push(sub.cmd.name); + (usage_line(&sub_path, sub), *sub) + }) + .collect(); + lines.sort_by(|a, b| a.0.cmp(&b.0)); + + for (usage, sub) in &lines { + let _ = write!(out, " {usage}"); + let visible_aliases: Vec<&str> = sub + .cmd + .aliases + .iter() + .copied() + .filter(|a| !sub.hidden_aliases.contains(a)) + .collect(); + if !visible_aliases.is_empty() { + let _ = write!(out, " [aliases: {}]", visible_aliases.join(", ")); + } + out.push('\n'); + if let Some(about) = sub.long_about.or(sub.about) { + write_indented(out, about, 4); + } + // A blank line between entries, which the wider layout can afford and which keeps a + // multi-line description from running into the next command's name. + out.push('\n'); + } + let _ = writeln!( + out, + " help\n Print this message or the help of the given subcommand(s)" + ); +} diff --git a/argv/src/spec.rs b/argv/src/spec.rs index f76b3a201..3464c2ad6 100644 --- a/argv/src/spec.rs +++ b/argv/src/spec.rs @@ -213,6 +213,15 @@ pub struct CommandMeta<'a> { /// A token that starts a fresh invocation of this command, such as mise's /// `:::`. pub restart_token: Option<&'a str>, + /// Text printed above the usage line, and below everything else. + /// + /// The spec's `before_help`/`after_help` and their long forms. mise puts an Examples + /// section in `after_long_help` on 115 commands, which is where the reference renders it + /// from — so a help page without these is missing the part a reader came for. + pub before_help: Option<&'a str>, + pub before_long_help: Option<&'a str>, + pub after_help: Option<&'a str>, + pub after_long_help: Option<&'a str>, pub examples: &'a [Example<'a>], /// Metadata for `cmd.flags`, in the same order. pub flags: &'a [FlagMeta<'a>], @@ -233,6 +242,10 @@ impl CommandMeta<'_> { effect: None, mount: None, restart_token: None, + before_help: None, + before_long_help: None, + after_help: None, + after_long_help: None, examples: &[], flags: &[], args: &[], @@ -561,6 +574,19 @@ fn write_command( indent(out, inner)?; writeln!(out, "long_help {}", quoted(long_about))?; } + // Text around the rest of the page. Written in the spec's order so a round trip reads the + // same way it was written. + for (node, text) in [ + ("before_help", meta.before_help), + ("before_long_help", meta.before_long_help), + ("after_help", meta.after_help), + ("after_long_help", meta.after_long_help), + ] { + if let Some(text) = text { + indent(out, inner)?; + writeln!(out, "{node} {}", quoted(text))?; + } + } if let Some(mount) = meta.mount { indent(out, inner)?; writeln!(out, "mount run={}", quoted(mount))?; diff --git a/benches/gate/tests/help.rs b/benches/gate/tests/help.rs index bdf4048b4..e7e3c4cc0 100644 --- a/benches/gate/tests/help.rs +++ b/benches/gate/tests/help.rs @@ -11,7 +11,7 @@ //! by hand. use usage::{Spec as LibSpec, SpecCommand}; -use usage_argv::help::{short_help, usage_line}; +use usage_argv::help::{long_help, short_help, usage_line}; use usage_argv::spec::CommandMeta; /// mise's committed spec, which the shadow was generated from. @@ -162,3 +162,45 @@ fn every_short_help_matches_the_reference() { .join("\n") ); } + +#[test] +fn every_long_help_matches_the_reference() { + // `--help`: the same content through the wider layout — help aligned into a column and + // wrapped, long descriptions preferred, annotations on their own lines. Both sides read + // `COLUMNS` the same way and fall back to the same 80, so they agree about where a line + // ends whatever the environment says. + let spec = mise_spec(); + let root = shadow_mise::Cli::spec(); + + let mut commands = Vec::new(); + walk(vec!["mise"], root.root, &mut commands); + + let mut differences = Vec::new(); + for (path, meta) in &commands { + let ours = long_help(root, path, meta); + let Some(cmd) = lib_command(&spec, &path[1..]) else { + continue; + }; + let theirs = usage::docs::cli::render_help(&spec, cmd, true); + if ours != theirs { + differences.push(format!( + "{}\n{}", + path.join(" "), + first_diff(&ours, &theirs) + )); + } + } + + assert!( + differences.is_empty(), + "{} of {} long help pages differ:\n{}", + differences.len(), + commands.len(), + differences + .iter() + .take(2) + .cloned() + .collect::>() + .join("\n") + ); +} diff --git a/benches/shadows/mise/src/lib.rs b/benches/shadows/mise/src/lib.rs index f8237555e..d5b098af1 100644 --- a/benches/shadows/mise/src/lib.rs +++ b/benches/shadows/mise/src/lib.rs @@ -24,6 +24,9 @@ use usage_derive::{Args, Cli, Subcommands}; /// /// Customize status output with `status` settings. #[derive(Args)] +#[usage( + after_long_help = "Examples:\n\n $ eval \"$(mise activate bash)\"\n $ eval \"$(mise activate zsh)\"\n $ mise activate fish | source\n $ execx($(mise activate xonsh))\n $ (&mise activate pwsh) | Out-String | Invoke-Expression" +)] pub struct ActivateArgs { /// Suppress non-error messages #[usage(long = "quiet", short = 'q')] @@ -64,6 +67,7 @@ pub struct ActivateArgs { /// /// This is the contents of a tool_alias. entry in ~/.config/mise/config.toml #[derive(Args)] +#[usage(after_long_help = "Examples:\n\n $ mise tool-alias get node lts-hydrogen\n 20.0.0")] pub struct ToolAliasGetArgs { /// The tool to show the alias for #[usage(arg, name = "TOOL")] @@ -74,6 +78,7 @@ pub struct ToolAliasGetArgs { } #[derive(Args)] +#[usage(after_long_help = "Examples:\n\n $ mise tool-alias ls\n node lts-jod 22")] pub struct ToolAliasLsArgs { /// Don't show table header #[usage(long = "no-header")] @@ -87,6 +92,9 @@ pub struct ToolAliasLsArgs { /// /// This modifies the contents of ~/.config/mise/config.toml #[derive(Args)] +#[usage( + after_long_help = "Examples:\n\n $ mise tool-alias set maven asdf:mise-plugins/mise-maven\n $ mise tool-alias set node lts-jod 22.0.0" +)] pub struct ToolAliasSetArgs { /// The tool/backend to set the alias for #[usage(arg, name = "TOOL")] @@ -103,6 +111,9 @@ pub struct ToolAliasSetArgs { /// /// This modifies the contents of ~/.config/mise/config.toml #[derive(Args)] +#[usage( + after_long_help = "Examples:\n\n $ mise tool-alias unset maven\n $ mise tool-alias unset node lts-jod" +)] pub struct ToolAliasUnsetArgs { /// The tool/backend to remove the alias from #[usage(arg, name = "TOOL")] @@ -155,10 +166,16 @@ pub struct AsdfArgs { /// List built-in backends #[derive(Args)] +#[usage( + after_long_help = "Examples:\n\n $ mise backends ls\n aqua\n asdf\n cargo\n core\n dotnet\n gem\n go\n npm\n pipx\n spm\n ubi\n vfox" +)] pub struct BackendsLsArgs {} /// Manage backends #[derive(Args)] +#[usage( + after_long_help = "Deprecation:\n\nThe `mise b` alias is deprecated and will be removed in mise 2027.4.0.\nUse `mise backends` instead." +)] pub struct BackendsArgs { #[usage(subcommand)] pub command: ::std::option::Option, @@ -286,6 +303,9 @@ pub enum BootstrapComposeCommands { /// target. Otherwise it creates a `[dotfiles]` entry and seeds the source /// under `dotfiles.root` unless `--source` is provided. #[derive(Args)] +#[usage( + after_long_help = "Examples:\n\n $ mise bootstrap dotfiles add ~/.zshrc\n $ mise bootstrap dotfiles add --mode copy ~/.config/starship.toml\n $ mise bootstrap dotfiles add --source dotfiles/gitconfig ~/.gitconfig" +)] pub struct BootstrapDotfilesAddArgs { /// Overwrite existing sources without prompting #[usage(long = "force", short = 'f')] @@ -326,6 +346,9 @@ pub struct BootstrapDotfilesAddArgs { /// Edit entries manage a marker-delimited block or a single line in a file /// mise doesn't otherwise own. #[derive(Args)] +#[usage( + after_long_help = "Examples:\n\n $ mise bootstrap dotfiles apply\n $ mise bootstrap dotfiles apply --dry-run\n $ mise bootstrap dotfiles apply --force --yes" +)] pub struct BootstrapDotfilesApplyArgs { /// Overwrite existing files that conflict with whole-file dotfile entries #[usage(long = "force", short = 'f')] @@ -343,6 +366,9 @@ pub struct BootstrapDotfilesApplyArgs { /// Edit a managed dotfile source #[derive(Args)] +#[usage( + after_long_help = "Examples:\n\n $ mise bootstrap dotfiles edit ~/.zshrc\n $ mise bootstrap dotfiles edit --apply ~/.config/starship.toml" +)] pub struct BootstrapDotfilesEditArgs { /// Apply this target after the editor exits #[usage(long = "apply")] @@ -363,6 +389,9 @@ pub struct BootstrapDotfilesEditArgs { /// Show the status of dotfiles from `[dotfiles]` #[derive(Args)] +#[usage( + after_long_help = "Examples:\n\n $ mise bootstrap dotfiles status\n $ mise bootstrap dotfiles status ~/.zshrc\n $ mise bootstrap dotfiles status --json\n $ mise bootstrap dotfiles status --missing # exit 1 if anything is out of sync" +)] pub struct BootstrapDotfilesStatusArgs { /// Output in JSON format #[usage(long = "json", short = 'J')] @@ -383,6 +412,9 @@ pub struct BootstrapDotfilesStatusArgs { /// mise cannot identify as managed. Modified copies, templates, and plain-line /// edits require `--force`. #[derive(Args)] +#[usage( + after_long_help = "Examples:\n\n $ mise bootstrap dotfiles unapply\n $ mise bootstrap dotfiles unapply ~/.zshrc\n $ mise bootstrap dotfiles unapply --dry-run\n $ mise bootstrap dotfiles unapply --force --yes" +)] pub struct BootstrapDotfilesUnapplyArgs { /// Remove modified or otherwise ambiguous managed files and lines #[usage(long = "force", short = 'f')] @@ -760,6 +792,9 @@ pub enum BootstrapMiseShellActivateCommands { /// the config. Explicit packages and `--manager` scope the run to packages /// only. `install` is accepted as an alias for this command. #[derive(Args)] +#[usage( + after_long_help = "Examples:\n\n $ mise bootstrap packages apply\n $ mise bootstrap packages apply apk:zlib-dev apt:curl brew:jq brew-cask:firefox flatpak:org.mozilla.firefox mas:497799835\n $ mise bootstrap packages apply --dry-run\n $ mise bootstrap packages apply --manager apt --yes" +)] pub struct BootstrapPackagesApplyArgs { /// Only install packages for this built-in or plugin manager #[usage(long = "manager", short = 'm', value_name = "MANAGER")] @@ -780,6 +815,9 @@ pub struct BootstrapPackagesApplyArgs { /// Add a Homebrew tap URL to [bootstrap.brew.taps] #[derive(Args)] +#[usage( + after_long_help = "Examples:\n\n $ mise bootstrap packages brew tap railwaycat/emacsmacport\n $ mise bootstrap packages brew tap acme/tools https://github.com/acme/homebrew-tools.git" +)] pub struct BootstrapPackagesBrewTapArgs { /// Write to the local config instead of the global config #[usage(long = "local", short = 'l')] @@ -800,6 +838,9 @@ pub struct BootstrapPackagesBrewTapArgs { /// Remove Homebrew tap URLs from [bootstrap.brew.taps] #[derive(Args)] +#[usage( + after_long_help = "Examples:\n\n $ mise bootstrap packages brew untap railwaycat/emacsmacport" +)] pub struct BootstrapPackagesBrewUntapArgs { /// Write to the local config instead of the global config #[usage(long = "local", short = 'l')] @@ -841,6 +882,9 @@ pub enum BootstrapPackagesBrewCommands { /// formulae whose active keg receipt says they were installed on request. /// Pass `--all` to import every linked formula, including dependencies. #[derive(Args)] +#[usage( + after_long_help = "Examples:\n\n $ mise bootstrap packages import --manager brew\n $ mise bootstrap packages import --manager brew --all\n $ mise bootstrap packages import --manager brew --global\n $ mise bootstrap packages import --manager brew --dry-run" +)] pub struct BootstrapPackagesImportArgs { /// Write to the config file for this environment (mise..toml) #[usage(long = "env", short = 'e', value_name = "ENV")] @@ -874,6 +918,9 @@ pub struct BootstrapPackagesImportArgs { /// that are not needed by the current config or by trusted, loadable tracked /// configs. #[derive(Args)] +#[usage( + after_long_help = "Examples:\n\n $ mise bootstrap packages prune --manager brew\n $ mise bootstrap packages prune --manager brew --dry-run\n $ mise bootstrap packages prune --manager brew --yes" +)] pub struct BootstrapPackagesPruneArgs { /// Only prune packages for this manager. Currently only `brew` is supported #[usage( @@ -894,6 +941,9 @@ pub struct BootstrapPackagesPruneArgs { /// Show the status of system packages from `[bootstrap.packages]` #[derive(Args)] +#[usage( + after_long_help = "Examples:\n\n $ mise bootstrap packages status\n $ mise bootstrap packages status --json\n $ mise bootstrap packages status --missing # exit 1 if anything is out of sync" +)] pub struct BootstrapPackagesStatusArgs { /// Output in JSON format #[usage(long = "json", short = 'J')] @@ -915,6 +965,9 @@ pub struct BootstrapPackagesStatusArgs { /// /// Packages can also be given explicitly in `manager:package` form. #[derive(Args)] +#[usage( + after_long_help = "Examples:\n\n $ mise bootstrap packages upgrade\n $ mise bootstrap packages upgrade brew:postgresql@17\n $ mise bootstrap packages upgrade --manager brew-cask\n $ mise bootstrap packages upgrade --manager mas\n $ mise bootstrap packages upgrade --manager apt --yes\n $ mise bootstrap packages upgrade --dry-run" +)] pub struct BootstrapPackagesUpgradeArgs { /// Only upgrade packages for this built-in or plugin manager #[usage(long = "manager", short = 'm', value_name = "MANAGER")] @@ -942,6 +995,9 @@ pub struct BootstrapPackagesUpgradeArgs { /// `brew-cask:temurin@17`), where `@` is part of the Homebrew name rather than /// a mise version selector. mas uses numeric ADAM IDs and does not support pins. #[derive(Args)] +#[usage( + after_long_help = "Examples:\n\n $ mise bootstrap packages use apk:zlib-dev apt:curl brew:jq brew-cask:firefox flatpak:org.mozilla.firefox mas:497799835\n $ mise bootstrap packages use -g brew:postgresql@17\n $ mise bootstrap packages use apt:curl@8.5.0-2" +)] pub struct BootstrapPackagesUseArgs { /// Write to the config file for this environment (mise..toml) #[usage(long = "env", short = 'e', value_name = "ENV")] @@ -1433,6 +1489,9 @@ pub enum BootstrapUserCommands { /// named parts. Both flags can be repeated or comma-separated, but they /// cannot be used together. #[derive(Args)] +#[usage( + after_long_help = "Examples:\n\n $ mise bootstrap # packages + repos + dotfiles + tools + bootstrap task\n $ mise bootstrap --force-dotfiles # replace conflicting dotfile targets\n $ mise bootstrap --skip tools,task # skip tool installation and the bootstrap task\n $ mise bootstrap --only tools # run just tool installation\n $ mise bootstrap status --missing\n $ mise bootstrap packages apply --yes\n $ mise bootstrap repos status\n $ mise bootstrap repos apply --dry-run\n $ mise bootstrap dotfiles status\n $ mise bootstrap mise-shell-activate apply --dry-run\n $ mise bootstrap macos defaults status\n $ mise bootstrap macos launchd-agents apply --dry-run\n $ mise bootstrap linux systemd-units apply --dry-run\n $ mise bootstrap user apply --dry-run" +)] pub struct BootstrapArgs { /// Print what would happen without installing anything #[usage(long = "dry-run", short = 'n')] @@ -1666,6 +1725,9 @@ pub enum CacheCommands { /// Generate shell completions #[derive(Args)] +#[usage( + after_long_help = "Examples:\n\n $ mise completion bash --include-bash-completion-lib > ~/.local/share/bash-completion/completions/mise\n $ mise completion zsh > /usr/local/share/zsh/site-functions/_mise\n $ mise completion fish > ~/.config/fish/completions/mise.fish\n $ mise completion powershell >> $PROFILE" +)] pub struct CompletionArgs { /// Shell type to generate completions for #[usage( @@ -1696,6 +1758,7 @@ pub struct CompletionArgs { /// Display the value of a setting in a mise.toml file #[derive(Args)] +#[usage(after_long_help = "Examples:\n\n $ mise toml get tools.python\n 3.12")] pub struct ConfigGetArgs { /// The path to the mise.toml file to read /// @@ -1711,6 +1774,9 @@ pub struct ConfigGetArgs { /// List config files currently in use #[derive(Args)] +#[usage( + after_long_help = "Examples:\n\n $ mise config ls\n Path Tools\n ~/.config/mise/config.toml pitchfork\n ~/src/mise/mise.toml actionlint, bun, cargo-binstall, cargo:cargo-insta" +)] pub struct ConfigLsArgs { /// Output in JSON format #[usage(long = "json", short = 'J')] @@ -1725,6 +1791,9 @@ pub struct ConfigLsArgs { /// Set the value of a setting in a mise.toml file #[derive(Args)] +#[usage( + after_long_help = "Examples:\n\n $ mise config set tools.python 3.12\n $ mise config set settings.always_keep_download true\n $ mise config set env.TEST_ENV_VAR ABC\n $ mise config set settings.disable_tools node,rust\n\n # Type for `settings` is inferred\n $ mise config set settings.jobs 4" +)] pub struct ConfigSetArgs { /// The path to the mise.toml file to edit /// @@ -1751,6 +1820,9 @@ pub struct ConfigSetArgs { /// Manage config files #[derive(Args)] +#[usage( + after_long_help = "Examples:\n\n $ mise config ls\n Path Tools\n ~/.config/mise/config.toml pitchfork\n ~/src/mise/mise.toml actionlint, bun, cargo-binstall, cargo:cargo-insta" +)] pub struct ConfigArgs { /// Output in JSON format #[usage(long = "json", short = 'J')] @@ -1783,6 +1855,9 @@ pub enum ConfigCommands { /// This is similar to `mise ls --current`, but this only shows the runtime /// and/or version. It's designed to fit into scripts more easily. #[derive(Args)] +#[usage( + after_long_help = "Examples:\n\n # outputs `.tool-versions` compatible format\n $ mise current\n python 3.11.0 3.10.0\n shfmt 3.6.0\n shellcheck 0.9.0\n node 20.0.0\n\n $ mise current node\n 20.0.0\n\n # can output multiple versions\n $ mise current python\n 3.11.0 3.10.0" +)] pub struct CurrentArgs { /// Plugin to show versions of e.g.: ruby, node, cargo:eza, npm:prettier, etc #[usage(arg, name = "PLUGIN")] @@ -1793,6 +1868,7 @@ pub struct CurrentArgs { /// /// This can be used to temporarily disable mise in a shell session. #[derive(Args)] +#[usage(after_long_help = "Examples:\n\n $ mise deactivate")] pub struct DeactivateArgs {} /// Output direnv function to use mise inside direnv @@ -1803,6 +1879,9 @@ pub struct DeactivateArgs {} /// you should run this command after installing new plugins. Otherwise /// direnv may not know to update environment variables when idiomatic file versions change. #[derive(Args)] +#[usage( + after_long_help = "Examples:\n\n $ mise direnv activate > ~/.config/direnv/lib/use_mise.sh\n $ echo 'use mise' > .envrc\n $ direnv allow" +)] pub struct DirenvActivateArgs {} #[derive(Args)] @@ -1849,6 +1928,9 @@ pub enum DirenvCommands { /// target. Otherwise it creates a `[dotfiles]` entry and seeds the source /// under `dotfiles.root` unless `--source` is provided. #[derive(Args)] +#[usage( + after_long_help = "Examples:\n\n $ mise bootstrap dotfiles add ~/.zshrc\n $ mise bootstrap dotfiles add --mode copy ~/.config/starship.toml\n $ mise bootstrap dotfiles add --source dotfiles/gitconfig ~/.gitconfig" +)] pub struct DotfilesAddArgs { /// Overwrite existing sources without prompting #[usage(long = "force", short = 'f')] @@ -1889,6 +1971,9 @@ pub struct DotfilesAddArgs { /// Edit entries manage a marker-delimited block or a single line in a file /// mise doesn't otherwise own. #[derive(Args)] +#[usage( + after_long_help = "Examples:\n\n $ mise bootstrap dotfiles apply\n $ mise bootstrap dotfiles apply --dry-run\n $ mise bootstrap dotfiles apply --force --yes" +)] pub struct DotfilesApplyArgs { /// Overwrite existing files that conflict with whole-file dotfile entries #[usage(long = "force", short = 'f')] @@ -1906,6 +1991,9 @@ pub struct DotfilesApplyArgs { /// Edit a managed dotfile source #[derive(Args)] +#[usage( + after_long_help = "Examples:\n\n $ mise bootstrap dotfiles edit ~/.zshrc\n $ mise bootstrap dotfiles edit --apply ~/.config/starship.toml" +)] pub struct DotfilesEditArgs { /// Apply this target after the editor exits #[usage(long = "apply")] @@ -1926,6 +2014,9 @@ pub struct DotfilesEditArgs { /// Show the status of dotfiles from `[dotfiles]` #[derive(Args)] +#[usage( + after_long_help = "Examples:\n\n $ mise bootstrap dotfiles status\n $ mise bootstrap dotfiles status ~/.zshrc\n $ mise bootstrap dotfiles status --json\n $ mise bootstrap dotfiles status --missing # exit 1 if anything is out of sync" +)] pub struct DotfilesStatusArgs { /// Output in JSON format #[usage(long = "json", short = 'J')] @@ -1946,6 +2037,9 @@ pub struct DotfilesStatusArgs { /// mise cannot identify as managed. Modified copies, templates, and plain-line /// edits require `--force`. #[derive(Args)] +#[usage( + after_long_help = "Examples:\n\n $ mise bootstrap dotfiles unapply\n $ mise bootstrap dotfiles unapply ~/.zshrc\n $ mise bootstrap dotfiles unapply --dry-run\n $ mise bootstrap dotfiles unapply --force --yes" +)] pub struct DotfilesUnapplyArgs { /// Remove modified or otherwise ambiguous managed files and lines #[usage(long = "force", short = 'f')] @@ -1991,6 +2085,9 @@ pub enum DotfilesCommands { /// Print the current PATH entries mise is providing #[derive(Args)] +#[usage( + after_long_help = "Examples:\n\n Get the current PATH entries mise is providing\n $ mise doctor path\n /home/user/.local/share/mise/installs/node/24.0.0/bin\n /home/user/.local/share/mise/installs/rust/1.90.0/bin\n /home/user/.local/share/mise/installs/python/3.10.0/bin" +)] pub struct DoctorPathArgs { /// Print all entries including those not provided by mise #[usage(long = "full", short = 'f')] @@ -1999,6 +2096,9 @@ pub struct DoctorPathArgs { /// Check mise installation for possible problems #[derive(Args)] +#[usage( + after_long_help = "Examples:\n\n $ mise doctor\n [WARN] plugin node is not installed" +)] pub struct DoctorArgs { #[usage(long = "json", short = 'J')] pub json: bool, @@ -2019,6 +2119,9 @@ pub enum DoctorCommands { /// It will have the tools and environment variables in the configs loaded. /// Note that changing directories will not update the mise environment. #[derive(Args)] +#[usage( + after_long_help = "Examples:\n\n $ mise en .\n $ node -v\n v20.0.0\n\n Skip loading bashrc:\n $ mise en -s \"bash --norc\"\n\n Skip loading zshrc:\n $ mise en -s \"zsh -f\"" +)] pub struct EnArgs { /// Shell to start /// @@ -2035,6 +2138,9 @@ pub struct EnArgs { /// Use this if you don't want to permanently install mise. It's not necessary to /// use this if you have `mise activate` in your shell rc file. #[derive(Args)] +#[usage( + after_long_help = "Examples:\n\n $ eval \"$(mise env -s bash)\"\n $ eval \"$(mise env -s zsh)\"\n $ mise env -s fish | source\n $ execx($(mise env -s xonsh))" +)] pub struct EnvArgs { /// Output in dotenv format #[usage(long = "dotenv", short = 'D')] @@ -2074,6 +2180,9 @@ pub struct EnvArgs { /// /// The "--" separates runtimes from the commands to pass along to the subprocess. #[derive(Args)] +#[usage( + after_long_help = "Examples:\n\n $ mise exec node@20 -- node ./app.js # launch app.js using node-20.x\n $ mise x node@20 -- node ./app.js # shorter alias\n\n # Specify command as a string:\n $ mise exec node@20 python@3.11 --command \"node -v && python -V\"\n\n # Run a command in a different directory:\n $ mise x -C /path/to/project node@20 -- node ./app.js" +)] pub struct ExecArgs { /// Command string to execute #[usage(long = "command", short = 'c', value_name = "C")] @@ -2141,6 +2250,7 @@ pub struct ExecArgs { /// /// Sorts keys and cleans up whitespace in mise.toml #[derive(Args)] +#[usage(after_long_help = "Examples:\n\n $ mise fmt")] pub struct FmtArgs { /// Format all files from the current directory #[usage(long = "all", short = 'a')] @@ -2157,6 +2267,9 @@ pub struct FmtArgs { /// /// This is designed to be used in a project where contributors may not have mise installed. #[derive(Args)] +#[usage( + after_long_help = "Examples:\n\n $ mise generate bootstrap >./bin/mise\n $ chmod +x ./bin/mise\n $ ./bin/mise install – automatically downloads mise to .mise if not already installed" +)] pub struct GenerateBootstrapArgs { /// Sandboxes mise internal directories like MISE_DATA_DIR and MISE_CACHE_DIR into a `.mise` directory in the project /// @@ -2180,6 +2293,9 @@ pub struct GenerateBootstrapArgs { /// Generate a mise.toml file #[derive(Args)] +#[usage( + after_long_help = "Examples:\n\n $ mise generate config # generate mise.toml interactively\n $ mise generate config .mise.toml # generate a specific file\n $ mise generate config -g # generate the global config file\n $ mise generate config -y # skip interactive editor\n $ mise generate config -n # preview without writing" +)] pub struct GenerateConfigArgs { /// Generate the global config file (~/.config/mise/config.toml) #[usage(long = "global", short = 'g')] @@ -2197,6 +2313,7 @@ pub struct GenerateConfigArgs { /// Generate a devcontainer to execute mise #[derive(Args)] +#[usage(after_long_help = "Examples:\n\n $ mise generate devcontainer")] pub struct GenerateDevcontainerArgs { /// The image to use for the devcontainer #[usage(long = "image", short = 'i', value_name = "IMAGE")] @@ -2221,6 +2338,9 @@ pub struct GenerateDevcontainerArgs { /// /// For more advanced pre-commit functionality, see mise's sister project: https://hk.jdx.dev/ #[derive(Args)] +#[usage( + after_long_help = "Examples:\n\n $ mise generate git-pre-commit --write --task=pre-commit\n $ git commit -m \"feat: add new feature\" # runs `mise run pre-commit`" +)] pub struct GenerateGitPreCommitArgs { /// The task to run when the pre-commit hook is triggered #[usage( @@ -2243,6 +2363,9 @@ pub struct GenerateGitPreCommitArgs { /// This command generates a GitHub Action workflow file that runs a mise task like `mise run ci` /// when you push changes to your repository. #[derive(Args)] +#[usage( + after_long_help = "Examples:\n\n $ mise generate github-action --write --task=ci\n $ git commit -m \"feat: add new feature\"\n $ git push # runs `mise run ci` on GitHub" +)] pub struct GenerateGithubActionArgs { /// The task to run when the workflow is triggered #[usage(long = "task", short = 't', value_name = "TASK", default = "ci")] @@ -2257,6 +2380,7 @@ pub struct GenerateGithubActionArgs { /// Generate documentation for tasks in a project #[derive(Args)] +#[usage(after_long_help = "Examples:\n\n $ mise generate task-docs")] pub struct GenerateTaskDocsArgs { /// inserts the documentation into an existing file /// @@ -2293,6 +2417,9 @@ pub struct GenerateTaskDocsArgs { /// By default, this will build shims like ./bin/. These can be paired with `mise generate bootstrap` /// so contributors to a project can execute mise tasks without installing mise into their system. #[derive(Args)] +#[usage( + after_long_help = "Examples:\n\n $ mise tasks add test -- echo 'running tests'\n $ mise generate task-stubs\n $ ./bin/test\n running tests" +)] pub struct GenerateTaskStubsArgs { /// Directory to create task stubs inside of #[usage(long = "dir", short = 'd', value_name = "DIR", default = "bin")] @@ -2319,6 +2446,9 @@ pub struct GenerateTaskStubsArgs { /// platforms to existing stub files rather than overwriting them. This allows you /// to incrementally build cross-platform tool stubs. #[derive(Args)] +#[usage( + after_long_help = "Examples:\n\n Generate a tool stub for a single URL:\n $ mise generate tool-stub ./bin/gh --url \"https://github.com/cli/cli/releases/download/v2.96.0/gh_2.96.0_linux_amd64.tar.gz\"\n\n Generate a tool stub with platform-specific URLs:\n $ mise generate tool-stub ./bin/rg \\\n --platform-url linux-x64:https://github.com/BurntSushi/ripgrep/releases/download/14.0.3/ripgrep-14.0.3-x86_64-unknown-linux-musl.tar.gz \\\n --platform-url darwin-arm64:https://github.com/BurntSushi/ripgrep/releases/download/14.0.3/ripgrep-14.0.3-aarch64-apple-darwin.tar.gz\n\n Append additional platforms to an existing stub:\n $ mise generate tool-stub ./bin/rg \\\n --platform-url linux-x64:https://example.com/rg-linux.tar.gz\n $ mise generate tool-stub ./bin/rg \\\n --platform-url darwin-arm64:https://example.com/rg-darwin.tar.gz\n # The stub now contains both platforms\n\n Use auto-detection for platform from URL:\n $ mise generate tool-stub ./bin/node \\\n --platform-url https://nodejs.org/dist/v22.17.1/node-v22.17.1-darwin-arm64.tar.gz\n # Platform 'macos-arm64' will be auto-detected from the URL\n\n Generate with platform-specific binary paths:\n $ mise generate tool-stub ./bin/tool \\\n --platform-url linux-x64:https://example.com/tool-linux.tar.gz \\\n --platform-url windows-x64:https://example.com/tool-windows.zip \\\n --platform-bin windows-x64:tool.exe\n\n Generate without downloading (faster):\n $ mise generate tool-stub ./bin/tool --url \"https://example.com/tool.tar.gz\" --skip-download\n\n Fetch checksums for an existing stub:\n $ mise generate tool-stub ./bin/jq --fetch\n # This will read the existing stub and download files to fill in any missing checksums/sizes\n\n Generate a bootstrap stub that installs mise if needed:\n $ mise generate tool-stub ./bin/tool --url \"https://example.com/tool.tar.gz\" --bootstrap\n # The stub will check for mise and install it automatically before running the tool\n\n Generate a bootstrap stub with a pinned mise version:\n $ mise generate tool-stub ./bin/tool --url \"https://example.com/tool.tar.gz\" --bootstrap --bootstrap-version 2025.1.0\n\n Lock an existing tool stub with pinned version and platform URLs/checksums:\n $ mise generate tool-stub ./bin/node --lock\n\n Bump the version in a locked stub:\n $ mise generate tool-stub ./bin/node --lock --version 22\n # Resolves the latest node 22.x, pins it, and updates platform URLs/checksums" +)] pub struct GenerateToolStubArgs { /// Binary path within the extracted archive /// @@ -2420,6 +2550,9 @@ pub enum GenerateCommands { /// Shows which token source mise would use, useful for debugging /// authentication issues. The token is masked by default. #[derive(Args)] +#[usage( + after_long_help = "Examples:\n\n $ mise github token\n github.com: ghp_…xxxx (source: GITHUB_TOKEN)\n\n $ mise github token --unmask\n github.com: ghp_xxxxxxxxxxxx (source: GITHUB_TOKEN)\n\n $ mise github token github.mycompany.com\n github.mycompany.com: (none)" +)] pub struct GithubTokenArgs { /// Force native GitHub OAuth device flow instead of normal token resolution #[usage(long = "oauth")] @@ -2463,6 +2596,9 @@ pub enum GithubCommands { /// /// Use `mise local` to set a tool version locally in the current directory. #[derive(Args)] +#[usage( + after_long_help = "Examples:\n # set the current version of node to 20.x\n # will use a fuzzy version (e.g.: 20) in .tool-versions file\n $ mise global --fuzzy node@20\n\n # set the current version of node to 20.x\n # will use a precise version (e.g.: 20.0.0) in .tool-versions file\n $ mise global --pin node@20\n\n # show the current version of node in ~/.tool-versions\n $ mise global node\n 20.0.0" +)] pub struct GlobalArgs { #[usage( help = "Save fuzzy version to `~/.tool-versions`\ne.g.: `mise global --fuzzy node@20` will save `node 20` to ~/.tool-versions\nthis is the default behavior unless MISE_ASDF_COMPAT=1", @@ -2549,6 +2685,9 @@ pub struct ImplodeArgs { /// Edit mise.toml interactively #[derive(Args)] +#[usage( + after_long_help = "Examples:\n\n $ mise edit # edit mise.toml interactively\n $ mise edit .mise.toml # edit a specific file\n $ mise edit -g # edit the global config file\n $ mise edit -y # skip interactive editor\n $ mise edit -n # preview without writing" +)] pub struct EditArgs { /// Edit the global config file (~/.config/mise/config.toml) #[usage(long = "global", short = 'g')] @@ -2574,6 +2713,9 @@ pub struct EditArgs { /// /// Tools will be installed in parallel. To disable, set `--jobs=1` or `MISE_JOBS=1` #[derive(Args)] +#[usage( + after_long_help = "Examples:\n\n $ mise install node@20.0.0 # install specific node version\n $ mise install node@20 # install fuzzy node version\n $ mise install node # install version specified in mise.toml\n $ mise install # installs everything specified in mise.toml" +)] pub struct InstallArgs { /// Force reinstall even if already installed #[usage(long = "force", short = 'f')] @@ -2633,6 +2775,9 @@ pub struct InstallArgs { /// /// Used for building a tool to a directory for use outside of mise #[derive(Args)] +#[usage( + after_long_help = "Examples:\n\n # install node@20.0.0 into ./mynode\n $ mise install-into node@20.0.0 ./mynode && ./mynode/bin/node -v\n 20.0.0" +)] pub struct InstallIntoArgs { /// Tool to install e.g.: node@20 #[usage(arg, name = "TOOL@VERSION")] @@ -2646,6 +2791,9 @@ pub struct InstallIntoArgs { /// /// Supports prefixes such as `node@20` to get the latest version of node 20. #[derive(Args)] +#[usage( + after_long_help = "Examples:\n\n $ mise latest node@20 # get the latest version of node 20\n 20.0.0\n\n $ mise latest node # get the latest stable version of node\n 20.0.0\n\n $ mise latest node --minimum-release-age 2024-01-01 # latest stable node released before 2024-01-01" +)] pub struct LatestArgs { /// Show latest installed instead of available version #[usage(long = "installed", short = 'i')] @@ -2668,6 +2816,9 @@ pub struct LatestArgs { /// /// Use this for adding installs either custom compiled outside mise or built with a different tool. #[derive(Args)] +#[usage( + after_long_help = "Examples:\n\n # build node-20.0.0 with node-build and link it into mise\n $ node-build 20.0.0 ~/.nodes/20.0.0\n $ mise link node@20.0.0 ~/.nodes/20.0.0\n\n # have mise use the node version provided by Homebrew\n $ brew install node\n $ mise link node@brew $(brew --prefix node)\n $ mise use node@brew" +)] pub struct LinkArgs { /// Overwrite an existing tool version if it exists #[usage(long = "force", short = 'f')] @@ -2690,6 +2841,9 @@ pub struct LinkArgs { /// This uses `.tool-version` by default unless there is a `mise.toml` file or if `MISE_USE_TOML` /// is set. A future v2 release of mise will default to using `mise.toml`. #[derive(Args)] +#[usage( + after_long_help = "Examples:\n # set the current version of node to 20.x for the current directory\n # will use a precise version (e.g.: 20.0.0) in .tool-versions file\n $ mise local node@20\n\n # set node to 20.x for the current project (recurses up to find .tool-versions)\n $ mise local -p node@20\n\n # set the current version of node to 20.x for the current directory\n # will use a fuzzy version (e.g.: 20) in .tool-versions file\n $ mise local --fuzzy node@20\n\n # removes node from .tool-versions\n $ mise local --remove=node\n\n # show the current version of node in .tool-versions\n $ mise local node\n 20.0.0" +)] pub struct LocalArgs { #[usage( help = "Recurse up to find a .tool-versions file rather than using the current directory only\nby default this command will only set the tool in the current directory (\"$PWD/.tool-versions\")", @@ -2726,6 +2880,9 @@ pub struct LocalArgs { /// This allows you to refresh lockfile data for platforms other than the one you're currently on. /// Operates on the lockfile in the current config root. Use TOOL arguments to target specific tools. #[derive(Args)] +#[usage( + after_long_help = "Examples:\n\n $ mise lock # update lockfile for all common platforms\n $ mise lock node python # update only node and python\n $ mise lock --platform linux-x64 # update only linux-x64 platform\n $ mise lock --dry-run # show what would be updated\n $ mise lock --bump # re-resolve selectors like \"latest\" or \"20\" to the latest matching versions\n $ mise lock --bump --dry-run --json # list available updates as JSON without writing\n $ mise lock --minimum-release-age 2024-01-01 # lock latest/fuzzy versions released before 2024-01-01\n $ mise lock --local # update mise.local.lock for local configs\n $ mise lock --global # update only global config lockfiles" +)] pub struct LockArgs { #[usage( help = "Target only global config lockfiles (~/.config/mise/mise.lock and system config)\nBy default, only the active project config root is locked", @@ -2798,6 +2955,9 @@ pub struct LockArgs { /// /// It's a useful command to get the current state of your tools. #[derive(Args)] +#[usage( + after_long_help = "Examples:\n\n $ mise ls\n node 20.0.0 ~/src/myapp/.tool-versions latest\n python 3.11.0 ~/.tool-versions 3.10\n python 3.10.0\n\n $ mise ls --current\n node 20.0.0 ~/src/myapp/.tool-versions 20\n python 3.11.0 ~/.tool-versions 3.11.0\n\n $ mise ls --json\n {\n \"node\": [\n {\n \"version\": \"20.0.0\",\n \"install_path\": \"/Users/jdx/.mise/installs/node/20.0.0\",\n \"source\": {\n \"type\": \"mise.toml\",\n \"path\": \"/Users/jdx/mise.toml\"\n }\n }\n ],\n \"python\": [...]\n }\n\n $ mise ls --all-sources\n node 20.0.0 ~/src/myapp/mise.toml 20\n ~/.config/mise/config.toml latest" +)] pub struct LsArgs { /// Only show tool versions currently specified in a mise.toml #[usage(long = "current", short = 'c')] @@ -2852,6 +3012,9 @@ pub struct LsArgs { /// /// Note that the results may be cached, run `mise cache clean` to clear the cache and get fresh results. #[derive(Args)] +#[usage( + after_long_help = "Examples:\n\n $ mise ls-remote node\n 18.0.0\n 20.0.0\n\n $ mise ls-remote node@20\n 20.0.0\n 20.1.0\n\n $ mise ls-remote node 20\n 20.0.0\n 20.1.0\n\n $ mise ls-remote node --minimum-release-age 2024-01-01\n 20.0.0\n\n $ mise ls-remote github:cli/cli --json\n [{\"version\":\"2.62.0\",\"created_at\":\"2024-11-14T15:40:35Z\",\"prerelease\":false},{\"version\":\"2.61.0\",\"created_at\":\"2024-10-23T19:22:15Z\",\"prerelease\":false}]" +)] pub struct LsRemoteArgs { /// Show all installed plugins and versions #[usage(long = "all")] @@ -2917,6 +3080,9 @@ pub struct LsRemoteArgs { /// Note: This is primarily intended for integration with AI assistants like Claude, /// Cursor, or other tools that support the Model Context Protocol. #[derive(Args)] +#[usage( + after_long_help = "Examples:\n\n # Start the MCP server (typically used by AI assistant tools)\n $ mise mcp\n\n # Example integration with Claude Desktop (add to claude_desktop_config.json):\n {\n \"mcpServers\": {\n \"mise\": {\n \"command\": \"mise\",\n \"args\": [\"mcp\"],\n \"env\": {}\n }\n }\n }\n\n # Interactive testing with JSON-RPC commands:\n $ echo '{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{\"protocolVersion\":\"2024-11-05\",\"capabilities\":{},\"clientInfo\":{\"name\":\"test\",\"version\":\"1.0\"}}}' | mise mcp\n\n # Resources you can query:\n - mise://tools - List active tools\n - mise://tools?include_inactive=true - List all installed tools\n - mise://tasks - List all tasks\n - mise://env - List environment variables\n - mise://config - Show configuration info\n\n # Tools available:\n - list_commands - Every mise command and what running it does\n Example: {\"include_hidden\": false}\n - install_tool - Install a tool (not yet implemented)\n - run_task - Execute a mise task with optional arguments\n Example: {\"task\": \"build\", \"args\": [\"--verbose\"]}" +)] pub struct McpArgs {} /// [experimental] Build an OCI image from the current mise.toml @@ -2929,6 +3095,9 @@ pub struct McpArgs {} /// /// Requires `mise settings experimental=true` (or `MISE_EXPERIMENTAL=1`). #[derive(Args)] +#[usage( + after_long_help = "Examples:\n\n Build with defaults (debian:bookworm-slim base):\n $ mise oci build\n\n Build with a specific base image and tag:\n $ mise oci build --from ubuntu:24.04 --tag myorg/dev:latest -o ./img\n\n Inspect the result with skopeo:\n $ skopeo inspect oci:./mise-oci\n\n Push to a registry:\n $ mise oci push --image-dir ./mise-oci ghcr.io/me/dev:latest\n\nNotes:\n\n - The image only contains tools from the project's mise config (and\n any configs at-or-below the project root). Tools from\n `~/.config/mise/config.toml` are not included; pass --include-global\n to package them too.\n - asdf and vfox plugins are not supported in v1; use a different backend\n (core, aqua, ubi, github, cargo, npm, go, pipx, spm, http) for each tool.\n - The host mise binary is embedded at /usr/local/bin/mise by default;\n build on the same OS/arch as your target image (or pass --no-mise)." +)] pub struct OciBuildArgs { /// Copy a host file, directory, or symlink into the image (repeatable, HOST:IMAGE) #[usage(long = "copy", value_name = "HOST_PATH:IMAGE_PATH", var)] @@ -2985,6 +3154,9 @@ pub struct OciBuildArgs { /// /// Requires `mise settings experimental=true` (or `MISE_EXPERIMENTAL=1`). #[derive(Args)] +#[usage( + after_long_help = "Examples:\n\n Build and push to GHCR:\n $ mise oci push ghcr.io/me/devenv:latest\n\n Push an image built earlier:\n $ mise oci build -o ./img\n $ mise oci push --image-dir ./img ghcr.io/me/devenv:v1\n\nAuth:\n\n Credentials are resolved the same way docker/podman resolve them:\n $REGISTRY_AUTH_FILE, $XDG_RUNTIME_DIR/containers/auth.json,\n ~/.config/containers/auth.json, then ~/.docker/config.json\n (inline auths and credential helpers). Log in with either:\n $ docker login ghcr.io\n $ podman login ghcr.io" +)] pub struct OciPushArgs { /// Reuse unchanged tool layers from this image instead of the destination ref /// @@ -3036,6 +3208,9 @@ pub struct OciPushArgs { /// Requires `mise settings experimental=true` (or `MISE_EXPERIMENTAL=1`) and /// one of: `podman`, `docker`. #[derive(Args)] +#[usage( + after_long_help = "Examples:\n\n Build the current mise.toml and drop into bash:\n $ mise oci run -it -- bash\n\n Run a one-shot command with env + volume (note: `-v` is reserved\n for --verbose, so use `--volume`):\n $ mise oci run -e DEBUG=1 --volume $PWD:/work -w /work -- npm test\n\n Re-use a previously built layout (skip the build step):\n $ mise oci build -o ./img && mise oci run --image-dir ./img -- node -e 'console.log(process.version)'\n\nEngines:\n\n Prefers podman (loads OCI layouts natively). Falls back to docker\n (loaded via docker load). Pass --engine podman or --engine docker to override." +)] pub struct OciRunArgs { /// Container engine to use (`auto`, `podman`, or `docker`) #[usage( @@ -3126,6 +3301,9 @@ pub enum OciCommands { /// /// See `mise upgrade` to upgrade these versions. #[derive(Args)] +#[usage( + after_long_help = "Examples:\n\n $ mise outdated\n Plugin Requested Current Latest\n python 3.11 3.11.0 3.11.1\n node 20 20.0.0 20.1.0\n\n $ mise outdated node\n Plugin Requested Current Latest\n node 20 20.0.0 20.1.0\n\n $ mise outdated --json\n {\"python\": {\"requested\": \"3.11\", \"current\": \"3.11.0\", \"latest\": \"3.11.1\"}, ...}\n\n $ mise outdated --local\n Plugin Requested Current Latest\n node 20 20.0.0 20.1.0" +)] pub struct OutdatedArgs { /// Output in JSON format #[usage(long = "json", short = 'J')] @@ -3171,6 +3349,9 @@ pub struct OutdatedArgs { /// /// To appear here, become a patron at . #[derive(Args)] +#[usage( + after_long_help = "Examples:\n\n $ mise patrons\n $ mise patrons -J\n $ mise patrons --refresh" +)] pub struct PatronsArgs { /// Output in JSON format #[usage(long = "json", short = 'J')] @@ -3187,6 +3368,9 @@ pub struct PatronsArgs { /// /// This behavior can be modified in ~/.config/mise/config.toml #[derive(Args)] +#[usage( + after_long_help = "Examples:\n\n # install the poetry via shorthand\n $ mise plugins install poetry\n\n # install the poetry plugin using a specific git url\n $ mise plugins install poetry https://github.com/mise-plugins/mise-poetry.git\n\n # install the poetry plugin using the git url only\n # (poetry is inferred from the url)\n $ mise plugins install https://github.com/mise-plugins/mise-poetry.git\n\n # install the poetry plugin using a specific ref\n $ mise plugins install poetry https://github.com/mise-plugins/mise-poetry.git#11d0c1e" +)] pub struct PluginsInstallArgs { #[usage( help = "Install all missing plugins\nThis will only install plugins that have matching shorthands.\ni.e.: they don't need the full git repo url", @@ -3220,6 +3404,9 @@ pub struct PluginsInstallArgs { /// /// This is used for developing a plugin. #[derive(Args)] +#[usage( + after_long_help = "Examples:\n\n # essentially just `ln -s ./vfox-cmake ~/.local/share/mise/plugins/cmake`\n $ mise plugins link cmake ./vfox-cmake\n\n # infer plugin name as \"cmake\"\n $ mise plugins link ./vfox-cmake" +)] pub struct PluginsLinkArgs { /// Overwrite existing plugin #[usage(long = "force", short = 'f')] @@ -3242,6 +3429,9 @@ pub struct PluginsLinkArgs { /// /// Can also show remotely available plugins to install. #[derive(Args)] +#[usage( + after_long_help = "Examples:\n\n $ mise plugins ls\n cmake\n poetry\n\n $ mise plugins ls --urls\n cmake https://github.com/mise-plugins/vfox-cmake.git\n poetry https://github.com/mise-plugins/vfox-poetry.git" +)] pub struct PluginsLsArgs { #[usage( help = "List all available remote plugins\nSame as `mise plugins ls-remote`", @@ -3292,6 +3482,7 @@ pub struct PluginsLsRemoteArgs { /// Removes a plugin #[derive(Args)] +#[usage(after_long_help = "Examples:\n\n $ mise plugins uninstall cmake")] pub struct PluginsUninstallArgs { /// Remove all plugins #[usage(long = "all", short = 'a')] @@ -3308,6 +3499,9 @@ pub struct PluginsUninstallArgs { /// /// note: this updates the plugin itself, not the runtime versions #[derive(Args)] +#[usage( + after_long_help = "Examples:\n\n $ mise plugins update # update all plugins\n $ mise plugins update cmake # update only cmake\n $ mise plugins update cmake#beta # specify a ref" +)] pub struct PluginsUpdateArgs { #[usage( help = "Number of jobs to run in parallel\nDefault: 4", @@ -3372,7 +3566,7 @@ pub enum PluginsCommands { #[usage( name = "ls-remote", help = "List all available remote plugins", - long_help = "\nList all available remote plugins\n\nThe full list is here: https://github.com/jdx/mise/blob/main/registry/\n\nExamples:\n\n $ mise plugins ls-remote", + long_help = "\nList all available remote plugins\n\nThe full list is here: https://github.com/jdx/mise/blob/main/registry/\n\nExamples:\n\n $ mise plugins ls-remote\n", alias("list-remote", "list-all") )] LsRemote(Box), @@ -3454,6 +3648,9 @@ pub struct DepsRemoveArgs { /// Providers with `auto = true` are automatically invoked before `mise x` and `mise run` /// unless skipped with the --no-deps flag. #[derive(Args)] +#[usage( + after_long_help = "Examples:\n\n $ mise deps # Install all project dependencies\n $ mise deps install # Same as bare `mise deps`\n $ mise deps install --force # Force reinstall even if fresh\n $ mise deps install --dry-run # Show what would run\n $ mise deps --monorepo # Install deps from explicit monorepo config roots\n $ mise deps add npm:react # Add a dependency\n $ mise deps add -D npm:vitest # Add a dev dependency\n $ mise deps remove npm:lodash # Remove a dependency\n\nConfiguration:\n\n```toml\n# Built-in npm provider (auto-detects lockfile)\n[deps.npm]\nauto = true # Auto-run before mise x/run\n\n# Custom provider\n[deps.codegen]\nauto = true\nsources = [\"schema/*.graphql\"]\noutputs = [\"src/generated/\"]\nrun = \"npm run codegen\"\n\n[deps]\ndisable = [\"npm\"] # Disable specific providers at runtime\n```" +)] pub struct DepsArgs { /// Show why a provider is fresh or stale (requires a provider argument) #[usage(long = "explain")] @@ -3511,6 +3708,9 @@ pub enum DepsCommands { /// /// You can list prunable tools with `mise ls --prunable` #[derive(Args)] +#[usage( + after_long_help = "Examples:\n\n $ mise prune --dry-run\n rm -rf ~/.local/share/mise/versions/node/20.0.0\n rm -rf ~/.local/share/mise/versions/node/20.0.1" +)] pub struct PruneArgs { /// Do not actually delete anything #[usage(long = "dry-run", short = 'n')] @@ -3540,6 +3740,9 @@ pub struct PruneArgs { /// /// For example, `poetry` is shorthand for `asdf:mise-plugins/mise-poetry`. #[derive(Args)] +#[usage( + after_long_help = "Examples:\n\n $ mise registry\n node core:node\n poetry asdf:mise-plugins/mise-poetry\n ubi cargo:ubi-cli\n\n $ mise registry poetry\n asdf:mise-plugins/mise-poetry" +)] pub struct RegistryArgs { /// Show only tools for this backend #[usage(long = "backend", short = 'b', value_name = "BACKEND")] @@ -3587,6 +3790,9 @@ pub struct RenderHelpArgs {} /// Note that this creates shims for _all_ installed tools, not just the ones that are /// currently active in mise.toml. #[derive(Args)] +#[usage( + after_long_help = "Examples:\n\n $ mise reshim\n $ ~/.local/share/mise/shims/node -v\n v20.0.0" +)] pub struct ReshimArgs { /// Removes all shims before reshimming #[usage(long = "force", short = 'f')] @@ -3623,7 +3829,11 @@ pub struct ReshimArgs { /// EOF /// $ mise run build #[derive(Args)] -#[usage(restart_token = ":::", mount = "mise tasks --usage")] +#[usage( + after_long_help = "Examples:\n\n # Runs the \"lint\" tasks. This needs to either be defined in mise.toml\n # or as a standalone script. See the project README for more information.\n $ mise run lint\n\n # Forces the \"build\" tasks to run even if its sources are up-to-date.\n $ mise run --force build\n\n # Run \"test\" with stdin/stdout/stderr all connected to the current terminal.\n # This forces `--jobs=1` to prevent interleaving of output.\n $ mise run --raw test\n\n # Runs the \"lint\", \"test\", and \"check\" tasks in parallel.\n $ mise run lint ::: test ::: check\n\n # Execute multiple tasks each with their own arguments.\n $ mise run cmd1 arg1 arg2 ::: cmd2 arg1 arg2", + restart_token = ":::", + mount = "mise tasks --usage" +)] pub struct RunArgs { /// Run matching tasks only for projects affected by Git changes #[usage(long = "affected")] @@ -3795,6 +4005,9 @@ pub struct RunArgs { /// By default, it will show all tools that fuzzy match the search term. For /// non-fuzzy matches, use the `--match-type` flag. #[derive(Args)] +#[usage( + after_long_help = "Examples:\n\n $ mise search jq\n Tool Description\n jq Command-line JSON processor. https://github.com/jqlang/jq\n jqp A TUI playground to experiment with jq. https://github.com/noahgorstein/jqp\n jiq jid on jq - interactive JSON query tool using jq expressions. https://github.com/fiatjaf/jiq\n gojq Pure Go implementation of jq. https://github.com/itchyny/gojq\n\n $ mise search --interactive\n Tool\n Search a tool\n ❯ jq Command-line JSON processor. https://github.com/jqlang/jq\n jqp A TUI playground to experiment with jq. https://github.com/noahgorstein/jqp\n jiq jid on jq - interactive JSON query tool using jq expressions. https://github.com/fiatjaf/jiq\n gojq Pure Go implementation of jq. https://github.com/itchyny/gojq\n /jq \n esc clear filter • enter confirm" +)] pub struct SearchArgs { /// Show interactive search #[usage(long = "interactive", short = 'i')] @@ -3850,6 +4063,9 @@ pub struct SelfUpdateArgs { /// /// Use `-E ` to create/modify environment-specific config files like `mise..toml`. #[derive(Args)] +#[usage( + after_long_help = "Examples:\n\n $ mise set NODE_ENV=production\n\n $ mise set NODE_ENV\n production\n\n $ mise set -E staging NODE_ENV=staging\n # creates or modifies mise.staging.toml\n\n $ mise set\n key value source\n NODE_ENV production ~/.config/mise/config.toml\n\n $ mise set --prompt PASSWORD\n Enter value for PASSWORD: [hidden input]\n\n Multiline Values (--stdin):\n\n $ cat private.key | mise set --stdin MY_KEY\n\n $ printf \"line1\\nline2\" | mise set --stdin MY_KEY\n\n [experimental] Age Encryption:\n\n $ mise set --age-encrypt API_KEY=secret\n\n $ mise set --age-encrypt --prompt API_KEY\n Enter value for API_KEY: [hidden input]" +)] pub struct SetArgs { /// Create/modify an environment-specific config file like .mise..toml #[usage(long = "env", short = 'E', value_name = "ENV")] @@ -3914,6 +4130,7 @@ pub struct SetArgs { /// Used with an array setting, this will append the value to the array. /// This modifies the contents of ~/.config/mise/config.toml #[derive(Args)] +#[usage(after_long_help = "Examples:\n\n $ mise settings add disable_hints python_multi")] pub struct SettingsAddArgs { /// Use the local config file instead of the global one #[usage(long = "local", short = 'l')] @@ -3933,6 +4150,7 @@ pub struct SettingsAddArgs { /// Note that aliases are also stored in this file /// but managed separately with `mise tool-alias get` #[derive(Args)] +#[usage(after_long_help = "Examples:\n\n $ mise settings get idiomatic_version_file\n true")] pub struct SettingsGetArgs { /// Use the local config file instead of the global one #[usage(long = "local", short = 'l')] @@ -3949,6 +4167,9 @@ pub struct SettingsGetArgs { /// Note that aliases are also stored in this file /// but managed separately with `mise tool-alias` #[derive(Args)] +#[usage( + after_long_help = "Examples:\n\n $ mise settings ls\n idiomatic_version_file = false\n ...\n\n $ mise settings ls python\n default_packages_file = \"~/.default-python-packages\"\n ..." +)] pub struct SettingsLsArgs { /// List all settings #[usage(long = "all", short = 'a')] @@ -3979,6 +4200,7 @@ pub struct SettingsLsArgs { /// With `--local`, modifies the local config file instead. /// See https://mise.jdx.dev/configuration.html#target-file-for-write-operations #[derive(Args)] +#[usage(after_long_help = "Examples:\n\n $ mise settings idiomatic_version_file=true")] pub struct SettingsSetArgs { /// Use the local config file instead of the global one #[usage(long = "local", short = 'l')] @@ -3995,6 +4217,7 @@ pub struct SettingsSetArgs { /// /// This modifies the contents of ~/.config/mise/config.toml #[derive(Args)] +#[usage(after_long_help = "Examples:\n\n $ mise settings unset idiomatic_version_file")] pub struct SettingsUnsetArgs { /// Use the local config file instead of the global one #[usage(long = "local", short = 'l')] @@ -4005,6 +4228,9 @@ pub struct SettingsUnsetArgs { } #[derive(Args)] +#[usage( + after_long_help = "Examples:\n # list all settings\n $ mise settings\n\n # get the value of the setting \"always_keep_download\"\n $ mise settings always_keep_download\n\n # set the value of the setting \"always_keep_download\" to \"true\"\n $ mise settings always_keep_download=true\n\n # set the value of the setting \"node.mirror_url\" to \"https://npmmirror.com/mirrors/node/\"\n $ mise settings node.mirror_url https://npmmirror.com/mirrors/node/" +)] pub struct SettingsArgs { /// List all settings #[usage(long = "all", short = 'a')] @@ -4060,6 +4286,7 @@ pub enum SettingsCommands { /// This works by setting environment variables for the current shell session /// such as `MISE_NODE_VERSION=20` which is "eval"ed as a shell function created by `mise activate`. #[derive(Args)] +#[usage(after_long_help = "Examples:\n\n $ mise shell node@20\n $ node -v\n v20.0.0")] pub struct ShellArgs { #[usage( help = "Number of jobs to run in parallel\n[default: 4]", @@ -4081,6 +4308,7 @@ pub struct ShellArgs { /// Show the command for a shell alias #[derive(Args)] +#[usage(after_long_help = "Examples:\n\n $ mise shell-alias get ll\n ls -la")] pub struct ShellAliasGetArgs { /// The alias to show #[usage(arg, name = "shell_alias")] @@ -4092,6 +4320,9 @@ pub struct ShellAliasGetArgs { /// Shows the shell aliases that are set in the current directory. /// These are defined in `mise.toml` under the `[shell_alias]` section. #[derive(Args)] +#[usage( + after_long_help = "Examples:\n\n $ mise shell-alias ls\n alias command\n ll ls -la\n gs git status" +)] pub struct ShellAliasLsArgs { /// Don't show table header #[usage(long = "no-header")] @@ -4102,6 +4333,9 @@ pub struct ShellAliasLsArgs { /// /// This modifies the contents of ~/.config/mise/config.toml #[derive(Args)] +#[usage( + after_long_help = "Examples:\n\n $ mise shell-alias set ll \"ls -la\"\n $ mise shell-alias set gs \"git status\"" +)] pub struct ShellAliasSetArgs { /// The alias name #[usage(arg, name = "shell_alias")] @@ -4115,6 +4349,7 @@ pub struct ShellAliasSetArgs { /// /// This modifies the contents of ~/.config/mise/config.toml #[derive(Args)] +#[usage(after_long_help = "Examples:\n\n $ mise shell-alias unset ll")] pub struct ShellAliasUnsetArgs { /// The alias to remove #[usage(arg, name = "shell_alias")] @@ -4157,6 +4392,9 @@ pub struct SponsorsArgs {} /// /// This won't overwrite any existing installs but will overwrite any existing symlinks #[derive(Args)] +#[usage( + after_long_help = "Examples:\n\n $ brew install node@18 node@20\n $ mise sync node --brew\n $ mise use -g node@18 - uses Homebrew-provided node" +)] pub struct SyncNodeArgs { /// Get tool versions from Homebrew #[usage(long = "brew")] @@ -4175,6 +4413,9 @@ pub struct SyncNodeArgs { /// /// This won't overwrite any existing installs but will overwrite any existing symlinks #[derive(Args)] +#[usage( + after_long_help = "Examples:\n\n $ pyenv install 3.11.0\n $ mise sync python --pyenv\n $ mise use -g python@3.11.0 - uses pyenv-provided python\n\n $ uv python install 3.11.0\n $ mise install python@3.10.0\n $ mise sync python --uv\n $ mise x python@3.11.0 -- python -V - uses uv-provided python\n $ uv run -p 3.10.0 -- python -V - uses mise-provided python" +)] pub struct SyncPythonArgs { /// Get tool versions from pyenv #[usage(long = "pyenv")] @@ -4186,6 +4427,9 @@ pub struct SyncPythonArgs { /// Symlinks all ruby tool versions from an external tool into mise #[derive(Args)] +#[usage( + after_long_help = "Examples:\n\n $ brew install ruby\n $ mise sync ruby --brew\n $ mise use -g ruby - Use the latest version of Ruby installed by Homebrew" +)] pub struct SyncRubyArgs { /// Get tool versions from Homebrew #[usage(long = "brew")] @@ -4217,6 +4461,9 @@ pub enum SyncCommands { /// Adds a task to the local mise.toml file. /// See https://mise.jdx.dev/configuration.html#target-file-for-write-operations #[derive(Args)] +#[usage( + after_long_help = "Examples:\n\n $ mise tasks add pre-commit --depends \"test\" --depends \"render\" -- echo pre-commit" +)] pub struct TasksAddArgs { /// Other names for the task #[usage(long = "alias", short = 'a', value_name = "ALIAS", var)] @@ -4272,6 +4519,9 @@ pub struct TasksAddArgs { /// Display a tree visualization of a dependency graph #[derive(Args)] +#[usage( + after_long_help = "Examples:\n\n # Show dependencies for all tasks\n $ mise tasks deps\n\n # Show dependencies for the \"lint\", \"test\" and \"check\" tasks\n $ mise tasks deps lint test check\n\n # Show dependencies in DOT format\n $ mise tasks deps --dot\n\n # Collapse repeated dependencies\n $ mise tasks deps --compact" +)] pub struct TasksDepsArgs { /// Collapse repeated dependencies after their first occurrence #[usage(long = "compact")] @@ -4294,6 +4544,7 @@ pub struct TasksDepsArgs { /// /// The task will be created as a standalone script if it does not already exist. #[derive(Args)] +#[usage(after_long_help = "Examples:\n\n $ mise tasks edit build\n $ mise tasks edit test")] pub struct TasksEditArgs { /// Display the path to the task instead of editing it #[usage(long = "path", short = 'p')] @@ -4305,6 +4556,9 @@ pub struct TasksEditArgs { /// [experimental] Inspect the workspace project graph #[derive(Args)] +#[usage( + after_long_help = "Examples:\n\n # Inspect projects and their dependency edges\n $ mise tasks graph\n\n # Emit the project graph as JSON\n $ mise tasks graph --json\n\n # Explain where inferred projects and task fields came from\n $ mise tasks graph --explain" +)] pub struct TasksGraphArgs { /// Output the project graph as JSON #[usage(long = "json", short = 'J')] @@ -4319,6 +4573,9 @@ pub struct TasksGraphArgs { /// Get information about a task #[derive(Args)] +#[usage( + after_long_help = "Examples:\n\n $ mise tasks info\n Name: test\n Aliases: t\n Description: Test the application\n Source: ~/src/myproj/mise.toml\n\n $ mise tasks info test --json\n {\n \"name\": \"test\",\n \"aliases\": \"t\",\n \"description\": \"Test the application\",\n \"source\": \"~/src/myproj/mise.toml\",\n \"config_sources\": [\"~/src/myproj/mise.toml\"],\n \"depends\": [],\n \"env\": {},\n \"dir\": null,\n \"hide\": false,\n \"raw\": false,\n \"sources\": [],\n \"outputs\": [],\n \"run\": [\n \"echo \\\"testing!\\\"\"\n ],\n \"file\": null,\n \"usage_spec\": {}\n }" +)] pub struct TasksInfoArgs { /// Output in JSON format #[usage(long = "json", short = 'J')] @@ -4329,6 +4586,7 @@ pub struct TasksInfoArgs { } #[derive(Args)] +#[usage(after_long_help = "Examples:\n\n $ mise tasks ls")] pub struct TasksLsArgs { /// Only show global tasks #[usage(long = "global", short = 'g')] @@ -4399,7 +4657,11 @@ pub struct TasksLsArgs { /// EOF /// $ mise run build #[derive(Args)] -#[usage(restart_token = ":::", mount = "mise tasks --usage")] +#[usage( + after_long_help = "Examples:\n\n # Runs the \"lint\" tasks. This needs to either be defined in mise.toml\n # or as a standalone script. See the project README for more information.\n $ mise run lint\n\n # Forces the \"build\" tasks to run even if its sources are up-to-date.\n $ mise run --force build\n\n # Run \"test\" with stdin/stdout/stderr all connected to the current terminal.\n # This forces `--jobs=1` to prevent interleaving of output.\n $ mise run --raw test\n\n # Runs the \"lint\", \"test\", and \"check\" tasks in parallel.\n $ mise run lint ::: test ::: check\n\n # Execute multiple tasks each with their own arguments.\n $ mise run cmd1 arg1 arg2 ::: cmd2 arg1 arg2", + restart_token = ":::", + mount = "mise tasks --usage" +)] pub struct TasksRunArgs { /// Run matching tasks only for projects affected by Git changes #[usage(long = "affected")] @@ -4579,6 +4841,9 @@ pub struct TasksRunArgs { /// Validate tasks for common errors and issues #[derive(Args)] +#[usage( + after_long_help = "Examples:\n\n # Validate all tasks\n $ mise tasks validate\n\n # Validate specific tasks\n $ mise tasks validate build test\n\n # Output results as JSON\n $ mise tasks validate --json\n\n # Only show errors (skip warnings)\n $ mise tasks validate --errors-only\n\nValidation Checks:\n\nThe validate command performs the following checks:\n\n • Circular Dependencies: Detects dependency cycles\n • Missing References: Finds references to nonexistent tasks\n • Usage Spec Parsing: Validates #USAGE directives and specs\n • Timeout Format: Checks timeout values are valid durations\n • Alias Conflicts: Detects duplicate aliases across tasks\n • File Existence: Verifies file-based tasks exist\n • Directory Templates: Validates directory paths and templates\n • Shell Commands: Checks shell executables exist\n • Glob Patterns: Validates source and output patterns\n • Run Entries: Ensures tasks reference valid dependencies" +)] pub struct TasksValidateArgs { /// Only show errors (skip warnings) #[usage(long = "errors-only")] @@ -4596,6 +4861,7 @@ pub struct TasksValidateArgs { /// Manage tasks #[derive(Args)] +#[usage(after_long_help = "Examples:\n\n $ mise tasks ls")] pub struct TasksArgs { /// Only show global tasks #[usage(long = "global", short = 'g')] @@ -4678,6 +4944,7 @@ pub enum TasksCommands { /// Test a tool installs and executes #[derive(Args)] +#[usage(after_long_help = "Examples:\n\n $ mise test-tool ripgrep")] pub struct TestToolArgs { /// Test every tool specified in registry/ #[usage(long = "all", short = 'a')] @@ -4705,6 +4972,9 @@ pub struct TestToolArgs { /// Forgejo token #[derive(Args)] +#[usage( + after_long_help = "Examples:\n\n $ mise token forgejo\n codeberg.org: a180…61f6 (source: FORGEJO_TOKEN)\n\n $ mise token forgejo --unmask\n codeberg.org: a18099ca69064be387fbe37b8ad1d333758361f6 (source: FORGEJO_TOKEN)\n\n $ mise token forgejo forgejo.mycompany.com\n forgejo.mycompany.com: (none)" +)] pub struct TokenForgejoArgs { /// Show the full unmasked token #[usage(long = "unmask")] @@ -4716,6 +4986,9 @@ pub struct TokenForgejoArgs { /// GitHub token #[derive(Args)] +#[usage( + after_long_help = "Examples:\n\n $ mise token github\n github.com: ghp_…xxxx (source: GITHUB_TOKEN)\n\n $ mise token github --unmask\n github.com: ghp_xxxxxxxxxxxx (source: GITHUB_TOKEN)\n\n $ mise token github github.mycompany.com\n github.mycompany.com: (none)\n\n $ mise token github --oauth --refresh\n github.com: gho_…xxxx (source: GitHub OAuth)" +)] pub struct TokenGithubArgs { /// Resolve only via the native GitHub OAuth source (cache, refresh, or device-code flow), bypassing other token sources #[usage(long = "oauth")] @@ -4736,6 +5009,9 @@ pub struct TokenGithubArgs { /// GitLab token #[derive(Args)] +#[usage( + after_long_help = "Examples:\n\n $ mise token gitlab\n gitlab.com: glpa…xxxx (source: GITLAB_TOKEN)\n\n $ mise token gitlab --unmask\n gitlab.com: glpat-xxxxxxxxxxxx (source: GITLAB_TOKEN)\n\n $ mise token gitlab gitlab.mycompany.com\n gitlab.mycompany.com: (none)" +)] pub struct TokenGitlabArgs { /// Show the full unmasked token #[usage(long = "unmask")] @@ -4767,6 +5043,9 @@ pub enum TokenCommands { /// Gets information about a tool #[derive(Args)] +#[usage( + after_long_help = "Examples:\n\n $ mise tool node\n Backend: core\n Installed Versions: 20.0.0 22.0.0\n Active Version: 20.0.0\n Requested Version: 20\n Config Source: ~/.config/mise/mise.toml\n Tool Options: [none]" +)] pub struct ToolArgs { /// Output in JSON format #[usage(long = "json", short = 'J')] @@ -4844,6 +5123,9 @@ pub struct ToolStubArgs { /// checkout has been trusted. Paranoid mode disables this sharing since /// worktrees can check out branches with different config contents. #[derive(Args)] +#[usage( + after_long_help = "Examples:\n\n # trusts ~/some_dir/mise.toml\n $ mise trust ~/some_dir/mise.toml\n\n # trusts mise.toml in the current or parent directory\n $ mise trust" +)] pub struct TrustArgs { /// Trust all config files in the current directory, its parents, and its subdirectories /// @@ -4871,6 +5153,9 @@ pub struct TrustArgs { /// /// This only removes the installed version, it does not modify mise.toml. #[derive(Args)] +#[usage( + after_long_help = "Examples:\n\n # will uninstall specific version\n $ mise uninstall node@18.0.0\n\n # will uninstall the current node version (if only one version is installed)\n $ mise uninstall node\n\n # will uninstall all installed versions of node\n $ mise uninstall --all node@18.0.0 # will uninstall all node versions" +)] pub struct UninstallArgs { /// Delete all installed versions #[usage(long = "all", short = 'a')] @@ -4892,6 +5177,9 @@ pub struct UninstallArgs { /// /// By default, this command modifies `mise.toml` in the current directory. #[derive(Args)] +#[usage( + after_long_help = "Examples:\n\n # Remove NODE_ENV from the current directory's config\n $ mise unset NODE_ENV\n\n # Remove NODE_ENV from the global config\n $ mise unset NODE_ENV -g" +)] pub struct UnsetArgs { /// Specify a file to use instead of `mise.toml` /// @@ -4938,6 +5226,9 @@ pub struct UntrustArgs { /// /// Will also prune the installed version if no other configurations are using it. #[derive(Args)] +#[usage( + after_long_help = "Examples:\n\n # will uninstall specific version\n $ mise unuse node@18.0.0\n\n # will uninstall specific version from global config\n $ mise unuse -g node@18.0.0\n\n # will uninstall specific version from .mise.local.toml\n $ mise unuse --env local node@20\n\n # will uninstall specific version from .mise.staging.toml\n $ mise unuse --env staging node@20" +)] pub struct UnuseArgs { /// Create/modify an environment-specific config file like .mise..toml #[usage(long = "env", short = 'e', value_name = "ENV")] @@ -4966,6 +5257,9 @@ pub struct UnuseArgs { /// /// This will update mise.lock if it is enabled, see https://mise.jdx.dev/configuration/settings.html#lockfile #[derive(Args)] +#[usage( + after_long_help = "Examples:\n\n # Upgrades node to the latest version matching the range in mise.toml\n $ mise upgrade node\n\n # Upgrades node to the latest version and bumps the version in mise.toml\n $ mise upgrade node --bump\n\n # Upgrades all tools to the latest versions\n $ mise upgrade\n\n # Upgrades all tools to the latest versions and bumps the version in mise.toml\n $ mise upgrade --bump\n\n # Just print what would be done, don't actually do it\n $ mise upgrade --dry-run\n\n # Upgrades node and python to the latest versions\n $ mise upgrade node python\n\n # Upgrade all tools except go\n $ mise upgrade --exclude go\n\n # Show a multiselect menu to choose which tools to upgrade\n $ mise upgrade --interactive\n\n # Only upgrade tools defined in local mise.toml, not global ones\n $ mise upgrade --local" +)] pub struct UpgradeArgs { /// Display multiselect menu to choose which tools to upgrade #[usage(long = "interactive", short = 'i')] @@ -5067,6 +5361,9 @@ pub struct UsageArgs {} /// /// Use the `--global` flag to use the global config file instead. #[derive(Args)] +#[usage( + after_long_help = "Examples:\n\n # run with no arguments to use the interactive selector\n $ mise use\n\n # set the current version of node to 20.x in mise.toml of current directory\n # will write the fuzzy version (e.g.: 20)\n $ mise use node@20\n\n # set the current version of node to 20.x in ~/.config/mise/config.toml\n # will write the precise version (e.g.: 20.0.0)\n $ mise use -g --pin node@20\n\n # sets .mise.local.toml (which is intended not to be committed to a project)\n $ mise use --env local node@20\n\n # sets .mise.staging.toml (which is used if MISE_ENV=staging)\n $ mise use --env staging node@20" +)] pub struct UseArgs { /// Create/modify an environment-specific config file like .mise..toml #[usage(long = "env", short = 'e', value_name = "ENV")] @@ -5138,6 +5435,9 @@ pub struct UseArgs { /// /// If the version is out of date, it will display a warning. #[derive(Args)] +#[usage( + after_long_help = "Examples:\n\n $ mise version\n $ mise --version\n $ mise -v\n $ mise -V" +)] pub struct VersionArgs { /// Print the version information in JSON format #[usage(long = "json", short = 'J')] @@ -5152,6 +5452,9 @@ pub struct VersionArgs { /// For more advanced process management (daemon management, auto-restart, readiness checks, /// cron scheduling), see mise's sister project: https://pitchfork.jdx.dev #[derive(Args)] +#[usage( + after_long_help = "Examples:\n\n $ mise watch build\n Runs the \"build\" tasks. Will re-run the tasks when any of its sources change.\n Uses \"sources\" from the tasks definition to determine which files to watch.\n\n $ mise watch build --glob src/**/*.rs\n Runs the \"build\" tasks but specify the files to watch with a glob pattern.\n This overrides the \"sources\" from the tasks definition.\n\n $ mise watch build --clear\n Extra arguments are passed to watchexec. See `watchexec --help` for details.\n\n $ mise watch serve --watch src --exts rs --restart\n Starts an api server, watching for changes to \"*.rs\" files in \"./src\" and kills/restarts the server when they change." +)] pub struct WatchArgs { /// Tasks to run #[usage(long = "task-flag", short = 't', hide, value_name = "TASK_FLAG", var)] @@ -5670,6 +5973,9 @@ pub struct WatchArgs { /// /// The tool must be installed for this to work. #[derive(Args)] +#[usage( + after_long_help = "Examples:\n\n # Show the latest installed version of node\n # If it is is not installed, errors\n $ mise where node@20\n /home/jdx/.local/share/mise/installs/node/20.0.0\n\n # Show the current, active install directory of node\n # Errors if node is not referenced in any .tool-version file\n $ mise where node\n /home/jdx/.local/share/mise/installs/node/20.0.0" +)] pub struct WhereArgs { #[usage( arg, @@ -5690,6 +5996,9 @@ pub struct WhereArgs { /// /// Use this to figure out what version of a tool is currently active. #[derive(Args)] +#[usage( + after_long_help = "Examples:\n\n $ mise which node\n /home/username/.local/share/mise/installs/node/20.0.0/bin/node\n\n $ mise which node --plugin\n node\n\n $ mise which node --version\n 20.0.0" +)] pub struct WhichArgs { #[usage( help = "Use a specific tool@version\ne.g.: `mise which npm --tool=node@20`", diff --git a/derive/src/codegen.rs b/derive/src/codegen.rs index 20d0e7e75..c8c4375e2 100644 --- a/derive/src/codegen.rs +++ b/derive/src/codegen.rs @@ -42,6 +42,10 @@ pub fn emit(cli: &Cli) -> TokenStream { let default_subcommand = option_str(cli.default_subcommand.as_deref()); let restart_token = option_str(cli.restart_token.as_deref()); let mount = option_str(cli.mount.as_deref()); + let before_help = option_str(cli.before_help.as_deref()); + let before_long_help = option_str(cli.before_long_help.as_deref()); + let after_help = option_str(cli.after_help.as_deref()); + let after_long_help = option_str(cli.after_long_help.as_deref()); let root_key = key_ident("COMMAND", None); let keys = key_consts(&cli.fingerprint, flags.len(), args.len()); let flag_tables = flags.iter().enumerate().map(|(i, f)| flag_table(i, f)); @@ -132,6 +136,10 @@ pub fn emit(cli: &Cli) -> TokenStream { long_about: #long_about, restart_token: #restart_token, mount: #mount, + before_help: #before_help, + before_long_help: #before_long_help, + after_help: #after_help, + after_long_help: #after_long_help, flags: #flag_meta_table_ref, args: #arg_meta_table_ref, #sub_metas @@ -1389,6 +1397,10 @@ pub fn emit_args(cli: &Cli) -> TokenStream { let command_key = key_ident("COMMAND", None); let restart_token = option_str(cli.restart_token.as_deref()); let mount = option_str(cli.mount.as_deref()); + let before_help = option_str(cli.before_help.as_deref()); + let before_long_help = option_str(cli.before_long_help.as_deref()); + let after_help = option_str(cli.after_help.as_deref()); + let after_long_help = option_str(cli.after_long_help.as_deref()); let flags: Vec<&Field> = cli .fields @@ -1477,6 +1489,10 @@ pub fn emit_args(cli: &Cli) -> TokenStream { long_about: #long_about, restart_token: #restart_token, mount: #mount, + before_help: #before_help, + before_long_help: #before_long_help, + after_help: #after_help, + after_long_help: #after_long_help, flags: #flag_meta_table_ref, args: #arg_meta_table_ref, #sub_metas diff --git a/derive/src/model.rs b/derive/src/model.rs index a98357618..3752e791d 100644 --- a/derive/src/model.rs +++ b/derive/src/model.rs @@ -35,6 +35,13 @@ pub struct Cli { /// not contain the short one. pub about_attr: Option, pub long_about_attr: Option, + /// Text around the rest of the help page. mise puts an Examples section in + /// `after_long_help` on 115 commands, and a page without it is missing what a reader came + /// for. Nothing derives these from the code, so they are declared. + pub before_help: Option, + pub before_long_help: Option, + pub after_help: Option, + pub after_long_help: Option, /// The word that starts another invocation of the same command: mise's `:::`. pub restart_token: Option, /// A command to run for subcommands discovered at completion time. @@ -218,6 +225,10 @@ impl Cli { default_subcommand: None, about_attr: None, long_about_attr: None, + before_help: None, + before_long_help: None, + after_help: None, + after_long_help: None, restart_token: None, mount: None, fields: Vec::new(), @@ -238,6 +249,10 @@ impl Cli { // declared. "about" => cli.about_attr = Some(string_value(&meta)?), "long_about" => cli.long_about_attr = Some(string_value(&meta)?), + "before_help" => cli.before_help = Some(string_value(&meta)?), + "before_long_help" => cli.before_long_help = Some(string_value(&meta)?), + "after_help" => cli.after_help = Some(string_value(&meta)?), + "after_long_help" => cli.after_long_help = Some(string_value(&meta)?), // A Rust CLI usually owns every flag it accepts, which is the // case the stricter reading is for — but it is still opt-in, // since a wrapper forwarding options wants the default. diff --git a/xtask/src/shadow.rs b/xtask/src/shadow.rs index d7a2cd97e..88de814fc 100644 --- a/xtask/src/shadow.rs +++ b/xtask/src/shadow.rs @@ -318,6 +318,19 @@ fn emit_command(out: &mut String, cmd: &SpecCommand, ty: &Type, is_root: bool, r usage_opts.push(format!("bin = {bin:?}")); usage_opts.extend(declared_about.iter().cloned()); } + // Text around the rest of the page. clap spells the long forms the same way, so both + // dialects can carry them. + for (node, text) in [ + ("before_help", cmd.before_help.as_deref()), + ("before_long_help", cmd.before_help_long.as_deref()), + ("after_help", cmd.after_help.as_deref()), + ("after_long_help", cmd.after_help_long.as_deref()), + ] { + if let Some(text) = text.filter(|t| !t.trim().is_empty()) { + usage_opts.push(format!("{node} = {:?}", text.trim_end())); + } + } + for (present, declaration, what) in [ ( is_root && run.default_subcommand.is_some(), @@ -1049,7 +1062,9 @@ fn declared_help(help: Option<&str>, long: Option<&str>) -> Vec { // The long form goes with it: read from the comment, it would be measured against a // short form that no longer matches, and written in full twice over. if let Some(long) = long.filter(|l| !l.trim().is_empty()) { - opts.push(format!("long_help = {:?}", long.trim_end())); + // Not trimmed: a long form that *ends* with a blank line means it, and the reference + // prints that emptiness — `plugins ls-remote` closes on one. + opts.push(format!("long_help = {:?}", long)); } } opts From 29a2e6625199e1e7f6b6e94e5c213a50644514ac Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Thu, 13 Aug 2026 18:53:53 +0000 Subject: [PATCH 2/6] fix(argv): render the text around a page, and an example's description first MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three things the 211-page comparison could not catch, because mise's spec does not have them: it carries its Examples in `after_long_help` and declares no `example` nodes at all. `before_help` reached `CommandMeta`, the emitted KDL and the documentation, and neither form printed it — so a command that sets a preamble got a page without one. The short form was also missing `after_help`. Both now render them, with the long form preferring the long variants. And an example's description belongs *before* its command line, which is the order the reference prints them in: it introduces the line rather than commenting on it. Verified against usage-lib directly rather than read off the template, since the two disagree about whitespace often enough that only the output is authoritative. Tested against the reference on a hand-built command declaring all of it — a fixture drawn from one real CLI cannot cover what that CLI never uses — and each of the three mutation-checked. Found by Cursor Bugbot and Greptile on #866. Co-Authored-By: Claude Opus 5 --- argv/src/help.rs | 17 +++++++++- benches/gate/tests/help.rs | 65 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 1 deletion(-) diff --git a/argv/src/help.rs b/argv/src/help.rs index 5c764dae5..4fe48ae1e 100644 --- a/argv/src/help.rs +++ b/argv/src/help.rs @@ -217,6 +217,12 @@ fn arg_usage(meta: &ArgMeta<'_>) -> String { pub fn short_help(spec: &Spec<'_>, path: &[&str], meta: &CommandMeta<'_>) -> String { let mut out = String::new(); + // Text the command puts above everything else, and below it. The short form has only the + // one pair; the long form prefers the long variants. + if let Some(before) = meta.before_help { + let _ = writeln!(out, "{before}\n"); + } + // The program, then what it is for. usage-lib prints the name when the spec gives one and // the binary otherwise, and only when there is a version to put beside it. if let Some(version) = spec.version { @@ -263,6 +269,9 @@ pub fn short_help(spec: &Spec<'_>, path: &[&str], meta: &CommandMeta<'_>) -> Str }, ); examples_section(&mut out, meta); + if let Some(after) = meta.after_help { + let _ = writeln!(out, "\n{after}"); + } // usage-lib trims the whole document and puts back one newline, which is what keeps the // blank lines between sections from becoming trailing ones. @@ -410,6 +419,10 @@ pub fn long_help(spec: &Spec<'_>, path: &[&str], meta: &CommandMeta<'_>) -> Stri let width = terminal_width(); let mut out = String::new(); + if let Some(before) = meta.before_long_help.or(meta.before_help) { + let _ = writeln!(out, "{before}\n"); + } + if let Some(version) = spec.version { let name = if spec.name.is_empty() { spec.bin.unwrap_or_default() @@ -469,10 +482,12 @@ pub fn long_help(spec: &Spec<'_>, path: &[&str], meta: &CommandMeta<'_>) -> Stri if let Some(header) = example.header { let _ = writeln!(out, " {header}:"); } - let _ = writeln!(out, " $ {}", example.code); + // The description comes *before* the command, which is the order the reference + // prints them in: it introduces the line rather than commenting on it. if let Some(help) = example.help { let _ = writeln!(out, " {help}"); } + let _ = writeln!(out, " $ {}", example.code); } } diff --git a/benches/gate/tests/help.rs b/benches/gate/tests/help.rs index e7e3c4cc0..67914b0b8 100644 --- a/benches/gate/tests/help.rs +++ b/benches/gate/tests/help.rs @@ -204,3 +204,68 @@ fn every_long_help_matches_the_reference() { .join("\n") ); } + +/// A command declaring everything mise's spec does not: an example with a description, and the +/// text that goes above and below the page. +/// +/// mise carries its Examples in `after_long_help` and declares no `example` nodes at all, so the +/// 211-page comparison never reaches this code. Three bugs hid there — a missing preamble in both +/// forms and an example's description printed after its command — which is what a fixture built +/// from one real CLI cannot catch on its own. +fn surrounded() -> LibSpec { + "name \"ex\"\nbin \"ex\"\ncmd go help=\"Go somewhere\" {\n \ + before_help \"Read this first.\"\n \ + before_long_help \"Read this first, at length.\"\n \ + after_help \"And this after.\"\n \ + after_long_help \"And this after, at length.\"\n \ + example \"ex go --fast\" help=\"the quick way\"\n}\n" + .parse() + .expect("valid spec") +} + +#[test] +fn the_text_around_a_page_is_rendered_where_the_reference_puts_it() { + let spec = surrounded(); + let go = spec.cmd.subcommands.get("go").expect("go"); + + // Built by hand rather than derived: the point is to compare the renderer against the + // reference for a shape the shadow does not have. + static GO: usage_argv::Command = usage_argv::Command { + name: "go", + ..usage_argv::Command::EMPTY + }; + static GO_META: CommandMeta = CommandMeta { + cmd: &GO, + about: Some("Go somewhere"), + before_help: Some("Read this first."), + before_long_help: Some("Read this first, at length."), + after_help: Some("And this after."), + after_long_help: Some("And this after, at length."), + examples: &[usage_argv::spec::Example { + code: "ex go --fast", + header: None, + help: Some("the quick way"), + }], + ..CommandMeta::EMPTY + }; + static SPEC: usage_argv::spec::Spec = usage_argv::spec::Spec { + name: "ex", + bin: Some("ex"), + version: None, + about: None, + long_about: None, + default_subcommand: None, + root: &GO_META, + }; + + assert_eq!( + short_help(&SPEC, &["ex", "go"], &GO_META), + usage::docs::cli::render_help(&spec, go, false), + "short form" + ); + assert_eq!( + long_help(&SPEC, &["ex", "go"], &GO_META), + usage::docs::cli::render_help(&spec, go, true), + "long form" + ); +} From 7b0a1ea6d7069b1b8c98ff310495dbfb6aba43d2 Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Thu, 13 Aug 2026 20:20:51 +0000 Subject: [PATCH 3/6] fix(argv): indent a line of spaces, and stop the long test skipping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `write_indented` tested each line with `trim().is_empty()`, so a continuation line holding only spaces came out empty. The reference's filter skips a line with *nothing* on it and still indents one that holds whitespace, so those spaces were being dropped from block-layout help. The first attempt at a test for it did not test anything: the whitespace-only line sat in a command's own long help, which its own page never renders — a command's description appears in its parent's list. Moved to a flag's long help, where the block layout reads it, and the mutation fails now. And the long-help parity test `continue`d when a command in the shadow was absent from the spec, where the short-form test records it. A command the reference does not have is a difference between the two, and passing silently on it would let an extra or misnamed one through. Found by Cursor Bugbot on #866. Co-Authored-By: Claude Opus 5 --- argv/src/help.rs | 5 ++++- benches/gate/tests/help.rs | 22 +++++++++++++++++++++- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/argv/src/help.rs b/argv/src/help.rs index 4fe48ae1e..8574537b4 100644 --- a/argv/src/help.rs +++ b/argv/src/help.rs @@ -515,7 +515,10 @@ fn write_indented(out: &mut String, text: &str, indent: usize) { // left blank. That is not a choice: the reference writes the indent literally before the // text and indents the *rest* with a filter that skips blanks, so an opening empty line // comes out as whitespace and a later one does not. - if i == 0 || !line.trim().is_empty() { + // `is_empty`, not `trim().is_empty()`: the reference's filter skips a line with nothing + // on it and still indents one that holds only spaces, so emptying the latter would lose + // whitespace the author wrote. + if i == 0 || !line.is_empty() { let _ = writeln!(out, "{pad}{line}"); } else { out.push('\n'); diff --git a/benches/gate/tests/help.rs b/benches/gate/tests/help.rs index 67914b0b8..3997adee8 100644 --- a/benches/gate/tests/help.rs +++ b/benches/gate/tests/help.rs @@ -178,7 +178,11 @@ fn every_long_help_matches_the_reference() { let mut differences = Vec::new(); for (path, meta) in &commands { let ours = long_help(root, path, meta); + // Recorded rather than skipped, as the short-form test records it: a command in the + // shadow that the spec does not have is a difference between the two, and passing + // silently on it would let an extra or misnamed command through. let Some(cmd) = lib_command(&spec, &path[1..]) else { + differences.push(format!("{}: not in the spec", path.join(" "))); continue; }; let theirs = usage::docs::cli::render_help(&spec, cmd, true); @@ -218,7 +222,10 @@ fn surrounded() -> LibSpec { before_long_help \"Read this first, at length.\"\n \ after_help \"And this after.\"\n \ after_long_help \"And this after, at length.\"\n \ - example \"ex go --fast\" help=\"the quick way\"\n}\n" + example \"ex go --fast\" help=\"the quick way\"\n \ + long_help \"Go somewhere.\"\n \ + flag \"--deep\" help=\"Dig\" {\n \ + long_help \"Dig deeper.\\n\\n indented\\n \\nand a line of only spaces above\"\n }\n}\n" .parse() .expect("valid spec") } @@ -230,17 +237,30 @@ fn the_text_around_a_page_is_rendered_where_the_reference_puts_it() { // Built by hand rather than derived: the point is to compare the renderer against the // reference for a shape the shadow does not have. + static DEEP: usage_argv::Flag = usage_argv::Flag { + name: "deep", + longs: &["deep"], + ..usage_argv::Flag::BOOL + }; static GO: usage_argv::Command = usage_argv::Command { name: "go", + flags: &[&DEEP], ..usage_argv::Command::EMPTY }; static GO_META: CommandMeta = CommandMeta { cmd: &GO, about: Some("Go somewhere"), + long_about: Some("Go somewhere."), before_help: Some("Read this first."), before_long_help: Some("Read this first, at length."), after_help: Some("And this after."), after_long_help: Some("And this after, at length."), + flags: &[usage_argv::spec::FlagMeta { + flag: &DEEP, + help: Some("Dig"), + long_help: Some("Dig deeper.\n\n indented\n \nand a line of only spaces above"), + ..usage_argv::spec::FlagMeta::EMPTY + }], examples: &[usage_argv::spec::Example { code: "ex go --fast", header: None, From b3499f5c6db91e2009b9755cbe1df0fe56eaa40d Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Thu, 13 Aug 2026 23:22:17 +0000 Subject: [PATCH 4/6] fix(argv): fall back to the spec's surrounding text, and write the root's MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit usage-lib falls back to the spec's `before_help`/`after_help` when a command declares none, so a preamble written once at the top appears on every page — which is the point of writing it there. The renderer stopped at the command, and `Spec` had nowhere to hold it, so it never appeared at all. Four fields and the fallback, in both forms. And the root's own surrounding text was rendered but never written to the KDL: the root's nodes go through a different path from every other command's, and that path did not repeat them. A declaration that shows in help and vanishes from the spec is one docs, manpages and completions disagree with. `Spec` gained a `Spec::EMPTY` while it was gaining fields, so the next one does not break every literal that builds one — three had to be edited for these four. Found by Greptile and Cursor Bugbot on #866; both mutation-checked. Co-Authored-By: Claude Opus 5 --- argv/src/help.rs | 18 ++++-- argv/src/spec.rs | 46 +++++++++++++++ benches/gate/tests/help.rs | 86 +++++++++++++++++++++++++++-- conformance/tests/spec_roundtrip.rs | 1 + derive/src/codegen.rs | 6 ++ 5 files changed, 149 insertions(+), 8 deletions(-) diff --git a/argv/src/help.rs b/argv/src/help.rs index 8574537b4..9eb3d470f 100644 --- a/argv/src/help.rs +++ b/argv/src/help.rs @@ -219,7 +219,7 @@ pub fn short_help(spec: &Spec<'_>, path: &[&str], meta: &CommandMeta<'_>) -> Str // Text the command puts above everything else, and below it. The short form has only the // one pair; the long form prefers the long variants. - if let Some(before) = meta.before_help { + if let Some(before) = meta.before_help.or(spec.before_help) { let _ = writeln!(out, "{before}\n"); } @@ -269,7 +269,7 @@ pub fn short_help(spec: &Spec<'_>, path: &[&str], meta: &CommandMeta<'_>) -> Str }, ); examples_section(&mut out, meta); - if let Some(after) = meta.after_help { + if let Some(after) = meta.after_help.or(spec.after_help) { let _ = writeln!(out, "\n{after}"); } @@ -419,7 +419,12 @@ pub fn long_help(spec: &Spec<'_>, path: &[&str], meta: &CommandMeta<'_>) -> Stri let width = terminal_width(); let mut out = String::new(); - if let Some(before) = meta.before_long_help.or(meta.before_help) { + if let Some(before) = meta + .before_long_help + .or(meta.before_help) + .or(spec.before_long_help) + .or(spec.before_help) + { let _ = writeln!(out, "{before}\n"); } @@ -493,7 +498,12 @@ pub fn long_help(spec: &Spec<'_>, path: &[&str], meta: &CommandMeta<'_>) -> Stri // mise puts an Examples section here on 115 commands, which is why a page without it is // missing the part a reader came for. - if let Some(after) = meta.after_long_help.or(meta.after_help) { + if let Some(after) = meta + .after_long_help + .or(meta.after_help) + .or(spec.after_long_help) + .or(spec.after_help) + { let _ = writeln!(out, "\n{after}"); } diff --git a/argv/src/spec.rs b/argv/src/spec.rs index 3464c2ad6..abdd1c7d9 100644 --- a/argv/src/spec.rs +++ b/argv/src/spec.rs @@ -122,9 +122,36 @@ pub struct Spec<'a> { /// Which command the root falls back to when a word matches no subcommand. /// mise uses this so `mise foo` completes as `mise run foo`. pub default_subcommand: Option<&'a str>, + /// Text around every page, where a command does not give its own. + /// + /// usage-lib falls back to the spec's when the command is silent, so a preamble declared + /// once at the top appears on every page — which is what it is for. + pub before_help: Option<&'a str>, + pub before_long_help: Option<&'a str>, + pub after_help: Option<&'a str>, + pub after_long_help: Option<&'a str>, pub root: &'a CommandMeta<'a>, } +impl Spec<'_> { + /// A spec with nothing declared but a root, for use with struct update syntax. + /// + /// Here so that gaining a field does not break every literal that builds one. + pub const EMPTY: Spec<'static> = Spec { + name: "", + bin: None, + version: None, + about: None, + long_about: None, + default_subcommand: None, + before_help: None, + before_long_help: None, + after_help: None, + after_long_help: None, + root: &CommandMeta::EMPTY, + }; +} + /// Join groups of flag metadata into one, at compile time. /// /// The metadata counterpart of [`concat_flags`](crate::concat_flags), for the same reason: @@ -442,6 +469,25 @@ impl Spec<'_> { if let Some(default_subcommand) = self.default_subcommand { prop(out, "default_subcommand", default_subcommand)?; } + // The text around the page. The root's nodes are written here rather than by + // `write_body`, so these had to be repeated — and were not, which left a root's + // preamble out of the spec that docs, manpages and completions read. + for (node, text) in [ + ("before_help", self.before_help.or(self.root.before_help)), + ( + "before_long_help", + self.before_long_help.or(self.root.before_long_help), + ), + ("after_help", self.after_help.or(self.root.after_help)), + ( + "after_long_help", + self.after_long_help.or(self.root.after_long_help), + ), + ] { + if let Some(text) = text { + prop(out, node, text)?; + } + } // The root's own nodes sit at the top level rather than inside a `cmd` // block, so they are written here instead of by write_command. // diff --git a/benches/gate/tests/help.rs b/benches/gate/tests/help.rs index 3997adee8..4ca24fd83 100644 --- a/benches/gate/tests/help.rs +++ b/benches/gate/tests/help.rs @@ -271,11 +271,8 @@ fn the_text_around_a_page_is_rendered_where_the_reference_puts_it() { static SPEC: usage_argv::spec::Spec = usage_argv::spec::Spec { name: "ex", bin: Some("ex"), - version: None, - about: None, - long_about: None, - default_subcommand: None, root: &GO_META, + ..usage_argv::spec::Spec::EMPTY }; assert_eq!( @@ -289,3 +286,84 @@ fn the_text_around_a_page_is_rendered_where_the_reference_puts_it() { "long form" ); } + +#[test] +fn a_spec_can_surround_every_page_at_once() { + // usage-lib falls back to the spec's text when a command declares none, so a preamble + // written once at the top appears on every page — which is the point of writing it there. + // The renderer stopped at the command, so it never appeared at all. + let spec: LibSpec = "name \"ex\"\nbin \"ex\"\nbefore_help \"Above every page.\"\n\ + after_help \"Below every page.\"\ncmd go help=\"Go\"\n" + .parse() + .expect("valid spec"); + let go = spec.cmd.subcommands.get("go").expect("go"); + + static GO: usage_argv::Command = usage_argv::Command { + name: "go", + ..usage_argv::Command::EMPTY + }; + static GO_META: CommandMeta = CommandMeta { + cmd: &GO, + about: Some("Go"), + ..CommandMeta::EMPTY + }; + static SPEC: usage_argv::spec::Spec = usage_argv::spec::Spec { + name: "ex", + bin: Some("ex"), + before_help: Some("Above every page."), + after_help: Some("Below every page."), + root: &GO_META, + ..usage_argv::spec::Spec::EMPTY + }; + + for long in [false, true] { + let ours = if long { + long_help(&SPEC, &["ex", "go"], &GO_META) + } else { + short_help(&SPEC, &["ex", "go"], &GO_META) + }; + assert_eq!( + ours, + usage::docs::cli::render_help(&spec, go, long), + "{}", + if long { "long form" } else { "short form" } + ); + } +} + +#[test] +fn the_root_writes_its_own_surrounding_text() { + // The root's nodes are written by a different path from every other command's, and that + // path did not repeat these — so a root's preamble was rendered and then missing from the + // spec that docs, manpages and completions read. + static ROOT: usage_argv::Command = usage_argv::Command { + name: "ex", + ..usage_argv::Command::EMPTY + }; + static ROOT_META: CommandMeta = CommandMeta { + cmd: &ROOT, + before_help: Some("Above."), + after_long_help: Some("Below, at length."), + ..CommandMeta::EMPTY + }; + static SPEC: usage_argv::spec::Spec = usage_argv::spec::Spec { + name: "ex", + bin: Some("ex"), + root: &ROOT_META, + ..usage_argv::spec::Spec::EMPTY + }; + + let kdl = SPEC.to_kdl(); + assert!(kdl.contains(r#"before_help "Above.""#), "{kdl}"); + assert!( + kdl.contains(r#"after_long_help "Below, at length.""#), + "{kdl}" + ); + + // And it parses back as what it said, which is the only claim that matters. + // Read back on the *spec*, which is where usage-lib puts a top-level declaration — the + // same place its template looks for the fallback. + let parsed: LibSpec = kdl.parse().expect("valid spec"); + assert_eq!(parsed.before_help.as_deref(), Some("Above.")); + assert_eq!(parsed.after_help_long.as_deref(), Some("Below, at length.")); +} diff --git a/conformance/tests/spec_roundtrip.rs b/conformance/tests/spec_roundtrip.rs index b712ff3a4..2631afb20 100644 --- a/conformance/tests/spec_roundtrip.rs +++ b/conformance/tests/spec_roundtrip.rs @@ -326,6 +326,7 @@ static SPEC: Spec = Spec { long_about: Some("Does things, at length."), default_subcommand: Some("run"), root: &ROOT_META, + ..Spec::EMPTY }; fn parsed() -> LibSpec { diff --git a/derive/src/codegen.rs b/derive/src/codegen.rs index c8c4375e2..a78f20756 100644 --- a/derive/src/codegen.rs +++ b/derive/src/codegen.rs @@ -186,6 +186,12 @@ pub fn emit(cli: &Cli) -> TokenStream { about: #about, long_about: #long_about, default_subcommand: #default_subcommand, + // A CLI declares these on its root, which is where they are emitted — the + // spec-level pair exists for the fallback a hand-written spec can use. + before_help: ::std::option::Option::None, + before_long_help: ::std::option::Option::None, + after_help: ::std::option::Option::None, + after_long_help: ::std::option::Option::None, root: &ROOT_META, }; } From 21f94eab1eb0d7262d6b9593df49ff725e324da0 Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Fri, 14 Aug 2026 00:01:54 +0000 Subject: [PATCH 5/6] fix(derive): let the root's surrounding text default every page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A root has nowhere else to put it. `to_kdl` writes the root's `before_help` at the top level, and the reference reads text there as the default for every page — so a CLI that declared a preamble showed it on its root page and nowhere else, while the same CLI rendered from its own emitted KDL showed it everywhere. One CLI, two answers, and the KDL is what docs, manpages and completions read. Emitted at both levels now: on the root's metadata, where it was, and on the spec, which is where the round trip puts it. The test asserts both sides of that — our subcommand page, and usage-lib's page for the same command parsed back from `to_kdl` — so the two cannot drift apart again. Found by Cursor Bugbot. Co-Authored-By: Claude Opus 5 --- conformance/tests/metadata.rs | 59 +++++++++++++++++++++++++++++++++++ derive/src/codegen.rs | 15 +++++---- 2 files changed, 68 insertions(+), 6 deletions(-) diff --git a/conformance/tests/metadata.rs b/conformance/tests/metadata.rs index 575855168..b38010645 100644 --- a/conformance/tests/metadata.rs +++ b/conformance/tests/metadata.rs @@ -348,3 +348,62 @@ fn the_split_description_cli_still_parses() { }; assert!(activate.shims); } + +/// A CLI whose root says something above and below every page. +#[derive(Args)] +struct Inner { + /// A value + #[usage(arg, name = "VALUE")] + value: Option, +} + +#[derive(Subcommands)] +enum SurroundedCommands { + /// Do the thing + Go(Box), +} + +#[derive(Cli)] +#[usage( + bin = "surrounded", + before_help = "Read this first.", + after_help = "And this after." +)] +struct Surrounded { + #[usage(subcommand)] + command: Option, +} + +#[test] +fn the_roots_surrounding_text_reaches_every_page() { + // A root has nowhere else to put this: `to_kdl` writes it at the top level, and the + // reference reads text there as the default for *every* page, not just the root's. Emitted + // only on the root's metadata, the preamble a CLI declared vanished from every subcommand + // page — and reappeared when the same CLI was rendered from its own emitted KDL, which is + // two answers to one question. + let spec = Surrounded::spec(); + assert_eq!(spec.before_help, Some("Read this first.")); + assert_eq!(spec.after_help, Some("And this after.")); + + let go = spec.root.subcommands[0]; + let page = usage_argv::help::short_help(spec, &["surrounded", "go"], go); + assert!(page.starts_with("Read this first.\n"), "{page}"); + assert!(page.trim_end().ends_with("And this after."), "{page}"); + + // The same CLI still parses, so what the pages describe is what it does. + let argv = [ + std::ffi::OsStr::new("go"), + std::ffi::OsStr::new("something"), + ]; + let parsed = Surrounded::parse_from(&argv).expect("a subcommand and its value"); + let Some(SurroundedCommands::Go(inner)) = parsed.command else { + panic!("expected go") + }; + assert_eq!(inner.value.as_deref(), Some("something")); + + // And the reference agrees, reading the KDL this CLI writes. + let kdl = spec.to_kdl(); + let lib: LibSpec = kdl.parse().expect("valid spec"); + let lib_go = lib.cmd.subcommands.get("go").expect("go"); + assert_eq!(page, usage::docs::cli::render_help(&lib, lib_go, false)); +} diff --git a/derive/src/codegen.rs b/derive/src/codegen.rs index a78f20756..f12104112 100644 --- a/derive/src/codegen.rs +++ b/derive/src/codegen.rs @@ -186,12 +186,15 @@ pub fn emit(cli: &Cli) -> TokenStream { about: #about, long_about: #long_about, default_subcommand: #default_subcommand, - // A CLI declares these on its root, which is where they are emitted — the - // spec-level pair exists for the fallback a hand-written spec can use. - before_help: ::std::option::Option::None, - before_long_help: ::std::option::Option::None, - after_help: ::std::option::Option::None, - after_long_help: ::std::option::Option::None, + // The root's surrounding text is also the spec's, because a spec has nowhere + // else to put the root's: `to_kdl` writes it at the top level, and the + // reference reads text there as the default for *every* page. Emitting it in + // both places is what makes the two descriptions of one CLI agree — declared + // here, or parsed back from the KDL this derive writes. + before_help: #before_help, + before_long_help: #before_long_help, + after_help: #after_help, + after_long_help: #after_long_help, root: &ROOT_META, }; } From c14af91b4da875ce0887f94672f29bb97527a75b Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Fri, 14 Aug 2026 00:21:23 +0000 Subject: [PATCH 6/6] fix(argv): give the root one home for what a spec says at its top level MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A KDL spec has one place for surrounding text and examples — the top level — and the reference reads what is written there as the root's *and* as the default for every other page. This crate had two places: four fields on `Spec` and the same four on the root's metadata. Two homes for one declaration is two answers to one question, and the two paths picked differently: the renderer preferred the root's, `to_kdl` preferred the spec's, so a root override was lost on the way out. The root is now the only home. The renderer falls back to `spec.root`, `to_kdl` writes `spec.root`, and the derive emits the root's text once, where it belongs. The same rule reaches examples, which had no fallback at all: a CLI's examples appeared on its root page and nowhere else, while the same CLI read back from `to_kdl` showed them on every page whose command declares none. `page_examples` is that rule, and the test compares both cases against usage-lib — a page that borrows the root's, and one that keeps its own and does not also show the root's. Found by greptile and Cursor Bugbot, one finding each side of the same seam. Co-Authored-By: Claude Opus 5 --- argv/src/help.rs | 41 +++++++++---- argv/src/spec.rs | 31 +++------- benches/gate/tests/help.rs | 94 ++++++++++++++++++++++++++++- conformance/tests/metadata.rs | 4 +- conformance/tests/spec_roundtrip.rs | 1 - derive/src/codegen.rs | 9 --- 6 files changed, 131 insertions(+), 49 deletions(-) diff --git a/argv/src/help.rs b/argv/src/help.rs index 9eb3d470f..ab014bc4b 100644 --- a/argv/src/help.rs +++ b/argv/src/help.rs @@ -19,7 +19,7 @@ use core::fmt::Write as _; -use crate::spec::{ArgMeta, CommandMeta, FlagMeta, Spec}; +use crate::spec::{ArgMeta, CommandMeta, Example, FlagMeta, Spec}; use crate::DoubleDash; /// How many flags or arguments are listed individually before collapsing to a placeholder. @@ -219,7 +219,7 @@ pub fn short_help(spec: &Spec<'_>, path: &[&str], meta: &CommandMeta<'_>) -> Str // Text the command puts above everything else, and below it. The short form has only the // one pair; the long form prefers the long variants. - if let Some(before) = meta.before_help.or(spec.before_help) { + if let Some(before) = meta.before_help.or(spec.root.before_help) { let _ = writeln!(out, "{before}\n"); } @@ -268,8 +268,8 @@ pub fn short_help(spec: &Spec<'_>, path: &[&str], meta: &CommandMeta<'_>) -> Str annotations(out, f.choices, f.env, &[]); }, ); - examples_section(&mut out, meta); - if let Some(after) = meta.after_help.or(spec.after_help) { + examples_section(&mut out, spec, meta); + if let Some(after) = meta.after_help.or(spec.root.after_help) { let _ = writeln!(out, "\n{after}"); } @@ -382,12 +382,13 @@ fn display_usage(meta: &FlagMeta<'_>) -> String { } } -fn examples_section(out: &mut String, meta: &CommandMeta<'_>) { - if meta.examples.is_empty() { +fn examples_section(out: &mut String, spec: &Spec<'_>, meta: &CommandMeta<'_>) { + let examples = page_examples(spec, meta); + if examples.is_empty() { return; } let _ = writeln!(out, "\nExamples:"); - for example in meta.examples { + for example in examples { if let Some(header) = example.header { let _ = writeln!(out, " {header}:"); } @@ -395,6 +396,19 @@ fn examples_section(out: &mut String, meta: &CommandMeta<'_>) { } } +/// The examples a page shows: the command's own, or the spec's where it has none. +/// +/// Top-level `example` nodes are the root's, and the reference shows them on every page whose +/// command declares none of its own — the same rule the text around a page follows, and for +/// the same reason: the top level is where a spec says something about the whole CLI. +fn page_examples<'a>(spec: &Spec<'a>, meta: &CommandMeta<'a>) -> &'a [Example<'a>] { + if meta.examples.is_empty() { + spec.root.examples + } else { + meta.examples + } +} + /// The width help is wrapped to, from `COLUMNS`. /// /// usage-lib reads the same variable and falls back to the same 80, so the two agree about @@ -422,8 +436,8 @@ pub fn long_help(spec: &Spec<'_>, path: &[&str], meta: &CommandMeta<'_>) -> Stri if let Some(before) = meta .before_long_help .or(meta.before_help) - .or(spec.before_long_help) - .or(spec.before_help) + .or(spec.root.before_long_help) + .or(spec.root.before_help) { let _ = writeln!(out, "{before}\n"); } @@ -481,9 +495,10 @@ pub fn long_help(spec: &Spec<'_>, path: &[&str], meta: &CommandMeta<'_>) -> Stri }, ); - if !meta.examples.is_empty() { + let examples = page_examples(spec, meta); + if !examples.is_empty() { let _ = writeln!(out, "\nExamples:"); - for example in meta.examples { + for example in examples { if let Some(header) = example.header { let _ = writeln!(out, " {header}:"); } @@ -501,8 +516,8 @@ pub fn long_help(spec: &Spec<'_>, path: &[&str], meta: &CommandMeta<'_>) -> Stri if let Some(after) = meta .after_long_help .or(meta.after_help) - .or(spec.after_long_help) - .or(spec.after_help) + .or(spec.root.after_long_help) + .or(spec.root.after_help) { let _ = writeln!(out, "\n{after}"); } diff --git a/argv/src/spec.rs b/argv/src/spec.rs index abdd1c7d9..935109655 100644 --- a/argv/src/spec.rs +++ b/argv/src/spec.rs @@ -122,14 +122,13 @@ pub struct Spec<'a> { /// Which command the root falls back to when a word matches no subcommand. /// mise uses this so `mise foo` completes as `mise run foo`. pub default_subcommand: Option<&'a str>, - /// Text around every page, where a command does not give its own. + /// The root command, and the home of everything a spec declares at its top level. /// - /// usage-lib falls back to the spec's when the command is silent, so a preamble declared - /// once at the top appears on every page — which is what it is for. - pub before_help: Option<&'a str>, - pub before_long_help: Option<&'a str>, - pub after_help: Option<&'a str>, - pub after_long_help: Option<&'a str>, + /// A KDL spec has one place for surrounding text and examples — the top level — and the + /// reference reads what is written there as the root's *and* as the default for every + /// other page. So they live here, on the root's metadata, rather than in a second set of + /// fields on the spec: two homes for one declaration is two answers to one question, and + /// `to_kdl` and the renderer picked differently. pub root: &'a CommandMeta<'a>, } @@ -144,10 +143,6 @@ impl Spec<'_> { about: None, long_about: None, default_subcommand: None, - before_help: None, - before_long_help: None, - after_help: None, - after_long_help: None, root: &CommandMeta::EMPTY, }; } @@ -473,16 +468,10 @@ impl Spec<'_> { // `write_body`, so these had to be repeated — and were not, which left a root's // preamble out of the spec that docs, manpages and completions read. for (node, text) in [ - ("before_help", self.before_help.or(self.root.before_help)), - ( - "before_long_help", - self.before_long_help.or(self.root.before_long_help), - ), - ("after_help", self.after_help.or(self.root.after_help)), - ( - "after_long_help", - self.after_long_help.or(self.root.after_long_help), - ), + ("before_help", self.root.before_help), + ("before_long_help", self.root.before_long_help), + ("after_help", self.root.after_help), + ("after_long_help", self.root.after_long_help), ] { if let Some(text) = text { prop(out, node, text)?; diff --git a/benches/gate/tests/help.rs b/benches/gate/tests/help.rs index 4ca24fd83..f58b03b3e 100644 --- a/benches/gate/tests/help.rs +++ b/benches/gate/tests/help.rs @@ -298,6 +298,8 @@ fn a_spec_can_surround_every_page_at_once() { .expect("valid spec"); let go = spec.cmd.subcommands.get("go").expect("go"); + // The text sits on the *root*, which is what a top-level declaration is: the page under + // test is a subcommand, so the fallback is what puts it there. static GO: usage_argv::Command = usage_argv::Command { name: "go", ..usage_argv::Command::EMPTY @@ -307,12 +309,22 @@ fn a_spec_can_surround_every_page_at_once() { about: Some("Go"), ..CommandMeta::EMPTY }; - static SPEC: usage_argv::spec::Spec = usage_argv::spec::Spec { + static ROOT: usage_argv::Command = usage_argv::Command { name: "ex", - bin: Some("ex"), + subcommands: &[&GO], + ..usage_argv::Command::EMPTY + }; + static ROOT_META: CommandMeta = CommandMeta { + cmd: &ROOT, before_help: Some("Above every page."), after_help: Some("Below every page."), - root: &GO_META, + subcommands: &[&GO_META], + ..CommandMeta::EMPTY + }; + static SPEC: usage_argv::spec::Spec = usage_argv::spec::Spec { + name: "ex", + bin: Some("ex"), + root: &ROOT_META, ..usage_argv::spec::Spec::EMPTY }; @@ -367,3 +379,79 @@ fn the_root_writes_its_own_surrounding_text() { assert_eq!(parsed.before_help.as_deref(), Some("Above.")); assert_eq!(parsed.after_help_long.as_deref(), Some("Below, at length.")); } + +#[test] +fn a_specs_examples_reach_a_page_that_has_none() { + // Top-level `example` nodes are the root's, and the reference shows them on every page + // whose command declares none — the same rule the text around a page follows. Rendering + // only the command's own meant a CLI's examples appeared on its root page and nowhere + // else, while the same CLI read back from `to_kdl` showed them everywhere. + let spec: LibSpec = + "name \"ex\"\nbin \"ex\"\nexample \"ex go --fast\" help=\"the quick way\"\n\ + cmd go help=\"Go\"\ncmd own help=\"Own\" {\n example \"ex own --mine\"\n}\n" + .parse() + .expect("valid spec"); + + static GO: usage_argv::Command = usage_argv::Command { + name: "go", + ..usage_argv::Command::EMPTY + }; + static OWN: usage_argv::Command = usage_argv::Command { + name: "own", + ..usage_argv::Command::EMPTY + }; + static GO_META: CommandMeta = CommandMeta { + cmd: &GO, + about: Some("Go"), + ..CommandMeta::EMPTY + }; + static OWN_META: CommandMeta = CommandMeta { + cmd: &OWN, + about: Some("Own"), + examples: &[usage_argv::spec::Example { + code: "ex own --mine", + header: None, + help: None, + }], + ..CommandMeta::EMPTY + }; + static ROOT: usage_argv::Command = usage_argv::Command { + name: "ex", + subcommands: &[&GO, &OWN], + ..usage_argv::Command::EMPTY + }; + static ROOT_META: CommandMeta = CommandMeta { + cmd: &ROOT, + examples: &[usage_argv::spec::Example { + code: "ex go --fast", + header: None, + help: Some("the quick way"), + }], + subcommands: &[&GO_META, &OWN_META], + ..CommandMeta::EMPTY + }; + static SPEC: usage_argv::spec::Spec = usage_argv::spec::Spec { + name: "ex", + bin: Some("ex"), + root: &ROOT_META, + ..usage_argv::spec::Spec::EMPTY + }; + + // `go` borrows the spec's; `own` keeps its own, and does not also show the spec's. + for (name, meta) in [("go", &GO_META), ("own", &OWN_META)] { + let cmd = spec.cmd.subcommands.get(name).expect("in the spec"); + for long in [false, true] { + let ours = if long { + long_help(&SPEC, &["ex", name], meta) + } else { + short_help(&SPEC, &["ex", name], meta) + }; + assert_eq!( + ours, + usage::docs::cli::render_help(&spec, cmd, long), + "{name}, {} form", + if long { "long" } else { "short" } + ); + } + } +} diff --git a/conformance/tests/metadata.rs b/conformance/tests/metadata.rs index b38010645..aea74c6a3 100644 --- a/conformance/tests/metadata.rs +++ b/conformance/tests/metadata.rs @@ -382,8 +382,8 @@ fn the_roots_surrounding_text_reaches_every_page() { // page — and reappeared when the same CLI was rendered from its own emitted KDL, which is // two answers to one question. let spec = Surrounded::spec(); - assert_eq!(spec.before_help, Some("Read this first.")); - assert_eq!(spec.after_help, Some("And this after.")); + assert_eq!(spec.root.before_help, Some("Read this first.")); + assert_eq!(spec.root.after_help, Some("And this after.")); let go = spec.root.subcommands[0]; let page = usage_argv::help::short_help(spec, &["surrounded", "go"], go); diff --git a/conformance/tests/spec_roundtrip.rs b/conformance/tests/spec_roundtrip.rs index 2631afb20..b712ff3a4 100644 --- a/conformance/tests/spec_roundtrip.rs +++ b/conformance/tests/spec_roundtrip.rs @@ -326,7 +326,6 @@ static SPEC: Spec = Spec { long_about: Some("Does things, at length."), default_subcommand: Some("run"), root: &ROOT_META, - ..Spec::EMPTY }; fn parsed() -> LibSpec { diff --git a/derive/src/codegen.rs b/derive/src/codegen.rs index f12104112..c8c4375e2 100644 --- a/derive/src/codegen.rs +++ b/derive/src/codegen.rs @@ -186,15 +186,6 @@ pub fn emit(cli: &Cli) -> TokenStream { about: #about, long_about: #long_about, default_subcommand: #default_subcommand, - // The root's surrounding text is also the spec's, because a spec has nowhere - // else to put the root's: `to_kdl` writes it at the top level, and the - // reference reads text there as the default for *every* page. Emitting it in - // both places is what makes the two descriptions of one CLI agree — declared - // here, or parsed back from the KDL this derive writes. - before_help: #before_help, - before_long_help: #before_long_help, - after_help: #after_help, - after_long_help: #after_long_help, root: &ROOT_META, }; }