cli/main: migrate main to use clap

Now that all commands can be parsed by clap, remove any custom parsing and
directly use `CliArgs::parse`.

The `Diff` command was removed, since only one `external_command` can be
declared for a subcommand. The handling of `rad diff` is now done via the
`External` variant. If the subcommand name is `diff` then this is executed,
otherwise delegate to the possible `rad-{exe}` command.

Also of note, the `rad inspect` command has an alias `rad .`. This changes the
behaviour of `rad . --help`. Before, it would print the help of the `rad`
command. Now, it prints the help of `rad inspect`.

Co-authored-by: Lorenz Leutgeb <lorenz.leutgeb@radicle.xyz>
This commit is contained in:
Fintan Halpenny 2025-10-29 10:31:43 +00:00 committed by Lorenz Leutgeb
parent 5053a1aa77
commit 23332fa318
3 changed files with 136 additions and 323 deletions

View File

@ -1,22 +1,29 @@
``` ```
$ rad --help $ rad --help
rad [..] Radicle is a sovereign code forge built on Git.
Radicle command line interface
Usage: rad <command> [--help] See `rad <COMMAND> --help` to learn about a specific command.
Common `rad` commands used in various situations:
Do you have feedback?
- Chat <radicle.zulipchat.com>
- Mail <feedback@radicle.xyz>
(Messages are automatically posted to the public #feedback channel on Zulip.)
Usage: rad <COMMAND>
Commands:
auth Manage identities and profiles auth Manage identities and profiles
block Block repositories or nodes from being seeded or followed block Block repositories or nodes from being seeded or followed
checkout Checkout a repository into the local directory checkout Checkout a repository into the local directory
clean Remove all remotes from a repository
clone Clone a Radicle repository clone Clone a Radicle repository
config Manage your local Radicle configuration config Manage your local Radicle configuration
debug Write out information to help debug your Radicle node remotely debug Write out information to help debug your Radicle node remotely
follow Manage node follow policies
fork Create a fork of a repository fork Create a fork of a repository
help CLI help
id Manage repository identities id Manage repository identities
init Initialize a Radicle repository
inbox Manage your Radicle notifications inbox Manage your Radicle notifications
init Initialize a Radicle repository
inspect Inspect a Radicle repository inspect Inspect a Radicle repository
issue Manage issues issue Manage issues
ls List repositories ls List repositories
@ -24,22 +31,22 @@ Common `rad` commands used in various situations:
patch Manage patches patch Manage patches
path Display the Radicle home path path Display the Radicle home path
publish Publish a repository to the network publish Publish a repository to the network
clean Remove all remotes from a repository remote Manage a repository's remotes
self Show information about your identity and device
seed Manage repository seeding policies seed Manage repository seeding policies
follow Manage node follow policies self Show information about your identity and device
stats Displays aggregated repository and node metrics
sync Sync repositories to the network
unblock Unblock repositories or nodes to allow them to be seeded or followed unblock Unblock repositories or nodes to allow them to be seeded or followed
unfollow Unfollow a peer unfollow Unfollow a peer
unseed Remove repository seeding policies unseed Remove repository seeding policies
remote Manage a repository's remotes
stats Displays aggregated repository and node metrics
sync Sync repositories to the network
watch Wait for some state to be updated watch Wait for some state to be updated
version Print the version information of the CLI
help Print this message or the help of the given subcommand(s)
See `rad <command> --help` to learn about a specific command. Options:
-h, --help
Print help (see a summary with '-h')
Do you have feedback? -V, --version
- Chat[..] Print version
- Mail[..]
(Messages are automatically posted to the public #feedback channel on Zulip.)
``` ```

View File

@ -1,6 +1,7 @@
use std::ffi::OsString; use std::ffi::OsString;
use std::io::{self, Write}; use std::io;
use std::{io::ErrorKind, iter, process}; use std::io::Write;
use std::{io::ErrorKind, process};
use anyhow::anyhow; use anyhow::anyhow;
use clap::builder::styling::AnsiColor; use clap::builder::styling::AnsiColor;
@ -18,7 +19,16 @@ pub const RADICLE_VERSION: &str = env!("RADICLE_VERSION");
pub const RADICLE_VERSION_LONG: &str = pub const RADICLE_VERSION_LONG: &str =
concat!(env!("RADICLE_VERSION"), " (", env!("GIT_HEAD"), ")"); concat!(env!("RADICLE_VERSION"), " (", env!("GIT_HEAD"), ")");
pub const DESCRIPTION: &str = "Radicle command line interface"; pub const DESCRIPTION: &str = "Radicle command line interface";
pub const LONG_DESCRIPTION: &str = "Radicle is a sovereign code forge built on Git."; pub const LONG_DESCRIPTION: &str = "
Radicle is a sovereign code forge built on Git.
See `rad <COMMAND> --help` to learn about a specific command.
Do you have feedback?
- Chat <\x1b]8;;https://radicle.zulipchat.com\x1b\\radicle.zulipchat.com\x1b]8;;\x1b\\>
- Mail <\x1b]8;;mailto:feedback@radicle.xyz\x1b\\feedback@radicle.xyz\x1b]8;;\x1b\\>
(Messages are automatically posted to the public #feedback channel on Zulip.)\
";
pub const TIMESTAMP: &str = env!("SOURCE_DATE_EPOCH"); pub const TIMESTAMP: &str = env!("SOURCE_DATE_EPOCH");
pub const VERSION: Version = Version { pub const VERSION: Version = Version {
name: NAME, name: NAME,
@ -36,15 +46,17 @@ const STYLES: Styles = Styles::styled()
#[command(name = NAME)] #[command(name = NAME)]
#[command(version = RADICLE_VERSION)] #[command(version = RADICLE_VERSION)]
#[command(long_version = RADICLE_VERSION_LONG)] #[command(long_version = RADICLE_VERSION_LONG)]
#[command(about = DESCRIPTION)]
#[command(long_about = LONG_DESCRIPTION)]
#[command(propagate_version = true)] #[command(propagate_version = true)]
#[command(styles = STYLES)] #[command(styles = STYLES)]
struct CliArgs { struct CliArgs {
#[command(subcommand)] #[command(subcommand)]
pub command: Option<Commands>, pub command: Command,
} }
#[derive(Subcommand, Debug)] #[derive(Subcommand, Debug)]
enum Commands { enum Command {
Auth(auth::Args), Auth(auth::Args),
Block(block::Args), Block(block::Args),
Checkout(checkout::Args), Checkout(checkout::Args),
@ -54,23 +66,12 @@ enum Commands {
Cob(cob::Args), Cob(cob::Args),
Config(config::Args), Config(config::Args),
Debug(debug::Args), Debug(debug::Args),
/// This command is deprecated and delegates to `git diff`.
/// Even before it was deprecated, it was not printed by
/// `rad -h`, so it is also hidden.
///
/// Since it is hidden, it makes no sense to add `about`
/// for the command listing, and since it is external,
/// `--help` will delegate to `git diff --help` it makes
/// no sense to add `long_about` for `rad diff --help`.
#[command(external_subcommand, hide = true)]
Diff(Vec<OsString>),
Follow(follow::Args), Follow(follow::Args),
Fork(fork::Args), Fork(fork::Args),
Id(id::Args), Id(id::Args),
Inbox(inbox::Args), Inbox(inbox::Args),
Init(init::Args), Init(init::Args),
#[command(alias = ".")]
Inspect(inspect::Args), Inspect(inspect::Args),
Issue(issue::Args), Issue(issue::Args),
Ls(ls::Args), Ls(ls::Args),
@ -88,13 +89,16 @@ enum Commands {
Unfollow(unfollow::Args), Unfollow(unfollow::Args),
Unseed(unseed::Args), Unseed(unseed::Args),
Watch(watch::Args), Watch(watch::Args),
}
#[derive(Debug)] /// Print the version information of the CLI
enum Command { Version {
Other(Vec<OsString>), /// Print the version information in JSON format
Help, #[arg(long)]
Version { json: bool }, json: bool,
},
#[command(external_subcommand)]
External(Vec<OsString>),
} }
fn main() { fn main() {
@ -113,291 +117,92 @@ fn main() {
if let Err(e) = radicle::io::set_file_limit(4096) { if let Err(e) = radicle::io::set_file_limit(4096) {
log::warn!(target: "cli", "Unable to set open file limit: {e}"); log::warn!(target: "cli", "Unable to set open file limit: {e}");
} }
match parse_args().map_err(Some).and_then(run) { let CliArgs { command } = CliArgs::parse();
match run(command, term::DefaultContext) {
Ok(_) => process::exit(0), Ok(_) => process::exit(0),
Err(err) => { Err(err) => {
if let Some(err) = err { term::error(format!("{err}"));
term::error(format!("rad: {err}"));
}
process::exit(1); process::exit(1);
} }
} }
} }
fn parse_args() -> anyhow::Result<Command> { fn write_version(as_json: bool) -> anyhow::Result<()> {
use lexopt::prelude::*;
let mut parser = lexopt::Parser::from_env();
let mut command = None;
let mut json = false;
while let Some(arg) = parser.next()? {
match arg {
Long("json") => {
json = true;
}
Long("help") | Short('h') => {
command = Some(Command::Help);
}
Long("version") => {
command = Some(Command::Version { json: false });
}
Value(val) if command.is_none() => {
if val == *"." {
command = Some(Command::Other(vec![OsString::from("inspect")]));
} else if val == "version" {
command = Some(Command::Version { json: false });
} else {
let args = iter::once(val)
.chain(iter::from_fn(|| parser.value().ok()))
.collect();
command = Some(Command::Other(args))
}
}
_ => anyhow::bail!(arg.unexpected()),
}
}
if let Some(Command::Version { json: j }) = &mut command {
*j = json;
}
Ok(command.unwrap_or_else(|| Command::Other(vec![])))
}
fn print_help() -> anyhow::Result<()> {
VERSION.write(&mut io::stdout())?;
println!("{DESCRIPTION}");
println!();
help::run(Default::default(), term::DefaultContext)
}
fn run(command: Command) -> Result<(), Option<anyhow::Error>> {
match command {
Command::Version { json } => {
let mut stdout = io::stdout(); let mut stdout = io::stdout();
if json { if as_json {
VERSION VERSION.write_json(&mut stdout)?;
.write_json(&mut stdout) writeln!(&mut stdout)?;
.map_err(|e| Some(e.into()))?; Ok(())
writeln!(&mut stdout).ok();
} else { } else {
VERSION.write(&mut stdout).map_err(|e| Some(e.into()))?; VERSION.write(&mut stdout)?;
}
}
Command::Help => {
print_help()?;
}
Command::Other(args) => {
let exe = args.first();
if let Some(Some(exe)) = exe.map(|s| s.to_str()) {
run_other(exe, &args[1..])?;
} else {
print_help()?;
}
}
}
Ok(()) Ok(())
} }
}
/// Runs a `rad` command. `exe` expects the commands' name, e.g. `issue`, fn run(command: Command, ctx: impl term::Context) -> Result<(), anyhow::Error> {
/// `args` expects all other arguments. match command {
/// Command::Auth(args) => term::run_command_fn(auth::run, args, ctx),
/// For commands that are already migrated to `clap`, we need to parse the Command::Block(args) => term::run_command_fn(block::run, args, ctx),
/// arguments again. This needs to be done for each migrated command Command::Checkout(args) => term::run_command_fn(checkout::run, args, ctx),
/// individually, otherwise `clap` would fail to parse on an non-migrated and Command::Clean(args) => term::run_command_fn(clean::run, args, ctx),
/// therefore unknown command. Command::Clone(args) => term::run_command_fn(clone::run, args, ctx),
pub(crate) fn run_other(exe: &str, args: &[OsString]) -> Result<(), Option<anyhow::Error>> { Command::Cob(args) => term::run_command_fn(cob::run, args, ctx),
match exe { Command::Config(args) => term::run_command_fn(config::run, args, ctx),
"auth" => { Command::Debug(args) => term::run_command_fn(debug::run, args, ctx),
if let Some(Commands::Auth(args)) = CliArgs::parse().command { Command::Follow(args) => term::run_command_fn(follow::run, args, ctx),
term::run_command_fn(auth::run, args); Command::Fork(args) => term::run_command_fn(fork::run, args, ctx),
} Command::Id(args) => term::run_command_fn(id::run, args, ctx),
} Command::Inbox(args) => term::run_command_fn(inbox::run, args, ctx),
"block" => { Command::Init(args) => term::run_command_fn(init::run, args, ctx),
if let Some(Commands::Block(args)) = CliArgs::parse().command { Command::Inspect(args) => term::run_command_fn(inspect::run, args, ctx),
term::run_command_fn(block::run, args); Command::Issue(args) => term::run_command_fn(issue::run, args, ctx),
} Command::Ls(args) => term::run_command_fn(ls::run, args, ctx),
} Command::Node(args) => term::run_command_fn(node::run, args, ctx),
"checkout" => { Command::Patch(args) => term::run_command_fn(patch::run, args, ctx),
if let Some(Commands::Checkout(args)) = CliArgs::parse().command { Command::Path(args) => term::run_command_fn(path::run, args, ctx),
term::run_command_fn(checkout::run, args); Command::Publish(args) => term::run_command_fn(publish::run, args, ctx),
} Command::Remote(args) => term::run_command_fn(remote::run, args, ctx),
} Command::Seed(args) => term::run_command_fn(seed::run, args, ctx),
"clone" => { Command::RadSelf(args) => term::run_command_fn(rad_self::run, args, ctx),
if let Some(Commands::Clone(args)) = CliArgs::parse().command { Command::Stats(args) => term::run_command_fn(stats::run, args, ctx),
term::run_command_fn(clone::run, args); Command::Sync(args) => term::run_command_fn(sync::run, args, ctx),
} Command::Unblock(args) => term::run_command_fn(unblock::run, args, ctx),
} Command::Unfollow(args) => term::run_command_fn(unfollow::run, args, ctx),
"cob" => { Command::Unseed(args) => term::run_command_fn(unseed::run, args, ctx),
if let Some(Commands::Cob(args)) = CliArgs::parse().command { Command::Watch(args) => term::run_command_fn(watch::run, args, ctx),
term::run_command_fn(cob::run, args); Command::Version { json } => write_version(json),
} Command::External(mut args) => {
} let exe = args.remove(0);
"config" => {
if let Some(Commands::Config(args)) = CliArgs::parse().command {
term::run_command_fn(config::run, args);
}
}
"diff" => {
if let Some(Commands::Diff(mut args)) = CliArgs::parse().command {
debug_assert_eq!(args[0], "diff");
args.remove(0);
return diff::run(args).map_err(Some);
}
}
"debug" => {
if let Some(Commands::Debug(args)) = CliArgs::parse().command {
term::run_command_fn(debug::run, args);
}
}
"follow" => {
if let Some(Commands::Follow(args)) = CliArgs::parse().command {
term::run_command_fn(follow::run, args);
}
}
"fork" => {
if let Some(Commands::Fork(args)) = CliArgs::parse().command {
term::run_command_fn(fork::run, args);
}
}
"help" => {
term::run_command_args::<help::Options, _>(help::HELP, help::run, args.to_vec());
}
"id" => {
if let Some(Commands::Id(args)) = CliArgs::parse().command {
term::run_command_fn(id::run, args);
}
}
"inbox" => {
if let Some(Commands::Inbox(args)) = CliArgs::parse().command {
term::run_command_fn(inbox::run, args)
}
}
"init" => {
if let Some(Commands::Init(args)) = CliArgs::parse().command {
term::run_command_fn(init::run, args);
}
}
"inspect" => {
let reconstructed_args = {
// This is a horrible workaround to reconstruct the original
// args after having them mangled by our `lexopt`-style parser
// in `parse_args()` in case they were `rad .`.
// TODO: Remove this, when `rad` is fully migrated to `clap`.
vec!["rad", "inspect"]
.into_iter()
.map(OsString::from)
.chain(args.iter().cloned())
};
if let Some(Commands::Inspect(args)) = CliArgs::parse_from(reconstructed_args).command { // This command is deprecated and delegates to `git diff`.
term::run_command_fn(inspect::run, args); // Even before it was deprecated, it was not printed by
// `rad -h`.
//
// Since it is external, `--help` will delegate to `git diff --help`.
if exe == "diff" {
return diff::run(args);
} }
}
"issue" => { let exe = format!("{NAME}-{exe:?}");
if let Some(Commands::Issue(args)) = CliArgs::parse().command { let status = process::Command::new(&exe).args(&args).status();
term::run_command_fn(issue::run, args);
}
}
"ls" => {
if let Some(Commands::Ls(args)) = CliArgs::parse().command {
term::run_command_fn(ls::run, args);
}
}
"node" => {
if let Some(Commands::Node(args)) = CliArgs::parse().command {
term::run_command_fn(node::run, args);
}
}
"patch" => {
if let Some(Commands::Patch(args)) = CliArgs::parse().command {
term::run_command_fn(patch::run, args);
}
}
"path" => {
if let Some(Commands::Path(args)) = CliArgs::parse().command {
term::run_command_fn(path::run, args);
}
}
"publish" => {
if let Some(Commands::Publish(args)) = CliArgs::parse().command {
term::run_command_fn(publish::run, args);
}
}
"clean" => {
if let Some(Commands::Clean(args)) = CliArgs::parse().command {
term::run_command_fn(clean::run, args);
}
}
"self" => {
if let Some(Commands::RadSelf(args)) = CliArgs::parse().command {
term::run_command_fn(rad_self::run, args)
}
}
"sync" => {
if let Some(Commands::Sync(args)) = CliArgs::parse().command {
term::run_command_fn(sync::run, args);
}
}
"seed" => {
if let Some(Commands::Seed(args)) = CliArgs::parse().command {
term::run_command_fn(seed::run, args);
}
}
"unblock" => {
if let Some(Commands::Unblock(args)) = CliArgs::parse().command {
term::run_command_fn(unblock::run, args);
}
}
"unfollow" => {
if let Some(Commands::Unfollow(args)) = CliArgs::parse().command {
term::run_command_fn(unfollow::run, args);
}
}
"unseed" => {
if let Some(Commands::Unseed(args)) = CliArgs::parse().command {
term::run_command_fn(unseed::run, args);
}
}
"remote" => {
if let Some(Commands::Remote(args)) = CliArgs::parse().command {
term::run_command_fn(remote::run, args);
}
}
"stats" => {
if let Some(Commands::Stats(args)) = CliArgs::parse().command {
term::run_command_fn(stats::run, args);
}
}
"watch" => {
if let Some(Commands::Watch(args)) = CliArgs::parse().command {
term::run_command_fn(watch::run, args);
}
}
other => {
let exe = format!("{NAME}-{exe}");
let status = process::Command::new(exe).args(args).status();
match status { match status {
Ok(status) => { Ok(status) => {
if !status.success() { if !status.success() {
return Err(None); return Err(anyhow!("`{exe}` exited with an error."));
}
}
Err(err) => {
if let ErrorKind::NotFound = err.kind() {
return Err(Some(anyhow!(
"`{other}` is not a command. See `rad --help` for a list of commands.",
)));
} else {
return Err(Some(err.into()));
}
}
}
}
} }
Ok(()) Ok(())
} }
Err(err) => {
if let ErrorKind::NotFound = err.kind() {
Err(anyhow!(
"`{exe}` is not a command. See `rad --help` for a list of commands.",
))
} else {
Err(err.into())
}
}
}
}
}
}

View File

@ -57,11 +57,12 @@ where
/// Execute a function `cmd` that runs a command with parsed the `args` /// Execute a function `cmd` that runs a command with parsed the `args`
/// and a default context. /// and a default context.
pub fn run_command_fn<F, P: Parser>(cmd: F, args: P) -> ! pub fn run_command_fn<F, P: Parser, C>(cmd: F, args: P, ctx: C) -> !
where where
F: FnOnce(P, DefaultContext) -> anyhow::Result<()>, F: FnOnce(P, C) -> anyhow::Result<()>,
C: Context,
{ {
match cmd(args, DefaultContext) { match cmd(args, ctx) {
Ok(()) => process::exit(0), Ok(()) => process::exit(0),
Err(err) => { Err(err) => {
// First parameter is not used and can just be empty. // First parameter is not used and can just be empty.