Radish alpha
h
rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5
Radicle Heartwood Protocol & Stack
Radicle
Git
cli/id: Use clap
Merged did:key:z6MkgFq6...nBGz opened 6 months ago

This implementation works around the fact that clap does currently not support value parsers for a series of values, so representing --payload as a Vec<Payload> does not work.

Instead, we parse eveything into a Vec<String> and do the validation on the application side.

Using value parsers for a series of values will probably be supported in clap v5, though.

5 files changed +425 -309 ec1d7543 63486688
modified crates/radicle-cli/src/commands/help.rs
@@ -60,7 +60,10 @@ const COMMANDS: &[CommandItem] = &[
        about: crate::commands::fork::ABOUT,
    },
    CommandItem::Lexopt(crate::commands::help::HELP),
-
    CommandItem::Lexopt(crate::commands::id::HELP),
+
    CommandItem::Clap {
+
        name: "id",
+
        about: crate::commands::id::ABOUT,
+
    },
    CommandItem::Clap {
        name: "init",
        about: crate::commands::init::ABOUT,
modified crates/radicle-cli/src/commands/id.rs
@@ -1,285 +1,36 @@
+
mod args;
+

use std::collections::BTreeSet;
-
use std::{ffi::OsString, io};

use anyhow::{anyhow, Context};

+
use itertools::Itertools as _;
+

use radicle::cob::identity::{self, IdentityMut, Revision, RevisionId};
use radicle::cob::Title;
use radicle::identity::doc::update;
-
use radicle::identity::doc::update::EditVisibility;
use radicle::identity::{doc, Doc, Identity, RawDoc};
use radicle::node::device::Device;
use radicle::node::NodeId;
-
use radicle::prelude::{Did, RepoId};
use radicle::storage::{ReadStorage as _, WriteRepository};
use radicle::{cob, crypto, Profile};
use radicle_surf::diff::Diff;
use radicle_term::Element;
-
use serde_json as json;

use crate::git::unified_diff::Encode as _;
use crate::git::Rev;
use crate::terminal as term;
-
use crate::terminal::args::{Args, Error, Help};
+
use crate::terminal::args::Error;
use crate::terminal::patch::Message;
-
use crate::terminal::Interactive;
-

-
pub const HELP: Help = Help {
-
    name: "id",
-
    description: "Manage repository identities",
-
    version: env!("RADICLE_VERSION"),
-
    usage: r#"
-
Usage
-

-
    rad id list [<option>...]
-
    rad id update [--title <string>] [--description <string>]
-
                  [--delegate <did>] [--rescind <did>]
-
                  [--threshold <num>] [--visibility <private | public>]
-
                  [--allow <did>] [--disallow <did>]
-
                  [--no-confirm] [--payload <id> <key> <val>...] [--edit] [<option>...]
-
    rad id edit <revision-id> [--title <string>] [--description <string>] [<option>...]
-
    rad id show <revision-id> [<option>...]
-
    rad id <accept | reject | redact> <revision-id> [<option>...]
-

-
    The *rad id* command is used to manage and propose changes to the
-
    identity of a Radicle repository.
-

-
    See the rad-id(1) man page for more information.
-

-
Options
-

-
    --repo <rid>           Repository (defaults to the current repository)
-
    --quiet, -q            Don't print anything
-
    --help                 Print help
-
"#,
-
};
-

-
#[derive(Clone, Debug, Default)]
-
pub enum Operation {
-
    Update {
-
        title: Option<Title>,
-
        description: Option<String>,
-
        delegate: Vec<Did>,
-
        rescind: Vec<Did>,
-
        threshold: Option<usize>,
-
        visibility: Option<EditVisibility>,
-
        allow: BTreeSet<Did>,
-
        disallow: BTreeSet<Did>,
-
        payload: Vec<(doc::PayloadId, String, json::Value)>,
-
        edit: bool,
-
    },
-
    AcceptRevision {
-
        revision: Rev,
-
    },
-
    RejectRevision {
-
        revision: Rev,
-
    },
-
    EditRevision {
-
        revision: Rev,
-
        title: Option<Title>,
-
        description: Option<String>,
-
    },
-
    RedactRevision {
-
        revision: Rev,
-
    },
-
    ShowRevision {
-
        revision: Rev,
-
    },
-
    #[default]
-
    ListRevisions,
-
}

-
#[derive(Default, PartialEq, Eq)]
-
pub enum OperationName {
-
    Accept,
-
    Reject,
-
    Edit,
-
    Update,
-
    Show,
-
    Redact,
-
    #[default]
-
    List,
-
}
+
pub use args::Args;
+
use args::Command;
+
pub(crate) use args::ABOUT;

-
pub struct Options {
-
    pub op: Operation,
-
    pub rid: Option<RepoId>,
-
    pub interactive: Interactive,
-
    pub quiet: bool,
-
}
-

-
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 revision: Option<Rev> = None;
-
        let mut rid: Option<RepoId> = None;
-
        let mut title: Option<Title> = None;
-
        let mut description: Option<String> = None;
-
        let mut delegate: Vec<Did> = Vec::new();
-
        let mut rescind: Vec<Did> = Vec::new();
-
        let mut visibility: Option<EditVisibility> = None;
-
        let mut allow: BTreeSet<Did> = BTreeSet::new();
-
        let mut disallow: BTreeSet<Did> = BTreeSet::new();
-
        let mut threshold: Option<usize> = None;
-
        let mut interactive = Interactive::new(io::stdout());
-
        let mut payload = Vec::new();
-
        let mut edit = false;
-
        let mut quiet = false;
-

-
        while let Some(arg) = parser.next()? {
-
            match arg {
-
                Long("help") => {
-
                    return Err(Error::HelpManual { name: "rad-id" }.into());
-
                }
-
                Short('h') => {
-
                    return Err(Error::Help.into());
-
                }
-
                Long("title")
-
                    if op == Some(OperationName::Edit) || op == Some(OperationName::Update) =>
-
                {
-
                    let val = parser.value()?;
-
                    title = Some(term::args::string(&val).try_into()?);
-
                }
-
                Long("description")
-
                    if op == Some(OperationName::Edit) || op == Some(OperationName::Update) =>
-
                {
-
                    description = Some(parser.value()?.to_string_lossy().into());
-
                }
-
                Long("quiet") | Short('q') => {
-
                    quiet = true;
-
                }
-
                Long("no-confirm") => {
-
                    interactive = Interactive::No;
-
                }
-
                Value(val) if op.is_none() => match val.to_string_lossy().as_ref() {
-
                    "e" | "edit" => op = Some(OperationName::Edit),
-
                    "u" | "update" => op = Some(OperationName::Update),
-
                    "l" | "list" => op = Some(OperationName::List),
-
                    "s" | "show" => op = Some(OperationName::Show),
-
                    "a" | "accept" => op = Some(OperationName::Accept),
-
                    "r" | "reject" => op = Some(OperationName::Reject),
-
                    "d" | "redact" => op = Some(OperationName::Redact),
-

-
                    unknown => anyhow::bail!("unknown operation '{}'", unknown),
-
                },
-
                Long("repo") => {
-
                    let val = parser.value()?;
-
                    let val = term::args::rid(&val)?;
-

-
                    rid = Some(val);
-
                }
-
                Long("delegate") => {
-
                    let did = term::args::did(&parser.value()?)?;
-
                    delegate.push(did);
-
                }
-
                Long("rescind") => {
-
                    let did = term::args::did(&parser.value()?)?;
-
                    rescind.push(did);
-
                }
-
                Long("allow") => {
-
                    let value = parser.value()?;
-
                    let did = term::args::did(&value)?;
-
                    allow.insert(did);
-
                }
-
                Long("disallow") => {
-
                    let value = parser.value()?;
-
                    let did = term::args::did(&value)?;
-
                    disallow.insert(did);
-
                }
-
                Long("visibility") => {
-
                    let value = parser.value()?;
-
                    let value = term::args::parse_value("visibility", value)?;
-

-
                    visibility = Some(value);
-
                }
-
                Long("threshold") => {
-
                    threshold = Some(parser.value()?.to_string_lossy().parse()?);
-
                }
-
                Long("payload") => {
-
                    let mut values = parser.values()?;
-
                    let id = values
-
                        .next()
-
                        .ok_or(anyhow!("expected payload id, eg. `xyz.radicle.project`"))?;
-
                    let id: doc::PayloadId = term::args::parse_value("payload", id)?;
-

-
                    let key = values
-
                        .next()
-
                        .ok_or(anyhow!("expected payload key, eg. 'defaultBranch'"))?;
-
                    let key = term::args::string(&key);
-

-
                    let val = values
-
                        .next()
-
                        .ok_or(anyhow!("expected payload value, eg. '\"heartwood\"'"))?;
-
                    let val = val.to_string_lossy().to_string();
-
                    let val = json::from_str(val.as_str())
-
                        .map_err(|e| anyhow!("invalid JSON value `{val}`: {e}"))?;
-

-
                    payload.push((id, key, val));
-
                }
-
                Long("edit") => {
-
                    edit = true;
-
                }
-
                Value(val) => {
-
                    let val = term::args::rev(&val)?;
-
                    revision = Some(val);
-
                }
-
                _ => {
-
                    return Err(anyhow!(arg.unexpected()));
-
                }
-
            }
-
        }
-

-
        let op = match op.unwrap_or_default() {
-
            OperationName::Accept => Operation::AcceptRevision {
-
                revision: revision.ok_or_else(|| anyhow!("a revision must be provided"))?,
-
            },
-
            OperationName::Reject => Operation::RejectRevision {
-
                revision: revision.ok_or_else(|| anyhow!("a revision must be provided"))?,
-
            },
-
            OperationName::Edit => Operation::EditRevision {
-
                title,
-
                description,
-
                revision: revision.ok_or_else(|| anyhow!("a revision must be provided"))?,
-
            },
-
            OperationName::Show => Operation::ShowRevision {
-
                revision: revision.ok_or_else(|| anyhow!("a revision must be provided"))?,
-
            },
-
            OperationName::List => Operation::ListRevisions,
-
            OperationName::Redact => Operation::RedactRevision {
-
                revision: revision.ok_or_else(|| anyhow!("a revision must be provided"))?,
-
            },
-
            OperationName::Update => Operation::Update {
-
                title,
-
                description,
-
                delegate,
-
                rescind,
-
                threshold,
-
                visibility,
-
                allow,
-
                disallow,
-
                payload,
-
                edit,
-
            },
-
        };
-
        Ok((
-
            Options {
-
                rid,
-
                op,
-
                interactive,
-
                quiet,
-
            },
-
            vec![],
-
        ))
-
    }
