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,45 +1,52 @@
```
$ rad --help
rad [..]
Radicle command line interface
Radicle is a sovereign code forge built on Git.
Usage: rad <command> [--help]
Common `rad` commands used in various situations:
auth Manage identities and profiles
block Block repositories or nodes from being seeded or followed
checkout Checkout a repository into the local directory
clone Clone a Radicle repository
config Manage your local Radicle configuration
debug Write out information to help debug your Radicle node remotely
fork Create a fork of a repository
help CLI help
id Manage repository identities
init Initialize a Radicle repository
inbox Manage your Radicle notifications
inspect Inspect a Radicle repository
issue Manage issues
ls List repositories
node Control and query the Radicle Node
patch Manage patches
path Display the Radicle home path
publish Publish a repository to the network
clean Remove all remotes from a repository
self Show information about your identity and device
seed Manage repository seeding policies
follow Manage node follow policies
unblock Unblock repositories or nodes to allow them to be seeded or followed
unfollow Unfollow a peer
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
See `rad <command> --help` to learn about a specific command.
See `rad <COMMAND> --help` to learn about a specific command.
Do you have feedback?
- Chat[..]
- Mail[..]
- 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
block Block repositories or nodes from being seeded or followed
checkout Checkout a repository into the local directory
clean Remove all remotes from a repository
clone Clone a Radicle repository
config Manage your local Radicle configuration
debug Write out information to help debug your Radicle node remotely
follow Manage node follow policies
fork Create a fork of a repository
id Manage repository identities
inbox Manage your Radicle notifications
init Initialize a Radicle repository
inspect Inspect a Radicle repository
issue Manage issues
ls List repositories
node Control and query the Radicle Node
patch Manage patches
path Display the Radicle home path
publish Publish a repository to the network
remote Manage a repository's remotes
seed Manage repository seeding 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
unfollow Unfollow a peer
unseed Remove repository seeding policies
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)
Options:
-h, --help
Print help (see a summary with '-h')
-V, --version
Print version
```

View File

