diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e81cb40f4..f7f164ca0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -89,7 +89,7 @@ jobs: matrix: include: - version: "1.91" - crates: usage-argv usage-derive usage-config + crates: usage-argv usage-derive usage-config usage-rs - version: "1.95" crates: usage-lib usage-config-build clap_usage usage-cli steps: diff --git a/Cargo.lock b/Cargo.lock index c05992993..16e88979c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "addr2line" @@ -290,15 +290,6 @@ dependencies = [ "clap_derive", ] -[[package]] -name = "clap-sort" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c9f374a541bd277ba6f4ccd08d955024ba09fda8dfc69ca1a750799ebed97a9" -dependencies = [ - "clap", -] - [[package]] name = "clap_builder" version = "4.6.6" @@ -1321,6 +1312,15 @@ dependencies = [ "yansi", ] +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + [[package]] name = "proc-macro2" version = "1.0.107" @@ -1965,7 +1965,7 @@ dependencies = [ "indexmap 2.14.0", "serde_core", "serde_spanned", - "toml_datetime", + "toml_datetime 0.7.5+spec-1.1.0", "toml_parser", "toml_writer", "winnow 0.7.15", @@ -1980,6 +1980,27 @@ dependencies = [ "serde_core", ] +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.13+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.4", +] + [[package]] name = "toml_parser" version = "1.1.3+spec-1.1.0" @@ -2059,9 +2080,6 @@ name = "usage-cli" version = "5.1.0" dependencies = [ "assert_cmd", - "clap", - "clap-sort", - "clap_usage", "ctor", "env_logger", "exec", @@ -2082,6 +2100,7 @@ dependencies = [ "thiserror", "tokio", "usage-lib", + "usage-rs", "xx", ] @@ -2119,6 +2138,7 @@ dependencies = [ name = "usage-derive" version = "5.1.0" dependencies = [ + "proc-macro-crate", "proc-macro2", "quote", "syn 3.0.3", @@ -2152,6 +2172,14 @@ dependencies = [ "xx", ] +[[package]] +name = "usage-rs" +version = "5.1.0" +dependencies = [ + "usage-argv", + "usage-derive", +] + [[package]] name = "utf8parse" version = "0.2.2" @@ -2535,6 +2563,9 @@ name = "winnow" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] [[package]] name = "xtask" diff --git a/Cargo.toml b/Cargo.toml index e82693b74..1dc771e7d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ members = [ "config", "config-build", "derive", + "usage-rs", "clap_usage", "cli", "conformance", @@ -33,6 +34,7 @@ usage-argv = { path = "./argv", version = "5.1.0" } usage-config = { path = "./config", version = "5.1.0" } usage-derive = { path = "./derive", version = "5.1.0" } usage-lib = { path = "./lib", version = "5.1.0", features = ["clap"] } +usage-rs = { path = "./usage-rs", version = "5.1.0" } [workspace.metadata.release] allow-branch = ["main"] diff --git a/argv/src/complete.rs b/argv/src/complete.rs index 3a17ee7a3..b7f6f4725 100644 --- a/argv/src/complete.rs +++ b/argv/src/complete.rs @@ -428,19 +428,26 @@ pub fn complete<'a>(spec: &'a Spec<'a>, split: &Split) -> Completions<'a> { // The name a value here would have, which is what says whether paths belong, and whether // that value declares its own set. - let (named, declares_choices) = if let Some(flag) = position.awaiting_value { + let (named, declares_choices, complete_type) = if let Some(flag) = position.awaiting_value { let meta = flag_meta(spec.root, flag); ( meta.and_then(|m| m.value_name).or(Some(flag.name)), meta.is_some_and(|m| !m.choices.is_empty()), + meta.and_then(|m| m.complete_type), ) } else if let Some(arg) = at_cursor { let meta = arg_meta(spec.root, arg); - (Some(arg.name), meta.is_some_and(|m| !m.choices.is_empty())) + ( + Some(arg.name), + meta.is_some_and(|m| !m.choices.is_empty()), + meta.and_then(|m| m.complete_type), + ) } else { - (None, false) + (None, false, None) }; - let asked_for = named.and_then(files_for); + let asked_for = complete_type + .and_then(files_for) + .or_else(|| named.and_then(files_for)); // An argument that requires a separator is not fillable yet, so nothing else belongs here — // not even a path, which the parser would reject exactly as it rejects a value. diff --git a/argv/src/diagnostic.rs b/argv/src/diagnostic.rs index 8fb024c1a..a6709fd2a 100644 --- a/argv/src/diagnostic.rs +++ b/argv/src/diagnostic.rs @@ -508,6 +508,13 @@ pub fn render( ); } Error::MissingSubcommand => { + // A bare command that can do nothing on its own is a request for orientation. + // clap prints the command's help page here, including the available subcommands, + // while keeping exit 2; an error plus only `` tells the reader what is + // missing and withholds the list they need to fix it. + if let Some(help) = crate::help::render_at(spec, &taken, false) { + return help; + } with_usage = true; let _ = writeln!( out, @@ -811,6 +818,15 @@ mod tests { assert_eq!(line, crate::help::usage_line(&["ex", "use"], &USE_META)); } + #[test] + fn a_missing_subcommand_prints_the_choices() { + let message = rendered(&[], Error::MissingSubcommand); + assert!(message.contains("Commands:"), "{message}"); + assert!(message.contains("use"), "{message}"); + assert!(message.contains("user"), "{message}"); + assert!(!message.contains("requires a subcommand"), "{message}"); + } + #[test] fn a_missing_value_names_what_it_wanted() { // No usage block: the shape of the command line was right, one value was missing — which diff --git a/argv/src/lib.rs b/argv/src/lib.rs index 5bf1850d3..fd077e237 100644 --- a/argv/src/lib.rs +++ b/argv/src/lib.rs @@ -83,6 +83,21 @@ use std::ffi::{OsStr, OsString}; +/// A value's filesystem completion class for `#[usage(value_hint = ...)]`. +/// +/// This lives in the runtime crate so a declaration never needs clap merely to describe what +/// kind of path a shell should offer. It is metadata only and adds no work to a successful +/// parse. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ValueHint { + /// A path to a file. + FilePath, + /// A path to either a file or a directory. + AnyPath, + /// A path to a directory. + DirPath, +} + #[cfg(feature = "complete")] pub mod complete; #[cfg(feature = "diagnostics")] diff --git a/argv/src/spec.rs b/argv/src/spec.rs index 565eef04d..57734d06a 100644 --- a/argv/src/spec.rs +++ b/argv/src/spec.rs @@ -269,6 +269,9 @@ pub struct Spec<'a> { pub min_usage_version: Option<&'a str>, pub about: Option<&'a str>, pub long_about: Option<&'a str>, + /// An exact usage synopsis, including the `Usage:` prefix, when the generated + /// shape needs alternatives that cannot be inferred from one command grammar. + pub usage: Option<&'a str>, /// 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>, @@ -293,6 +296,7 @@ impl Spec<'_> { min_usage_version: None, about: None, long_about: None, + usage: None, default_subcommand: None, root: &CommandMeta::EMPTY, }; @@ -485,6 +489,8 @@ pub struct FlagMeta<'a> { /// that asks *this binary*, so a spec stays complete for every other consumer while the /// binary answers itself. pub complete: Option, + /// A built-in completion class such as `path` or `dir`. + pub complete_type: Option<&'a str>, /// Whether the flag may be given more than once. Distinct from /// [`Flag::variadic`], which is one occurrence taking several values. pub repeatable: bool, @@ -518,6 +524,7 @@ impl FlagMeta<'_> { /// Metadata for a flag with nothing declared, for struct update syntax. pub const EMPTY: FlagMeta<'static> = FlagMeta { complete: None, + complete_type: None, flag: &Flag::BOOL, help: None, long_help: None, @@ -561,12 +568,15 @@ pub struct ArgMeta<'a> { pub help_heading: Option<&'a str>, /// What answers for this argument when a shell asks. See [`FlagMeta::complete`]. pub complete: Option, + /// A built-in completion class such as `path` or `dir`. + pub complete_type: Option<&'a str>, } impl ArgMeta<'_> { /// Metadata for an argument with nothing declared, for struct update syntax. pub const EMPTY: ArgMeta<'static> = ArgMeta { complete: None, + complete_type: None, arg: &Arg::REQUIRED, help: None, long_help: None, @@ -666,6 +676,9 @@ impl Spec<'_> { if let Some(long_about) = self.long_about.or(self.root.long_about) { prop(out, "long_about", long_about)?; } + if let Some(usage) = self.usage { + prop(out, "usage", usage)?; + } // Written only when it is not the default, so an ordinary spec stays quiet // about it. if self.root.cmd.unknown_flags == Some(UnknownFlags::Error) { @@ -785,6 +798,7 @@ fn write_body( ); write_arg(out, arg, depth)?; } + write_completion_types(out, meta, depth)?; #[cfg(feature = "complete")] write_completers(out, meta, bin, depth)?; for sub in meta.subcommands { @@ -793,6 +807,36 @@ fn write_body( Ok(()) } +/// Built-in completion types declared by this command, written in the spec's vocabulary. +fn write_completion_types( + out: &mut String, + meta: &CommandMeta<'_>, + depth: usize, +) -> core::fmt::Result { + for arg in meta.args { + if let Some(type_) = arg.complete_type { + indent(out, depth)?; + writeln!( + out, + "complete {} type={}", + quoted(&arg.arg.name.to_ascii_lowercase()), + quoted(type_) + )?; + } + } + for flag in meta.flags { + if let Some(type_) = flag.complete_type { + let name = flag + .value_name + .unwrap_or(flag.flag.name) + .to_ascii_lowercase(); + indent(out, depth)?; + writeln!(out, "complete {} type={}", quoted(&name), quoted(type_))?; + } + } + Ok(()) +} + fn write_command( out: &mut String, meta: &CommandMeta<'_>, diff --git a/cli/Cargo.toml b/cli/Cargo.toml index bb414550b..2ddc98c3f 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -26,8 +26,6 @@ name = "usage_cli" path = "src/lib.rs" [dependencies] -clap = { version = "4", features = ["derive", "string", "env"] } -clap_usage = { workspace = true } env_logger = "0.11" indexmap = "2" itertools = "0.15" @@ -43,6 +41,10 @@ serde_with = "3" tera = "2" thiserror = "2" tokio = { version = "1", features = ["rt", "macros", "io-std"] } +# The CLI is the facade's first adopter: `usage` parses its own command line with +# the parser it ships. `diagnostics` includes spec emission for `--usage-spec` and +# the errors a person needs when a command line does not parse. +usage-rs = { workspace = true, features = ["diagnostics"] } usage-lib = { workspace = true, features = ["clap", "docs", "unstable_choices_env"] } xx = "2" @@ -51,7 +53,6 @@ exec = "0.3" [dev-dependencies] assert_cmd = { version = "2", features = ["color-auto"] } -clap-sort = "1" ctor = "1" insta = "1" predicates = "3" diff --git a/cli/assets/fig.ts b/cli/assets/fig.ts index c2b857f02..a4f3eaa38 100644 --- a/cli/assets/fig.ts +++ b/cli/assets/fig.ts @@ -460,6 +460,7 @@ const completionSpec: Fig.Spec = { }, { name: ["manpage", "man"], + description: "Generate a manpage from a usage spec", options: [ { name: ["-f", "--file"], @@ -715,18 +716,21 @@ const completionSpec: Fig.Spec = { }, ], options: [ + { + name: "--completions", + description: + "Outputs completions for the specified shell for completing the `usage` CLI itself", + isRepeatable: false, + args: { + name: "completions", + }, + }, { name: "--usage-spec", description: "Outputs a `usage.kdl` spec for this CLI itself", isRepeatable: false, }, ], - args: { - name: "completions", - description: - "Outputs completions for the specified shell for completing the `usage` CLI itself", - isOptional: true, - }, }; export default completionSpec; diff --git a/cli/assets/usage.1 b/cli/assets/usage.1 index 5948c8c75..e9520ae18 100644 --- a/cli/assets/usage.1 +++ b/cli/assets/usage.1 @@ -1,19 +1,20 @@ -.TH USAGE-CLI 1 +.TH USAGE 1 .SH NAME -usage\-cli \- CLI for working with usage\-based CLIs +usage \- CLI for working with usage\-based CLIs .SH SYNOPSIS -\fBusage\fR [OPTIONS] [] [COMMAND] +\fBusage\fR +\fBusage\fR \-\-completions +\fBusage\fR \-\-usage\-spec .SH DESCRIPTION CLI for working with usage\-based CLIs .PP .SH OPTIONS .TP +\fB\-\-completions\fR \fI\fR +Outputs completions for the specified shell for completing the `usage` CLI itself +.TP \fB\-\-usage\-spec\fR Outputs a `usage.kdl` spec for this CLI itself -.SH ARGUMENTS -.TP -\fB\fR -Outputs completions for the specified shell for completing the `usage` CLI itself .SH COMMANDS .TP \fBbash\fR @@ -65,6 +66,7 @@ Outputs a usage spec in json format Generate a JSON Schema for a CLI's config file from its usage spec .TP \fBgenerate manpage\fR +Generate a manpage from a usage spec .RS \fIAliases: \fRman .RE @@ -118,10 +120,14 @@ Show help .TP \fB\fR Arguments to pass to script + +Anything `usage` does not recognise is a value rather than a mistake, which is what +lets a shebang script take flags of its own. .SH "USAGE COMPLETE-WORD" Generate shell completion candidates for a partial command line -This is used internally by shell completion scripts to provide intelligent completions for commands, flags, and arguments. +This is used internally by shell completion scripts to provide +intelligent completions for commands, flags, and arguments. .PP \fBUsage:\fR usage complete\-word [OPTIONS] [] ... .PP @@ -193,6 +199,9 @@ Show help .TP \fB\fR Arguments to pass to script + +Anything `usage` does not recognise is a value rather than a mistake, which is what +lets a shebang script take flags of its own. .SH "USAGE GENERATE COMPLETION" Generate shell completion scripts for bash, fish, nu, powershell, or zsh .PP @@ -219,9 +228,14 @@ You may need to set this if you have a different bin named "usage" .RS \fIDefault: \fRusage .RE +.RS +\fIEnvironment: \fR\fBJDX_USAGE_BIN\fR +.RE .TP \fB\-\-usage\-cmd\fR \fI\fR -A command which generates a usage spec e.g.: `mycli \-\-usage` or `mycli completion usage` Defaults to "$bin \-\-usage" +A command which generates a usage spec +e.g.: `mycli \-\-usage` or `mycli completion usage` +Defaults to "$bin \-\-usage" \fBArguments:\fR .PP .TP @@ -233,7 +247,9 @@ The CLI which we're generating completions for .SH "USAGE GENERATE COMPLETION-INIT" Generate a shell init script that auto\-completes any usage shebang script on $PATH -Source the output once from your shell rc (e.g. ~/.bashrc) to enable tab\-completion for any executable whose first line is a `usage` shebang — no per\-script `usage g completion` step required. +Source the output once from your shell rc (e.g. ~/.bashrc) to enable +tab\-completion for any executable whose first line is a `usage` shebang — +no per\-script `usage g completion` step required. .PP \fBUsage:\fR usage generate completion\-init [OPTIONS] .PP @@ -247,6 +263,9 @@ You may need to set this if you have a different bin named "usage" .RS \fIDefault: \fRusage .RE +.RS +\fIEnvironment: \fR\fBJDX_USAGE_BIN\fR +.RE \fBArguments:\fR .PP .TP @@ -271,9 +290,11 @@ Raw string spec input .SH "USAGE GENERATE GO" Generate Go parse tables from a usage spec -The tables are read by github.com/jdx/usage/go/argv. Go has no macros, so what a Rust CLI gets from a derive at compile time, a Go CLI gets from this at build time — typically from a `go:generate` line: +The tables are read by github.com/jdx/usage/go/argv. Go has no macros, so what +a Rust CLI gets from a derive at compile time, a Go CLI gets from this at build +time — typically from a `go:generate` line: -//go:generate usage generate go \-f mycli.usage.kdl \-o tables.go + //go:generate usage generate go \-f mycli.usage.kdl \-o tables.go .PP \fBUsage:\fR usage generate go [OPTIONS] .PP @@ -327,6 +348,8 @@ The schema's title, shown by editors \fB\-\-url\fR \fI\fR Where the schema is published, for its `$id` .SH "USAGE GENERATE MANPAGE" +Generate a manpage from a usage spec +.PP \fBUsage:\fR usage generate manpage [OPTIONS] .PP \fBOptions:\fR @@ -341,7 +364,11 @@ Output file path, or "\-" for stdout (default) \fB\-s, \-\-section\fR \fI
\fR Manual section number (default: 1) -Common sections: \- 1: User commands \- 5: File formats \- 7: Miscellaneous \- 8: System administration commands +Common sections: +\- 1: User commands +\- 5: File formats +\- 7: Miscellaneous +\- 8: System administration commands .RS \fIDefault: \fR1 .RE @@ -415,7 +442,9 @@ Treat warnings as errors \fB\-\-sorted\fR Also check that subcommands and flags are declared in sorted order -Off by default: declaration order is a house convention rather than a correctness question, so a spec that keeps a different order is not wrong. Pair it with \-\-warnings\-as\-errors to hold the order in CI. +Off by default: declaration order is a house convention rather than a +correctness question, so a spec that keeps a different order is not wrong. +Pair it with \-\-warnings\-as\-errors to hold the order in CI. \fBArguments:\fR .PP .TP @@ -460,6 +489,9 @@ Show help .TP \fB\fR Arguments to pass to script + +Anything `usage` does not recognise is a value rather than a mistake, which is what +lets a shebang script take flags of its own. .SH "USAGE ZSH" Execute a shell script with the specified shell @@ -483,3 +515,6 @@ Show help .TP \fB\fR Arguments to pass to script + +Anything `usage` does not recognise is a value rather than a mistake, which is what +lets a shebang script take flags of its own. diff --git a/cli/src/cli/complete_word.rs b/cli/src/cli/complete_word.rs index 7f1850d6f..17db39aea 100644 --- a/cli/src/cli/complete_word.rs +++ b/cli/src/cli/complete_word.rs @@ -4,10 +4,10 @@ use std::fmt::Debug; use std::path::{Path, PathBuf}; use std::sync::Arc; -use clap::Args; use itertools::Itertools; use miette::IntoDiagnostic; use std::sync::LazyLock; +use usage_rs::Args; use xx::regex; use usage::parse::{ParseOutput, ParseValue}; @@ -23,24 +23,28 @@ use crate::cli::generate; /// This is used internally by shell completion scripts to provide /// intelligent completions for commands, flags, and arguments. #[derive(Debug, Args)] -#[clap(visible_alias = "cw")] +#[usage(alias = "cw", effect = "read")] pub struct CompleteWord { /// User's input from the command line words: Vec, /// Usage spec file or script with usage shebang, use "-" to read from stdin - #[clap(short, long)] + #[usage(short, long)] file: Option, /// Raw string spec input - #[clap(short, long, required_unless_present = "file", overrides_with = "file")] + #[usage(short, long, required_unless = "--file", overrides = "--file")] spec: Option, /// Current word index - #[clap(long, allow_hyphen_values = true)] + #[usage(long)] cword: Option, - #[clap(long, default_value = "bash", value_parser = ["bash", "fish", "nu", "powershell", "zsh"])] + #[usage( + long, + default = "bash", + choices("bash", "fish", "nu", "powershell", "zsh") + )] shell: String, } diff --git a/cli/src/cli/exec.rs b/cli/src/cli/exec.rs index c56c621f5..ef525290c 100644 --- a/cli/src/cli/exec.rs +++ b/cli/src/cli/exec.rs @@ -2,35 +2,33 @@ use std::fmt::Debug; use std::path::PathBuf; use std::process::Stdio; -use clap::Args; use itertools::Itertools; use miette::IntoDiagnostic; +use usage_rs::Args; use usage::Spec; use crate::env; +/// Execute a script, parsing args and exposing them as environment variables #[derive(Debug, Args)] -#[clap( - disable_help_flag = true, - visible_alias = "x", - about = "Execute a script, parsing args and exposing them as environment variables" -)] +// The words after the script are the script's, so a flag `usage` does not know is a value to +// forward rather than a mistake to report — the root's `error` stops here. +#[usage(alias = "x", unknown_flags = "value")] pub struct Exec { /// command to execute after parsing usage spec command: String, /// path to script to execute bin: PathBuf, /// arguments to pass to script - #[clap(allow_hyphen_values = true)] args: Vec, /// Show help - #[clap(short)] + #[usage(short)] h: bool, /// Show help - #[clap(long)] + #[usage(long)] help: bool, } diff --git a/cli/src/cli/generate/completion.rs b/cli/src/cli/generate/completion.rs index b39deb3a8..4d2449039 100644 --- a/cli/src/cli/generate/completion.rs +++ b/cli/src/cli/generate/completion.rs @@ -1,45 +1,45 @@ -use clap::Args; use std::path::PathBuf; use usage::complete::CompleteOptions; use usage::Spec; +use usage_rs::Args; use super::parse_file_or_stdin; /// Generate shell completion scripts for bash, fish, nu, powershell, or zsh #[derive(Args)] -#[clap(visible_alias = "c", aliases = ["complete", "completions"])] +#[usage(alias = "c", alias_hidden("complete", "completions"), effect = "read")] pub struct Completion { /// Shell to generate completions for - #[clap(value_parser = ["bash", "fish", "nu", "powershell", "zsh"])] + #[usage(choices("bash", "fish", "nu", "powershell", "zsh"))] shell: String, /// The CLI which we're generating completions for bin: String, /// A .usage.kdl spec file to use for generating completions, use "-" to read from stdin - #[clap(short, long)] + #[usage(short, long)] file: Option, /// A cache key to use for storing the results of calling the CLI with --usage-cmd - #[clap(long, requires = "usage_cmd")] + #[usage(long, requires = "--usage-cmd")] cache_key: Option, /// Include https://github.com/scop/bash-completion /// /// This is required for usage completions to work in bash, but the user may already provide it - #[clap(long, verbatim_doc_comment)] + #[usage(long, verbatim_doc_comment)] include_bash_completion_lib: bool, /// Override the bin used for calling back to usage-cli /// /// You may need to set this if you have a different bin named "usage" - #[clap(long, default_value = "usage", env = "JDX_USAGE_BIN")] + #[usage(long, default = "usage", env = "JDX_USAGE_BIN")] usage_bin: String, /// A command which generates a usage spec /// e.g.: `mycli --usage` or `mycli completion usage` /// Defaults to "$bin --usage" - #[clap(long, required_unless_present = "file")] + #[usage(long, required_unless = "--file")] usage_cmd: Option, } diff --git a/cli/src/cli/generate/completion_init.rs b/cli/src/cli/generate/completion_init.rs index 908fe2f52..66e273268 100644 --- a/cli/src/cli/generate/completion_init.rs +++ b/cli/src/cli/generate/completion_init.rs @@ -1,5 +1,5 @@ -use clap::Args; use usage::complete::complete_init; +use usage_rs::Args; /// Generate a shell init script that auto-completes any usage shebang script on $PATH /// @@ -7,16 +7,20 @@ use usage::complete::complete_init; /// tab-completion for any executable whose first line is a `usage` shebang — /// no per-script `usage g completion` step required. #[derive(Args)] -#[clap(visible_alias = "ci", aliases = ["init", "completions-init"])] +#[usage( + alias = "ci", + alias_hidden("init", "completions-init"), + effect = "read" +)] pub struct CompletionInit { /// Shell to generate the init script for - #[clap(value_parser = ["bash", "fish", "zsh"])] + #[usage(choices("bash", "fish", "zsh"))] shell: String, /// Override the bin used for calling back to usage-cli /// /// You may need to set this if you have a different bin named "usage" - #[clap(long, default_value = "usage", env = "JDX_USAGE_BIN")] + #[usage(long, default = "usage", env = "JDX_USAGE_BIN")] usage_bin: String, } diff --git a/cli/src/cli/generate/fig.rs b/cli/src/cli/generate/fig.rs index 0f80bfe8c..b41486f87 100644 --- a/cli/src/cli/generate/fig.rs +++ b/cli/src/cli/generate/fig.rs @@ -1,10 +1,10 @@ use std::path::PathBuf; use std::vec; -use clap::Args; use indexmap::IndexMap; use itertools::Itertools; use usage::{SpecArg, SpecCommand, SpecComplete, SpecFlag}; +use usage_rs::Args; use crate::cli::generate; use serde::{Deserialize, Serialize, Serializer}; @@ -46,18 +46,22 @@ mod description_format { /// Generate Fig completion spec for Amazon Q / Fig #[derive(Args)] -#[clap()] +#[usage(effect = "read")] pub struct Fig { /// A usage spec taken in as a file, use "-" to read from stdin - #[clap(short, long)] + #[usage(short, long)] file: Option, /// File path where the generated Fig spec will be saved, or "-" for stdout - #[clap(long, value_hint = clap::ValueHint::FilePath)] + #[usage( + long, + value_hint = usage_rs::ValueHint::FilePath, + effect = "write" + )] out_file: Option, /// Raw string spec input - #[clap(long, required_unless_present = "file", overrides_with = "file")] + #[usage(long, required_unless = "--file", overrides = "--file")] spec: Option, } diff --git a/cli/src/cli/generate/go.rs b/cli/src/cli/generate/go.rs index 060fb85df..76bfb7c47 100644 --- a/cli/src/cli/generate/go.rs +++ b/cli/src/cli/generate/go.rs @@ -1,8 +1,8 @@ use std::path::PathBuf; -use clap::Args; use miette::Result; use usage::go::GoOptions; +use usage_rs::Args; use crate::cli::generate; @@ -14,22 +14,27 @@ use crate::cli::generate; /// /// //go:generate usage generate go -f mycli.usage.kdl -o tables.go #[derive(Args)] -#[clap()] +#[usage(effect = "read")] pub struct Go { /// A usage spec taken in as a file, use "-" to read from stdin - #[clap(short, long)] + #[usage(short, long)] file: Option, /// File path where the generated Go source will be saved, or "-" for stdout - #[clap(short, long, value_hint = clap::ValueHint::FilePath)] + #[usage( + short, + long, + value_hint = usage_rs::ValueHint::FilePath, + effect = "write" + )] out_file: Option, /// Go package clause for the generated file (defaults to the spec's bin name) - #[clap(short, long)] + #[usage(short, long)] package: Option, /// Raw string spec input - #[clap(long, required_unless_present = "file", overrides_with = "file")] + #[usage(long, required_unless = "--file", overrides = "--file")] spec: Option, } diff --git a/cli/src/cli/generate/json.rs b/cli/src/cli/generate/json.rs index b4cbbbbd1..71e3b601a 100644 --- a/cli/src/cli/generate/json.rs +++ b/cli/src/cli/generate/json.rs @@ -4,15 +4,15 @@ use miette::IntoDiagnostic; use std::path::PathBuf; /// Outputs a usage spec in json format -#[derive(clap::Args)] -#[clap()] +#[derive(usage_rs::Args)] +#[usage(effect = "read")] pub struct Json { /// A usage spec taken in as a file, use "-" to read from stdin - #[clap(short, long)] + #[usage(short, long)] file: Option, /// raw string spec input - #[clap(long, required_unless_present = "file", overrides_with = "file")] + #[usage(long, required_unless = "--file", overrides = "--file")] spec: Option, } diff --git a/cli/src/cli/generate/json_schema.rs b/cli/src/cli/generate/json_schema.rs index e6811f166..ae5053918 100644 --- a/cli/src/cli/generate/json_schema.rs +++ b/cli/src/cli/generate/json_schema.rs @@ -7,27 +7,31 @@ use crate::schema::{config_schema, SchemaOptions}; use crate::Result; /// Generate a JSON Schema for a CLI's config file from its usage spec -#[derive(clap::Args)] -#[clap()] +#[derive(usage_rs::Args)] +#[usage(effect = "read")] pub struct JsonSchema { /// A usage spec taken in as a file, use "-" to read from stdin - #[clap(short, long)] + #[usage(short, long)] file: Option, /// Write the schema here instead of to stdout - #[clap(long, value_hint = clap::ValueHint::FilePath)] + #[usage( + long, + value_hint = usage_rs::ValueHint::FilePath, + effect = "write" + )] out_file: Option, /// raw string spec input - #[clap(long, required_unless_present = "file", overrides_with = "file")] + #[usage(long, required_unless = "--file", overrides = "--file")] spec: Option, /// The schema's title, shown by editors - #[clap(long)] + #[usage(long)] title: Option, /// Where the schema is published, for its `$id` - #[clap(long)] + #[usage(long)] url: Option, } diff --git a/cli/src/cli/generate/manpage.rs b/cli/src/cli/generate/manpage.rs index 218c5db5f..4e03cf388 100644 --- a/cli/src/cli/generate/manpage.rs +++ b/cli/src/cli/generate/manpage.rs @@ -1,18 +1,24 @@ use std::path::PathBuf; use super::{parse_file_or_stdin, write_or_stdout}; -use clap::Args; use usage::docs::manpage::ManpageRenderer; +use usage_rs::Args; +/// Generate a manpage from a usage spec #[derive(Args)] -#[clap(visible_alias = "man")] +#[usage(alias = "man", effect = "read")] pub struct Manpage { /// A usage spec taken in as a file, use "-" to read from stdin - #[clap(short, long)] + #[usage(short, long)] file: PathBuf, /// Output file path, or "-" for stdout (default) - #[clap(short, long, value_hint = clap::ValueHint::FilePath)] + #[usage( + short, + long, + value_hint = usage_rs::ValueHint::FilePath, + effect = "write" + )] out_file: Option, /// Manual section number (default: 1) @@ -22,7 +28,7 @@ pub struct Manpage { /// - 5: File formats /// - 7: Miscellaneous /// - 8: System administration commands - #[clap(short, long, default_value = "1")] + #[usage(short, long, default = "1")] section: u8, } diff --git a/cli/src/cli/generate/markdown.rs b/cli/src/cli/generate/markdown.rs index 72da13562..5a69e44cc 100644 --- a/cli/src/cli/generate/markdown.rs +++ b/cli/src/cli/generate/markdown.rs @@ -1,41 +1,51 @@ use std::path::PathBuf; use super::{parse_file_or_stdin, write_or_stdout}; -use clap::Args; use usage::docs::markdown::MarkdownRenderer; +use usage_rs::Args; /// Generate markdown documentation from usage specs #[derive(Args)] -#[clap(visible_alias = "md")] +#[usage(alias = "md", effect = "read")] pub struct Markdown { /// A usage spec taken in as a file, use "-" to read from stdin - #[clap(short, long)] + #[usage(short, long)] file: PathBuf, // /// Pass a usage spec in an argument instead of a file - // #[clap(short, long, required_unless_present = "file", overrides_with = "file")] + // #[usage(short, long, required_unless = "--file", overrides = "--file")] // spec: Option, /// Render each subcommand as a separate markdown file - #[clap(short, long, requires = "out_dir", conflicts_with = "out_file")] + #[usage(short, long, conflicts = "--out-file")] multi: bool, /// Escape HTML in markdown - #[clap(long)] + #[usage(long)] html_encode: bool, /// Output markdown files to this directory (required when using --multi) - #[clap(long, value_hint = clap::ValueHint::DirPath, requires = "multi")] + #[usage( + long, + value_hint = usage_rs::ValueHint::DirPath, + requires = "--multi", + required_if = "--multi", + effect = "write" + )] out_dir: Option, /// Output file path for single-file markdown generation, or "-" for stdout (default) - #[clap(long, value_hint = clap::ValueHint::FilePath)] + #[usage( + long, + value_hint = usage_rs::ValueHint::FilePath, + effect = "write" + )] out_file: Option, /// Replace `
` tags with markdown code fences
-    #[clap(long)]
+    #[usage(long)]
     replace_pre_with_code_fences: bool,
 
     /// Prefix to add to all URLs
-    #[clap(long)]
+    #[usage(long)]
     url_prefix: Option,
 }
 
diff --git a/cli/src/cli/generate/mod.rs b/cli/src/cli/generate/mod.rs
index 7f6c23072..b6353f1bc 100644
--- a/cli/src/cli/generate/mod.rs
+++ b/cli/src/cli/generate/mod.rs
@@ -15,14 +15,19 @@ mod markdown;
 mod sdk;
 
 /// Generate completions, documentation, and other artifacts from usage specs
-#[derive(clap::Args)]
-#[clap(visible_alias = "g")]
+// Cannot run alone, and every child starts at `read`, so the parent is `read` too.
+#[derive(usage_rs::Args)]
+#[usage(alias = "g", effect = "read")]
 pub struct Generate {
-    #[clap(subcommand)]
+    #[usage(subcommand)]
     pub command: Command,
 }
 
-#[derive(clap::Subcommand)]
+/// The generators.
+///
+/// Each command's help is its struct's doc comment rather than a second one here, which the
+/// derive would let win: one description, in the file that owns the command.
+#[derive(usage_rs::Subcommands)]
 pub enum Command {
     Completion(completion::Completion),
     CompletionInit(completion_init::CompletionInit),
diff --git a/cli/src/cli/generate/sdk.rs b/cli/src/cli/generate/sdk.rs
index cbbcee32c..6fced9eb6 100644
--- a/cli/src/cli/generate/sdk.rs
+++ b/cli/src/cli/generate/sdk.rs
@@ -1,32 +1,36 @@
 use std::path::PathBuf;
 
-use clap::Args;
+use usage_rs::Args;
 
 use crate::cli::generate;
 
 use usage::sdk::{SdkLanguage, SdkOptions};
 
+/// Generate a type-safe SDK from a usage spec
+// The only generator whose output flag is required: it cannot print an SDK to stdout, so
+// every invocation writes a directory, and the effect belongs on the command rather than on
+// a flag that raises it.
 #[derive(Args)]
-#[clap(about = "Generate a type-safe SDK from a usage spec")]
+#[usage(effect = "write")]
 pub struct Sdk {
     /// A usage spec taken in as a file
-    #[clap(short, long)]
+    #[usage(short, long)]
     file: Option,
 
     /// Target language for the SDK
-    #[clap(short, long, value_parser = ["typescript", "python"])]
+    #[usage(short, long, choices("typescript", "python"))]
     language: String,
 
     /// Output directory for generated SDK files
-    #[clap(short, long)]
+    #[usage(short, long)]
     output: PathBuf,
 
     /// Override the package/module name (defaults to spec bin name)
-    #[clap(short, long)]
+    #[usage(short, long)]
     package_name: Option,
 
     /// Raw string spec input
-    #[clap(long, required_unless_present = "file", overrides_with = "file")]
+    #[usage(long, required_unless = "--file", overrides = "--file")]
     spec: Option,
 }
 
diff --git a/cli/src/cli/lint.rs b/cli/src/cli/lint.rs
index 328878d86..1e4a75bcb 100644
--- a/cli/src/cli/lint.rs
+++ b/cli/src/cli/lint.rs
@@ -5,18 +5,18 @@ use usage::{Spec, SpecArg, SpecCommand, SpecFlag};
 use crate::cli::generate::parse_file_or_stdin;
 
 /// Lint a usage spec file for common issues
-#[derive(clap::Args)]
+#[derive(usage_rs::Args)]
+#[usage(effect = "read")]
 pub struct Lint {
     /// A usage spec file to lint, use "-" to read from stdin
-    #[clap(required = true)]
     file: PathBuf,
 
     /// Output format
-    #[clap(long, short, default_value = "text")]
+    #[usage(long, short, default = "text", value_enum)]
     format: OutputFormat,
 
     /// Treat warnings as errors
-    #[clap(long, short = 'W')]
+    #[usage(long, short = 'W')]
     warnings_as_errors: bool,
 
     /// Also check that subcommands and flags are declared in sorted order
@@ -24,7 +24,7 @@ pub struct Lint {
     /// Off by default: declaration order is a house convention rather than a
     /// correctness question, so a spec that keeps a different order is not wrong.
     /// Pair it with --warnings-as-errors to hold the order in CI.
-    #[clap(long)]
+    #[usage(long)]
     sorted: bool,
 }
 
@@ -35,7 +35,7 @@ pub struct LintOptions {
     pub sorted: bool,
 }
 
-#[derive(Clone, Copy, Default, clap::ValueEnum)]
+#[derive(Clone, Copy, Default, usage_rs::ValueEnum)]
 enum OutputFormat {
     #[default]
     Text,
diff --git a/cli/src/cli/mcp.rs b/cli/src/cli/mcp.rs
index 16568b114..b23b8c989 100644
--- a/cli/src/cli/mcp.rs
+++ b/cli/src/cli/mcp.rs
@@ -42,17 +42,17 @@ means unknown — treat it as needing confirmation, not as safe.";
 ///
 /// Reads JSON-RPC over stdin and writes responses to stdout, which is how MCP
 /// clients launch a local server. Point one at `usage mcp -f mycli.usage.kdl`.
-#[derive(Debug, clap::Args)]
-#[clap(visible_alias = "mcp-server", verbatim_doc_comment)]
+#[derive(Debug, usage_rs::Args)]
+#[usage(alias = "mcp-server", verbatim_doc_comment, effect = "read")]
 pub struct Mcp {
     // Unlike other subcommands this cannot be "-": stdin is the transport, so
     // reading the spec from it would consume the session.
     /// Usage spec file (not "-": stdin is the MCP transport)
-    #[clap(short, long)]
+    #[usage(short, long)]
     file: Option,
 
     /// Raw string spec input
-    #[clap(short, long, required_unless_present = "file", overrides_with = "file")]
+    #[usage(short, long, required_unless = "--file", overrides = "--file")]
     spec: Option,
 }
 
diff --git a/cli/src/cli/mod.rs b/cli/src/cli/mod.rs
index 13fde5183..ff52eb860 100644
--- a/cli/src/cli/mod.rs
+++ b/cli/src/cli/mod.rs
@@ -1,6 +1,7 @@
-use crate::usage_spec;
-use clap::{Parser, Subcommand};
+use std::ffi::OsStr;
+
 use miette::Result;
+use usage_rs::{Cli as DeriveCli, Subcommands};
 
 pub mod complete_word;
 mod exec;
@@ -10,55 +11,132 @@ mod mcp;
 mod shell;
 mod sponsors;
 
-#[derive(Parser)]
-#[clap(author, version, about)]
+/// CLI for working with usage-based CLIs
+// `usage` parses its own command line with the parser it ships: the tables below are the same
+// ones an adopter's CLI compiles into, and `--usage-spec` prints the spec they emit rather
+// than a transcription of a clap command. Said here rather than in the doc comment, which is
+// the help page a user reads.
+//
+// 3.6 added `effect=` and 4.0 added it on flags and args; older `usage` CLIs reject the spec
+// outright with "unsupported cmd prop effect", so this moves in lockstep with the fields the
+// spec actually carries.
+#[derive(DeriveCli)]
+#[usage(
+    bin = "usage",
+    version,
+    min_usage_version = "4.0",
+    usage = "Usage: usage \n       usage --completions \n       usage --usage-spec",
+    // Every flag `usage` accepts is one it declares, so an unrecognised one is a mistake and
+    // saying so beats offering it to a positional — `usage lint --nope f.kdl` would otherwise
+    // make `--nope` the file and call the real file unexpected.
+    //
+    // Declared once, on the root: every subcommand inherits it. The five that hand a command
+    // line to somebody else's script say `value` for themselves, which is the whole reason
+    // both halves are needed.
+    unknown_flags = "error"
+)]
 pub struct Cli {
-    #[clap(subcommand)]
+    #[usage(subcommand)]
     command: Command,
 
     /// Outputs completions for the specified shell for completing the `usage` CLI itself
+    // `--completions ` is normally answered in `crate::run` before a parse happens,
+    // because a shell init script asks for it on every new shell and it does not need a
+    // subcommand to answer. It remains a real parsed field so direct callers of `Cli::run`
+    // and the help, spec, and completions all read the same declaration.
+    //
+    // A flag, which is how it is typed. The clap declaration made it a *positional* — so the
+    // spec, the docs and the generated completions all described a `[COMPLETIONS]` argument
+    // that nothing accepts, while the flag that does work went undocumented. Carried over
+    // faithfully at first, wrong included; the point of emitting the spec from the
+    // declaration is that the two cannot disagree, so the declaration is what changes.
+    #[usage(long)]
     completions: Option,
 
     /// Outputs a `usage.kdl` spec for this CLI itself
-    #[clap(long)]
+    #[usage(long)]
     usage_spec: bool,
 }
 
-#[derive(Subcommand)]
+/// What `--version` and `-v` answer with.
+///
+/// The binary's name, not the crate's. They differ here — `usage-cli` ships `usage` — and
+/// everything else this CLI says about itself now comes from the spec, where the name is
+/// `usage`. Read from the spec rather than written out again, so a rename cannot leave the
+/// version line saying something the help page above it contradicts.
+pub(crate) fn version() -> String {
+    let spec = Cli::spec();
+    format!(
+        "{} {}",
+        spec.bin.unwrap_or(spec.name),
+        spec.version.unwrap_or(env!("CARGO_PKG_VERSION"))
+    )
+}
+
+/// What `usage` can be asked to do.
+///
+/// Each command's description is its struct's doc comment, in the file that owns it, except
+/// where a variant holds nothing and there is no struct to carry one.
+#[derive(Subcommands)]
 enum Command {
-    #[clap(about = "Execute a shell script using bash")]
-    Bash(shell::Shell),
+    Bash(shell::Bash),
     CompleteWord(complete_word::CompleteWord),
     Exec(exec::Exec),
-    #[clap(about = "Execute a shell script using fish")]
-    Fish(shell::Shell),
+    Fish(shell::Fish),
     Generate(generate::Generate),
     Lint(lint::Lint),
     Mcp(mcp::Mcp),
-    #[clap(name = "powershell", about = "Execute a shell script using PowerShell")]
-    PowerShell(shell::Shell),
-    Sponsors(sponsors::Sponsors),
-    #[clap(about = "Execute a shell script using zsh")]
-    Zsh(shell::Shell),
+    #[usage(name = "powershell")]
+    PowerShell(shell::PowerShell),
+    /// Show the companies sponsoring usage and the jdx.dev open source tools
+    #[usage(effect = "read")]
+    Sponsors,
+    Zsh(shell::Zsh),
 }
 
 impl Cli {
     pub fn run(argv: &[String]) -> Result<()> {
-        let cli = Self::parse_from(argv);
+        // `parse_from` takes the command line without the program name, and hands back what
+        // went wrong instead of ending the process — which is what lets the error come out
+        // through the same path as every other failure here.
+        let words: Vec<&OsStr> = argv.iter().skip(1).map(OsStr::new).collect();
+        let cli = match Self::parse_from(&words) {
+            Ok(cli) => cli,
+            // Not failures: someone asked a question, and the answer goes to stdout.
+            Err(usage_rs::Error::Help { cmd, long }) => {
+                if let Some(page) = usage_rs::help::render(Self::spec(), cmd, long) {
+                    print!("{page}");
+                }
+                return Ok(());
+            }
+            Err(usage_rs::Error::Version) => {
+                println!("{}", version());
+                return Ok(());
+            }
+            Err(err) => {
+                eprint!("{}", usage_rs::render_failure(Self::spec(), &words, &err));
+                // clap's status for a command line it could not parse, which is what the
+                // scripts that call this have been checking for.
+                std::process::exit(2);
+            }
+        };
+        if let Some(shell) = cli.completions.as_deref() {
+            return crate::usage_spec::complete(shell);
+        }
         if cli.usage_spec {
-            return usage_spec::generate();
+            return crate::usage_spec::generate();
         }
         match cli.command {
-            Command::Bash(mut cmd) => cmd.run("bash"),
-            Command::Fish(mut cmd) => cmd.run("fish"),
-            Command::PowerShell(mut cmd) => cmd.run("pwsh"),
-            Command::Zsh(mut cmd) => cmd.run("zsh"),
+            Command::Bash(mut cmd) => cmd.run(),
+            Command::Fish(mut cmd) => cmd.run(),
+            Command::PowerShell(mut cmd) => cmd.run(),
+            Command::Zsh(mut cmd) => cmd.run(),
             Command::Generate(cmd) => cmd.run(),
             Command::Exec(mut cmd) => cmd.run(),
             Command::CompleteWord(cmd) => cmd.run(),
             Command::Lint(cmd) => cmd.run(),
             Command::Mcp(cmd) => cmd.run(),
-            Command::Sponsors(cmd) => cmd.run(),
+            Command::Sponsors => sponsors::run(),
         }
     }
 }
diff --git a/cli/src/cli/shell.rs b/cli/src/cli/shell.rs
index f5880ee17..3f308fd3c 100644
--- a/cli/src/cli/shell.rs
+++ b/cli/src/cli/shell.rs
@@ -2,37 +2,82 @@ use std::fmt::Debug;
 use std::path::PathBuf;
 use std::process::Stdio;
 
-use clap::Args;
 use itertools::Itertools;
 use miette::IntoDiagnostic;
+use usage_rs::Args;
 
 use usage::Spec;
 
 use crate::env;
 
-/// Execute a shell script with the specified shell
+/// What the four shell commands accept, declared once.
 ///
-/// Typically, this will be called by a script's shebang.
-///
-/// If using `var=#true` on args/flags, they will be joined with spaces using `shell_words::join()`
-/// to properly escape and quote values with spaces in them.
+/// Not a command itself: each of `bash`, `fish`, `powershell` and `zsh` flattens it, so the
+/// emitted spec lists these on all four exactly as a hand-written spec would. They cannot
+/// share one struct instead — a command collects into the struct that declares it, so the
+/// derive refuses two variants wrapping one — and the shell to run is the command that ran.
 #[derive(Debug, Args)]
-#[clap(disable_help_flag = true, verbatim_doc_comment)]
 pub struct Shell {
     script: PathBuf,
+
     /// Arguments to pass to script
-    #[clap(allow_hyphen_values = true)]
+    ///
+    /// Anything `usage` does not recognise is a value rather than a mistake, which is what
+    /// lets a shebang script take flags of its own.
     args: Vec,
 
     /// Show help
-    #[clap(short)]
+    #[usage(short)]
     h: bool,
 
     /// Show help
-    #[clap(long)]
+    #[usage(long)]
     help: bool,
 }
 
+/// One of the four commands that run a script, which differ only in the shell they name.
+///
+/// The long help is the same paragraph four times over, so it is written here once. A
+/// `concat!` in a `long_about` would not do: the attribute takes a literal, and by the time
+/// the derive reads it a macro call is not one.
+macro_rules! shell_command {
+    ($ty:ident, $program:literal, $about:tt) => {
+        #[doc = "Execute a shell script with the specified shell"]
+        #[doc = ""]
+        #[doc = "Typically, this will be called by a script's shebang."]
+        #[doc = ""]
+        #[doc = "If using `var=#true` on args/flags, they will be joined with spaces using `shell_words::join()`"]
+        #[doc = "to properly escape and quote values with spaces in them."]
+        #[derive(Debug, Args)]
+        // The words after the script are the script's, so a flag `usage` does not know is a
+        // value to forward rather than a mistake to report — the root's `error` stops here.
+        #[usage(
+            about = $about,
+            unknown_flags = "value",
+            verbatim_doc_comment
+        )]
+        pub struct $ty {
+            #[usage(flatten)]
+            pub shell: Shell,
+        }
+
+        impl $ty {
+            pub fn run(&mut self) -> miette::Result<()> {
+                self.shell.run($program)
+            }
+        }
+    };
+}
+
+shell_command!(Bash, "bash", "Execute a shell script using bash");
+shell_command!(Fish, "fish", "Execute a shell script using fish");
+shell_command!(
+    PowerShell,
+    "pwsh",
+    "Execute a shell script using PowerShell"
+);
+shell_command!(Zsh, "zsh", "Execute a shell script using zsh");
+
 impl Shell {
     pub fn run(&mut self, shell: &str) -> miette::Result<()> {
         let spec = Spec::parse_file(&self.script)?;
diff --git a/cli/src/cli/sponsors.rs b/cli/src/cli/sponsors.rs
index c34c5b9a0..11a712a64 100644
--- a/cli/src/cli/sponsors.rs
+++ b/cli/src/cli/sponsors.rs
@@ -1,12 +1,12 @@
-/// Show the companies sponsoring usage and the jdx.dev open source tools
-#[derive(clap::Args)]
-pub struct Sponsors;
+//! Show the companies sponsoring usage and the jdx.dev open source tools.
+//!
+//! A command that takes nothing, so it is a bare variant of the command enum rather than a
+//! struct with no fields: the derive writes the struct such a variant implies, and the help
+//! text and `effect` are declared on the variant.
 
-impl Sponsors {
-    pub fn run(&self) -> miette::Result<()> {
-        println!(
-            "usage and the jdx.dev open source tools are sponsored by:\n\n  entire.io - https://entire.io\n  37signals - https://37signals.com\n\nView all sponsors: https://jdx.dev/sponsors.html"
-        );
-        Ok(())
-    }
+pub fn run() -> miette::Result<()> {
+    println!(
+        "usage and the jdx.dev open source tools are sponsored by:\n\n  entire.io - https://entire.io\n  37signals - https://37signals.com\n\nView all sponsors: https://jdx.dev/sponsors.html"
+    );
+    Ok(())
 }
diff --git a/cli/src/command_effects.rs b/cli/src/command_effects.rs
index 1184eb3f8..091a28bfd 100644
--- a/cli/src/command_effects.rs
+++ b/cli/src/command_effects.rs
@@ -1,60 +1,21 @@
 //! What each `usage` command does to the world.
 //!
-//! clap has no way to express this, so it is applied to the derived spec on the
-//! way out. The same shape mise, hk, pitchfork, aube and communique use.
+//! Declared where the command is, as `#[usage(effect = "…")]`. It used to be a table applied
+//! to the spec on the way out, because clap had no way to express it; the derive does, so the
+//! table is gone and what is left here is the coverage that kept it honest.
 //!
-//! The rule is what the command does to *state the user would miss*, not how
-//! much work it does. Reading a spec file and printing a manpage is `read` no
-//! matter how much parsing happens in between; writing that manpage to a path
-//! the user named is `write`, because something on disk changed.
+//! The rule is what the command does to *state the user would miss*, not how much work it
+//! does. Reading a spec file and printing a manpage is `read` no matter how much parsing
+//! happens in between; writing that manpage to a path the user named is `write`, because
+//! something on disk changed.
 //!
-//! Most commands here print to stdout and take an optional flag to write to a
-//! file instead, so they are `read` with the flag raising them — which is the
-//! composition rule doing its job: the effect of an invocation is the highest
-//! of the command's and those of the flags and args actually supplied.
+//! Most commands print to stdout and take an optional flag to write to a file instead, so
+//! they are `read` with the flag raising them — which is the composition rule doing its job:
+//! the effect of an invocation is the highest of the command's and those of the flags and
+//! args actually supplied.
 //!
-//! A command that runs code the user supplied is left unset rather than
-//! guessed at. See [`UNCLASSIFIED`].
-
-use usage::SpecCommandEffect::{self, Read, Write};
-
-/// Commands whose effect is fixed, keyed by their full path under `usage`.
-const EFFECTS: &[(&str, SpecCommandEffect)] = &[
-    ("complete-word", Read),
-    // Cannot run alone (`subcommand_required`), and every child starts at
-    // `read`, so the parent is `read` too.
-    ("generate", Read),
-    ("generate completion", Read),
-    ("generate completion-init", Read),
-    ("generate fig", Read),
-    ("generate go", Read),
-    ("generate json", Read),
-    ("generate json-schema", Read),
-    ("generate manpage", Read),
-    ("generate markdown", Read),
-    // The only generator whose output flag is required: it cannot print an SDK
-    // to stdout, so every invocation writes a directory.
-    ("generate sdk", Write),
-    ("lint", Read),
-    // Long-running, but every tool it serves only reads the spec it was given.
-    // Unlike `mise mcp`, which is unclassified because it serves a tool that
-    // runs tasks, nothing here can act on the CLI it describes.
-    ("mcp", Read),
-    ("sponsors", Read),
-];
-
-/// Flags that raise the effect of the command they are passed to, keyed by
-/// `` and the flag's name.
-///
-/// All of these redirect output that would otherwise go to stdout.
-const FLAG_EFFECTS: &[(&str, &str, SpecCommandEffect)] = &[
-    ("generate fig", "out-file", Write),
-    ("generate go", "out-file", Write),
-    ("generate json-schema", "out-file", Write),
-    ("generate manpage", "out-file", Write),
-    ("generate markdown", "out-dir", Write),
-    ("generate markdown", "out-file", Write),
-];
+//! A command that runs code the user supplied is left unset rather than guessed at. See
+//! [`UNCLASSIFIED`].
 
 /// Commands with no fixed effect, and why.
 ///
@@ -72,50 +33,23 @@ const UNCLASSIFIED: &[(&str, &str)] = &[
     ("zsh", "runs a user-supplied script"),
 ];
 
-/// Apply the tables above to a derived spec.
-///
-/// A path that no longer exists is skipped rather than panicking — the stale
-/// entry is caught by the test below, where a failure is readable, instead of
-/// at runtime in a user's shell.
-pub(crate) fn apply(spec: &mut usage::Spec) {
-    for (path, effect) in EFFECTS {
-        if let Some(cmd) = find_mut(spec, path) {
-            cmd.effect = Some(*effect);
-        }
-    }
-    for (path, flag_name, effect) in FLAG_EFFECTS {
-        if let Some(cmd) = find_mut(spec, path) {
-            if let Some(flag) = cmd.flags.iter_mut().find(|f| f.name == *flag_name) {
-                flag.effect = Some(*effect);
-            }
-        }
-    }
-}
-
-fn find_mut<'a>(spec: &'a mut usage::Spec, path: &str) -> Option<&'a mut usage::SpecCommand> {
-    let mut cmd = &mut spec.cmd;
-    for segment in path.split(' ') {
-        cmd = cmd.subcommands.get_mut(segment)?;
-    }
-    Some(cmd)
-}
-
 #[cfg(test)]
 mod tests {
     use super::*;
     use std::collections::HashSet;
+    use usage_rs::spec::{CommandMeta, Effect};
+
+    use crate::cli::Cli;
 
-    fn spec() -> usage::Spec {
-        let mut cli = ::command();
-        let mut spec = clap_usage::spec(&mut cli, "usage");
-        apply(&mut spec);
-        spec
+    /// The static metadata the derive compiled, which is what `--usage-spec` prints.
+    fn root() -> &'static CommandMeta<'static> {
+        Cli::spec().root
     }
 
-    /// Every command path in the spec, deepest-first order irrelevant.
-    fn walk(cmd: &usage::SpecCommand, path: &mut Vec, out: &mut Vec<(String, bool)>) {
-        for (name, sub) in &cmd.subcommands {
-            path.push(name.clone());
+    /// Every command path under the root, with whether it declares an effect.
+    fn walk(cmd: &CommandMeta, path: &mut Vec, out: &mut Vec<(String, bool)>) {
+        for sub in cmd.subcommands {
+            path.push(sub.cmd.name.to_string());
             out.push((path.join(" "), sub.effect.is_some()));
             walk(sub, path, out);
             path.pop();
@@ -123,40 +57,33 @@ mod tests {
     }
 
     fn commands() -> Vec<(String, bool)> {
-        let spec = spec();
         let mut out = vec![];
-        walk(&spec.cmd, &mut vec![], &mut out);
+        walk(root(), &mut vec![], &mut out);
         out
     }
 
+    fn find(path: &str) -> Option<&'static CommandMeta<'static>> {
+        let mut cmd = root();
+        for segment in path.split(' ') {
+            cmd = cmd.subcommands.iter().find(|s| s.cmd.name == segment)?;
+        }
+        Some(cmd)
+    }
+
     #[test]
-    fn no_entry_points_at_a_command_that_does_not_exist() {
-        // `apply` skips a stale path silently so a rename cannot break the CLI.
-        // This is where that shows up instead.
+    fn nothing_names_a_command_that_does_not_exist() {
+        // The effects themselves can no longer go stale — a `#[usage(effect)]` on a command
+        // that was renamed moves with it, and one on a command that was deleted is deleted
+        // too. `UNCLASSIFIED` is a list of names again, so this is where it reports itself.
         let real: HashSet<_> = commands().into_iter().map(|(path, _)| path).collect();
-        let stale: Vec<_> = EFFECTS
+        let stale: Vec<_> = UNCLASSIFIED
             .iter()
             .map(|(path, _)| *path)
-            .chain(FLAG_EFFECTS.iter().map(|(path, _, _)| *path))
-            .chain(UNCLASSIFIED.iter().map(|(path, _)| *path))
             .filter(|path| !real.contains(*path))
             .collect();
         assert!(stale.is_empty(), "no such commands: {stale:?}");
     }
 
-    #[test]
-    fn no_flag_entry_points_at_a_flag_that_does_not_exist() {
-        let spec = spec();
-        let missing: Vec<_> = FLAG_EFFECTS
-            .iter()
-            .filter(|(path, flag_name, _)| {
-                find(&spec, path).is_none_or(|cmd| !cmd.flags.iter().any(|f| f.name == *flag_name))
-            })
-            .map(|(path, flag_name, _)| format!("{path} --{flag_name}"))
-            .collect();
-        assert!(missing.is_empty(), "no such flags: {missing:?}");
-    }
-
     #[test]
     fn nothing_is_unclassified_by_accident() {
         // An unset effect means "unknown, ask", which is right for the shell
@@ -178,31 +105,45 @@ mod tests {
         // The composition rule is the reason flags carry effects at all:
         // `usage g markdown -f x.kdl` only reads, the same command with
         // `--out-file` writes.
-        let spec = spec();
-        let md = find(&spec, "generate markdown").unwrap();
-        assert_eq!(md.effect, Some(Read));
-        let out_file = md.flags.iter().find(|f| f.name == "out-file").unwrap();
-        assert_eq!(out_file.effect, Some(Write));
+        let md = find("generate markdown").unwrap();
+        assert_eq!(md.effect, Some(Effect::Read));
+        let flag = |name: &str| md.flags.iter().find(|f| f.flag.name == name).unwrap();
+        assert_eq!(flag("out-file").effect, Some(Effect::Write));
+        assert_eq!(flag("out-dir").effect, Some(Effect::Write));
         // A flag that changes only the rendering stays unset.
-        let html = md.flags.iter().find(|f| f.name == "html-encode").unwrap();
-        assert_eq!(html.effect, None);
+        assert_eq!(flag("html-encode").effect, None);
+    }
+
+    #[test]
+    fn every_generator_that_can_redirect_its_output_says_so() {
+        // The four generators that print to stdout unless told otherwise. Kept as one list
+        // because the risk is a new `--out-file` arriving without an effect, which reads to
+        // an agent as a command that only ever prints.
+        for path in [
+            "generate fig",
+            "generate go",
+            "generate json-schema",
+            "generate manpage",
+        ] {
+            let cmd = find(path).unwrap_or_else(|| panic!("no such command: {path}"));
+            let out_file = cmd
+                .flags
+                .iter()
+                .find(|f| f.flag.name == "out-file")
+                .unwrap_or_else(|| panic!("{path} has no --out-file"));
+            assert_eq!(out_file.effect, Some(Effect::Write), "{path} --out-file");
+        }
     }
 
     #[test]
     fn a_required_output_flag_makes_the_command_write() {
         // `generate sdk` cannot print to stdout, so there is no read-only way
         // to invoke it and the effect belongs on the command.
-        let spec = spec();
-        let sdk = find(&spec, "generate sdk").unwrap();
-        assert_eq!(sdk.effect, Some(Write));
-        assert!(sdk.flags.iter().any(|f| f.name == "output" && f.required));
-    }
-
-    fn find<'a>(spec: &'a usage::Spec, path: &str) -> Option<&'a usage::SpecCommand> {
-        let mut cmd = &spec.cmd;
-        for segment in path.split(' ') {
-            cmd = cmd.subcommands.get(segment)?;
-        }
-        Some(cmd)
+        let sdk = find("generate sdk").unwrap();
+        assert_eq!(sdk.effect, Some(Effect::Write));
+        assert!(sdk
+            .flags
+            .iter()
+            .any(|f| f.flag.name == "output" && f.required));
     }
 }
diff --git a/cli/src/lib.rs b/cli/src/lib.rs
index 82309db76..e687ce41b 100644
--- a/cli/src/lib.rs
+++ b/cli/src/lib.rs
@@ -13,6 +13,9 @@ pub use cli::complete_word::candidates as complete_candidates;
 pub use cli::Cli;
 
 mod cli;
+// Nothing but coverage now: each command declares its own effect, so what is left is the
+// check that none of them forgot to.
+#[cfg(test)]
 mod command_effects;
 pub mod env;
 mod schema;
@@ -31,7 +34,9 @@ pub fn run(args: &[String]) -> Result<()> {
     // } else if let Some(script) = args.get(1) {
     if let Some(script) = args.get(1) {
         if script.to_lowercase() == "-v" {
-            println!("{} {}", env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION"));
+            // The same line `--version` prints, from the same place. These used to be two
+            // copies of the crate name, which is not what this binary is called.
+            println!("{}", cli::version());
             return Ok(());
         } else if script == "--usage-spec" {
             return usage_spec::generate();
diff --git a/cli/src/test.rs b/cli/src/test.rs
index 51d46154c..646c865d7 100644
--- a/cli/src/test.rs
+++ b/cli/src/test.rs
@@ -1,6 +1,27 @@
-use crate::env;
+use crate::{cli::Cli, env};
 
 #[ctor::ctor(unsafe)]
 fn init() {
     env::set_var("USAGE_BIN", "usage");
 }
+
+#[test]
+fn shell_commands_keep_their_verbatim_long_help() {
+    let bash = Cli::spec()
+        .root
+        .subcommands
+        .iter()
+        .find(|cmd| cmd.cmd.name == "bash")
+        .expect("bash command");
+    assert_eq!(bash.about, Some("Execute a shell script using bash"));
+    assert_eq!(
+        bash.long_about,
+        Some(
+            "Execute a shell script with the specified shell\n\n\
+             Typically, this will be called by a script's shebang.\n\n\
+             If using `var=#true` on args/flags, they will be joined with spaces using \
+             `shell_words::join()`\n\
+             to properly escape and quote values with spaces in them."
+        )
+    );
+}
diff --git a/cli/src/usage_spec.rs b/cli/src/usage_spec.rs
index 3474a3d76..633fc53e6 100644
--- a/cli/src/usage_spec.rs
+++ b/cli/src/usage_spec.rs
@@ -1,21 +1,12 @@
 use crate::cli::Cli;
-use clap::CommandFactory;
 use miette::Result;
 
 pub(crate) fn generate() -> Result<()> {
-    let mut cli = Cli::command().version(env!("CARGO_PKG_VERSION"));
-    let mut spec = clap_usage::spec(&mut cli, "usage");
-
-    // Declare what each command does to the world. clap cannot express this,
-    // so it is applied to the derived spec; see command_effects.
-    crate::command_effects::apply(&mut spec);
-
-    println!("// @generated by usage-cli from clap metadata");
-    // 3.6 added `effect=` and 4.0 added it on flags and args; older `usage`
-    // CLIs reject the spec outright with "unsupported cmd prop effect", so this
-    // moves in lockstep with the fields the spec actually carries.
-    println!(r#"min_usage_version "4.0""#);
-    println!("{spec}");
+    // The declaration *is* the spec: the same tables that parsed the command line print it,
+    // with no bridge and no second model in between. `effect` in particular is now declared
+    // on each command rather than patched in here afterwards, since clap could not say it.
+    println!("// @generated by usage-cli from its own parse tables");
+    println!("{}", Cli::to_kdl().trim());
     println!("{}", include_str!("../assets/usage-extra.usage.kdl").trim());
 
     Ok(())
diff --git a/cli/tests/clap_sort.rs b/cli/tests/clap_sort.rs
deleted file mode 100644
index 5bb909652..000000000
--- a/cli/tests/clap_sort.rs
+++ /dev/null
@@ -1,7 +0,0 @@
-use clap::CommandFactory;
-use usage_cli::Cli;
-
-#[test]
-fn verify_cli_sorted() {
-    clap_sort::assert_sorted(&Cli::command());
-}
diff --git a/cli/tests/shell_completions_integration.rs b/cli/tests/shell_completions_integration.rs
index cfa422510..5c9d2a602 100644
--- a/cli/tests/shell_completions_integration.rs
+++ b/cli/tests/shell_completions_integration.rs
@@ -1609,7 +1609,9 @@ cat "$XDG_CACHE_HOME/usage/usage__usage_spec_usage.spec"
         "spec file was written by the shell function instead of the CLI.\nstdout:\n{stdout}\nstderr:\n{stderr}"
     );
     assert!(
-        stdout.contains("bin usage"),
+        // Quoted, which is how the parse tables write a spec: `usage --usage-spec` is
+        // emitted by the CLI's own derive rather than by the clap bridge.
+        stdout.contains(r#"bin "usage""#),
         "spec file should hold the real usage spec.\nstdout:\n{stdout}\nstderr:\n{stderr}"
     );
 
@@ -1763,7 +1765,9 @@ cat "$XDG_CACHE_HOME/usage/usage__usage_spec_usage.spec"
         "spec file was written by the shell function instead of the CLI.\nstdout:\n{stdout}\nstderr:\n{stderr}"
     );
     assert!(
-        stdout.contains("bin usage"),
+        // Quoted, which is how the parse tables write a spec: `usage --usage-spec` is
+        // emitted by the CLI's own derive rather than by the clap bridge.
+        stdout.contains(r#"bin "usage""#),
         "spec file should hold the real usage spec.\nstdout:\n{stdout}\nstderr:\n{stderr}"
     );
 
diff --git a/cli/tests/snapshots/manpage__generate_manpage_with_flags.snap b/cli/tests/snapshots/manpage__generate_manpage_with_flags.snap
index 71f061814..5817627df 100644
--- a/cli/tests/snapshots/manpage__generate_manpage_with_flags.snap
+++ b/cli/tests/snapshots/manpage__generate_manpage_with_flags.snap
@@ -6,7 +6,7 @@ expression: stdout
 .SH NAME
 mise \- The front\-end to your dev env
 .SH SYNOPSIS
-\fBmise\fR [OPTIONS] [] [] ... [] ... [COMMAND]
+\fBmise\fR [OPTIONS] [TASK] [COMMAND]
 .SH DESCRIPTION
 mise is a tool for managing runtime versions. https://github.com/jdx/mise
 .PP
diff --git a/cli/tests/snapshots/manpage__manpage_output_first_50_lines.snap b/cli/tests/snapshots/manpage__manpage_output_first_50_lines.snap
index a5324e146..a2ed24fe2 100644
--- a/cli/tests/snapshots/manpage__manpage_output_first_50_lines.snap
+++ b/cli/tests/snapshots/manpage__manpage_output_first_50_lines.snap
@@ -6,7 +6,7 @@ expression: "first_lines.join(\"\\n\")"
 .SH NAME
 mise \- The front\-end to your dev env
 .SH SYNOPSIS
-\fBmise\fR [OPTIONS] [] [] ... [] ... [COMMAND]
+\fBmise\fR [OPTIONS] [TASK] [COMMAND]
 .SH DESCRIPTION
 mise is a tool for managing runtime versions. https://github.com/jdx/mise
 .PP
diff --git a/cli/usage.usage.kdl b/cli/usage.usage.kdl
index de554ef04..3103e54b1 100644
--- a/cli/usage.usage.kdl
+++ b/cli/usage.usage.kdl
@@ -1,299 +1,251 @@
-// @generated by usage-cli from clap metadata
+// @generated by usage-cli from its own parse tables
 min_usage_version "4.0"
-name usage-cli
-bin usage
+name "usage"
+bin "usage"
 version "5.1.0"
 about "CLI for working with usage-based CLIs"
-unknown_flags error
-usage "Usage: usage-cli [OPTIONS] [COMPLETIONS] "
-flag --usage-spec help="Outputs a `usage.kdl` spec for this CLI itself"
-arg "[COMPLETIONS]" help="Outputs completions for the specified shell for completing the `usage` CLI itself" required=#false
-cmd bash help="Execute a shell script using bash" unknown_flags=value {
-    long_help #"""
-Execute a shell script with the specified shell
-
-Typically, this will be called by a script's shebang.
-
-If using `var=#true` on args/flags, they will be joined with spaces using `shell_words::join()`
-to properly escape and quote values with spaces in them.
-"""#
-    flag -h help="Show help"
-    flag --help help="Show help"
-    arg