Radish alpha
h
rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5
Radicle Heartwood Protocol & Stack
Radicle
Git
Migration of `rad` to `clap`
Merged fintohaps opened 6 months ago

This patch migrates the top-level rad command to use all the clap machinery for parsing.

This also results in removing a lot of no longer needed code around argument parsing, and maintaining a help command.

65 files changed +1447 -2146 3c895250 bc1d9ed4
modified Cargo.lock
@@ -2822,7 +2822,6 @@ dependencies = [
 "dunce",
 "human-panic",
 "itertools",
-
 "lexopt",
 "localtime",
 "log",
 "nonempty",
modified crates/radicle-cli/Cargo.toml
@@ -20,7 +20,6 @@ clap = { version = "4.5.44", features = ["derive"] }
dunce = { workspace = true }
human-panic.workspace = true
itertools.workspace = true
-
lexopt = { workspace = true }
localtime = { workspace = true }
log = { workspace = true, features = ["std"] }
nonempty = { workspace = true }
modified crates/radicle-cli/examples/rad-help.md
@@ -1,45 +1,54 @@
```
$ rad --help
-
rad [..]
-
Radicle command line interface
-

-
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.
+
Radicle is a sovereign code forge built on Git.
+

+
See `rad <COMMAND> --help` to learn about a specific command.

Do you have feedback?
-
 - Chat[..]
-
 - Mail[..]
+

+
 - 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.)
+

+

+
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
```
modified crates/radicle-cli/src/commands.rs
@@ -9,7 +9,6 @@ pub mod debug;
pub mod diff;
pub mod follow;
pub mod fork;
-
pub mod help;
pub mod id;
pub mod inbox;
pub mod init;
modified crates/radicle-cli/src/commands/auth.rs
@@ -14,7 +14,6 @@ use radicle::{profile, Profile};
use crate::terminal as term;

pub use args::Args;
-
pub(crate) use args::ABOUT;

pub fn run(args: Args, ctx: impl term::Context) -> anyhow::Result<()> {
    match ctx.profile() {
modified crates/radicle-cli/src/commands/auth/args.rs
@@ -1,7 +1,7 @@
use clap::Parser;
use radicle::node::Alias;

-
pub(crate) const ABOUT: &str = "Manage identities and profiles";
+
const ABOUT: &str = "Manage identities and profiles";
const LONG_ABOUT: &str = r#"
A passphrase may be given via the environment variable `RAD_PASSPHRASE` or
via the standard input stream if `--stdin` is used. Using either of these
modified crates/radicle-cli/src/commands/block.rs
@@ -7,7 +7,6 @@ use crate::terminal as term;
use term::args::BlockTarget;

pub use args::Args;
-
pub(crate) use args::ABOUT;

pub fn run(args: Args, ctx: impl term::Context) -> anyhow::Result<()> {
    let profile = ctx.profile()?;
modified crates/radicle-cli/src/commands/block/args.rs
@@ -2,7 +2,7 @@ use clap::Parser;

use crate::terminal::args::BlockTarget;

-
pub(crate) const ABOUT: &str = "Block repositories or nodes from being seeded or followed";
+
const ABOUT: &str = "Block repositories or nodes from being seeded or followed";

#[derive(Parser, Debug)]
#[command(about = ABOUT, disable_version_flag = true)]
modified crates/radicle-cli/src/commands/checkout.rs
@@ -15,7 +15,6 @@ use crate::project;
use crate::terminal as term;

pub use args::Args;
-
pub(crate) use args::ABOUT;

pub fn run(args: Args, ctx: impl term::Context) -> anyhow::Result<()> {
    let profile = ctx.profile()?;
modified crates/radicle-cli/src/commands/checkout/args.rs
@@ -1,7 +1,7 @@
use clap::Parser;
use radicle::prelude::{Did, RepoId};

-
pub(crate) const ABOUT: &str = "Checkout a repository into the local directory";
+
const ABOUT: &str = "Checkout a repository into the local directory";
const LONG_ABOUT: &str = r#"
Creates a working copy from a repository in local storage.
"#;
modified crates/radicle-cli/src/commands/clean.rs
@@ -6,7 +6,6 @@ use radicle::storage::WriteStorage;
use crate::terminal as term;

pub use args::Args;
-
pub(crate) use args::ABOUT;

pub fn run(args: Args, ctx: impl term::Context) -> anyhow::Result<()> {
    let profile = ctx.profile()?;
modified crates/radicle-cli/src/commands/clean/args.rs
@@ -2,7 +2,7 @@ use clap::Parser;

use radicle::prelude::RepoId;

-
pub const ABOUT: &str = "Remove all remotes from a repository";
+
const ABOUT: &str = "Remove all remotes from a repository";

const LONG_ABOUT: &str = r#"
Removes all remotes from a repository, as long as they are not the
modified crates/radicle-cli/src/commands/clone.rs
@@ -26,7 +26,6 @@ use crate::terminal as term;
use crate::terminal::Element as _;

pub use args::Args;
-
pub(crate) use args::ABOUT;

pub fn run(args: Args, ctx: impl term::Context) -> anyhow::Result<()> {
    let profile = ctx.profile()?;
modified crates/radicle-cli/src/commands/clone/args.rs
@@ -11,7 +11,7 @@ use radicle::prelude::*;

use crate::terminal;

-
pub(crate) const ABOUT: &str = "Clone a Radicle repository";
+
const ABOUT: &str = "Clone a Radicle repository";

const LONG_ABOUT: &str = r#"
The `clone` command will use your local node's routing table to find seeds from
modified crates/radicle-cli/src/commands/config.rs
@@ -2,7 +2,6 @@ mod args;

pub use args::Args;
use args::Command;
-
pub(crate) use args::ABOUT;

use std::path::Path;

modified crates/radicle-cli/src/commands/config/args.rs
@@ -1,7 +1,7 @@
use clap::{Parser, Subcommand};
use radicle::node::Alias;

-
pub(crate) const ABOUT: &str = "Manage your local Radicle configuration";
+
const ABOUT: &str = "Manage your local Radicle configuration";

const LONG_ABOUT: &str = r#"
If no argument is specified, prints the current radicle configuration as JSON.
modified crates/radicle-cli/src/commands/debug.rs
@@ -13,7 +13,6 @@ use radicle::Profile;
use crate::terminal as term;

pub use args::Args;
-
pub(crate) use args::ABOUT;

pub const NAME: &str = "rad";
pub const VERSION: &str = env!("RADICLE_VERSION");
modified crates/radicle-cli/src/commands/debug/args.rs
@@ -1,6 +1,6 @@
use clap::Parser;

-
pub const ABOUT: &str = "Write out information to help debug your Radicle node remotely";
+
const ABOUT: &str = "Write out information to help debug your Radicle node remotely";

const LONG_ABOUT: &str = r#"
Run this if you are reporting a problem in Radicle. The output is
modified crates/radicle-cli/src/commands/follow.rs
@@ -8,7 +8,6 @@ use crate::terminal as term;

pub use args::Args;
use args::Operation;
-
pub(crate) use args::ABOUT;

pub fn run(args: Args, ctx: impl term::Context) -> anyhow::Result<()> {
    let profile = ctx.profile()?;
modified crates/radicle-cli/src/commands/follow/args.rs
@@ -4,7 +4,7 @@ use radicle::node::{Alias, NodeId};

use crate::terminal as term;

-
pub(crate) const ABOUT: &str = "Manage node follow policies";
+
const ABOUT: &str = "Manage node follow policies";

const LONG_ABOUT: &str = r#"
The `follow` command will print all nodes being followed, optionally filtered by alias, if no
modified crates/radicle-cli/src/commands/fork.rs
@@ -7,7 +7,6 @@ use radicle::rad;
use crate::terminal as term;

pub use args::Args;
-
pub(crate) use args::ABOUT;

pub fn run(args: Args, ctx: impl term::Context) -> anyhow::Result<()> {
    let profile = ctx.profile()?;
modified crates/radicle-cli/src/commands/fork/args.rs
@@ -1,6 +1,6 @@
use radicle::identity::RepoId;

-
pub(crate) const ABOUT: &str = "Create a fork of a repository";
+
const ABOUT: &str = "Create a fork of a repository";

#[derive(Debug, clap::Parser)]
#[command(about = ABOUT, disable_version_flag = true)]
deleted crates/radicle-cli/src/commands/help.rs
@@ -1,207 +0,0 @@
-
use std::ffi::OsString;
-

-
use crate::terminal as term;
-
use crate::terminal::args::{Args, Error, Help};
-

-
pub const HELP: Help = Help {
-
    name: "help",
-
    description: "CLI help",
-
    version: env!("RADICLE_VERSION"),
-
    usage: "Usage: rad help [--help]",
-
};
-

-
enum CommandItem {
-
    Lexopt(Help),
-
    Clap {
-
        name: &'static str,
-
        about: &'static str,
-
    },
-
}
-

-
impl CommandItem {
-
    fn name(&self) -> &str {
-
        match self {
-
            CommandItem::Lexopt(help) => help.name,
-
            CommandItem::Clap { name, .. } => name,
-
        }
-
    }
-

-
    fn description(&self) -> &str {
-
        match self {
-
            CommandItem::Lexopt(help) => help.description,
-
            CommandItem::Clap {
-
                about: description, ..
-
            } => description,
-
        }
-
    }
-
}
-

-
const COMMANDS: &[CommandItem] = &[
-
    CommandItem::Clap {
-
        name: "auth",
-
        about: crate::commands::auth::ABOUT,
-
    },
-
    CommandItem::Clap {
-
        name: "block",
-
        about: crate::commands::block::ABOUT,
-
    },
-
    CommandItem::Clap {
-
        name: "checkout",
-
        about: crate::commands::checkout::ABOUT,
-
    },
-
    CommandItem::Clap {
-
        name: "clone",
-
        about: crate::commands::clone::ABOUT,
-
    },
-
    CommandItem::Clap {
-
        name: "config",
-
        about: crate::commands::config::ABOUT,
-
    },
-
    CommandItem::Clap {
-
        name: "debug",
-
        about: crate::commands::debug::ABOUT,
-
    },
-
    CommandItem::Clap {
-
        name: "fork",
-
        about: crate::commands::fork::ABOUT,
-
    },
-
    CommandItem::Lexopt(crate::commands::help::HELP),
-
    CommandItem::Clap {
-
        name: "id",
-
        about: crate::commands::id::ABOUT,
-
    },
-
    CommandItem::Clap {
-
        name: "init",
-
        about: crate::commands::init::ABOUT,
-
    },
-
    CommandItem::Clap {
-
        name: "inbox",
-
        about: crate::commands::inbox::ABOUT,
-
    },
-
    CommandItem::Clap {
-
        name: "inspect",
-
        about: crate::commands::inspect::ABOUT,
-
    },
-
    CommandItem::Clap {
-
        name: "issue",
-
        about: crate::commands::issue::ABOUT,
-
    },
-
    CommandItem::Clap {
-
        name: "ls",
-
        about: crate::commands::ls::ABOUT,
-
    },
-
    CommandItem::Clap {
-
        name: "node",
-
        about: crate::commands::node::ABOUT,
-
    },
-
    CommandItem::Lexopt(crate::commands::patch::HELP),
-
    CommandItem::Clap {
-
        name: "path",
-
        about: crate::commands::path::ABOUT,
-
    },
-
    CommandItem::Clap {
-
        name: "publish",
-
        about: crate::commands::publish::ABOUT,
-
    },
-
    CommandItem::Clap {
-
        name: "clean",
-
        about: crate::commands::clean::ABOUT,
-
    },
-
    CommandItem::Clap {
-
        name: "self",
-
        about: crate::commands::rad_self::ABOUT,
-
    },
-
    CommandItem::Clap {
-
        name: "seed",
-
        about: crate::commands::seed::ABOUT,
-
    },
-
    CommandItem::Clap {
-
        name: "follow",
-
        about: crate::commands::follow::ABOUT,
-
    },
-
    CommandItem::Clap {
-
        name: "unblock",
-
        about: crate::commands::unblock::ABOUT,
-
    },
-
    CommandItem::Clap {
-
        name: "unfollow",
-
        about: crate::commands::unfollow::ABOUT,
-
    },
-
    CommandItem::Clap {
-
        name: "unseed",
-
        about: crate::commands::unseed::ABOUT,
-
    },
-
    CommandItem::Clap {
-
        name: "remote",
-
        about: crate::commands::remote::ABOUT,
-
    },
-
    CommandItem::Clap {
-
        name: "stats",
-
        about: crate::commands::stats::ABOUT,
-
    },
-
    CommandItem::Lexopt(crate::commands::sync::HELP),
-
    CommandItem::Clap {
-
        name: "watch",
-
        about: crate::commands::watch::ABOUT,
-
    },
-
];
-

-
#[derive(Default)]
-
pub struct Options {}
-

-
impl Args for Options {
-
    fn from_args(args: Vec<OsString>) -> anyhow::Result<(Self, Vec<OsString>)> {
-
        let mut parser = lexopt::Parser::from_args(args);
-

-
        if let Some(arg) = parser.next()? {
-
            anyhow::bail!(arg.unexpected());
-
        }
-
        Err(Error::HelpManual { name: "rad" }.into())
-
    }
-
}
-

-
pub fn run(_options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
-
    term::print("Usage: rad <command> [--help]");
-

-
    if let Err(e) = ctx.profile() {
-
        term::blank();
-
        match e.downcast_ref() {
-
            Some(term::args::Error::WithHint { err, hint }) => {
-
                term::print(term::format::yellow(err));
-
                term::print(term::format::yellow(hint));
-
            }
-
            Some(e) => {
-
                term::error(e);
-
            }
-
            None => {
-
                term::error(e);
-
            }
-
        }
-
        term::blank();
-
    }
-

-
    term::print("Common `rad` commands used in various situations:");
-
    term::blank();
-

-
    for help in COMMANDS {
-
        term::info!(
-
            "\t{} {}",
-
            term::format::bold(format!("{:-12}", help.name())),
-
            term::format::dim(help.description())
-
        );
-
    }
-
    term::blank();
-
    term::print("See `rad <command> --help` to learn about a specific command.");
-
    term::blank();
-

-
    term::print("Do you have feedback?");
-
    term::print(
-
        " - Chat <\x1b]8;;https://radicle.zulipchat.com\x1b\\radicle.zulipchat.com\x1b]8;;\x1b\\>",
-
    );
-
    term::print(
-
        " - Mail <\x1b]8;;mailto:feedback@radicle.xyz\x1b\\feedback@radicle.xyz\x1b]8;;\x1b\\>",
-
    );
-
    term::print("   (Messages are automatically posted to the public #feedback channel on Zulip.)");
-

-
    Ok(())
-
}
modified crates/radicle-cli/src/commands/id.rs
@@ -23,7 +23,6 @@ use crate::terminal::patch::Message;

pub use args::Args;
use args::Command;
-
pub(crate) use args::ABOUT;

pub fn run(args: Args, ctx: impl term::Context) -> anyhow::Result<()> {
    let profile = ctx.profile()?;
modified crates/radicle-cli/src/commands/id/args.rs
@@ -17,7 +17,7 @@ use crate::git::Rev;

use crate::terminal::Interactive;

-
pub(crate) const ABOUT: &str = "Manage repository identities";
+
const ABOUT: &str = "Manage repository identities";
const LONG_ABOUT: &str = r#"
The `id` command is used to manage and propose changes to the
identity of a Radicle repository.
modified crates/radicle-cli/src/commands/inbox.rs
@@ -1,7 +1,6 @@
mod args;

pub use args::Args;
-
pub(crate) use args::ABOUT;

use std::path::Path;
use std::process;
modified crates/radicle-cli/src/commands/inbox/args.rs
@@ -3,7 +3,7 @@ use std::{fmt::Display, str::FromStr};
use clap::{Parser, Subcommand, ValueEnum};
use radicle::{node::notifications::NotificationId, prelude::RepoId};

-
pub(crate) const ABOUT: &str = "Manage your Radicle notifications";
+
const ABOUT: &str = "Manage your Radicle notifications";

const LONG_ABOUT: &str = r#"
By default, this command lists all items in your inbox.
modified crates/radicle-cli/src/commands/init.rs
@@ -4,7 +4,6 @@
mod args;

pub use args::Args;
-
pub(crate) use args::ABOUT;

use std::collections::HashSet;
use std::convert::TryFrom;
modified crates/radicle-cli/src/commands/init/args.rs
@@ -8,7 +8,7 @@ use radicle::{
};
use radicle_term::Interactive;

-
pub(crate) const ABOUT: &str = "Initialize a Radicle repository";
+
const ABOUT: &str = "Initialize a Radicle repository";

#[derive(Debug, Parser)]
#[command(about = ABOUT, disable_version_flag = true)]
modified crates/radicle-cli/src/commands/inspect.rs
@@ -23,7 +23,6 @@ use crate::terminal::Element;

pub use args::Args;
use args::Target;
-
pub(crate) use args::ABOUT;

pub fn run(args: Args, ctx: impl term::Context) -> anyhow::Result<()> {
    let rid = match args.repo {
modified crates/radicle-cli/src/commands/inspect/args.rs
@@ -1,6 +1,6 @@
use clap::Parser;

-
pub(crate) const ABOUT: &str = "Inspect a Radicle repository";
+
const ABOUT: &str = "Inspect a Radicle repository";
const LONG_ABOUT: &str = r#"Inspects the given path or RID. If neither is specified,
the current repository is inspected.
"#;
modified crates/radicle-cli/src/commands/issue.rs
@@ -30,7 +30,7 @@ use crate::terminal::format::Author;
use crate::terminal::issue::Format;
use crate::terminal::Element;

-
pub(crate) const ABOUT: &str = "Manage issues";
+
const ABOUT: &str = "Manage issues";

pub fn run(args: Args, ctx: impl term::Context) -> anyhow::Result<()> {
    let profile = ctx.profile()?;
modified crates/radicle-cli/src/commands/ls.rs
@@ -1,7 +1,6 @@
mod args;

pub use args::Args;
-
pub(crate) use args::ABOUT;

use radicle::storage::{ReadStorage, RepositoryInfo};

modified crates/radicle-cli/src/commands/ls/args.rs
@@ -1,6 +1,6 @@
use clap::Parser;

-
pub(crate) const ABOUT: &str = "List repositories";
+
const ABOUT: &str = "List repositories";
const LONG_ABOUT: &str = r#"
By default, this command shows you all repositories that you have forked or initialized.
If you wish to see all seeded repositories, use the `--seeded` option.
modified crates/radicle-cli/src/commands/node.rs
@@ -19,7 +19,6 @@ use crate::terminal::Element as _;
use crate::warning;

pub use args::Args;
-
pub(crate) use args::ABOUT;
use args::{Addr, Command};

pub fn run(args: Args, ctx: impl term::Context) -> anyhow::Result<()> {
modified crates/radicle-cli/src/commands/node/args.rs
@@ -11,7 +11,7 @@ use radicle::crypto::{PublicKey, PublicKeyError};
use radicle::node::{Address, NodeId, PeerAddr, PeerAddrParseError};
use radicle::prelude::RepoId;

-
pub(crate) const ABOUT: &str = "Control and query the Radicle Node";
+
const ABOUT: &str = "Control and query the Radicle Node";

#[derive(Parser, Debug)]
#[command(about = ABOUT, long_about, disable_version_flag = true)]
modified crates/radicle-cli/src/commands/patch.rs
@@ -1,4 +1,5 @@
mod archive;
+
mod args;
mod assign;
mod cache;
mod checkout;
@@ -17,14 +18,11 @@ mod show;
mod update;

use std::collections::BTreeSet;
-
use std::ffi::OsString;
-
use std::str::FromStr as _;

use anyhow::anyhow;

use radicle::cob::patch::PatchId;
-
use radicle::cob::{patch, Label, Reaction};
-
use radicle::git::fmt::RefString;
+
use radicle::cob::{patch, Label};
use radicle::patch::cache::Patches as _;
use radicle::storage::git::transport;
use radicle::{prelude::*, Node};
@@ -32,811 +30,14 @@ use radicle::{prelude::*, Node};
use crate::git::Rev;
use crate::node;
use crate::terminal as term;
-
use crate::terminal::args::{string, Args, Error, Help};
use crate::terminal::patch::Message;

-
pub const HELP: Help = Help {
-
    name: "patch",
-
    description: "Manage patches",
-
    version: env!("RADICLE_VERSION"),
-
    usage: r#"
-
Usage
+
pub use args::Args;

-
    rad patch [<option>...]
-
    rad patch list [--all|--merged|--open|--archived|--draft|--authored] [--author <did>]... [<option>...]
-
    rad patch show <patch-id> [<option>...]
-
    rad patch diff <patch-id> [<option>...]
-
    rad patch archive <patch-id> [--undo] [<option>...]
-
    rad patch update <patch-id> [<option>...]
-
    rad patch checkout <patch-id> [<option>...]
-
    rad patch review <patch-id> [--accept | --reject] [-m [<string>]] [-d | --delete] [<option>...]
-
    rad patch resolve <patch-id> [--review <review-id>] [--comment <comment-id>] [--unresolve] [<option>...]
-
    rad patch delete <patch-id> [<option>...]
-
    rad patch redact <revision-id> [<option>...]
-
    rad patch react <patch-id | revision-id> [--react <emoji>] [<option>...]
-
    rad patch assign <revision-id> [--add <did>] [--delete <did>] [<option>...]
-
    rad patch label <revision-id> [--add <label>] [--delete <label>] [<option>...]
-
    rad patch ready <patch-id> [--undo] [<option>...]
-
    rad patch edit <patch-id> [<option>...]
-
    rad patch set <patch-id> [<option>...]
-
    rad patch comment <patch-id | revision-id> [<option>...]
-
    rad patch cache [<patch-id>] [--storage] [<option>...]
+
use args::{AssignArgs, Command, CommentAction, LabelArgs};

-
Show options
-

-
    -p, --patch                Show the actual patch diff
-
    -v, --verbose              Show additional information about the patch
-

-
Diff options
-

-
    -r, --revision <id>        The revision to diff (default: latest)
-

-
Comment options
-

-
    -m, --message <string>     Provide a comment message via the command-line
-
        --reply-to <comment>   The comment to reply to
-
        --edit <comment>       The comment to edit (use --message to edit with the provided message)
-
        --react <comment>      The comment to react to
-
        --emoji <char>         The emoji to react with when --react is used
-
        --redact <comment>     The comment to redact
-

-
Edit options
-

-
    -m, --message [<string>]   Provide a comment message to the patch or revision (default: prompt)
-

-
Review options
-

-
    -r, --revision <id>        Review the given revision of the patch
-
    -p, --patch                Review by patch hunks
-
        --hunk <index>         Only review a specific hunk
-
        --accept               Accept a patch or set of hunks
-
        --reject               Reject a patch or set of hunks
-
    -U, --unified <n>          Generate diffs with <n> lines of context instead of the usual three
-
    -d, --delete               Delete a review draft
-
    -m, --message [<string>]   Provide a comment with the review (default: prompt)
-

-
Resolve options
-

-
    --review <id>              The review id which the comment is under
-
    --comment <id>             The comment to (un)resolve
-
    --undo                     Unresolve the comment
-

-
Assign options
-

-
    -a, --add    <did>         Add an assignee to the patch (may be specified multiple times).
-
                               Note: --add will take precedence over --delete
-

-
    -d, --delete <did>         Delete an assignee from the patch (may be specified multiple times).
-
                               Note: --add will take precedence over --delete
-

-
Archive options
-

-
        --undo                 Unarchive a patch
-

-
Label options
-

-
    -a, --add    <label>       Add a label to the patch (may be specified multiple times).
-
                               Note: --add will take precedence over --delete
-

-
    -d, --delete <label>       Delete a label from the patch (may be specified multiple times).
-
                               Note: --add will take precedence over --delete
-

-
Update options
-

-
    -b, --base <revspec>       Provide a Git revision as the base commit
-
    -m, --message [<string>]   Provide a comment message to the patch or revision (default: prompt)
-
        --no-message           Leave the patch or revision comment message blank
-

-
List options
-

-
        --all                  Show all patches, including merged and archived patches
-
        --archived             Show only archived patches
-
        --merged               Show only merged patches
-
        --open                 Show only open patches (default)
-
        --draft                Show only draft patches
-
        --authored             Show only patches that you have authored
-
        --author <did>         Show only patched where the given user is an author
-
                               (may be specified multiple times)
-

-
Ready options
-

-
        --undo                 Convert a patch back to a draft
-

-
Checkout options
-

-
        --revision <id>        Checkout the given revision of the patch
-
        --name <string>        Provide a name for the branch to checkout
-
        --remote <string>      Provide the git remote to use as the upstream
-
    -f, --force                Checkout the head of the revision, even if the branch already exists
-

-
Set options
-

-
        --remote <string>      Provide the git remote to use as the upstream
-

-
React options
-

-
        --emoji <char>         The emoji to react to the patch or revision with
-

-
Other options
-

-
        --repo <rid>           Operate on the given repository (default: cwd)
-
        --[no-]announce        Announce changes made to the network
-
    -q, --quiet                Quiet output
-
        --help                 Print help
-
"#,
-
};
-

-
#[derive(Debug, Default, PartialEq, Eq)]
-
pub enum OperationName {
-
    Assign,
-
    Show,
-
    Diff,
-
    Update,
-
    Archive,
-
    Delete,
-
    Checkout,
-
    Comment,
-
    React,
-
    Ready,
-
    Review,
-
    Resolve,
-
    Label,
-
    #[default]
-
    List,
-
    Edit,
-
    Redact,
-
    Set,
-
    Cache,
-
}
-

-
#[derive(Debug, PartialEq, Eq)]
-
pub enum CommentOperation {
-
    Edit,
-
    React,
-
    Redact,
-
}
-

-
#[derive(Debug, Default, PartialEq, Eq)]
-
pub struct AssignOptions {
-
    pub add: BTreeSet<Did>,
-
    pub delete: BTreeSet<Did>,
-
}
-

-
#[derive(Debug, Default, PartialEq, Eq)]
-
pub struct LabelOptions {
-
    pub add: BTreeSet<Label>,
-
    pub delete: BTreeSet<Label>,
-
}
-

-
#[derive(Debug)]
-
pub enum Operation {
-
    Show {
-
        patch_id: Rev,
-
        diff: bool,
-
        verbose: bool,
-
    },
-
    Diff {
-
        patch_id: Rev,
-
        revision_id: Option<Rev>,
-
    },
-
    Update {
-
        patch_id: Rev,
-
        base_id: Option<Rev>,
-
        message: Message,
-
    },
-
    Archive {
-
        patch_id: Rev,
-
        undo: bool,
-
    },
-
    Ready {
-
        patch_id: Rev,
-
        undo: bool,
-
    },
-
    Delete {
-
        patch_id: Rev,
-
    },
-
    Checkout {
-
        patch_id: Rev,
-
        revision_id: Option<Rev>,
-
        opts: checkout::Options,
-
    },
-
    Comment {
-
        revision_id: Rev,
-
        message: Message,
-
        reply_to: Option<Rev>,
-
    },
-
    CommentEdit {
-
        revision_id: Rev,
-
        comment_id: Rev,
-
        message: Message,
-
    },
-
    CommentRedact {
-
        revision_id: Rev,
-
        comment_id: Rev,
-
    },
-
    CommentReact {
-
        revision_id: Rev,
-
        comment_id: Rev,
-
        reaction: Reaction,
-
        undo: bool,
-
    },
-
    React {
-
        revision_id: Rev,
-
        reaction: Reaction,
-
        undo: bool,
-
    },
-
    Review {
-
        patch_id: Rev,
-
        revision_id: Option<Rev>,
-
        opts: review::Options,
-
    },
-
    Resolve {
-
        patch_id: Rev,
-
        review_id: Rev,
-
        comment_id: Rev,
-
        undo: bool,
-
    },
-
    Assign {
-
        patch_id: Rev,
-
        opts: AssignOptions,
-
    },
-
    Label {
-
        patch_id: Rev,
-
        opts: LabelOptions,
-
    },
-
    List {
-
        filter: Option<patch::Status>,
-
    },
-
    Edit {
-
        patch_id: Rev,
-
        revision_id: Option<Rev>,
-
        message: Message,
-
    },
-
    Redact {
-
        revision_id: Rev,
-
    },
-
    Set {
-
        patch_id: Rev,
-
        remote: Option<RefString>,
-
    },
-
    Cache {
-
        patch_id: Option<Rev>,
-
        storage: bool,
-
    },
-
}
-

-
impl Operation {
-
    fn is_announce(&self) -> bool {
-
        match self {
-
            Operation::Update { .. }
-
            | Operation::Archive { .. }
-
            | Operation::Ready { .. }
-
            | Operation::Delete { .. }
-
            | Operation::Comment { .. }
-
            | Operation::CommentEdit { .. }
-
            | Operation::CommentRedact { .. }
-
            | Operation::CommentReact { .. }
-
            | Operation::Review { .. }
-
            | Operation::Resolve { .. }
-
            | Operation::Assign { .. }
-
            | Operation::Label { .. }
-
            | Operation::Edit { .. }
-
            | Operation::Redact { .. }
-
            | Operation::React { .. }
-
            | Operation::Set { .. } => true,
-
            Operation::Show { .. }
-
            | Operation::Diff { .. }
-
            | Operation::Checkout { .. }
-
            | Operation::List { .. }
-
            | Operation::Cache { .. } => false,
-
        }
-
    }
-
}
-

-
#[derive(Debug)]
-
pub struct Options {
-
    pub op: Operation,
-
    pub repo: Option<RepoId>,
-
    pub announce: bool,
-
    pub quiet: bool,
-
    pub authored: bool,
-
    pub authors: Vec<Did>,
-
}
-

-
impl Args for Options {
-
    fn from_args(args: Vec<OsString>) -> anyhow::Result<(Self, Vec<OsString>)> {
-
        use lexopt::prelude::*;
-

-
        let mut parser = lexopt::Parser::from_args(args);
-
        let mut op: Option<OperationName> = None;
-
        let mut verbose = false;
-
        let mut quiet = false;
-
        let mut authored = false;
-
        let mut authors = vec![];
-
        let mut announce = true;
-
        let mut patch_id = None;
-
        let mut revision_id = None;
-
        let mut review_id = None;
-
        let mut comment_id = None;
-
        let mut message = Message::default();
-
        let mut filter = Some(patch::Status::Open);
-
        let mut diff = false;
-
        let mut undo = false;
-
        let mut reaction: Option<Reaction> = None;
-
        let mut reply_to: Option<Rev> = None;
-
        let mut comment_op: Option<(CommentOperation, Rev)> = None;
-
        let mut checkout_opts = checkout::Options::default();
-
        let mut remote: Option<RefString> = None;
-
        let mut assign_opts = AssignOptions::default();
-
        let mut label_opts = LabelOptions::default();
-
        let mut review_op = review::Operation::default();
-
        let mut base_id = None;
-
        let mut repo = None;
-
        let mut cache_storage = false;
-

-
        while let Some(arg) = parser.next()? {
-
            match arg {
-
                // Options.
-
                Long("message") | Short('m') => {
-
                    if message != Message::Blank {
-
                        // We skip this code when `no-message` is specified.
-
                        let txt: String = term::args::string(&parser.value()?);
-
                        message.append(&txt);
-
                    }
-
                }
-
                Long("no-message") => {
-
                    message = Message::Blank;
-
                }
-
                Long("announce") => {
-
                    announce = true;
-
                }
-
                Long("no-announce") => {
-
                    announce = false;
-
                }
-

-
                // Show options.
-
                Long("patch") | Short('p') if op == Some(OperationName::Show) => {
-
                    diff = true;
-
                }
-
                Long("verbose") | Short('v') if op == Some(OperationName::Show) => {
-
                    verbose = true;
-
                }
-

-
                // Ready options.
-
                Long("undo") if op == Some(OperationName::Ready) => {
-
                    undo = true;
-
                }
-

-
                // Archive options.
-
                Long("undo") if op == Some(OperationName::Archive) => {
-
                    undo = true;
-
                }
-

-
                // Update options.
-
                Short('b') | Long("base") if op == Some(OperationName::Update) => {
-
                    let val = parser.value()?;
-
                    let rev = term::args::rev(&val)?;
-

-
                    base_id = Some(rev);
-
                }
-

-
                // React options.
-
                Long("emoji") if op == Some(OperationName::React) => {
-
                    if let Some(emoji) = parser.value()?.to_str() {
-
                        reaction =
-
                            Some(Reaction::from_str(emoji).map_err(|_| anyhow!("invalid emoji"))?);
-
                    }
-
                }
-
                Long("undo") if op == Some(OperationName::React) => {
-
                    undo = true;
-
                }
-

-
                // Comment options.
-
                Long("reply-to") if op == Some(OperationName::Comment) => {
-
                    let val = parser.value()?;
-
                    let rev = term::args::rev(&val)?;
-

-
                    reply_to = Some(rev);
-
                }
-

-
                Long("edit") if op == Some(OperationName::Comment) => {
-
                    let val = parser.value()?;
-
                    let rev = term::args::rev(&val)?;
-

-
                    comment_op = Some((CommentOperation::Edit, rev));
-
                }
-

-
                Long("react") if op == Some(OperationName::Comment) => {
-
                    let val = parser.value()?;
-
                    let rev = term::args::rev(&val)?;
-

-
                    comment_op = Some((CommentOperation::React, rev));
-
                }
-
                Long("emoji")
-
                    if op == Some(OperationName::Comment)
-
                        && matches!(comment_op, Some((CommentOperation::React, _))) =>
-
                {
-
                    if let Some(emoji) = parser.value()?.to_str() {
-
                        reaction =
-
                            Some(Reaction::from_str(emoji).map_err(|_| anyhow!("invalid emoji"))?);
-
                    }
-
                }
-
                Long("undo")
-
                    if op == Some(OperationName::Comment)
-
                        && matches!(comment_op, Some((CommentOperation::React, _))) =>
-
                {
-
                    undo = true;
-
                }
-

-
                Long("redact") if op == Some(OperationName::Comment) => {
-
                    let val = parser.value()?;
-
                    let rev = term::args::rev(&val)?;
-

-
                    comment_op = Some((CommentOperation::Redact, rev));
-
                }
-

-
                // Edit options.
-
                Long("revision") | Short('r') if op == Some(OperationName::Edit) => {
-
                    let val = parser.value()?;
-
                    let rev = term::args::rev(&val)?;
-

-
                    revision_id = Some(rev);
-
                }
-

-
                // Review/diff options.
-
                Long("revision") | Short('r')
-
                    if op == Some(OperationName::Review) || op == Some(OperationName::Diff) =>
-
                {
-
                    let val = parser.value()?;
-
                    let rev = term::args::rev(&val)?;
-

-
                    revision_id = Some(rev);
-
                }
-
                Long("patch") | Short('p') if op == Some(OperationName::Review) => {
-
                    if let review::Operation::Review { by_hunk, .. } = &mut review_op {
-
                        *by_hunk = true;
-
                    } else {
-
                        return Err(arg.unexpected().into());
-
                    }
-
                }
-
                Long("unified") | Short('U') if op == Some(OperationName::Review) => {
-
                    if let review::Operation::Review { unified, .. } = &mut review_op {
-
                        let val = parser.value()?;
-
                        *unified = term::args::number(&val)?;
-
                    } else {
-
                        return Err(arg.unexpected().into());
-
                    }
-
                }
-
                Long("hunk") if op == Some(OperationName::Review) => {
-
                    if let review::Operation::Review { hunk, .. } = &mut review_op {
-
                        let val = parser.value()?;
-
                        let val = term::args::number(&val)
-
                            .map_err(|e| anyhow!("invalid hunk value: {e}"))?;
-

-
                        *hunk = Some(val);
-
                    } else {
-
                        return Err(arg.unexpected().into());
-
                    }
-
                }
-
                Long("delete") | Short('d') if op == Some(OperationName::Review) => {
-
                    review_op = review::Operation::Delete;
-
                }
-
                Long("accept") if op == Some(OperationName::Review) => {
-
                    if let review::Operation::Review {
-
                        verdict: verdict @ None,
-
                        ..
-
                    } = &mut review_op
-
                    {
-
                        *verdict = Some(patch::Verdict::Accept);
-
                    } else {
-
                        return Err(arg.unexpected().into());
-
                    }
-
                }
-
                Long("reject") if op == Some(OperationName::Review) => {
-
                    if let review::Operation::Review {
-
                        verdict: verdict @ None,
-
                        ..
-
                    } = &mut review_op
-
                    {
-
                        *verdict = Some(patch::Verdict::Reject);
-
                    } else {
-
                        return Err(arg.unexpected().into());
-
                    }
-
                }
-

-
                // Resolve options
-
                Long("undo") if op == Some(OperationName::Resolve) => {
-
                    undo = true;
-
                }
-
                Long("review") if op == Some(OperationName::Resolve) => {
-
                    let val = parser.value()?;
-
                    let rev = term::args::rev(&val)?;
-

-
                    review_id = Some(rev);
-
                }
-
                Long("comment") if op == Some(OperationName::Resolve) => {
-
                    let val = parser.value()?;
-
                    let rev = term::args::rev(&val)?;
-

-
                    comment_id = Some(rev);
-
                }
-

-
                // Checkout options
-
                Long("revision") if op == Some(OperationName::Checkout) => {
-
                    let val = parser.value()?;
-
                    let rev = term::args::rev(&val)?;
-

-
                    revision_id = Some(rev);
-
                }
-

-
                Long("force") | Short('f') if op == Some(OperationName::Checkout) => {
-
                    checkout_opts.force = true;
-
                }
-

-
                Long("name") if op == Some(OperationName::Checkout) => {
-
                    let val = parser.value()?;
-
                    checkout_opts.name = Some(term::args::refstring("name", val)?);
-
                }
-

-
                Long("remote") if op == Some(OperationName::Checkout) => {
-
                    let val = parser.value()?;
-
                    checkout_opts.remote = Some(term::args::refstring("remote", val)?);
-
                }
-

-
                // Assign options.
-
                Short('a') | Long("add") if matches!(op, Some(OperationName::Assign)) => {
-
                    assign_opts.add.insert(term::args::did(&parser.value()?)?);
-
                }
-

-
                Short('d') | Long("delete") if matches!(op, Some(OperationName::Assign)) => {
-
                    assign_opts
-
                        .delete
-
                        .insert(term::args::did(&parser.value()?)?);
-
                }
-

-
                // Label options.
-
                Short('a') | Long("add") if matches!(op, Some(OperationName::Label)) => {
-
                    let val = parser.value()?;
-
                    let name = term::args::string(&val);
-
                    let label = Label::new(name)?;
-

-
                    label_opts.add.insert(label);
-
                }
-

-
                Short('d') | Long("delete") if matches!(op, Some(OperationName::Label)) => {
-
                    let val = parser.value()?;
-
                    let name = term::args::string(&val);
-
                    let label = Label::new(name)?;
-

-
                    label_opts.delete.insert(label);
-
                }
-

-
                // Set options.
-
                Long("remote") if op == Some(OperationName::Set) => {
-
                    let val = parser.value()?;
-
                    remote = Some(term::args::refstring("remote", val)?);
-
                }
-

-
                // List options.
-
                Long("all") => {
-
                    filter = None;
-
                }
-
                Long("draft") => {
-
                    filter = Some(patch::Status::Draft);
-
                }
-
                Long("archived") => {
-
                    filter = Some(patch::Status::Archived);
-
                }
-
                Long("merged") => {
-
                    filter = Some(patch::Status::Merged);
-
                }
-
                Long("open") => {
-
                    filter = Some(patch::Status::Open);
-
                }
-
                Long("authored") => {
-
                    authored = true;
-
                }
-
                Long("author") if op == Some(OperationName::List) => {
-
                    authors.push(term::args::did(&parser.value()?)?);
-
                }
-

-
                // Cache options.
-
                Long("storage") if op == Some(OperationName::Cache) => {
-
                    cache_storage = true;
-
                }
-

-
                // Common.
-
                Long("quiet") | Short('q') => {
-
                    quiet = true;
-
                }
-
                Long("repo") => {
-
                    let val = parser.value()?;
-
                    let rid = term::args::rid(&val)?;
-

-
                    repo = Some(rid);
-
                }
-
                Long("help") => {
-
                    return Err(Error::HelpManual { name: "rad-patch" }.into());
-
                }
-
                Short('h') => {
-
                    return Err(Error::Help.into());
-
                }
-

-
                Value(val) if op.is_none() => match val.to_string_lossy().as_ref() {
-
                    "l" | "list" => op = Some(OperationName::List),
-
                    "s" | "show" => op = Some(OperationName::Show),
-
                    "u" | "update" => op = Some(OperationName::Update),
-
                    "d" | "delete" => op = Some(OperationName::Delete),
-
                    "c" | "checkout" => op = Some(OperationName::Checkout),
-
                    "a" | "archive" => op = Some(OperationName::Archive),
-
                    "y" | "ready" => op = Some(OperationName::Ready),
-
                    "e" | "edit" => op = Some(OperationName::Edit),
-
                    "r" | "redact" => op = Some(OperationName::Redact),
-
                    "diff" => op = Some(OperationName::Diff),
-
                    "assign" => op = Some(OperationName::Assign),
-
                    "label" => op = Some(OperationName::Label),
-
                    "comment" => op = Some(OperationName::Comment),
-
                    "review" => op = Some(OperationName::Review),
-
                    "resolve" => op = Some(OperationName::Resolve),
-
                    "set" => op = Some(OperationName::Set),
-
                    "cache" => op = Some(OperationName::Cache),
-
                    unknown => anyhow::bail!("unknown operation '{}'", unknown),
-
                },
-
                Value(val) if op == Some(OperationName::Redact) => {
-
                    let rev = term::args::rev(&val)?;
-
                    revision_id = Some(rev);
-
                }
-
                Value(val)
-
                    if patch_id.is_none()
-
                        && [
-
                            Some(OperationName::Show),
-
                            Some(OperationName::Diff),
-
                            Some(OperationName::Update),
-
                            Some(OperationName::Delete),
-
                            Some(OperationName::Archive),
-
                            Some(OperationName::Ready),
-
                            Some(OperationName::Checkout),
-
                            Some(OperationName::Comment),
-
                            Some(OperationName::Review),
-
                            Some(OperationName::Resolve),
-
                            Some(OperationName::Edit),
-
                            Some(OperationName::Set),
-
                            Some(OperationName::Assign),
-
                            Some(OperationName::Label),
-
                            Some(OperationName::Cache),
-
                        ]
-
                        .contains(&op) =>
-
                {
-
                    let val = string(&val);
-
                    patch_id = Some(Rev::from(val));
-
                }
-
                _ => anyhow::bail!(arg.unexpected()),
-
            }
-
        }
-

-
        let op = match op.unwrap_or_default() {
-
            OperationName::List => Operation::List { filter },
-
            OperationName::Show => Operation::Show {
-
                patch_id: patch_id.ok_or_else(|| anyhow!("a patch must be provided"))?,
-
                diff,
-
                verbose,
-
            },
-
            OperationName::Diff => Operation::Diff {
-
                patch_id: patch_id.ok_or_else(|| anyhow!("a patch must be provided"))?,
-
                revision_id,
-
            },
-
            OperationName::Delete => Operation::Delete {
-
                patch_id: patch_id.ok_or_else(|| anyhow!("a patch must be provided"))?,
-
            },
-
            OperationName::Update => Operation::Update {
-
                patch_id: patch_id.ok_or_else(|| anyhow!("a patch must be provided"))?,
-
                base_id,
-
                message,
-
            },
-
            OperationName::Archive => Operation::Archive {
-
                patch_id: patch_id.ok_or_else(|| anyhow!("a patch id must be provided"))?,
-
                undo,
-
            },
-
            OperationName::Checkout => Operation::Checkout {
-
                patch_id: patch_id.ok_or_else(|| anyhow!("a patch must be provided"))?,
-
                revision_id,
-
                opts: checkout_opts,
-
            },
-
            OperationName::Comment => match comment_op {
-
                Some((CommentOperation::Edit, comment)) => Operation::CommentEdit {
-
                    revision_id: patch_id
-
                        .ok_or_else(|| anyhow!("a patch or revision must be provided"))?,
-
                    comment_id: comment,
-
                    message,
-
                },
-
                Some((CommentOperation::React, comment)) => Operation::CommentReact {
-
                    revision_id: patch_id
-
                        .ok_or_else(|| anyhow!("a patch or revision must be provided"))?,
-
                    comment_id: comment,
-
                    reaction: reaction
-
                        .ok_or_else(|| anyhow!("a reaction emoji must be provided"))?,
-
                    undo,
-
                },
-
                Some((CommentOperation::Redact, comment)) => Operation::CommentRedact {
-
                    revision_id: patch_id
-
                        .ok_or_else(|| anyhow!("a patch or revision must be provided"))?,
-
                    comment_id: comment,
-
                },
-
                None => Operation::Comment {
-
                    revision_id: patch_id
-
                        .ok_or_else(|| anyhow!("a patch or revision must be provided"))?,
-
                    message,
-
                    reply_to,
-
                },
-
            },
-
            OperationName::React => Operation::React {
-
                revision_id: patch_id
-
                    .ok_or_else(|| anyhow!("a patch or revision must be provided"))?,
-
                reaction: reaction.ok_or_else(|| anyhow!("a reaction emoji must be provided"))?,
-
                undo,
-
            },
-
            OperationName::Review => Operation::Review {
-
                patch_id: patch_id
-
                    .ok_or_else(|| anyhow!("a patch or revision must be provided"))?,
-
                revision_id,
-
                opts: review::Options {
-
                    message,
-
                    op: review_op,
-
                },
-
            },
-
            OperationName::Resolve => Operation::Resolve {
-
                patch_id: patch_id
-
                    .ok_or_else(|| anyhow!("a patch or revision must be provided"))?,
-
                review_id: review_id.ok_or_else(|| anyhow!("a review must be provided"))?,
-
                comment_id: comment_id.ok_or_else(|| anyhow!("a comment must be provided"))?,
-
                undo,
-
            },
-
            OperationName::Ready => Operation::Ready {
-
                patch_id: patch_id.ok_or_else(|| anyhow!("a patch must be provided"))?,
-
                undo,
-
            },
-
            OperationName::Edit => Operation::Edit {
-
                patch_id: patch_id.ok_or_else(|| anyhow!("a patch must be provided"))?,
-
                revision_id,
-
                message,
-
            },
-
            OperationName::Redact => Operation::Redact {
-
                revision_id: revision_id.ok_or_else(|| anyhow!("a revision must be provided"))?,
-
            },
-
            OperationName::Assign => Operation::Assign {
-
                patch_id: patch_id.ok_or_else(|| anyhow!("a patch must be provided"))?,
-
                opts: assign_opts,
-
            },
-
            OperationName::Label => Operation::Label {
-
                patch_id: patch_id.ok_or_else(|| anyhow!("a patch must be provided"))?,
-
                opts: label_opts,
-
            },
-
            OperationName::Set => Operation::Set {
-
                patch_id: patch_id.ok_or_else(|| anyhow!("a patch must be provided"))?,
-
                remote,
-
            },
-
            OperationName::Cache => Operation::Cache {
-
                patch_id,
-
                storage: cache_storage,
-
            },
-
        };
-

-
        Ok((
-
            Options {
-
                op,
-
                repo,
-
                quiet,
-
                announce,
-
                authored,
-
                authors,
-
            },
-
            vec![],
-
        ))
-
    }
-
}
-

-
pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
-
    let (workdir, rid) = if let Some(rid) = options.repo {
+
pub fn run(args: Args, ctx: impl term::Context) -> anyhow::Result<()> {
+
    let (workdir, rid) = if let Some(rid) = args.repo {
        (None, rid)
    } else {
        radicle::rad::cwd()
@@ -846,51 +47,51 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {

    let profile = ctx.profile()?;
    let repository = profile.storage.repository(rid)?;
-
    let announce = options.announce && options.op.is_announce();
+

+
    // Fallback to [`Command::List`] if no subcommand is provided.
+
    // Construct it using the [`EmptyArgs`] in `args.empty`.
+
    let mut announce = args.should_announce();
+
    let command = args
+
        .command
+
        .unwrap_or_else(|| Command::List(args.empty.into()));
+
    announce &= command.should_announce();

    transport::local::register(profile.storage.clone());

-
    match options.op {
-
        Operation::List { filter } => {
-
            let mut authors: BTreeSet<Did> = options.authors.iter().cloned().collect();
-
            if options.authored {
+
    match command {
+
        Command::List(args) => {
+
            let mut authors: BTreeSet<Did> = args.authors.iter().cloned().collect();
+
            if args.authored {
                authors.insert(profile.did());
            }
-
            list::run(filter.as_ref(), authors, &repository, &profile)?;
+
            list::run((&args.state).into(), authors, &repository, &profile)?;
        }
-
        Operation::Show {
-
            patch_id,
-
            diff,
-
            verbose,
-
        } => {
-
            let patch_id = patch_id.resolve(&repository.backend)?;
+

+
        Command::Show { id, patch, verbose } => {
+
            let patch_id = id.resolve(&repository.backend)?;
            show::run(
                &patch_id,
-
                diff,
+
                patch,
                verbose,
                &profile,
                &repository,
                workdir.as_ref(),
            )?;
        }
-
        Operation::Diff {
-
            patch_id,
-
            revision_id,
-
        } => {
-
            let patch_id = patch_id.resolve(&repository.backend)?;
-
            let revision_id = revision_id
+

+
        Command::Diff { id, revision } => {
+
            let patch_id = id.resolve(&repository.backend)?;
+
            let revision_id = revision
                .map(|rev| rev.resolve::<radicle::git::Oid>(&repository.backend))
                .transpose()?
                .map(patch::RevisionId::from);
            diff::run(&patch_id, revision_id, &repository, &profile)?;
        }
-
        Operation::Update {
-
            ref patch_id,
-
            ref base_id,
-
            ref message,
-
        } => {
-
            let patch_id = patch_id.resolve(&repository.backend)?;
-
            let base_id = base_id
+

+
        Command::Update { id, base, message } => {
+
            let message = Message::from(message);
+
            let patch_id = id.resolve(&repository.backend)?;
+
            let base_id = base
                .as_ref()
                .map(|base| base.resolve(&repository.backend))
                .transpose()?;
@@ -898,21 +99,16 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
                "this command must be run from a repository checkout"
            ))?;

-
            update::run(
-
                patch_id,
-
                base_id,
-
                message.clone(),
-
                &profile,
-
                &repository,
-
                &workdir,
-
            )?;
+
            update::run(patch_id, base_id, message, &profile, &repository, &workdir)?;
        }
-
        Operation::Archive { ref patch_id, undo } => {
-
            let patch_id = patch_id.resolve::<PatchId>(&repository.backend)?;
+

+
        Command::Archive { id, undo } => {
+
            let patch_id = id.resolve::<PatchId>(&repository.backend)?;
            archive::run(&patch_id, undo, &profile, &repository)?;
        }
-
        Operation::Ready { ref patch_id, undo } => {
-
            let patch_id = patch_id.resolve::<PatchId>(&repository.backend)?;
+

+
        Command::Ready { id, undo } => {
+
            let patch_id = id.resolve::<PatchId>(&repository.backend)?;

            if !ready::run(&patch_id, undo, &profile, &repository)? {
                if undo {
@@ -922,17 +118,15 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
                }
            }
        }
-
        Operation::Delete { patch_id } => {
-
            let patch_id = patch_id.resolve::<PatchId>(&repository.backend)?;
+

+
        Command::Delete { id } => {
+
            let patch_id = id.resolve::<PatchId>(&repository.backend)?;
            delete::run(&patch_id, &profile, &repository)?;
        }
-
        Operation::Checkout {
-
            patch_id,
-
            revision_id,
-
            opts,
-
        } => {
-
            let patch_id = patch_id.resolve::<radicle::git::Oid>(&repository.backend)?;
-
            let revision_id = revision_id
+

+
        Command::Checkout { id, revision, opts } => {
+
            let patch_id = id.resolve::<radicle::git::Oid>(&repository.backend)?;
+
            let revision_id = revision
                .map(|rev| rev.resolve::<radicle::git::Oid>(&repository.backend))
                .transpose()?
                .map(patch::RevisionId::from);
@@ -945,86 +139,136 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
                &repository,
                &workdir,
                &profile,
-
                opts,
+
                opts.into(),
            )?;
        }
-
        Operation::Comment {
-
            revision_id,
-
            message,
-
            reply_to,
-
        } => {
-
            comment::run(
-
                revision_id,
+

+
        Command::Comment(c) => match CommentAction::from(c) {
+
            CommentAction::Comment {
+
                revision,
                message,
                reply_to,
-
                options.quiet,
-
                &repository,
-
                &profile,
-
            )?;
-
        }
-
        Operation::Review {
-
            patch_id,
-
            revision_id,
-
            opts,
+
            } => {
+
                comment::run(
+
                    revision,
+
                    message,
+
                    reply_to,
+
                    args.quiet,
+
                    &repository,
+
                    &profile,
+
                )?;
+
            }
+
            CommentAction::Edit {
+
                revision,
+
                comment,
+
                message,
+
            } => {
+
                let comment = comment.resolve(&repository.backend)?;
+
                comment::edit::run(
+
                    revision,
+
                    comment,
+
                    message,
+
                    args.quiet,
+
                    &repository,
+
                    &profile,
+
                )?;
+
            }
+
            CommentAction::Redact { revision, comment } => {
+
                let comment = comment.resolve(&repository.backend)?;
+
                comment::redact::run(revision, comment, &repository, &profile)?;
+
            }
+
            CommentAction::React {
+
                revision,
+
                comment,
+
                emoji,
+
                undo,
+
            } => {
+
                let comment = comment.resolve(&repository.backend)?;
+
                if undo {
+
                    comment::react::run(revision, comment, emoji, false, &repository, &profile)?;
+
                } else {
+
                    comment::react::run(revision, comment, emoji, true, &repository, &profile)?;
+
                }
+
            }
+
        },
+

+
        Command::Review {
+
            id,
+
            revision,
+
            options,
        } => {
-
            let patch_id = patch_id.resolve(&repository.backend)?;
-
            let revision_id = revision_id
+
            let patch_id = id.resolve(&repository.backend)?;
+
            let revision_id = revision
                .map(|rev| rev.resolve::<radicle::git::Oid>(&repository.backend))
                .transpose()?
                .map(patch::RevisionId::from);
-
            review::run(patch_id, revision_id, opts, &profile, &repository)?;
+
            review::run(patch_id, revision_id, options.into(), &profile, &repository)?;
        }
-
        Operation::Resolve {
-
            ref patch_id,
-
            ref review_id,
-
            ref comment_id,
-
            undo,
+

+
        Command::Resolve {
+
            id,
+
            review,
+
            comment,
+
            unresolve,
        } => {
-
            let patch = patch_id.resolve(&repository.backend)?;
+
            let patch = id.resolve(&repository.backend)?;
            let review = patch::ReviewId::from(
-
                review_id.resolve::<radicle::cob::EntryId>(&repository.backend)?,
+
                review.resolve::<radicle::cob::EntryId>(&repository.backend)?,
            );
-
            let comment = comment_id.resolve(&repository.backend)?;
-
            if undo {
+
            let comment = comment.resolve(&repository.backend)?;
+
            if unresolve {
                resolve::unresolve(patch, review, comment, &repository, &profile)?;
-
                term::success!("Unresolved comment {comment_id}");
+
                term::success!("Unresolved comment {comment}");
            } else {
                resolve::resolve(patch, review, comment, &repository, &profile)?;
-
                term::success!("Resolved comment {comment_id}");
+
                term::success!("Resolved comment {comment}");
            }
        }
-
        Operation::Edit {
-
            patch_id,
-
            revision_id,
+
        Command::Edit {
+
            id,
+
            revision,
            message,
        } => {
-
            let patch_id = patch_id.resolve(&repository.backend)?;
-
            let revision_id = revision_id
+
            let message = Message::from(message);
+
            let patch_id = id.resolve(&repository.backend)?;
+
            let revision_id = revision
                .map(|id| id.resolve::<radicle::git::Oid>(&repository.backend))
                .transpose()?
                .map(patch::RevisionId::from);
            edit::run(&patch_id, revision_id, message, &profile, &repository)?;
        }
-
        Operation::Redact { revision_id } => {
-
            redact::run(&revision_id, &profile, &repository)?;
+
        Command::Redact { id } => {
+
            redact::run(&id, &profile, &repository)?;
        }
-
        Operation::Assign {
-
            patch_id,
-
            opts: AssignOptions { add, delete },
+
        Command::Assign {
+
            id,
+
            args: AssignArgs { add, delete },
        } => {
-
            let patch_id = patch_id.resolve(&repository.backend)?;
-
            assign::run(&patch_id, add, delete, &profile, &repository)?;
+
            let patch_id = id.resolve(&repository.backend)?;
+
            assign::run(
+
                &patch_id,
+
                add.into_iter().collect(),
+
                delete.into_iter().collect(),
+
                &profile,
+
                &repository,
+
            )?;
        }
-
        Operation::Label {
-
            patch_id,
-
            opts: LabelOptions { add, delete },
+
        Command::Label {
+
            id,
+
            args: LabelArgs { add, delete },
        } => {
-
            let patch_id = patch_id.resolve(&repository.backend)?;
-
            label::run(&patch_id, add, delete, &profile, &repository)?;
+
            let patch_id = id.resolve(&repository.backend)?;
+
            label::run(
+
                &patch_id,
+
                add.into_iter().collect(),
+
                delete.into_iter().collect(),
+
                &profile,
+
                &repository,
+
            )?;
        }
-
        Operation::Set { patch_id, remote } => {
+
        Command::Set { id, remote } => {
            let patches = term::cob::patches(&profile, &repository)?;
-
            let patch_id = patch_id.resolve(&repository.backend)?;
+
            let patch_id = id.resolve(&repository.backend)?;
            let patch = patches
                .get(&patch_id)?
                .ok_or_else(|| anyhow!("patch {patch_id} not found"))?;
@@ -1039,13 +283,11 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
                true,
            )?;
        }
-
        Operation::Cache { patch_id, storage } => {
+
        Command::Cache { id, storage } => {
            let mode = if storage {
                cache::CacheMode::Storage
            } else {
-
                let patch_id = patch_id
-
                    .map(|id| id.resolve(&repository.backend))
-
                    .transpose()?;
+
                let patch_id = id.map(|id| id.resolve(&repository.backend)).transpose()?;
                patch_id.map_or(
                    cache::CacheMode::Repository {
                        repository: &repository,
@@ -1058,50 +300,15 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
            };
            cache::run(mode, &profile)?;
        }
-
        Operation::CommentEdit {
-
            revision_id,
-
            comment_id,
-
            message,
-
        } => {
-
            let comment = comment_id.resolve(&repository.backend)?;
-
            comment::edit::run(
-
                revision_id,
-
                comment,
-
                message,
-
                options.quiet,
-
                &repository,
-
                &profile,
-
            )?;
-
        }
-
        Operation::CommentRedact {
-
            revision_id,
-
            comment_id,
-
        } => {
-
            let comment = comment_id.resolve(&repository.backend)?;
-
            comment::redact::run(revision_id, comment, &repository, &profile)?;
-
        }
-
        Operation::CommentReact {
-
            revision_id,
-
            comment_id,
-
            reaction,
-
            undo,
-
        } => {
-
            let comment = comment_id.resolve(&repository.backend)?;
-
            if undo {
-
                comment::react::run(revision_id, comment, reaction, false, &repository, &profile)?;
-
            } else {
-
                comment::react::run(revision_id, comment, reaction, true, &repository, &profile)?;
-
            }
-
        }
-
        Operation::React {
-
            revision_id,
-
            reaction,
+
        Command::React {
+
            id,
+
            emoji: react,
            undo,
        } => {
            if undo {
-
                react::run(&revision_id, reaction, false, &repository, &profile)?;
+
                react::run(&id, react, false, &repository, &profile)?;
            } else {
-
                react::run(&revision_id, reaction, true, &repository, &profile)?;
+
                react::run(&id, react, true, &repository, &profile)?;
            }
        }
    }
added crates/radicle-cli/src/commands/patch/args.rs
@@ -0,0 +1,755 @@
+
use clap::{Parser, Subcommand};
+

+
use radicle::cob::Label;
+
use radicle::git;
+
use radicle::git::fmt::RefString;
+
use radicle::patch::Status;
+
use radicle::patch::Verdict;
+
use radicle::prelude::Did;
+
use radicle::prelude::RepoId;
+

+
use crate::commands::patch::checkout;
+
use crate::commands::patch::review;
+

+
use crate::git::Rev;
+
use crate::terminal::patch::Message;
+

+
const ABOUT: &str = "Manage patches";
+

+
#[derive(Debug, Parser)]
+
#[command(about = ABOUT, disable_version_flag = true)]
+
pub struct Args {
+
    #[command(subcommand)]
+
    pub(super) command: Option<Command>,
+

+
    /// Quiet output
+
    #[arg(short, long, global = true)]
+
    pub(super) quiet: bool,
+

+
    /// Announce changes made to the network
+
    #[arg(long, global = true, conflicts_with = "no_announce")]
+
    announce: bool,
+

+
    /// Do not announce changes made to the network
+
    #[arg(long, global = true, conflicts_with = "announce")]
+
    no_announce: bool,
+

+
    /// Operate on the given repository [default: cwd]
+
    #[arg(long, global = true, value_name = "RID")]
+
    pub(super) repo: Option<RepoId>,
+

+
    /// Verbose output
+
    #[arg(long, short, global = true)]
+
    pub(super) verbose: bool,
+

+
    /// Arguments for the empty subcommand.
+
    /// Will fall back to [`Command::List`].
+
    #[clap(flatten)]
+
    pub(super) empty: EmptyArgs,
+
}
+

+
impl Args {
+
    pub(super) fn should_announce(&self) -> bool {
+
        self.announce || !self.no_announce
+
    }
+
}
+

+
/// Commands to create, view, and edit Radicle patches
+
#[derive(Subcommand, Debug)]
+
pub(super) enum Command {
+
    /// List the patches of a repository
+
    #[command(alias = "l")]
+
    List(ListArgs),
+

+
    /// Show a specific patch
+
    #[command(alias = "s")]
+
    Show {
+
        /// ID of the patch
+
        #[arg(value_name = "PATCH_ID")]
+
        id: Rev,
+

+
        /// Show the diff of the changes in the patch
+
        #[arg(long, short)]
+
        patch: bool,
+

+
        /// Verbose output
+
        #[arg(long, short)]
+
        verbose: bool,
+
    },
+

+
    /// Show the diff of a specific patch
+
    ///
+
    /// The `git diff` of the revision's base and head will be shown
+
    Diff {
+
        /// ID of the patch
+
        #[arg(value_name = "PATCH_ID")]
+
        id: Rev,
+

+
        /// The revision to diff
+
        ///
+
        /// If not specified, the latest revision of the original author
+
        /// will be used
+
        #[arg(long, short)]
+
        revision: Option<Rev>,
+
    },
+

+
    /// Mark a patch as archived
+
    #[command(alias = "a")]
+
    Archive {
+
        /// ID of the patch
+
        #[arg(value_name = "PATCH_ID")]
+
        id: Rev,
+

+
        /// Unarchive a patch
+
        ///
+
        /// The patch will be marked as open
+
        #[arg(long)]
+
        undo: bool,
+
    },
+

+
    /// Update the metadata of a patch
+
    #[command(alias = "u")]
+
    Update {
+
        /// ID of the patch
+
        #[arg(value_name = "PATCH_ID")]
+
        id: Rev,
+

+
        /// Provide a Git revision as the base commit
+
        #[arg(long, short, value_name = "REVSPEC")]
+
        base: Option<Rev>,
+

+
        /// Change the message of the original revision of the patch
+
        #[clap(flatten)]
+
        message: MessageArgs,
+
    },
+

+
    /// Checkout a Git branch pointing to the head of a patch revision
+
    ///
+
    /// If no revision is specified, the latest revision of the original author
+
    /// is chosen
+
    #[command(alias = "c")]
+
    Checkout {
+
        /// ID of the patch
+
        #[arg(value_name = "PATCH_ID")]
+
        id: Rev,
+

+
        /// Checkout the given revision of the patch
+
        #[arg(long)]
+
        revision: Option<Rev>,
+

+
        #[clap(flatten)]
+
        opts: CheckoutArgs,
+
    },
+

+
    /// Create a review of a patch revision
+
    Review {
+
        /// ID of the patch
+
        #[arg(value_name = "PATCH_ID")]
+
        id: Rev,
+

+
        /// The particular revision to review
+
        ///
+
        /// If none is specified, the initial revision will be reviewed
+
        #[arg(long, short)]
+
        revision: Option<Rev>,
+

+
        #[clap(flatten)]
+
        options: ReviewArgs,
+
    },
+

+
    /// Mark a comment of a review as resolved or unresolved
+
    Resolve {
+
        /// ID of the patch
+
        #[arg(value_name = "PATCH_ID")]
+
        id: Rev,
+

+
        /// The review id which the comment is under
+
        #[arg(long, value_name = "REVIEW_ID")]
+
        review: Rev,
+

+
        /// The comment to (un)resolve
+
        #[arg(long, value_name = "COMMENT_ID")]
+
        comment: Rev,
+

+
        /// Unresolve the comment
+
        #[arg(long)]
+
        unresolve: bool,
+
    },
+

+
    /// Delete a patch
+
    ///
+
    /// This will delete any patch data associated with this user. Note that
+
    /// other user's data will remain, meaning the patch will remain until all
+
    /// other data is also deleted.
+
    #[command(alias = "d")]
+
    Delete {
+
        /// ID of the patch
+
        #[arg(value_name = "PATCH_ID")]
+
        id: Rev,
+
    },
+

+
    /// Redact a patch revision
+
    #[command(alias = "r")]
+
    Redact {
+
        /// ID of the patch revision
+
        #[arg(value_name = "REVISION_ID")]
+
        id: Rev,
+
    },
+

+
    /// React to a patch or patch revision
+
    React {
+
        /// ID of the patch or patch revision
+
        #[arg(value_name = "PATCH_ID|REVISION_ID")]
+
        id: Rev,
+

+
        /// The reaction being used
+
        #[arg(long, value_name = "CHAR")]
+
        emoji: radicle::cob::Reaction,
+

+
        /// Remove the reaction
+
        #[arg(long)]
+
        undo: bool,
+
    },
+

+
    /// Add or remove assignees to/from a patch
+
    Assign {
+
        /// ID of the patch
+
        #[arg(value_name = "PATCH_ID")]
+
        id: Rev,
+

+
        #[clap(flatten)]
+
        args: AssignArgs,
+
    },
+

+
    /// Add or remove labels to/from a patch
+
    Label {
+
        /// ID of the patch
+
        #[arg(value_name = "PATCH_ID")]
+
        id: Rev,
+

+
        #[clap(flatten)]
+
        args: LabelArgs,
+
    },
+

+
    /// If the patch is marked as a draft, then mark it as open
+
    #[command(alias = "y")]
+
    Ready {
+
        /// ID of the patch
+
        #[arg(value_name = "PATCH_ID")]
+
        id: Rev,
+

+
        /// Convert a patch back to a draft
+
        #[arg(long)]
+
        undo: bool,
+
    },
+

+
    #[command(alias = "e")]
+
    Edit {
+
        /// ID of the patch
+
        #[arg(value_name = "PATCH_ID")]
+
        id: Rev,
+

+
        /// ID of the patch revision
+
        #[arg(long, value_name = "REVISION_ID")]
+
        revision: Option<Rev>,
+

+
        #[clap(flatten)]
+
        message: MessageArgs,
+
    },
+

+
    /// Set an upstream branch for a patch
+
    Set {
+
        /// ID of the patch
+
        #[arg(value_name = "PATCH_ID")]
+
        id: Rev,
+

+
        /// Provide the git remote to use as the upstream
+
        #[arg(long, value_name = "REF", value_parser = parse_refstr)]
+
        remote: Option<RefString>,
+
    },
+

+
    /// Comment on, reply to, edit, or react to a comment
+
    Comment(CommentArgs),
+

+
    /// Re-cache the patches
+
    Cache {
+
        /// ID of the patch
+
        #[arg(value_name = "PATCH_ID")]
+
        id: Option<Rev>,
+

+
        /// Re-cache all patches in storage, as opposed to the current repository
+
        #[arg(long)]
+
        storage: bool,
+
    },
+
}
+

+
impl Command {
+
    pub(super) fn should_announce(&self) -> bool {
+
        match self {
+
            Self::Update { .. }
+
            | Self::Archive { .. }
+
            | Self::Ready { .. }
+
            | Self::Delete { .. }
+
            | Self::Comment { .. }
+
            | Self::Review { .. }
+
            | Self::Resolve { .. }
+
            | Self::Assign { .. }
+
            | Self::Label { .. }
+
            | Self::Edit { .. }
+
            | Self::Redact { .. }
+
            | Self::React { .. }
+
            | Self::Set { .. } => true,
+
            Self::Show { .. }
+
            | Self::Diff { .. }
+
            | Self::Checkout { .. }
+
            | Self::List { .. }
+
            | Self::Cache { .. } => false,
+
        }
+
    }
+
}
+

+
#[derive(Parser, Debug)]
+
pub(super) struct CommentArgs {
+
    /// ID of the revision to comment on
+
    #[arg(value_name = "REVISION_ID")]
+
    revision: Rev,
+

+
    #[clap(flatten)]
+
    message: MessageArgs,
+

+
    /// The comment to edit
+
    ///
+
    /// Use `--message` to edit with the provided message
+
    #[arg(
+
        long,
+
        value_name = "COMMENT_ID",
+
        conflicts_with = "react",
+
        conflicts_with = "redact"
+
    )]
+
    edit: Option<Rev>,
+

+
    /// The comment to react to
+
    ///
+
    /// Use `--emoji` for the character to react with
+
    ///
+
    /// Use `--undo` with `--emoji` to remove the reaction
+
    #[arg(
+
        long,
+
        value_name = "COMMENT_ID",
+
        conflicts_with = "edit",
+
        conflicts_with = "redact",
+
        requires = "emoji",
+
        group = "reaction"
+
    )]
+
    react: Option<Rev>,
+

+
    /// The comment to redact
+
    #[arg(
+
        long,
+
        value_name = "COMMENT_ID",
+
        conflicts_with = "react",
+
        conflicts_with = "edit"
+
    )]
+
    redact: Option<Rev>,
+

+
    /// The emoji to react with
+
    ///
+
    /// Requires using `--react <COMMENT_ID>`
+
    #[arg(long, requires = "reaction")]
+
    emoji: Option<radicle::cob::Reaction>,
+

+
    /// The comment to reply to
+
    #[arg(long, value_name = "COMMENT_ID")]
+
    reply_to: Option<Rev>,
+

+
    /// Remove the reaction
+
    ///
+
    /// Requires using `--react <COMMENT_ID> --emoji <EMOJI>`
+
    #[arg(long, requires = "reaction")]
+
    undo: bool,
+
}
+

+
#[derive(Debug)]
+
pub(super) enum CommentAction {
+
    Comment {
+
        revision: Rev,
+
        message: Message,
+
        reply_to: Option<Rev>,
+
    },
+
    Edit {
+
        revision: Rev,
+
        comment: Rev,
+
        message: Message,
+
    },
+
    Redact {
+
        revision: Rev,
+
        comment: Rev,
+
    },
+
    React {
+
        revision: Rev,
+
        comment: Rev,
+
        emoji: radicle::cob::Reaction,
+
        undo: bool,
+
    },
+
}
+

+
impl From<CommentArgs> for CommentAction {
+
    fn from(
+
        CommentArgs {
+
            revision,
+
            message,
+
            edit,
+
            react,
+
            redact,
+
            reply_to,
+
            emoji,
+
            undo,
+
        }: CommentArgs,
+
    ) -> Self {
+
        match (edit, react, redact) {
+
            (Some(edit), None, None) => CommentAction::Edit {
+
                revision,
+
                comment: edit,
+
                message: Message::from(message),
+
            },
+
            (None, Some(react), None) => CommentAction::React {
+
                revision,
+
                comment: react,
+
                emoji: emoji.unwrap(),
+
                undo,
+
            },
+
            (None, None, Some(redact)) => CommentAction::Redact {
+
                revision,
+
                comment: redact,
+
            },
+
            (None, None, None) => Self::Comment {
+
                revision,
+
                message: Message::from(message),
+
                reply_to,
+
            },
+
            _ => unreachable!("`--edit`, `--react`, and `--redact` cannot be used together"),
+
        }
+
    }
+
}
+

+
#[derive(Parser, Debug, Default)]
+
pub(super) struct EmptyArgs {
+
    #[arg(long, hide = true, value_name = "DID", num_args = 1.., action = clap::ArgAction::Append)]
+
    authors: Vec<Did>,
+

+
    #[arg(long, hide = true)]
+
    authored: bool,
+

+
    #[clap(flatten)]
+
    state: EmptyStateArgs,
+
}
+

+
#[derive(Parser, Debug, Default)]
+
#[group(multiple = false)]
+
pub(super) struct EmptyStateArgs {
+
    #[arg(long, hide = true)]
+
    all: bool,
+

+
    #[arg(long, hide = true)]
+
    draft: bool,
+

+
    #[arg(long, hide = true)]
+
    open: bool,
+

+
    #[arg(long, hide = true)]
+
    merged: bool,
+

+
    #[arg(long, hide = true)]
+
    archived: bool,
+
}
+

+
#[derive(Parser, Debug, Default)]
+
pub(super) struct ListArgs {
+
    /// Show only patched where the given user is an author (may be specified
+
    /// multiple times)
+
    #[arg(
+
        long = "author",
+
        value_name = "DID",
+
        num_args = 1..,
+
        action = clap::ArgAction::Append,
+
    )]
+
    pub(super) authors: Vec<Did>,
+

+
    /// Show only patches that you have authored
+
    #[arg(long)]
+
    pub(super) authored: bool,
+

+
    #[clap(flatten)]
+
    pub(super) state: ListStateArgs,
+
}
+

+
impl From<EmptyArgs> for ListArgs {
+
    fn from(args: EmptyArgs) -> Self {
+
        Self {
+
            authors: args.authors,
+
            authored: args.authored,
+
            state: ListStateArgs::from(args.state),
+
        }
+
    }
+
}
+

+
#[derive(Parser, Debug, Default)]
+
#[group(multiple = false)]
+
pub(crate) struct ListStateArgs {
+
    /// Show all patches, including draft, merged, and archived patches
+
    #[arg(long)]
+
    pub(crate) all: bool,
+

+
    /// Show only draft patches
+
    #[arg(long)]
+
    pub(crate) draft: bool,
+

+
    /// Show only open patches (default)
+
    #[arg(long)]
+
    pub(crate) open: bool,
+

+
    /// Show only merged patches
+
    #[arg(long)]
+
    pub(crate) merged: bool,
+

+
    /// Show only archived patches
+
    #[arg(long)]
+
    pub(crate) archived: bool,
+
}
+

+
impl From<EmptyStateArgs> for ListStateArgs {
+
    fn from(args: EmptyStateArgs) -> Self {
+
        Self {
+
            all: args.all,
+
            draft: args.draft,
+
            open: args.open,
+
            merged: args.merged,
+
            archived: args.archived,
+
        }
+
    }
+
}
+

+
impl From<&ListStateArgs> for Option<&Status> {
+
    fn from(args: &ListStateArgs) -> Self {
+
        match (args.all, args.draft, args.open, args.merged, args.archived) {
+
            (true, false, false, false, false) => None,
+
            (false, true, false, false, false) => Some(&Status::Draft),
+
            (false, false, true, false, false) | (false, false, false, false, false) => {
+
                Some(&Status::Open)
+
            }
+
            (false, false, false, true, false) => Some(&Status::Merged),
+
            (false, false, false, false, true) => Some(&Status::Archived),
+
            _ => unreachable!(),
+
        }
+
    }
+
}
+

+
#[derive(Debug, Parser)]
+
pub(super) struct ReviewArgs {
+
    /// Review by patch hunks
+
    ///
+
    /// This operation is obsolete
+
    #[arg(long, short, group = "by-hunk", conflicts_with = "delete")]
+
    patch: bool,
+

+
    /// Generate diffs with <N> lines of context
+
    ///
+
    /// This operation is obsolete
+
    #[arg(
+
        long,
+
        short = 'U',
+
        value_name = "N",
+
        requires = "by-hunk",
+
        default_value_t = 3
+
    )]
+
    unified: usize,
+

+
    /// Only review a specific hunk
+
    ///
+
    /// This operation is obsolete
+
    #[arg(long, value_name = "INDEX", requires = "by-hunk")]
+
    hunk: Option<usize>,
+

+
    /// Accept a patch revision
+
    #[arg(long, conflicts_with = "reject", conflicts_with = "delete")]
+
    accept: bool,
+

+
    /// Reject a patch revision
+
    #[arg(long, conflicts_with = "delete")]
+
    reject: bool,
+

+
    /// Delete a review draft
+
    ///
+
    /// This operation is obsolete
+
    #[arg(long, short)]
+
    delete: bool,
+

+
    #[clap(flatten)]
+
    message_args: MessageArgs,
+
}
+

+
impl ReviewArgs {
+
    fn as_operation(&self) -> review::Operation {
+
        let Self {
+
            patch,
+
            accept,
+
            reject,
+
            delete,
+
            ..
+
        } = self;
+

+
        if *patch {
+
            let verdict = if *accept {
+
                Some(Verdict::Accept)
+
            } else if *reject {
+
                Some(Verdict::Reject)
+
            } else {
+
                None
+
            };
+
            return review::Operation::Review(review::ReviewOptions {
+
                by_hunk: true,
+
                unified: self.unified,
+
                hunk: self.hunk,
+
                verdict,
+
            });
+
        }
+

+
        if *delete {
+
            return review::Operation::Delete;
+
        }
+

+
        if *accept {
+
            return review::Operation::Review(review::ReviewOptions {
+
                by_hunk: false,
+
                unified: 3,
+
                hunk: None,
+
                verdict: Some(Verdict::Accept),
+
            });
+
        }
+

+
        if *reject {
+
            return review::Operation::Review(review::ReviewOptions {
+
                by_hunk: false,
+
                unified: 3,
+
                hunk: None,
+
                verdict: Some(Verdict::Reject),
+
            });
+
        }
+

+
        panic!("expected one of `--patch`, `--delete`, `--accept`, or `--reject`");
+
    }
+
}
+

+
impl From<ReviewArgs> for review::Options {
+
    fn from(args: ReviewArgs) -> Self {
+
        let op = args.as_operation();
+
        Self {
+
            message: Message::from(args.message_args),
+
            op,
+
        }
+
    }
+
}
+

+
#[derive(Debug, clap::Args)]
+
#[group(required = false, multiple = false)]
+
pub(super) struct MessageArgs {
+
    /// Provide a message (default: prompt)
+
    ///
+
    /// This can be specified multiple times. This will result in newlines
+
    /// between the specified messages.
+
    #[clap(
+
        long,
+
        short,
+
        value_name = "MESSAGE",
+
        num_args = 1..,
+
        action = clap::ArgAction::Append
+
    )]
+
    pub(super) message: Option<Vec<String>>,
+

+
    /// Do not provide a message
+
    #[arg(long, conflicts_with = "message")]
+
    pub(super) no_message: bool,
+
}
+

+
impl From<MessageArgs> for Message {
+
    fn from(
+
        MessageArgs {
+
            message,
+
            no_message,
+
        }: MessageArgs,
+
    ) -> Self {
+
        if no_message {
+
            assert!(message.is_none());
+
            return Self::Blank;
+
        }
+

+
        match message {
+
            Some(messages) => messages.into_iter().fold(Self::Blank, |mut result, m| {
+
                result.append(&m);
+
                result
+
            }),
+
            None => Self::Edit,
+
        }
+
    }
+
}
+

+
#[derive(Debug, clap::Args)]
+
pub(super) struct CheckoutArgs {
+
    /// Provide a name for the branch to checkout
+
    #[arg(long, value_name = "BRANCH", value_parser = parse_refstr)]
+
    pub(super) name: Option<RefString>,
+

+
    /// Provide the git remote to use as the upstream
+
    #[arg(long, value_parser = parse_refstr)]
+
    pub(super) remote: Option<RefString>,
+

+
    /// Checkout the head of the revision, even if the branch already exists
+
    #[arg(long, short)]
+
    pub(super) force: bool,
+
}
+

+
impl From<CheckoutArgs> for checkout::Options {
+
    fn from(value: CheckoutArgs) -> Self {
+
        Self {
+
            name: value.name,
+
            remote: value.remote,
+
            force: value.force,
+
        }
+
    }
+
}
+

+
#[derive(Parser, Debug)]
+
#[group(required = true)]
+
pub(super) struct AssignArgs {
+
    /// Add an assignee to the patch (may be specified multiple times).
+
    ///
+
    /// Note: `--add` takes precedence over `--delete`
+
    #[arg(long, short, value_name = "DID", num_args = 1.., action = clap::ArgAction::Append)]
+
    pub(super) add: Vec<Did>,
+

+
    /// Remove an assignee from the patch (may be specified multiple times).
+
    ///
+
    /// Note: `--add` takes precedence over `--delete`
+
    #[clap(long, short, value_name = "DID", num_args = 1.., action = clap::ArgAction::Append)]
+
    pub(super) delete: Vec<Did>,
+
}
+

+
#[derive(Parser, Debug)]
+
#[group(required = true)]
+
pub(super) struct LabelArgs {
+
    /// Add a label to the patch (may be specified multiple times).
+
    ///
+
    /// Note: `--add` takes precedence over `--delete`
+
    #[arg(long, short, value_name = "LABEL", num_args = 1.., action = clap::ArgAction::Append)]
+
    pub(super) add: Vec<Label>,
+

+
    /// Remove a label from the patch (may be specified multiple times).
+
    ///
+
    /// Note: `--add` takes precedence over `--delete`
+
    #[clap(long, short, value_name = "LABEL", num_args = 1.., action = clap::ArgAction::Append)]
+
    pub(super) delete: Vec<Label>,
+
}
+

+
fn parse_refstr(refstr: &str) -> Result<RefString, git::fmt::Error> {
+
    RefString::try_from(refstr)
+
}
modified crates/radicle-cli/src/commands/patch/review.rs
@@ -21,19 +21,16 @@ Markdown supported.
"#;

#[derive(Debug, PartialEq, Eq)]
-
pub enum Operation {
-
    Delete,
-
    Review {
-
        by_hunk: bool,
-
        unified: usize,
-
        hunk: Option<usize>,
-
        verdict: Option<Verdict>,
-
    },
+
pub(super) struct ReviewOptions {
+
    pub(super) by_hunk: bool,
+
    pub(super) unified: usize,
+
    pub(super) hunk: Option<usize>,
+
    pub(super) verdict: Option<Verdict>,
}

-
impl Default for Operation {
+
impl Default for ReviewOptions {
    fn default() -> Self {
-
        Self::Review {
+
        Self {
            by_hunk: false,
            unified: 3,
            hunk: None,
@@ -42,6 +39,18 @@ impl Default for Operation {
    }
}

+
#[derive(Debug, PartialEq, Eq)]
+
pub(super) enum Operation {
+
    Delete,
+
    Review(ReviewOptions),
+
}
+

+
impl Default for Operation {
+
    fn default() -> Self {
+
        Operation::Review(ReviewOptions::default())
+
    }
+
}
+

#[derive(Debug, Default)]
pub struct Options {
    pub message: Message,
@@ -77,12 +86,12 @@ pub fn run(

    let patch_id_pretty = term::format::tertiary(term::format::cob(&patch_id));
    match options.op {
-
        Operation::Review {
-
            verdict,
+
        Operation::Review(ReviewOptions {
            by_hunk,
            unified,
            hunk,
-
        } if by_hunk => {
+
            verdict,
+
        }) if by_hunk => {
            crate::warning::obsolete("rad patch review --patch");
            let mut opts = git::raw::DiffOptions::new();
            opts.patience(true)
@@ -94,7 +103,7 @@ pub fn run(
                .verdict(verdict)
                .run(revision, &mut opts, &signer)?;
        }
-
        Operation::Review { verdict, .. } => {
+
        Operation::Review(ReviewOptions { verdict, .. }) => {
            let message = options.message.get(REVIEW_HELP_MSG)?;
            let message = message.replace(REVIEW_HELP_MSG.trim(), "");
            let message = if message.is_empty() {
modified crates/radicle-cli/src/commands/patch/show.rs
@@ -5,6 +5,7 @@ use radicle::git;
use radicle::storage::git::Repository;

use crate::terminal as term;
+
use crate::terminal::Error;

use super::*;

modified crates/radicle-cli/src/commands/path.rs
@@ -5,7 +5,6 @@ use radicle::profile;
use crate::terminal as term;

pub use args::Args;
-
pub(crate) use args::ABOUT;

pub fn run(_args: Args, _ctx: impl term::Context) -> anyhow::Result<()> {
    let home = profile::home()?;
modified crates/radicle-cli/src/commands/path/args.rs
@@ -1,6 +1,6 @@
use clap::Parser;

-
pub const ABOUT: &str = "Display the Radicle home path";
+
const ABOUT: &str = "Display the Radicle home path";

#[derive(Parser, Debug)]
#[command(about = ABOUT, disable_version_flag = true)]
modified crates/radicle-cli/src/commands/publish.rs
@@ -10,7 +10,6 @@ use radicle::storage::{SignRepository, ValidateRepository, WriteRepository, Writ
use crate::terminal as term;

pub use args::Args;
-
pub(crate) use args::ABOUT;

pub fn run(args: Args, ctx: impl term::Context) -> anyhow::Result<()> {
    let profile = ctx.profile()?;
modified crates/radicle-cli/src/commands/publish/args.rs
@@ -1,6 +1,6 @@
use radicle::identity::RepoId;

-
pub(crate) const ABOUT: &str = "Publish a repository to the network";
+
const ABOUT: &str = "Publish a repository to the network";

const LONG_ABOUT: &str = r#"
Publishing a private repository makes it public and discoverable
modified crates/radicle-cli/src/commands/remote.rs
@@ -14,7 +14,6 @@ use crate::terminal as term;
use crate::terminal::Context;

pub use args::Args;
-
pub(crate) use args::ABOUT;
use args::{Command, ListOption};

pub fn run(args: Args, ctx: impl Context) -> anyhow::Result<()> {
modified crates/radicle-cli/src/commands/remote/args.rs
@@ -6,7 +6,7 @@ use radicle::node::NodeId;

use crate::terminal as term;

-
pub(crate) const ABOUT: &str = "Manage a repository's remotes";
+
const ABOUT: &str = "Manage a repository's remotes";

#[derive(Parser, Debug)]
#[command(about = ABOUT, disable_version_flag = true)]
modified crates/radicle-cli/src/commands/seed.rs
@@ -10,7 +10,6 @@ use crate::commands::sync;
use crate::terminal as term;

pub use args::Args;
-
pub(crate) use args::ABOUT;

pub fn run(args: Args, ctx: impl term::Context) -> anyhow::Result<()> {
    let profile = ctx.profile()?;
modified crates/radicle-cli/src/commands/seed/args.rs
@@ -9,7 +9,7 @@ use radicle::prelude::*;
use crate::node::SyncSettings;
use crate::terminal;

-
pub(crate) const ABOUT: &str = "Manage repository seeding policies";
+
const ABOUT: &str = "Manage repository seeding policies";

const LONG_ABOUT: &str = r#"
The `seed` command, when no Repository ID is provided, will list the
modified crates/radicle-cli/src/commands/self.rs
@@ -2,7 +2,6 @@
mod args;

pub use args::Args;
-
pub(crate) use args::ABOUT;

use radicle::crypto::ssh;
use radicle::node::Handle as _;
modified crates/radicle-cli/src/commands/self/args.rs
@@ -1,6 +1,6 @@
use clap::Parser;

-
pub(crate) const ABOUT: &str = "Show information about your identity and device";
+
const ABOUT: &str = "Show information about your identity and device";

#[derive(Debug, Parser)]
#[command(about = ABOUT, disable_version_flag = true)]
modified crates/radicle-cli/src/commands/stats.rs
@@ -16,7 +16,6 @@ use serde::Serialize;
use crate::terminal as term;

pub use args::Args;
-
pub(crate) use args::ABOUT;

#[derive(Default, Serialize)]
#[serde(rename_all = "camelCase")]
modified crates/radicle-cli/src/commands/stats/args.rs
@@ -1,6 +1,6 @@
use clap::Parser;

-
pub(crate) const ABOUT: &str = "Displays aggregated repository and node metrics";
+
const ABOUT: &str = "Displays aggregated repository and node metrics";

#[derive(Debug, Parser)]
#[command(about = ABOUT, disable_version_flag = true)]
modified crates/radicle-cli/src/commands/sync.rs
@@ -1,9 +1,8 @@
+
mod args;
+

use std::cmp::Ordering;
use std::collections::BTreeMap;
-
use std::collections::BTreeSet;
use std::collections::HashSet;
-
use std::ffi::OsString;
-
use std::str::FromStr;
use std::time;

use anyhow::{anyhow, Context as _};
@@ -23,266 +22,13 @@ use radicle_term::Element;
use crate::node::SyncReporting;
use crate::node::SyncSettings;
use crate::terminal as term;
-
use crate::terminal::args::{Args, Error, Help};
use crate::terminal::format::Author;
use crate::terminal::{Table, TableOptions};

-
pub const HELP: Help = Help {
-
    name: "sync",
-
    description: "Sync repositories to the network",
-
    version: env!("RADICLE_VERSION"),
-
    usage: r#"
-
Usage
-

-
    rad sync [--fetch | --announce] [<rid>] [<option>...]
-
    rad sync --inventory [<option>...]
-
    rad sync status [<rid>] [<option>...]
-

-
    By default, the current repository is synchronized both ways.
-
    If an <rid> is specified, that repository is synced instead.
-

-
    The process begins by fetching changes from connected seeds,
-
    followed by announcing local refs to peers, thereby prompting
-
    them to fetch from us.
-

-
    When `--fetch` is specified, any number of seeds may be given
-
    using the `--seed` option, eg. `--seed <nid>@<addr>:<port>`.
-

-
    When `--replicas` is specified, the given replication factor will try
-
    to be matched. For example, `--replicas 5` will sync with 5 seeds.
-

-
    The synchronization process can be configured using `--replicas <min>` and
-
    `--replicas-max <max>`. If these options are used independently, then the
-
    replication factor is taken as the given `<min>`/`<max>` value. If the
-
    options are used together, then the replication factor has a minimum and
-
    maximum bound.
-

-
    For fetching, the synchronization process will be considered successful if
-
    at least `<min>` seeds were fetched from *or* all preferred seeds were
-
    fetched from. If `<max>` is specified then the process will continue and
-
    attempt to sync with `<max>` seeds.
-

-
    For reference announcing, the synchronization process will be considered
-
    successful if at least `<min>` seeds were pushed to *and* all preferred
-
    seeds were pushed to.
-

-
    When `--fetch` or `--announce` are specified on their own, this command
-
    will only fetch or announce.
-

-
    If `--inventory` is specified, the node's inventory is announced to
-
    the network. This mode does not take an `<rid>`.
-

-
Commands
-

-
    status                    Display the sync status of a repository
-

-
Options
-

-
        --sort-by       <field>   Sort the table by column (options: nid, alias, status)
-
    -f, --fetch                   Turn on fetching (default: true)
-
    -a, --announce                Turn on ref announcing (default: true)
-
    -i, --inventory               Turn on inventory announcing (default: false)
-
        --timeout       <secs>    How many seconds to wait while syncing
-
        --seed          <nid>     Sync with the given node (may be specified multiple times)
-
    -r, --replicas      <count>   Sync with a specific number of seeds
-
        --replicas-max  <count>   Sync with an upper bound number of seeds
-
    -v, --verbose                 Verbose output
-
        --debug                   Print debug information afer sync
-
        --help                    Print help
-
"#,
-
};
-

-
#[derive(Debug, Clone, PartialEq, Eq, Default)]
-
pub enum Operation {
-
    Synchronize(SyncMode),
-
    #[default]
-
    Status,
-
}
-

-
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
-
pub enum SortBy {
-
    Nid,
-
    Alias,
-
    #[default]
-
    Status,
-
}
-

-
impl FromStr for SortBy {
-
    type Err = &'static str;
-

-
    fn from_str(s: &str) -> Result<Self, Self::Err> {
-
        match s {
-
            "nid" => Ok(Self::Nid),
-
            "alias" => Ok(Self::Alias),
-
            "status" => Ok(Self::Status),
-
            _ => Err("invalid `--sort-by` field"),
-
        }
-
    }
-
}
-

-
#[derive(Debug, Clone, PartialEq, Eq)]
-
pub enum SyncMode {
-
    Repo {
-
        settings: SyncSettings,
-
        direction: SyncDirection,
-
    },
-
    Inventory,
-
}
-

-
impl Default for SyncMode {
-
    fn default() -> Self {
-
        Self::Repo {
-
            settings: SyncSettings::default(),
-
            direction: SyncDirection::default(),
-
        }
-
    }
-
}
-

-
#[derive(Debug, Default, PartialEq, Eq, Clone)]
-
pub enum SyncDirection {
-
    Fetch,
-
    Announce,
-
    #[default]
-
    Both,
-
}
-

-
#[derive(Default, Debug)]
-
pub struct Options {
-
    pub rid: Option<RepoId>,
-
    pub debug: bool,
-
    pub verbose: bool,
-
    pub sort_by: SortBy,
-
    pub op: Operation,
-
}
-

-
impl Args for Options {
-
    fn from_args(args: Vec<OsString>) -> anyhow::Result<(Self, Vec<OsString>)> {
-
        use lexopt::prelude::*;
-

-
        let mut parser = lexopt::Parser::from_args(args);
-
        let mut verbose = false;
-
        let mut timeout = time::Duration::from_secs(9);
-
        let mut rid = None;
-
        let mut fetch = false;
-
        let mut announce = false;
-
        let mut inventory = false;
-
        let mut debug = false;
-
        let mut replicas = None;
-
        let mut max_replicas = None;
-
        let mut seeds = BTreeSet::new();
-
        let mut sort_by = SortBy::default();
-
        let mut op: Option<Operation> = None;
-

-
        while let Some(arg) = parser.next()? {
-
            match arg {
-
                Long("debug") => {
-
                    debug = true;
-
                }
-
                Long("verbose") | Short('v') => {
-
                    verbose = true;
-
                }
-
                Long("fetch") | Short('f') => {
-
                    fetch = true;
-
                }
-
                Long("replicas") | Short('r') => {
-
                    let val = parser.value()?;
-
                    let count = term::args::number(&val)?;
-

-
                    if count == 0 {
-
                        anyhow::bail!("value for `--replicas` must be greater than zero");
-
                    }
-
                    replicas = Some(count);
-
                }
-
                Long("replicas-max") => {
-
                    let val = parser.value()?;
-
                    let count = term::args::number(&val)?;
-

-
                    if count == 0 {
-
                        anyhow::bail!("value for `--replicas-max` must be greater than zero");
-
                    }
-
                    max_replicas = Some(count);
-
                }
-
                Long("seed") => {
-
                    let val = parser.value()?;
-
                    let nid = term::args::nid(&val)?;
-

-
                    seeds.insert(nid);
-
                }
-
                Long("announce") | Short('a') => {
-
                    announce = true;
-
                }
-
                Long("inventory") | Short('i') => {
-
                    inventory = true;
-
                }
-
                Long("sort-by") if matches!(op, Some(Operation::Status)) => {
-
                    let value = parser.value()?;
-
                    sort_by = value.parse()?;
-
                }
-
                Long("timeout") | Short('t') => {
-
                    let value = parser.value()?;
-
                    let secs = term::args::parse_value("timeout", value)?;
-

-
                    timeout = time::Duration::from_secs(secs);
-
                }
-
                Long("help") | Short('h') => {
-
                    return Err(Error::Help.into());
-
                }
-
                Value(val) if rid.is_none() => match val.to_string_lossy().as_ref() {
-
                    "s" | "status" => {
-
                        op = Some(Operation::Status);
-
                    }
-
                    _ => {
-
                        rid = Some(term::args::rid(&val)?);
-
                    }
-
                },
-
                arg => {
-
                    return Err(anyhow!(arg.unexpected()));
-
                }
-
            }
-
        }
-

-
        let sync = if inventory && fetch {
-
            anyhow::bail!("`--inventory` cannot be used with `--fetch`");
-
        } else if inventory {
-
            SyncMode::Inventory
-
        } else {
-
            let direction = match (fetch, announce) {
-
                (true, true) | (false, false) => SyncDirection::Both,
-
                (true, false) => SyncDirection::Fetch,
-
                (false, true) => SyncDirection::Announce,
-
            };
-
            let mut settings = SyncSettings::default().timeout(timeout);
-

-
            let replicas = match (replicas, max_replicas) {
-
                (None, None) => sync::ReplicationFactor::default(),
-
                (None, Some(min)) => sync::ReplicationFactor::must_reach(min),
-
                (Some(min), None) => sync::ReplicationFactor::must_reach(min),
-
                (Some(min), Some(max)) => sync::ReplicationFactor::range(min, max),
-
            };
-
            settings.replicas = replicas;
-
            if !seeds.is_empty() {
-
                settings.seeds = seeds;
-
            }
-
            SyncMode::Repo {
-
                settings,
-
                direction,
-
            }
-
        };
-

-
        Ok((
-
            Options {
-
                rid,
-
                debug,
-
                verbose,
-
                sort_by,
-
                op: op.unwrap_or(Operation::Synchronize(sync)),
-
            },
-
            vec![],
-
        ))
-
    }
-
}
+
pub use args::Args;
+
use args::{Command, SortBy, SyncDirection, SyncMode};

-
pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
+
pub fn run(args: Args, ctx: impl term::Context) -> anyhow::Result<()> {
    let profile = ctx.profile()?;
    let mut node = radicle::Node::new(profile.socket());
    if !node.is_running() {
@@ -290,10 +36,12 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
            "to sync a repository, your node must be running. To start it, run `rad node start`"
        );
    }
+
    let verbose = args.verbose;
+
    let debug = args.verbose;

-
    match &options.op {
-
        Operation::Status => {
-
            let rid = match options.rid {
+
    match args.command {
+
        Some(Command::Status { rid, sort_by }) => {
+
            let rid = match rid {
                Some(rid) => rid,
                None => {
                    let (_, rid) = radicle::rad::cwd()
@@ -301,37 +49,41 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
                    rid
                }
            };
-
            sync_status(rid, &mut node, &profile, &options)?;
+
            sync_status(rid, &mut node, &profile, &sort_by, verbose)?;
        }
-
        Operation::Synchronize(SyncMode::Repo {
-
            settings,
-
            direction,
-
        }) => {
-
            let rid = match options.rid {
-
                Some(rid) => rid,
-
                None => {
-
                    let (_, rid) = radicle::rad::cwd()
-
                        .context("Current directory is not a Radicle repository")?;
-
                    rid
-
                }
-
            };
-
            let settings = settings.clone().with_profile(&profile);
+
        None => match SyncMode::from(args.sync) {
+
            SyncMode::Repo {
+
                rid,
+
                settings,
+
                direction,
+
            } => {
+
                let rid = match rid {
+
                    Some(rid) => rid,
+
                    None => {
+
                        let (_, rid) = radicle::rad::cwd()
+
                            .context("Current directory is not a Radicle repository")?;
+
                        rid
+
                    }
+
                };
+
                let settings = settings.clone().with_profile(&profile);

-
            if [SyncDirection::Fetch, SyncDirection::Both].contains(direction) {
-
                if !profile.policies()?.is_seeding(&rid)? {
-
                    anyhow::bail!("repository {rid} is not seeded");
+
                if matches!(direction, SyncDirection::Fetch | SyncDirection::Both) {
+
                    if !profile.policies()?.is_seeding(&rid)? {
+
                        anyhow::bail!("repository {rid} is not seeded");
+
                    }
+
                    let result = fetch(rid, settings.clone(), &mut node, &profile)?;
+
                    display_fetch_result(&result, verbose)
+
                }
+
                if matches!(direction, SyncDirection::Announce | SyncDirection::Both) {
+
                    announce_refs(rid, settings, &mut node, &profile, verbose, debug)?;
                }
-
                let result = fetch(rid, settings.clone(), &mut node, &profile)?;
-
                display_fetch_result(&result, options.verbose)
            }
-
            if [SyncDirection::Announce, SyncDirection::Both].contains(direction) {
-
                announce_refs(rid, settings, &mut node, &profile, &options)?;
+
            SyncMode::Inventory => {
+
                announce_inventory(node)?;
            }
-
        }
-
        Operation::Synchronize(SyncMode::Inventory) => {
-
            announce_inventory(node)?;
-
        }
+
        },
    }
+

    Ok(())
}

@@ -339,7 +91,8 @@ fn sync_status(
    rid: RepoId,
    node: &mut Node,
    profile: &Profile,
-
    options: &Options,
+
    sort_by: &SortBy,
+
    verbose: bool,
) -> anyhow::Result<()> {
    const SYMBOL_STATE: &str = "?";
    const SYMBOL_STATE_UNKNOWN: &str = "•";
@@ -358,7 +111,7 @@ fn sync_status(
    ]);
    table.divider();

-
    sort_seeds_by(local_nid, &mut seeds, &aliases, &options.sort_by);
+
    sort_seeds_by(local_nid, &mut seeds, &aliases, sort_by);

    let seeds = seeds.into_iter().flat_map(|seed| {
        let (status, head, time) = match seed.sync {
@@ -386,7 +139,7 @@ fn sync_status(
                term::format::oid(oid),
                term::format::timestamp(timestamp),
            ),
-
            None if options.verbose => (
+
            None if verbose => (
                term::format::dim(SYMBOL_STATE_UNKNOWN),
                term::paint(String::new()),
                term::paint(String::new()),
@@ -394,7 +147,7 @@ fn sync_status(
            None => return None,
        };

-
        let (alias, nid) = Author::new(&seed.nid, profile, options.verbose).labels();
+
        let (alias, nid) = Author::new(&seed.nid, profile, verbose).labels();

        Some([
            nid,
@@ -448,7 +201,8 @@ fn announce_refs(
    settings: SyncSettings,
    node: &mut Node,
    profile: &Profile,
-
    options: &Options,
+
    verbose: bool,
+
    debug: bool,
) -> anyhow::Result<()> {
    let Ok(repo) = profile.storage.repository(rid) else {
        return Err(anyhow!(
@@ -470,14 +224,14 @@ fn announce_refs(
        &repo,
        settings,
        SyncReporting {
-
            debug: options.debug,
+
            debug,
            ..SyncReporting::default()
        },
        node,
        profile,
    )?;
    if let Some(result) = result {
-
        print_announcer_result(&result, options.verbose)
+
        print_announcer_result(&result, verbose)
    }

    Ok(())
added crates/radicle-cli/src/commands/sync/args.rs
@@ -0,0 +1,254 @@
+
use std::str::FromStr;
+
use std::time;
+

+
use clap::{Parser, Subcommand, ValueEnum};
+

+
use radicle::{
+
    node::{sync, NodeId},
+
    prelude::RepoId,
+
};
+

+
use crate::node::SyncSettings;
+

+
const ABOUT: &str = "Sync repositories to the network";
+

+
const LONG_ABOUT: &str = r#"
+
By default, the current repository is synchronized both ways.
+
If an <RID> is specified, that repository is synced instead.
+

+
The process begins by fetching changes from connected seeds,
+
followed by announcing local refs to peers, thereby prompting
+
them to fetch from us.
+

+
When `--fetch` is specified, any number of seeds may be given
+
using the `--seed` option, eg. `--seed <NID>@<ADDR>:<PORT>`.
+

+
When `--replicas` is specified, the given replication factor will try
+
to be matched. For example, `--replicas 5` will sync with 5 seeds.
+

+
The synchronization process can be configured using `--replicas <MIN>` and
+
`--replicas-max <MAX>`. If these options are used independently, then the
+
replication factor is taken as the given `<MIN>`/`<MAX>` value. If the
+
options are used together, then the replication factor has a minimum and
+
maximum bound.
+

+
For fetching, the synchronization process will be considered successful if
+
at least `<MIN>` seeds were fetched from *or* all preferred seeds were
+
fetched from. If `<MAX>` is specified then the process will continue and
+
attempt to sync with `<MAX>` seeds.
+

+
For reference announcing, the synchronization process will be considered
+
successful if at least `<MIN>` seeds were pushed to *and* all preferred
+
seeds were pushed to.
+

+
When `--fetch` or `--announce` are specified on their own, this command
+
will only fetch or announce.
+

+
If `--inventory` is specified, the node's inventory is announced to
+
the network. This mode does not take an `<RID>`.
+
"#;
+

+
#[derive(Parser, Debug)]
+
#[clap(about = ABOUT, long_about = LONG_ABOUT, disable_version_flag = true)]
+
pub struct Args {
+
    #[clap(subcommand)]
+
    pub(super) command: Option<Command>,
+

+
    #[clap(flatten)]
+
    pub(super) sync: SyncArgs,
+

+
    /// Enable debug information when synchronizing
+
    #[arg(long)]
+
    pub(super) debug: bool,
+

+
    /// Enable verbose information when synchronizing
+
    #[arg(long, short)]
+
    pub(super) verbose: bool,
+
}
+

+
#[derive(Parser, Debug)]
+
pub(super) struct SyncArgs {
+
    /// Enable fetching [default: true]
+
    ///
+
    /// Providing --announce without --fetch will disable fetching
+
    #[arg(long, short, conflicts_with = "inventory")]
+
    fetch: bool,
+

+
    /// Enable announcing [default: true]
+
    ///
+
    /// Providing --fetch without --announce will disable announcing
+
    #[arg(long, short, conflicts_with = "inventory")]
+
    announce: bool,
+

+
    /// Synchronize with the given node (may be specified multiple times)
+
    #[arg(
+
        long = "seed",
+
        value_name = "NID",
+
        action = clap::ArgAction::Append,
+
        conflicts_with = "inventory",
+
    )]
+
    seeds: Vec<NodeId>,
+

+
    /// How many seconds to wait while synchronizing
+
    #[arg(
+
        long,
+
        short,
+
        default_value_t = 9,
+
        value_name = "SECS",
+
        conflicts_with = "inventory"
+
    )]
+
    timeout: u64,
+

+
    /// The repository to perform the synchronizing for [default: cwd]
+
    rid: Option<RepoId>,
+

+
    /// Synchronize with a specific number of seeds
+
    ///
+
    /// The value must be greater than zero
+
    #[arg(
+
        long,
+
        short,
+
        value_name = "COUNT",
+
        value_parser = replicas_non_zero,
+
        conflicts_with = "inventory",
+
    )]
+
    replicas: Option<usize>,
+

+
    /// Synchronize with an upper bound number of seeds
+
    ///
+
    /// The value must be greater than zero
+
    #[arg(
+
        long,
+
        value_name = "COUNT",
+
        value_parser = replicas_non_zero,
+
        conflicts_with = "inventory",
+
    )]
+
    max_replicas: Option<usize>,
+

+
    /// Enable announcing inventory [default: false]
+
    ///
+
    /// --inventory is a standalone mode and is not compatible with the other
+
    /// options
+
    ///
+
    /// <RID> is ignored when announcing --inventory
+
    #[arg(long, short)]
+
    inventory: bool,
+
}
+

+
impl SyncArgs {
+
    fn direction(&self) -> SyncDirection {
+
        match (self.fetch, self.announce) {
+
            (true, true) | (false, false) => SyncDirection::Both,
+
            (true, false) => SyncDirection::Fetch,
+
            (false, true) => SyncDirection::Announce,
+
        }
+
    }
+

+
    fn timeout(&self) -> time::Duration {
+
        time::Duration::from_secs(self.timeout)
+
    }
+

+
    fn replication(&self) -> sync::ReplicationFactor {
+
        match (self.replicas, self.max_replicas) {
+
            (None, None) => sync::ReplicationFactor::default(),
+
            (None, Some(min)) => sync::ReplicationFactor::must_reach(min),
+
            (Some(min), None) => sync::ReplicationFactor::must_reach(min),
+
            (Some(min), Some(max)) => sync::ReplicationFactor::range(min, max),
+
        }
+
    }
+
}
+

+
#[derive(Subcommand, Debug)]
+
pub(super) enum Command {
+
    /// Display the sync status of a repository
+
    #[clap(alias = "s")]
+
    Status {
+
        /// The repository to display the status for [default: cwd]
+
        rid: Option<RepoId>,
+
        /// Sort the table by column
+
        #[arg(long, value_name = "FIELD", value_enum, default_value_t)]
+
        sort_by: SortBy,
+
    },
+
}
+

+
/// Sort the status table by the provided field
+
#[derive(ValueEnum, Clone, Copy, Debug, Default, PartialEq, Eq)]
+
pub(super) enum SortBy {
+
    /// The NID of the entry
+
    Nid,
+
    /// The alias of the entry
+
    Alias,
+
    /// The status of the entry
+
    #[default]
+
    Status,
+
}
+

+
impl FromStr for SortBy {
+
    type Err = &'static str;
+

+
    fn from_str(s: &str) -> Result<Self, Self::Err> {
+
        match s {
+
            "nid" => Ok(Self::Nid),
+
            "alias" => Ok(Self::Alias),
+
            "status" => Ok(Self::Status),
+
            _ => Err("invalid `--sort-by` field"),
+
        }
+
    }
+
}
+

+
/// Whether we are performing a fetch/announce of a repository or only
+
/// announcing the node's inventory
+
pub(super) enum SyncMode {
+
    /// Fetch and/or announce a repositories references
+
    Repo {
+
        /// The repository being synchronized
+
        rid: Option<RepoId>,
+
        /// The settings for fetch/announce
+
        settings: SyncSettings,
+
        /// The direction of the synchronization
+
        direction: SyncDirection,
+
    },
+
    /// Announce the node's inventory
+
    Inventory,
+
}
+

+
impl From<SyncArgs> for SyncMode {
+
    fn from(args: SyncArgs) -> Self {
+
        if args.inventory {
+
            Self::Inventory
+
        } else {
+
            assert!(!args.inventory);
+
            let direction = args.direction();
+
            let mut settings = SyncSettings::default()
+
                .timeout(args.timeout())
+
                .replicas(args.replication());
+
            if !args.seeds.is_empty() {
+
                settings.seeds = args.seeds.into_iter().collect();
+
            }
+
            Self::Repo {
+
                rid: args.rid,
+
                settings,
+
                direction,
+
            }
+
        }
+
    }
+
}
+

+
/// The direction of the [`SyncMode`]
+
#[derive(Debug, PartialEq, Eq)]
+
pub(super) enum SyncDirection {
+
    /// Only fetching
+
    Fetch,
+
    /// Only announcing
+
    Announce,
+
    /// Both fetching and announcing
+
    Both,
+
}
+

+
fn replicas_non_zero(s: &str) -> Result<usize, String> {
+
    let r = usize::from_str(s).map_err(|_| format!("{s} is not a number"))?;
+
    if r == 0 {
+
        return Err(format!("{s} must be a value greater than zero"));
+
    }
+
    Ok(r)
+
}
modified crates/radicle-cli/src/commands/unblock.rs
@@ -5,7 +5,6 @@ use crate::terminal as term;
use term::args::BlockTarget;

pub use args::Args;
-
pub(crate) use args::ABOUT;

pub fn run(args: Args, ctx: impl term::Context) -> anyhow::Result<()> {
    let profile = ctx.profile()?;
modified crates/radicle-cli/src/commands/unblock/args.rs
@@ -2,8 +2,7 @@ use clap::Parser;

use crate::terminal::args::BlockTarget;

-
pub(crate) const ABOUT: &str =
-
    "Unblock repositories or nodes to allow them to be seeded or followed";
+
const ABOUT: &str = "Unblock repositories or nodes to allow them to be seeded or followed";

#[derive(Parser, Debug)]
#[command(about = ABOUT, disable_version_flag = true)]
modified crates/radicle-cli/src/commands/unfollow.rs
@@ -5,12 +5,11 @@ use radicle::node::Handle;
use crate::terminal as term;

pub use args::Args;
-
pub(crate) use args::ABOUT;

-
pub fn run(options: Args, ctx: impl term::Context) -> anyhow::Result<()> {
+
pub fn run(args: Args, ctx: impl term::Context) -> anyhow::Result<()> {
    let profile = ctx.profile()?;
    let mut node = radicle::Node::new(profile.socket());
-
    let nid = options.nid;
+
    let nid = args.nid;

    let unfollowed = match node.unfollow(nid) {
        Ok(updated) => updated,
modified crates/radicle-cli/src/commands/unfollow/args.rs
@@ -4,7 +4,7 @@ use radicle::node::NodeId;

use crate::terminal as term;

-
pub(crate) const ABOUT: &str = "Unfollow a peer";
+
const ABOUT: &str = "Unfollow a peer";

const LONG_ABOUT: &str = r#"
The `unfollow` command takes a Node ID, optionally in DID format,
modified crates/radicle-cli/src/commands/unseed.rs
@@ -5,13 +5,12 @@ use radicle::{prelude::*, Node};
use crate::terminal as term;

pub use args::Args;
-
pub(crate) use args::ABOUT;

-
pub fn run(options: Args, ctx: impl term::Context) -> anyhow::Result<()> {
+
pub fn run(args: Args, ctx: impl term::Context) -> anyhow::Result<()> {
    let profile = ctx.profile()?;
    let mut node = radicle::Node::new(profile.socket());

-
    for rid in options.rids {
+
    for rid in args.rids {
        delete(rid, &mut node, &profile)?;
    }

modified crates/radicle-cli/src/commands/unseed/args.rs
@@ -1,7 +1,7 @@
use clap::Parser;
use radicle::prelude::RepoId;

-
pub(crate) const ABOUT: &str = "Remove repository seeding policies";
+
const ABOUT: &str = "Remove repository seeding policies";

const LONG_ABOUT: &str = r#"
The `unseed` command removes the seeding policy, if found,
modified crates/radicle-cli/src/commands/watch.rs
@@ -12,7 +12,6 @@ use radicle::storage::{ReadRepository, ReadStorage};
use crate::terminal as term;

pub use args::Args;
-
pub(crate) use args::ABOUT;

pub fn run(args: Args, ctx: impl term::Context) -> anyhow::Result<()> {
    let profile = ctx.profile()?;
modified crates/radicle-cli/src/commands/watch/args.rs
@@ -7,7 +7,7 @@ use radicle::git;
use radicle::git::fmt::RefString;
use radicle::prelude::{NodeId, RepoId};

-
pub(crate) const ABOUT: &str = "Wait for some state to be updated";
+
const ABOUT: &str = "Wait for some state to be updated";

const LONG_ABOUT: &str = r#"
Watches a Git reference, and optionally exits when it reaches a target value.
modified crates/radicle-cli/src/main.rs
@@ -1,6 +1,8 @@
use std::ffi::OsString;
-
use std::io::{self, Write};
-
use std::{io::ErrorKind, iter, process};
+
use std::fmt::Display;
+
use std::io;
+
use std::io::Write;
+
use std::{io::ErrorKind, process};

use anyhow::anyhow;
use clap::builder::styling::AnsiColor;
@@ -18,7 +20,17 @@ 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 = r#"
+
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 +48,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,27 +68,17 @@ 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),
    Node(node::Args),
+
    Patch(patch::Args),
    Path(path::Args),
    Publish(publish::Args),
    Remote(remote::Args),
@@ -82,17 +86,21 @@ enum Commands {
    #[command(name = "self")]
    RadSelf(rad_self::Args),
    Stats(stats::Args),
+
    Sync(sync::Args),
    Unblock(unblock::Args),
    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() {
@@ -111,287 +119,125 @@ 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) {
-
        Ok(_) => process::exit(0),
+
    let CliArgs { command } = CliArgs::parse();
+
    run(command, term::DefaultContext)
+
}
+

+
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(())
+
    }
+
}
+

+
fn run(command: Command, ctx: impl term::Context) -> ! {
+
    match run_command(command, ctx) {
+
        Ok(()) => process::exit(0),
        Err(err) => {
-
            if let Some(err) = err {
-
                term::error(format!("rad: {err}"));
-
            }
+
            term::fail(&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()),
-
        }
-
    }
-
    if let Some(Command::Version { json: j }) = &mut command {
-
        *j = json;
+
fn run_command(command: Command, ctx: impl term::Context) -> Result<(), anyhow::Error> {
+
    match command {
+
        Command::Auth(args) => auth::run(args, ctx),
+
        Command::Block(args) => block::run(args, ctx),
+
        Command::Checkout(args) => checkout::run(args, ctx),
+
        Command::Clean(args) => clean::run(args, ctx),
+
        Command::Clone(args) => clone::run(args, ctx),
+
        Command::Cob(args) => cob::run(args, ctx),
+
        Command::Config(args) => config::run(args, ctx),
+
        Command::Debug(args) => debug::run(args, ctx),
+
        Command::Follow(args) => follow::run(args, ctx),
+
        Command::Fork(args) => fork::run(args, ctx),
+
        Command::Id(args) => id::run(args, ctx),
+
        Command::Inbox(args) => inbox::run(args, ctx),
+
        Command::Init(args) => init::run(args, ctx),
+
        Command::Inspect(args) => inspect::run(args, ctx),
+
        Command::Issue(args) => issue::run(args, ctx),
+
        Command::Ls(args) => ls::run(args, ctx),
+
        Command::Node(args) => node::run(args, ctx),
+
        Command::Patch(args) => patch::run(args, ctx),
+
        Command::Path(args) => path::run(args, ctx),
+
        Command::Publish(args) => publish::run(args, ctx),
+
        Command::Remote(args) => remote::run(args, ctx),
+
        Command::Seed(args) => seed::run(args, ctx),
+
        Command::RadSelf(args) => rad_self::run(args, ctx),
+
        Command::Stats(args) => stats::run(args, ctx),
+
        Command::Sync(args) => sync::run(args, ctx),
+
        Command::Unblock(args) => unblock::run(args, ctx),
+
        Command::Unfollow(args) => unfollow::run(args, ctx),
+
        Command::Unseed(args) => unseed::run(args, ctx),
+
        Command::Watch(args) => watch::run(args, ctx),
+
        Command::Version { json } => write_version(json),
+
        Command::External(args) => ExternalCommand::new(args).run(),
    }
-
    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)
+
struct ExternalCommand {
+
    command: OsString,
+
    args: Vec<OsString>,
}

-
fn run(command: Command) -> Result<(), Option<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();
-

-
            if let Some(Some(exe)) = exe.map(|s| s.to_str()) {
-
                run_other(exe, &args[1..])?;
-
            } else {
-
                print_help()?;
-
            }
-
        }
+
impl ExternalCommand {
+
    fn new(mut args: Vec<OsString>) -> Self {
+
        let command = args.remove(0);
+
        Self { command, args }
    }

-
    Ok(())
-
}
+
    fn is_diff(&self) -> bool {
+
        self.command == "diff"
+
    }

-
/// 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())
-
            };
+
    fn exe(&self) -> OsString {
+
        let mut exe = OsString::from(NAME);
+
        exe.push("-");
+
        exe.push(self.command.clone());
+
        exe
+
    }

-
            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);
-
            }
+
    fn display_exe(&self) -> impl Display {
+
        match self.exe().into_string() {
+
            Ok(exe) => exe,
+
            Err(exe) => format!("{exe:?}"),
        }
-
        "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" => {
-
            term::run_command_args::<patch::Options, _>(patch::HELP, patch::run, args.to_vec());
-
        }
-
        "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" => {
-
            term::run_command_args::<sync::Options, _>(sync::HELP, sync::run, args.to_vec());
-
        }
-
        "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 {
-
                Ok(status) => {
-
                    if !status.success() {
-
                        return Err(None);
-
                    }
+
    fn run(self) -> anyhow::Result<()> {
+
        // 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 self.is_diff() {
+
            return diff::run(self.args);
+
        }
+

+
        let status = process::Command::new(self.exe()).args(&self.args).status();
+
        match status {
+
            Ok(status) => {
+
                if !status.success() {
+
                    return Err(anyhow!("`{}` exited with an error.", self.display_exe()));
                }
-
                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(())
+
            }
+
            Err(err) => {
+
                if let ErrorKind::NotFound = err.kind() {
+
                    Err(anyhow!(
+
                        "`{}` is not a known command. See `rad --help` for a list of commands.",
+
                        self.display_exe(),
+
                    ))
+
                } else {
+
                    Err(err.into())
                }
            }
        }
    }
-
    Ok(())
}
modified crates/radicle-cli/src/terminal.rs
@@ -1,5 +1,6 @@
-
pub mod args;
-
pub use args::{Args, Error, Help};
+
pub(crate) mod args;
+
pub(crate) use args::Error;
+

pub mod format;
pub mod io;
pub use io::signer;
@@ -11,17 +12,10 @@ pub mod json;
pub mod patch;
pub mod upload_pack;

-
use std::ffi::OsString;
-
use std::process;
-

-
use clap::Parser;
-

pub use radicle_term::*;

use radicle::profile::{Home, Profile};

-
use crate::terminal;
-

/// Context passed to all commands.
pub trait Context {
    /// Return the currently active profile, or an error if no profile is active.
@@ -40,105 +34,6 @@ impl Context for Profile {
    }
}

-
/// A command that can be run.
-
pub trait Command<A: Args, C: Context> {
-
    /// Run the command, given arguments and a context.
-
    fn run(self, args: A, context: C) -> anyhow::Result<()>;
-
}
-

-
impl<F, A: Args, C: Context> Command<A, C> for F
-
where
-
    F: FnOnce(A, C) -> anyhow::Result<()>,
-
{
-
    fn run(self, args: A, context: C) -> anyhow::Result<()> {
-
        self(args, context)
-
    }
-
}
-

-
/// 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) -> !
-
where
-
    F: FnOnce(P, DefaultContext) -> anyhow::Result<()>,
-
{
-
    match cmd(args, DefaultContext) {
-
        Ok(()) => process::exit(0),
-
        Err(err) => {
-
            // First parameter is not used and can just be empty.
-
            fail("", &err);
-
            process::exit(1);
-
        }
-
    }
-
}
-

-
pub fn run_command<A, C>(help: Help, cmd: C) -> !
-
where
-
    A: Args,
-
    C: Command<A, DefaultContext>,
-
{
-
    let args = std::env::args_os().skip(1).collect();
-

-
    run_command_args(help, cmd, args)
-
}
-

-
pub fn run_command_args<A, C>(help: Help, cmd: C, args: Vec<OsString>) -> !
-
where
-
    A: Args,
-
    C: Command<A, DefaultContext>,
-
{
-
    use io as term;
-

-
    let options = match A::from_args(args) {
-
        Ok((opts, unparsed)) => {
-
            if let Err(err) = args::finish(unparsed) {
-
                term::error(err);
-
                process::exit(1);
-
            }
-
            opts
-
        }
-
        Err(err) => {
-
            let hint = match err.downcast_ref::<Error>() {
-
                Some(Error::Help) => {
-
                    help.print();
-
                    process::exit(0);
-
                }
-
                // Print the manual, or the regular help if there's an error.
-
                Some(Error::HelpManual { name }) => {
-
                    let Ok(status) = term::manual(name) else {
-
                        help.print();
-
                        process::exit(0);
-
                    };
-
                    if !status.success() {
-
                        help.print();
-
                        process::exit(0);
-
                    }
-
                    process::exit(status.code().unwrap_or(0));
-
                }
-
                Some(Error::Usage) => {
-
                    term::usage(help.name, help.usage);
-
                    process::exit(1);
-
                }
-
                Some(Error::WithHint { hint, .. }) => Some(hint),
-
                None => None,
-
            };
-
            io::error(format!("rad {}: {err}", help.name));
-

-
            if let Some(hint) = hint {
-
                io::hint(hint);
-
            }
-
            process::exit(1);
-
        }
-
    };
-

-
    match cmd.run(options, DefaultContext) {
-
        Ok(()) => process::exit(0),
-
        Err(err) => {
-
            terminal::fail(help.name, &err);
-
            process::exit(1);
-
        }
-
    }
-
}
-

/// Gets the default profile. Fails if there is no profile.
pub struct DefaultContext;

@@ -161,7 +56,7 @@ impl Context for DefaultContext {
    }
}

-
pub fn fail(_name: &str, error: &anyhow::Error) {
+
pub fn fail(error: &anyhow::Error) {
    let err = error.to_string();
    let err = err.trim_end();

modified crates/radicle-cli/src/terminal/args.rs
@@ -1,33 +1,11 @@
-
use std::ffi::OsString;
-
use std::net::SocketAddr;
-
use std::str::FromStr;
-
use std::time;
-

-
use anyhow::anyhow;
use clap::builder::TypedValueParser;
use thiserror::Error;

-
use radicle::cob::{self, issue, patch};
-
use radicle::crypto;
-
use radicle::git::{fmt::RefString, Oid};
use radicle::node::policy::Scope;
-
use radicle::node::{Address, Alias};
use radicle::prelude::{Did, NodeId, RepoId};

-
use crate::git::Rev;
-
use crate::terminal as term;
-

#[derive(thiserror::Error, Debug)]
-
pub enum Error {
-
    /// If this error is returned from argument parsing, help is displayed.
-
    #[error("help invoked")]
-
    Help,
-
    /// If this error is returned from argument parsing, the manual page is displayed.
-
    #[error("help manual invoked")]
-
    HelpManual { name: &'static str },
-
    /// If this error is returned from argument parsing, usage is displayed.
-
    #[error("usage invoked")]
-
    Usage,
+
pub(crate) enum Error {
    /// An error with a hint.
    #[error("{err}")]
    WithHint {
@@ -36,177 +14,6 @@ pub enum Error {
    },
}

-
pub struct Help {
-
    pub name: &'static str,
-
    pub description: &'static str,
-
    pub version: &'static str,
-
    pub usage: &'static str,
-
}
-

-
impl Help {
-
    /// Print help to stdout.
-
    pub fn print(&self) {
-
        term::help(self.name, self.version, self.description, self.usage);
-
    }
-
}
-

-
pub trait Args: Sized {
-
    fn from_env() -> anyhow::Result<Self> {
-
        let args: Vec<_> = std::env::args_os().skip(1).collect();
-

-
        match Self::from_args(args) {
-
            Ok((opts, unparsed)) => {
-
                self::finish(unparsed)?;
-

-
                Ok(opts)
-
            }
-
            Err(err) => Err(err),
-
        }
-
    }
-

-
    fn from_args(args: Vec<OsString>) -> anyhow::Result<(Self, Vec<OsString>)>;
-
}
-

-
pub fn parse_value<T: FromStr>(flag: &str, value: OsString) -> anyhow::Result<T>
-
where
-
    <T as FromStr>::Err: std::error::Error,
-
{
-
    value
-
        .into_string()
-
        .map_err(|_| anyhow!("the value specified for '--{}' is not valid UTF-8", flag))?
-
        .parse()
-
        .map_err(|e| anyhow!("invalid value specified for '--{}' ({})", flag, e))
-
}
-

-
pub fn format(arg: lexopt::Arg) -> OsString {
-
    match arg {
-
        lexopt::Arg::Long(flag) => format!("--{flag}").into(),
-
        lexopt::Arg::Short(flag) => format!("-{flag}").into(),
-
        lexopt::Arg::Value(val) => val,
-
    }
-
}
-

-
pub fn finish(unparsed: Vec<OsString>) -> anyhow::Result<()> {
-
    if let Some(arg) = unparsed.first() {
-
        anyhow::bail!("unexpected argument `{}`", arg.to_string_lossy())
-
    }
-
    Ok(())
-
}
-

-
pub fn refstring(flag: &str, value: OsString) -> anyhow::Result<RefString> {
-
    RefString::try_from(
-
        value
-
            .into_string()
-
            .map_err(|_| anyhow!("the value specified for '--{}' is not valid UTF-8", flag))?,
-
    )
-
    .map_err(|_| {
-
        anyhow!(
-
            "the value specified for '--{}' is not a valid ref string",
-
            flag
-
        )
-
    })
-
}
-

-
pub fn did(val: &OsString) -> anyhow::Result<Did> {
-
    let val = val.to_string_lossy();
-
    let Ok(peer) = Did::from_str(&val) else {
-
        if crypto::PublicKey::from_str(&val).is_ok() {
-
            return Err(anyhow!("expected DID, did you mean 'did:key:{val}'?"));
-
        } else {
-
            return Err(anyhow!("invalid DID '{}', expected 'did:key'", val));
-
        }
-
    };
-
    Ok(peer)
-
}
-

-
pub fn nid(val: &OsString) -> anyhow::Result<NodeId> {
-
    let val = val.to_string_lossy();
-
    NodeId::from_str(&val).map_err(|_| anyhow!("invalid Node ID '{}'", val))
-
}
-

-
pub fn rid(val: &OsString) -> anyhow::Result<RepoId> {
-
    let val = val.to_string_lossy();
-
    RepoId::from_str(&val).map_err(|_| anyhow!("invalid Repository ID '{}'", val))
-
}
-

-
pub fn pubkey(val: &OsString) -> anyhow::Result<NodeId> {
-
    let Ok(did) = did(val) else {
-
        let nid = nid(val)?;
-
        return Ok(nid);
-
    };
-
    Ok(did.as_key().to_owned())
-
}
-

-
pub fn socket_addr(val: &OsString) -> anyhow::Result<SocketAddr> {
-
    let val = val.to_string_lossy();
-
    SocketAddr::from_str(&val).map_err(|_| anyhow!("invalid socket address '{}'", val))
-
}
-

-
pub fn addr(val: &OsString) -> anyhow::Result<Address> {
-
    let val = val.to_string_lossy();
-
    Address::from_str(&val).map_err(|_| anyhow!("invalid address '{}'", val))
-
}
-

-
pub fn number(val: &OsString) -> anyhow::Result<usize> {
-
    let val = val.to_string_lossy();
-
    usize::from_str(&val).map_err(|_| anyhow!("invalid number '{}'", val))
-
}
-

-
pub fn seconds(val: &OsString) -> anyhow::Result<time::Duration> {
-
    let val = val.to_string_lossy();
-
    let secs = u64::from_str(&val).map_err(|_| anyhow!("invalid number of seconds '{}'", val))?;
-

-
    Ok(time::Duration::from_secs(secs))
-
}
-

-
pub fn milliseconds(val: &OsString) -> anyhow::Result<time::Duration> {
-
    let val = val.to_string_lossy();
-
    let secs =
-
        u64::from_str(&val).map_err(|_| anyhow!("invalid number of milliseconds '{}'", val))?;
-

-
    Ok(time::Duration::from_millis(secs))
-
}
-

-
pub fn string(val: &OsString) -> String {
-
    val.to_string_lossy().to_string()
-
}
-

-
pub fn rev(val: &OsString) -> anyhow::Result<Rev> {
-
    let s = val.to_str().ok_or(anyhow!("invalid git rev {val:?}"))?;
-
    Ok(Rev::from(s.to_owned()))
-
}
-

-
pub fn oid(val: &OsString) -> anyhow::Result<Oid> {
-
    let s = string(val);
-
    let o = radicle::git::Oid::from_str(&s).map_err(|_| anyhow!("invalid git oid '{s}'"))?;
-

-
    Ok(o)
-
}
-

-
pub fn alias(val: &OsString) -> anyhow::Result<Alias> {
-
    let val = val.as_os_str();
-
    let val = val
-
        .to_str()
-
        .ok_or_else(|| anyhow!("alias must be valid UTF-8"))?;
-

-
    Alias::from_str(val).map_err(|e| e.into())
-
}
-

-
pub fn issue(val: &OsString) -> anyhow::Result<issue::IssueId> {
-
    let val = val.to_string_lossy();
-
    issue::IssueId::from_str(&val).map_err(|_| anyhow!("invalid Issue ID '{}'", val))
-
}
-

-
pub fn patch(val: &OsString) -> anyhow::Result<patch::PatchId> {
-
    let val = val.to_string_lossy();
-
    patch::PatchId::from_str(&val).map_err(|_| anyhow!("invalid Patch ID '{}'", val))
-
}
-

-
pub fn cob(val: &OsString) -> anyhow::Result<cob::ObjectId> {
-
    let val = val.to_string_lossy();
-
    cob::ObjectId::from_str(&val).map_err(|_| anyhow!("invalid Object ID '{}'", val))
-
}
-

/// Targets used in the `block` and `unblock` commands
#[derive(Clone, Debug)]
pub(crate) enum BlockTarget {