@ -1,6 +1,7 @@
use std::ffi::OsString;
use std::io::{self, Write};
use std::{io::ErrorKind, iter, process};
use std::io;
use std::io::Write;
use std::{io::ErrorKind, process};
use anyhow::anyhow;
use clap::builder::styling::AnsiColor;
@ -18,7 +19,16 @@ pub const RADICLE_VERSION: &str = env!("RADICLE_VERSION");
pub const RADICLE_VERSION_LONG: &str =
concat!(env!("RADICLE_VERSION"), " (", env!("GIT_HEAD"), ")");
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 VERSION: Version = Version {
name: NAME,
@ -36,15 +46,17 @@ const STYLES: Styles = Styles::styled()
#[command(name = NAME)]
#[command(version = RADICLE_VERSION)]
#[command(long_version = RADICLE_VERSION_LONG)]
#[command(about = DESCRIPTION)]
#[command(long_about = LONG_DESCRIPTION)]
#[command(propagate_version = true)]
#[command(styles = STYLES)]
struct CliArgs {
#[command(subcommand)]
pub command: Option<Commands>,
pub command: Command,
}
#[derive(Subcommand, Debug)]
enum Commands {
enum Command {
Auth(auth::Args),
Block(block::Args),
Checkout(checkout::Args),
@ -54,23 +66,12 @@ enum Commands {
Cob(cob::Args),
Config(config::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),
Fork(fork::Args),
Id(id::Args),
Inbox(inbox::Args),
Init(init::Args),
#[command(alias = ".")]
Inspect(inspect::Args),
Issue(issue::Args),
Ls(ls::Args),
@ -88,13 +89,16 @@ enum Commands {
Unfollow(unfollow::Args),
Unseed(unseed::Args),
Watch(watch::Args),
}
#[derive(Debug)]
enum Command {
Other(Vec<OsString>),
Help,
Version { json: bool },
/// Print the version information of the CLI
Version {
/// Print the version information in JSON format
#[arg(long)]
json: bool,
},
#[command(external_subcommand)]
External(Vec<OsString>),
}
fn main() {
@ -113,291 +117,92 @@ fn main() {
if let Err(e) = radicle::io::set_file_limit(4096) {
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),
Err(err) => {
if let Some(err) = err {
term::error(format!("rad: {err}"));
}
term::error(format!("{err}"));
process::exit(1);
}
}
}
fn parse_args() -> anyhow::Result<Command> {
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()),
}
fn write_version(as_json: bool) -> anyhow::Result<()> {
let mut stdout = io::stdout();
if as_json {
VERSION.write_json(&mut stdout)?;
writeln!(&mut stdout)?;
Ok(())
} else {
VERSION.write(&mut stdout)?;
Ok(())
}
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>> {
fn run(command: Command, ctx: impl term::Context) -> Result<(), anyhow::Error> {
match command {
Command::Version { json } => {
let mut stdout = io::stdout();
if json {
VERSION
.write_json(&mut stdout)
.map_err(|e| Some(e.into()))?;
writeln!(&mut stdout).ok();
} else {
VERSION.write(&mut stdout).map_err(|e| Some(e.into()))?;
}
}
Command::Help => {
print_help()?;
}
Command::Other(args) => {
let exe = args.first();
Command::Auth(args) => term::run_command_fn(auth::run, args, ctx),
Command::Block(args) => term::run_command_fn(block::run, args, ctx),
Command::Checkout(args) => term::run_command_fn(checkout::run, args, ctx),
Command::Clean(args) => term::run_command_fn(clean::run, args, ctx),
Command::Clone(args) => term::run_command_fn(clone::run, args, ctx),
Command::Cob(args) => term::run_command_fn(cob::run, args, ctx),
Command::Config(args) => term::run_command_fn(config::run, args, ctx),
Command::Debug(args) => term::run_command_fn(debug::run, args, ctx),
Command::Follow(args) => term::run_command_fn(follow::run, args, ctx),
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),
Command::Init(args) => term::run_command_fn(init::run, args, ctx),
Command::Inspect(args) => term::run_command_fn(inspect::run, args, ctx),
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),
Command::Patch(args) => term::run_command_fn(patch::run, args, ctx),
Command::Path(args) => term::run_command_fn(path::run, args, ctx),
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),
Command::RadSelf(args) => term::run_command_fn(rad_self::run, args, ctx),
Command::Stats(args) => term::run_command_fn(stats::run, args, ctx),
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),
Command::Unseed(args) => term::run_command_fn(unseed::run, args, ctx),
Command::Watch(args) => term::run_command_fn(watch::run, args, ctx),
Command::Version { json } => write_version(json),
Command::External(mut args) => {
let exe = args.remove(0);
if let Some(Some(exe)) = exe.map(|s| s.to_str()) {
run_other(exe, &args[1..])?;
} else {
print_help()?;
// This command is deprecated and delegates to `git diff`.
// 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);
}
}
}
Ok(())
}
/// Runs a `rad` command. `exe` expects the commands' name, e.g. `issue`,
/// `args` expects all other arguments.
///
/// For commands that are already migrated to `clap`, we need to parse the
/// arguments again. This needs to be done for each migrated command
/// individually, otherwise `clap` would fail to parse on an non-migrated and
/// therefore unknown command.
pub(crate) fn run_other(exe: &str, args: &[OsString]) -> Result<(), Option<anyhow::Error>> {
match exe {
"auth" => {
if let Some(Commands::Auth(args)) = CliArgs::parse().command {
term::run_command_fn(auth::run, args);
}
}
"block" => {
if let Some(Commands::Block(args)) = CliArgs::parse().command {
term::run_command_fn(block::run, args);
}
}
"checkout" => {
if let Some(Commands::Checkout(args)) = CliArgs::parse().command {
term::run_command_fn(checkout::run, args);
}
}
"clone" => {
if let Some(Commands::Clone(args)) = CliArgs::parse().command {
term::run_command_fn(clone::run, args);
}
}
"cob" => {
if let Some(Commands::Cob(args)) = CliArgs::parse().command {
term::run_command_fn(cob::run, args);
}
}
"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 {
term::run_command_fn(inspect::run, args);
}
}
"issue" => {
if let Some(Commands::Issue(args)) = CliArgs::parse().command {
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();
let exe = format!("{NAME}-{exe:?}");
let status = process::Command::new(&exe).args(&args).status();
match status {
Ok(status) => {
if !status.success() {
return Err(None);
return Err(anyhow!("`{exe}` exited with an error."));
}
Ok(())
}
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.",
)));
Err(anyhow!(
"`{exe}` is not a command. See `rad --help` for a list of commands.",
))
} else {
return Err(Some(err.into()));
Err(err.into())
}
}
}
}
}
Ok(())
}

View File

@ -57,11 +57,12 @@ where
/// Execute a function `cmd` that runs a command with parsed the `args`
/// 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
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),
Err(err) => {
// First parameter is not used and can just be empty.