-
}
-

-
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 storage = &profile.storage;
-
    let rid = if let Some(rid) = options.rid {
+
    let rid = if let Some(rid) = args.repo {
        rid
    } else {
        let (_, rid) = radicle::rad::cwd()?;
@@ -291,8 +42,11 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
    let mut identity = Identity::load_mut(&repo)?;
    let current = identity.current().clone();

-
    match options.op {
-
        Operation::AcceptRevision { revision } => {
+
    let interactive = args.interactive();
+
    let command = args.command.unwrap_or(Command::List);
+

+
    match command {
+
        Command::Accept { revision } => {
            let revision = get(revision, &identity, &repo)?.clone();
            let id = revision.id;
            let signer = term::signer(&profile)?;
@@ -301,10 +55,7 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
                anyhow::bail!("cannot vote on revision that is {}", revision.state);
            }

-
            if options
-
                .interactive
-
                .confirm(format!("Accept revision {}?", term::format::tertiary(id)))
-
            {
+
            if interactive.confirm(format!("Accept revision {}?", term::format::tertiary(id))) {
                identity.accept(&revision.id, &signer)?;

                if let Some(revision) = identity.revision(&id) {
@@ -314,14 +65,14 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
                    }
                    // TODO: Different output if canonical changed?

-
                    if !options.quiet {
+
                    if !args.quiet {
                        term::success!("Revision {id} accepted");
                        print_meta(revision, &current, &profile)?;
                    }
                }
            }
        }
-
        Operation::RejectRevision { revision } => {
+
        Command::Reject { revision } => {
            let revision = get(revision, &identity, &repo)?.clone();
            let signer = term::signer(&profile)?;

@@ -329,19 +80,19 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
                anyhow::bail!("cannot vote on revision that is {}", revision.state);
            }

-
            if options.interactive.confirm(format!(
+
            if interactive.confirm(format!(
                "Reject revision {}?",
                term::format::tertiary(revision.id)
            )) {
                identity.reject(revision.id, &signer)?;

-
                if !options.quiet {
+
                if !args.quiet {
                    term::success!("Revision {} rejected", revision.id);
                    print_meta(&revision, &current, &profile)?;
                }
            }
        }
-
        Operation::EditRevision {
+
        Command::Edit {
            revision,
            title,
            description,
@@ -357,11 +108,11 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
            };
            identity.edit(revision.id, title, description, &signer)?;

-
            if !options.quiet {
+
            if !args.quiet {
                term::success!("Revision {} edited", revision.id);
            }
        }
-
        Operation::Update {
+
        Command::Update {
            title,
            description,
            delegate: delegates,
@@ -375,6 +126,9 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
        } => {
            let proposal = {
                let mut proposal = current.doc.clone().edit();
+
                let allow = allow.into_iter().collect::<BTreeSet<_>>();
+
                let disallow = disallow.into_iter().collect::<BTreeSet<_>>();
+

                proposal.threshold = threshold.unwrap_or(proposal.threshold);

                let proposal = match visibility {
@@ -407,7 +161,13 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
                    }
                };

-
                update::payload(proposal, payload)?
+
                // TODO(erikli): whenever `clap` starts supporting custom value parsers
+
                // for a series of values, we can parse into `Payload` implicitly.
+
                let payloads = args::PayloadUpsert::parse_many(&payload)
+
                    .map_ok(|p| (p.id, p.key, p.value))
+
                    .collect::<Result<Vec<_>, _>>()?;
+

+
                update::payload(proposal, payloads)?
            };

            // If `--edit` is specified, the document can also be edited via a text edit.
@@ -431,7 +191,7 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {

            let proposal = update::verify(proposal)?;
            if proposal == current.doc {
-
                if !options.quiet {
+
                if !args.quiet {
                    term::print(term::format::italic(
                        "Nothing to do. The document is up to date. See `rad inspect --identity`.",
                    ));
@@ -445,7 +205,7 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
                // Update the canonical head to point to the latest accepted revision.
                repo.set_identity_head_to(revision.id)?;
            }
-
            if options.quiet {
+
            if args.quiet {
                term::print(revision.id);
            } else {
                term::success!(
@@ -455,7 +215,7 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
                print(&revision, &current, &repo, &profile)?;
            }
        }
-
        Operation::ListRevisions => {
+
        Command::List => {
            let mut revisions =
                term::Table::<7, term::Label>::new(term::table::TableOptions::bordered());

@@ -489,25 +249,25 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
            }
            revisions.print();
        }
-
        Operation::RedactRevision { revision } => {
+
        Command::Redact { revision } => {
            let revision = get(revision, &identity, &repo)?.clone();
            let signer = term::signer(&profile)?;

            if revision.is_accepted() {
                anyhow::bail!("cannot redact accepted revision");
            }
-
            if options.interactive.confirm(format!(
+
            if interactive.confirm(format!(
                "Redact revision {}?",
                term::format::tertiary(revision.id)
            )) {
                identity.redact(revision.id, &signer)?;

-
                if !options.quiet {
+
                if !args.quiet {
                    term::success!("Revision {} redacted", revision.id);
                }
            }
        }
-
        Operation::ShowRevision { revision } => {
+
        Command::Show { revision } => {
            let revision = get(revision, &identity, &repo)?;
            let previous = revision.parent.unwrap_or(revision.id);
            let previous = identity
added crates/radicle-cli/src/commands/id/args.rs
@@ -0,0 +1,336 @@
+
use std::io;
+
use std::str::FromStr;
+

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

+
use serde_json as json;
+

+
use thiserror::Error;
+

+
use radicle::cob::{Title, TypeNameParse};
+
use radicle::identity::doc::update::EditVisibility;
+
use radicle::identity::doc::PayloadId;
+
use radicle::prelude::{Did, RepoId};
+

+
use crate::git::Rev;
+

+
use crate::terminal::Interactive;
+

+
pub(crate) 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.
+

+
See the rad-id(1) man page for more information.
+
"#;
+

+
#[derive(Debug, Error)]
+
pub enum PayloadUpsertParseError {
+
    #[error("could not parse payload id: {0}")]
+
    IdParse(#[from] TypeNameParse),
+
    #[error("could not parse json value: {0}")]
+
    Value(#[from] json::Error),
+
}
+

+
#[derive(Clone, Debug)]
+
pub struct PayloadUpsert {
+
    pub(super) id: PayloadId,
+
    pub(super) key: String,
+
    pub(super) value: json::Value,
+
}
+

+
impl PayloadUpsert {
+
    /// Parses a slice of all payload upserts as aggregated by `clap`
+
    /// (see [`Command::Update::payload`]).
+
    /// E.g. `["com.example.one", "name", "1", "com.example.two", "name2", "2"]`
+
    /// will result in iterator over two [`PayloadUpsert`]s.
+
    ///
+
    /// # Panics
+
    ///
+
    /// If the length of `values` is not divisible by 3.
+
    /// (To catch errors in the definition of the parser derived from
+
    /// [`Command::Update`] or `clap` itself, and unexpected changes to
+
    /// `clap`s behaviour in the future.)
+
    pub(super) fn parse_many(
+
        values: &[String],
+
    ) -> impl Iterator<Item = Result<Self, PayloadUpsertParseError>> + use<'_> {
+
        // `clap` ensures we have 3 values per option occurrence,
+
        // so we can chunk the aggregated slice exactly.
+
        let chunks = values.chunks_exact(3);
+

+
        assert!(chunks.remainder().is_empty());
+

+
        chunks.map(|chunk| {
+
            // Slice accesses will not panic, guaranteed by `chunks_exact(3)`.
+
            Ok(PayloadUpsert {
+
                id: PayloadId::from_str(&chunk[0])?,
+
                key: chunk[1].to_owned(),
+
                value: json::from_str(&chunk[2].to_owned())?,
+
            })
+
        })
+
    }
+
}
+

+
#[derive(Clone, Debug)]
+
struct EditVisibilityParser;
+

+
impl clap::builder::TypedValueParser for EditVisibilityParser {
+
    type Value = EditVisibility;
+

+
    fn parse_ref(
+
        &self,
+
        cmd: &clap::Command,
+
        arg: Option<&clap::Arg>,
+
        value: &std::ffi::OsStr,
+
    ) -> Result<Self::Value, clap::Error> {
+
        <EditVisibility as std::str::FromStr>::from_str.parse_ref(cmd, arg, value)
+
    }
+

+
    fn possible_values(
+
        &self,
+
    ) -> Option<Box<dyn Iterator<Item = clap::builder::PossibleValue> + '_>> {
+
        use clap::builder::PossibleValue;
+
        Some(Box::new(
+
            [PossibleValue::new("private"), PossibleValue::new("public")].into_iter(),
+
        ))
+
    }
+
}
+

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

+
    /// Specify the repository to operate on. Defaults to the current repository
+
    ///
+
    /// [example values: rad:z3Tr6bC7ctEg2EHmLvknUr29mEDLH, z3Tr6bC7ctEg2EHmLvknUr29mEDLH]
+
    #[arg(long)]
+
    #[arg(value_name = "RID", global = true)]
+
    pub(super) repo: Option<RepoId>,
+

+
    /// Do not ask for confirmation
+
    #[arg(long)]
+
    #[arg(global = true)]
+
    no_confirm: bool,
+

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

+
impl Args {
+
    pub(super) fn interactive(&self) -> Interactive {
+
        if self.no_confirm {
+
            Interactive::No
+
        } else {
+
            Interactive::new(io::stdout())
+
        }
+
    }
+
}
+

+
#[derive(Subcommand, Debug)]
+
pub(super) enum Command {
+
    /// Accept a proposed revision to the identity document
+
    #[clap(alias("a"))]
+
    Accept {
+
        /// Proposed revision to accept
+
        #[arg(value_name = "REVISION_ID")]
+
        revision: Rev,
+
    },
+

+
    /// Reject a proposed revision to the identity document
+
    #[clap(alias("r"))]
+
    Reject {
+
        /// Proposed revision to reject
+
        #[arg(value_name = "REVISION_ID")]
+
        revision: Rev,
+
    },
+

+
    /// Edit an existing revision to the identity document
+
    #[clap(alias("e"))]
+
    Edit {
+
        /// Proposed revision to edit
+
        #[arg(value_name = "REVISION_ID")]
+
        revision: Rev,
+

+
        /// Title of the edit
+
        #[arg(long)]
+
        title: Option<Title>,
+

+
        /// Description of the edit
+
        #[arg(long)]
+
        description: Option<String>,
+
    },
+

+
    /// Propose a new revision to the identity document
+
    #[clap(alias("u"))]
+
    Update {
+
        /// Set the title for the new proposal
+
        #[arg(long)]
+
        title: Option<Title>,
+

+
        /// Set the description for the new proposal
+
        #[arg(long)]
+
        description: Option<String>,
+

+
        /// Update the identity by adding a new delegate, identified by their DID
+
        #[arg(long, short)]
+
        #[arg(value_name = "DID")]
+
        #[arg(action = clap::ArgAction::Append)]
+
        delegate: Vec<Did>,
+

+
        /// Update the identity by removing a delegate, identified by their DID
+
        #[arg(long, short)]
+
        #[arg(value_name = "DID")]
+
        #[arg(action = clap::ArgAction::Append)]
+
        rescind: Vec<Did>,
+

+
        /// Update the identity by setting the number of delegates required to accept a revision
+
        #[arg(long)]
+
        threshold: Option<usize>,
+

+
        /// Update the identity by setting the repository's visibility to private or public
+
        #[arg(long)]
+
        #[arg(value_parser = EditVisibilityParser)]
+
        visibility: Option<EditVisibility>,
+

+
        /// Update the identity by giving a specific DID access to a private repository
+
        #[arg(long)]
+
        #[arg(value_name = "DID")]
+
        #[arg(action = clap::ArgAction::Append)]
+
        allow: Vec<Did>,
+

+
        /// Update the identity by removing a specific DID's access from a private repository
+
        #[arg(long)]
+
        #[arg(value_name = "DID")]
+
        #[arg(action = clap::ArgAction::Append)]
+
        disallow: Vec<Did>,
+

+
        /// Update the identity by setting metadata in one of the identity payloads
+
        ///
+
        /// [example values: xyz.radicle.project name '"radicle-example"']
+
        // TODO(erikili:) Value parsers do not operate on series of values, yet. This will
+
        // change with clap v5, so we can hopefully use `Vec<Payload>`.
+
        // - https://github.com/clap-rs/clap/discussions/5930#discussioncomment-12315889
+
        // - https://docs.rs/clap/latest/clap/_derive/index.html#arg-types
+
        #[arg(long)]
+
        #[arg(value_names = ["TYPE", "KEY", "VALUE"], num_args = 3)]
+
        payload: Vec<String>,
+

+
        /// Opens your $EDITOR to edit the JSON contents directly
+
        #[arg(long)]
+
        edit: bool,
+
    },
+

+
    /// Lists all proposed revisions to the identity document
+
    #[clap(alias("l"))]
+
    List,
+

+
    /// Show a specific identity proposal
+
    #[clap(alias("s"))]
+
    Show {
+
        /// Proposed revision to show
+
        #[arg(value_name = "REVISION_ID")]
+
        revision: Rev,
+
    },
+

+
    /// Redact a revision
+
    #[clap(alias("d"))]
+
    Redact {
+
        /// Proposed revision to redact
+
        #[arg(value_name = "REVISION_ID")]
+
        revision: Rev,
+
    },
+
}
+

+
#[cfg(test)]
+
mod test {
+
    use super::{Args, PayloadUpsert};
+
    use clap::error::ErrorKind;
+
    use clap::Parser;
+

+
    #[test]
+
    fn should_parse_single_payload() {
+
        let args = Args::try_parse_from(["id", "update", "--payload", "key", "name", "value"]);
+
        assert!(args.is_ok())
+
    }
+

+
    #[test]
+
    fn should_not_parse_single_payload() {
+
        let err = Args::try_parse_from(["id", "update", "--payload", "key", "name"]).unwrap_err();
+
        assert_eq!(err.kind(), ErrorKind::WrongNumberOfValues);
+
    }
+

+
    #[test]
+
    fn should_parse_multiple_payloads() {
+
        let args = Args::try_parse_from([
+
            "id",
+
            "update",
+
            "--payload",
+
            "key_1",
+
            "name_1",
+
            "value_1",
+
            "--payload",
+
            "key_2",
+
            "name_2",
+
            "value_2",
+
        ]);
+
        assert!(args.is_ok())
+
    }
+

+
    #[test]
+
    fn should_not_parse_single_payloads() {
+
        let err = Args::try_parse_from([
+
            "id",
+
            "update",
+
            "--payload",
+
            "key_1",
+
            "name_1",
+
            "value_1",
+
            "--payload",
+
            "key_2",
+
            "name_2",
+
        ])
+
        .unwrap_err();
+
        assert_eq!(err.kind(), ErrorKind::WrongNumberOfValues);
+
    }
+

+
    #[test]
+
    fn should_not_clobber_payload_args() {
+
        let err = Args::try_parse_from([
+
            "id",
+
            "update",
+
            "--payload",
+
            "key_1",
+
            "name_1",
+
            "--payload", // ensure `--payload is not treated as an argument`
+
            "key_2",
+
            "name_2",
+
            "value_2",
+
        ])
+
        .unwrap_err();
+
        assert_eq!(err.kind(), ErrorKind::WrongNumberOfValues);
+
    }
+

+
    #[test]
+
    fn should_parse_into_payload() {
+
        let payload: Result<Vec<_>, _> = PayloadUpsert::parse_many(&[
+
            "xyz.radicle.project".to_string(),
+
            "name".to_string(),
+
            "{}".to_string(),
+
        ])
+
        .collect();
+
        assert!(payload.is_ok())
+
    }
+

+
    #[test]
+
    #[should_panic(expected = "assertion failed: chunks.remainder().is_empty()")]
+
    fn should_not_parse_into_payload() {
+
        let _: Result<Vec<_>, _> =
+
            PayloadUpsert::parse_many(&["xyz.radicle.project".to_string(), "name".to_string()])
+
                .collect();
+
    }
+
}
modified crates/radicle-cli/src/main.rs
@@ -51,6 +51,7 @@ enum Commands {
    Clone(clone::Args),
    Debug(debug::Args),
    Fork(fork::Args),
+
    Id(id::Args),
    Init(init::Args),
    Issue(issue::Args),
    Ls(ls::Args),
@@ -231,7 +232,9 @@ pub(crate) fn run_other(exe: &str, args: &[OsString]) -> Result<(), Option<anyho
            term::run_command_args::<help::Options, _>(help::HELP, help::run, args.to_vec());
        }
        "id" => {
-
            term::run_command_args::<id::Options, _>(id::HELP, id::run, args.to_vec());
+
            if let Some(Commands::Id(args)) = CliArgs::parse().command {
+
                term::run_command_fn(id::run, args);
+
            }
        }
        "inbox" => {
            term::run_command_args::<inbox::Options, _>(inbox::HELP, inbox::run, args.to_vec())
modified crates/radicle/src/identity/doc/update.rs
@@ -139,40 +139,52 @@ where
    }
}

+
/// [`Payload`]: super::Payload
+
/// A change (update or insertion) to particular `key` within a [`Payload`]
+
/// in a document.
+
#[derive(Clone, Debug, PartialEq, Eq)]
+
pub struct PayloadUpsert {
+
    /// [`Payload`]: super::Payload
+
    /// The identifier for the document [`Payload`].
+
    pub id: PayloadId,
+
    /// [`Payload`]: super::Payload
+
    /// The key within the [`Payload`] that is being updated.
+
    pub key: String,
+
    /// [`Payload`]: super::Payload
+
    /// The value to update within the [`Payload`].
+
    pub value: json::Value,
+
}
+

// TODO(finto): I think this API would likely be much nicer if we use [JSON Patch][patch] and [JSON Merge Patch][merge]
//
// [patch]: https://datatracker.ietf.org/doc/html/rfc6902
// [merge]: https://datatracker.ietf.org/doc/html/rfc7396
-
/// Change the payload of the document, using the set of triples:
-
///
-
///   - [`PayloadId`]: the identifier for the document [`Payload`]
-
///   - [`String`]: the key within the [`Payload`] that is being updated
-
///   - [`json::Value`]: the value to update the [`Payload`]
+
/// [`Payload`]: super::Payload
+
/// Change (update or insert) a key in a [`Payload`] of the document,
+
/// using the provided `updates`.
///
/// # Errors
///
/// This fails if one of the [`PayloadId`]s does not point to a JSON object as
/// its value.
-
///
-
/// [`Payload`]: super::Payload
pub fn payload(
    mut raw: RawDoc,
-
    payload: Vec<(PayloadId, String, json::Value)>,
+
    upserts: impl IntoIterator<Item = PayloadUpsert>,
) -> Result<RawDoc, error::PayloadError> {
-
    for (id, key, val) in payload {
+
    for PayloadUpsert { id, key, value } in upserts {
        if let Some(ref mut payload) = raw.payload.get_mut(&id) {
            if let Some(obj) = payload.as_object_mut() {
-
                if val.is_null() {
+
                if value.is_null() {
                    obj.remove(&key);
                } else {
-
                    obj.insert(key, val);
+
                    obj.insert(key, value);
                }
            } else {
                return Err(error::PayloadError::ExpectedObject { id });
            }
        } else {
            raw.payload
-
                .insert(id, serde_json::json!({ key: val }).into());
+
                .insert(id, serde_json::json!({ key: value }).into());
        }
    }
    Ok(raw)
@@ -277,21 +289,23 @@ mod test {
        test::arbitrary,
    };

+
    use super::PayloadUpsert;
+

    #[test]
    fn test_can_update_crefs() {
        let raw = arbitrary::gen::<RawDoc>(1);
        let raw = super::payload(
            raw,
-
            vec![(
-
                PayloadId::canonical_refs(),
-
                "rules".to_string(),
-
                json!({
+
            [PayloadUpsert {
+
                id: PayloadId::canonical_refs(),
+
                key: "rules".to_string(),
+
                value: json!({
                    "refs/tags/*": {
                        "threshold": 1,
                        "allow": "delegates"
                    }
                }),
-
            )],
+
            }],
        )
        .unwrap();
        let verified = super::verify(raw);
@@ -306,10 +320,10 @@ mod test {
        ));
        let raw = super::payload(
            raw,
-
            vec![(
-
                PayloadId::canonical_refs(),
-
                "rules".to_string(),
-
                json!({
+
            [PayloadUpsert {
+
                id: PayloadId::canonical_refs(),
+
                key: "rules".to_string(),
+
                value: json!({
                    "refs/tags/*": {
                        "threshold": 1,
                        "allow": "delegates"
@@ -319,7 +333,7 @@ mod test {
                        "allow": "delegates",
                    }
                }),
-
            )],
+
            }],
        )
        .unwrap();
        assert!(
@@ -339,16 +353,16 @@ mod test {
        ));
        let raw = super::payload(
            raw,
-
            vec![(
-
                PayloadId::canonical_refs(),
-
                "rules".to_string(),
-
                json!({
+
            [PayloadUpsert {
+
                id: PayloadId::canonical_refs(),
+
                key: "rules".to_string(),
+
                value: json!({
                    "refs/tags/*": {
                        "threshold": 1,
                        "allow": "delegates"
                    }
                }),
-
            )],
+
            }],
        )
        .unwrap();
        let verified = super::verify(raw).unwrap();