Radish alpha
h
rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5
Radicle Heartwood Protocol & Stack
Radicle
Git
Add clap for parsing cli args
Draft did:key:z6MkqRSj...ghpk opened 2 years ago

Refactored the issue command using clap

Set clap help_template

cli: issue command shouldn’t ask for passphrase on read-only operations

cli: remove cli.rs and set default value for issue list –assigned

cli: Add completion stubs for bash/zsh/fish

Point clap fork dep to github.com/icetan instead of local path

Bump nixpkgs to support latest version of clap

11 files changed +730 -470 646d4360 0cb40e01
modified Cargo.lock
@@ -527,6 +527,55 @@ dependencies = [
]

[[package]]
+
name = "clap"
+
version = "4.5.1"
+
source = "git+https://github.com/icetan/clap.git?branch=propagate-template#362f08485f2d548fb5212e1a0b9c9632bbfb3ec1"
+
dependencies = [
+
 "clap_builder",
+
 "clap_derive",
+
]
+

+
[[package]]
+
name = "clap_builder"
+
version = "4.5.1"
+
source = "git+https://github.com/icetan/clap.git?branch=propagate-template#362f08485f2d548fb5212e1a0b9c9632bbfb3ec1"
+
dependencies = [
+
 "anstream",
+
 "anstyle",
+
 "clap_lex",
+
 "strsim",
+
]
+

+
[[package]]
+
name = "clap_complete"
+
version = "4.5.1"
+
source = "git+https://github.com/icetan/clap.git?branch=propagate-template#362f08485f2d548fb5212e1a0b9c9632bbfb3ec1"
+
dependencies = [
+
 "clap",
+
 "clap_lex",
+
 "is_executable",
+
 "pathdiff",
+
 "shlex",
+
 "unicode-xid",
+
]
+

+
[[package]]
+
name = "clap_derive"
+
version = "4.5.0"
+
source = "git+https://github.com/icetan/clap.git?branch=propagate-template#362f08485f2d548fb5212e1a0b9c9632bbfb3ec1"
+
dependencies = [
+
 "heck",
+
 "proc-macro2",
+
 "quote",
+
 "syn 2.0.48",
+
]
+

+
[[package]]
+
name = "clap_lex"
+
version = "0.7.0"
+
source = "git+https://github.com/icetan/clap.git?branch=propagate-template#362f08485f2d548fb5212e1a0b9c9632bbfb3ec1"
+

+
[[package]]
name = "colorchoice"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1425,6 +1474,12 @@ dependencies = [
]

[[package]]
+
name = "heck"
+
version = "0.4.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8"
+

+
[[package]]
name = "hermit-abi"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1615,6 +1670,15 @@ dependencies = [
]

[[package]]
+
name = "is_executable"
+
version = "1.0.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "fa9acdc6d67b75e626ad644734e8bc6df893d9cd2a834129065d3dd6158ea9c8"
+
dependencies = [
+
 "winapi",
+
]
+

+
[[package]]
name = "itoa"
version = "1.0.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2063,6 +2127,12 @@ dependencies = [
]

[[package]]
+
name = "pathdiff"
+
version = "0.2.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "8835116a5c179084a830efb3adc117ab007512b535bc1a21c991d3b32a6b44dd"
+

+
[[package]]
name = "pbkdf2"
version = "0.12.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2317,6 +2387,8 @@ version = "0.8.0"
dependencies = [
 "anyhow",
 "chrono",
+
 "clap",
+
 "clap_complete",
 "git-ref-format",
 "lexopt",
 "localtime",
@@ -3104,6 +3176,12 @@ dependencies = [
]

[[package]]
+
name = "strsim"
+
version = "0.11.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "5ee073c9e4cd00e28217186dbe12796d692868f432bf2e97ee73bed0c56dfa01"
+

+
[[package]]
name = "subtle"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3611,6 +3689,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e51733f11c9c4f72aa0c160008246859e340b00807569a0da0e7a1079b27ba85"

[[package]]
+
name = "unicode-xid"
+
version = "0.2.4"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "f962df74c8c05a667b5ee8bcf162993134c104e96440b663c8daa176dc772d8c"
+

+
[[package]]
name = "universal-hash"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
modified flake.lock
@@ -56,16 +56,15 @@
    },
    "nixpkgs": {
      "locked": {
-
        "lastModified": 1701961404,
-
        "narHash": "sha256-ieWHyh6kJtabQYUam/dXXi22MgcgLQvl+f2x/95n+us=",
+
        "lastModified": 1710513883,
+
        "narHash": "sha256-MKYur1pNCrk8bU7agCpMRi3dCCEbv3GwH3pS0XuM1Ps=",
        "owner": "NixOS",
        "repo": "nixpkgs",
-
        "rev": "8fef9eee026f0d95c06b5880ef9c1af0f643aadf",
+
        "rev": "bda93c2221bc4185056723795c62e1b4cc661c4b",
        "type": "github"
      },
      "original": {
        "owner": "NixOS",
-
        "ref": "release-23.11",
        "repo": "nixpkgs",
        "type": "github"
      }
modified flake.nix
@@ -2,7 +2,7 @@
  description = "Radicle";

  inputs = {
-
    nixpkgs.url = "github:NixOS/nixpkgs/release-23.11";
+
    nixpkgs.url = "github:NixOS/nixpkgs";

    crane = {
      url = "github:ipetkov/crane";
modified radicle-cli/Cargo.toml
@@ -45,6 +45,9 @@ tree-sitter-bash = { version = "0.20" }
tree-sitter-go = { version = "0.20.0" }
tree-sitter-md = { version = "0.1.5" }
zeroize = { version = "1.1" }
+
clap = { git = "https://github.com/icetan/clap.git", branch = "propagate-template", features = ["derive"] }
+
clap_complete = { git = "https://github.com/icetan/clap.git", branch = "propagate-template", features = ["unstable-dynamic"] }
+


[dependencies.radicle]
version = "0"
added radicle-cli/completion/_rad.zsh
@@ -0,0 +1,23 @@
+
#compdef rad
+
# ZSH suggestion shim for Radicle CLI
+

+
# DEBUG:
+
# echo "\n\n\nstate:\t'$state'"
+
# echo "line:\t'$line'"
+
# echo "words:\t$words"
+

+
export _CLAP_COMPLETE_INDEX=$((${#words[@]} - 1))
+
# export _CLAP_COMPLETE_COMP_TYPE=9
+
# export _CLAP_COMPLETE_SPACE=false
+

+
local completions=($(rad complete --shell bash -- $words "" 2>/dev/null))
+

+
if [ -z "$completions" ]
+
then
+
  # If the completion list is empty, just continue with filename selection
+
  # _arguments '*::arguments: _normal'
+
  return
+
fi
+

+
# This is not a variable assignment, don't remove spaces!
+
_values = "${(ps:\v:)completions}"
added radicle-cli/completion/bash/_rad_bash_complete.sh
@@ -0,0 +1,19 @@
+

+
_clap_complete_rad() {
+
    export IFS=$'\013'
+
    export _CLAP_COMPLETE_INDEX=${COMP_CWORD}
+
    export _CLAP_COMPLETE_COMP_TYPE=${COMP_TYPE}
+
    if compopt +o nospace 2> /dev/null; then
+
        export _CLAP_COMPLETE_SPACE=false
+
    else
+
        export _CLAP_COMPLETE_SPACE=true
+
    fi
+
    COMPREPLY=( $("rad" complete --shell bash -- "${COMP_WORDS[@]}") )
+
    if [[ $? != 0 ]]; then
+
        unset COMPREPLY
+
    elif [[ $SUPPRESS_SPACE == 1 ]] && [[ "${COMPREPLY-}" =~ [=/:]$ ]]; then
+
        compopt -o nospace
+
    fi
+
}
+
complete -o nospace -o bashdefault -F _clap_complete_rad rad
+

added radicle-cli/completion/fish/_rad
@@ -0,0 +1 @@
+
complete -x -c rad -a "("'rad'" complete --shell fish -- (commandline --current-process --tokenize --cut-at-cursor) (commandline --current-token))"
added radicle-cli/completion/zsh/_rad
@@ -0,0 +1,24 @@
+
#compdef rad
+
# ZSH suggestion shim for Radicle CLI
+

+
# DEBUG:
+
# echo "\n\n\nstate:\t'$state'"
+
# echo "line:\t'$line'"
+
# echo "words:\t$words"
+

+
export IFS=$'\013'
+
export _CLAP_COMPLETE_INDEX=$((${#words[@]} - 1))
+
# export _CLAP_COMPLETE_COMP_TYPE=9
+
# export _CLAP_COMPLETE_SPACE=false
+

+
local completions=($(rad complete --shell bash -- $words "" 2>/dev/null))
+

+
if [ -z "$completions" ]
+
then
+
  # If the completion list is empty, just continue with filename selection
+
  # _arguments '*::arguments: _normal'
+
  return
+
fi
+

+
# This is not a variable assignment, don't remove spaces!
+
_values = "${(ps:\v:)completions}"
modified radicle-cli/src/commands/issue.rs
@@ -1,21 +1,23 @@
#[path = "issue/cache.rs"]
mod cache;

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

-
use anyhow::{anyhow, Context as _};
+
use anyhow::Context as _;
+
use clap::{ArgGroup, Parser, Subcommand, ValueHint};

use radicle::cob::common::{Label, Reaction};
use radicle::cob::issue;
use radicle::cob::issue::{CloseReason, State};
use radicle::cob::thread;
use radicle::crypto::Signer;
+
use radicle::identity::did::DidError;
+
use radicle::identity::RepoId;
use radicle::issue::cache::Issues as _;
-
use radicle::prelude::{Did, RepoId};
+
use radicle::issue::Issues;
+
use radicle::prelude::Did;
use radicle::profile;
-
use radicle::storage;
+
use radicle::storage::{self, ReadStorage as _};
use radicle::storage::{ReadRepository, WriteRepository, WriteStorage};
use radicle::Profile;
use radicle::{cob, Node};
@@ -23,7 +25,7 @@ use radicle::{cob, Node};
use crate::git::Rev;
use crate::node;
use crate::terminal as term;
-
use crate::terminal::args::{Args, Error, Help};
+
use crate::terminal::args::Help;
use crate::terminal::format::Author;
use crate::terminal::issue::Format;
use crate::terminal::patch::Message;
@@ -77,524 +79,561 @@ Options
"#,
};

-
#[derive(Default, Debug, PartialEq, Eq)]
-
pub enum OperationName {
-
    Assign,
-
    Edit,
-
    Open,
-
    Comment,
-
    Delete,
-
    Label,
-
    #[default]
-
    List,
-
    React,
-
    Show,
-
    State,
-
    Cache,
-
}
-

/// Command line Peer argument.
-
#[derive(Default, Debug, PartialEq, Eq)]
+
#[derive(Default, Debug, Clone, PartialEq, Eq)]
pub enum Assigned {
    #[default]
    Me,
    Peer(Did),
}

-
#[derive(Debug, PartialEq, Eq)]
-
pub enum Operation {
+
#[derive(Parser, Debug)]
+
pub struct IssueArgs {
+
    #[command(subcommand)]
+
    command: Option<IssueCommands>,
+

+
    /// Don't print anything
+
    #[arg(short, long)]
+
    #[clap(global = true)]
+
    quiet: bool,
+

+
    /// Don't announce issue to peers
+
    #[arg(long)]
+
    #[arg(value_name = "no-announce")]
+
    #[clap(global = true)]
+
    no_announce: bool,
+

+
    /// Show only the issue header, hiding the comments
+
    #[arg(long)]
+
    #[clap(global = true)]
+
    header: bool,
+

+
    #[arg(long, short)]
+
    repo: Option<RepoId>,
+
}
+

+
#[derive(Subcommand, Debug)]
+
enum IssueCommands {
+
    /// Delete an issue
+
    Delete {
+
        #[arg(value_name = "issue-id")]
+
        #[clap(value_hint = ValueHint::Dynamic(get_issue_id_hints))]
+
        id: Rev,
+
    },
+

+
    /// Edit an issue
    Edit {
+
        #[arg(value_name = "issue-id")]
+
        #[clap(value_hint = ValueHint::Dynamic(get_issue_id_hints))]
        id: Rev,
+

+
        #[arg(long, short)]
        title: Option<String>,
+

+
        #[arg(long, short)]
        description: Option<String>,
    },
+

+
    /// List and filter issues
+
    List(ListArgs),
+

+
    /// Create a new issue
    Open {
+
        #[arg(long, short)]
        title: Option<String>,
+

+
        #[arg(long, short)]
        description: Option<String>,
+

+
        #[arg(long)]
        labels: Vec<Label>,
+

+
        #[arg(long)]
        assignees: Vec<Did>,
    },
-
    Show {
-
        id: Rev,
-
        format: Format,
-
        debug: bool,
-
    },
-
    Comment {
-
        id: Rev,
-
        message: Message,
-
        reply_to: Option<Rev>,
-
    },
-
    State {
-
        id: Rev,
-
        state: State,
-
    },
-
    Delete {
-
        id: Rev,
-
    },
+

+
    /// Add a reaction emoji to an issue or comment
    React {
+
        #[arg(value_name = "issue-id")]
+
        #[clap(value_hint = ValueHint::Dynamic(get_issue_id_hints))]
        id: Rev,
+

+
        #[arg(long = "emoji")]
+
        #[arg(value_name = "char")]
        reaction: Reaction,
+

+
        #[arg(long = "to")]
+
        #[arg(value_name = "comment")]
+
        // TODO: Add dynamic hint for comment ids
        comment_id: Option<thread::CommentId>,
    },
+

+
    /// Manage assignees of an issue
    Assign {
+
        #[clap(value_hint = ValueHint::Dynamic(get_issue_id_hints))]
+
        #[arg(value_name = "issue-id")]
        id: Rev,
-
        opts: AssignOptions,
+

+
        /// Add an assignee to the issue (may be specified multiple times).
+
        #[arg(long, short)]
+
        #[arg(value_name = "did")]
+
        #[arg(action = clap::ArgAction::Append)]
+
        add: Vec<Did>,
+

+
        /// Delete an assignee from the issue (may be specified multiple times).
+
        #[arg(long, short)]
+
        #[arg(value_name = "did")]
+
        #[arg(action = clap::ArgAction::Append)]
+
        delete: Vec<Did>,
    },
+

+
    /// Update labels on an issue
    Label {
+
        /// The issue to label.
+
        #[arg(value_name = "issue-id")]
+
        #[clap(value_hint = ValueHint::Dynamic(get_issue_id_hints))]
        id: Rev,
-
        opts: LabelOptions,
-
    },
-
    List {
-
        assigned: Option<Assigned>,
-
        state: Option<State>,
-
    },
-
    Cache {
-
        id: Option<Rev>,
-
    },
-
}
-

-
#[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 struct Options {
-
    pub op: Operation,
-
    pub repo: Option<RepoId>,
-
    pub announce: bool,
-
    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 id: Option<Rev> = None;
-
        let mut assigned: Option<Assigned> = None;
-
        let mut title: Option<String> = None;
-
        let mut reaction: Option<Reaction> = None;
-
        let mut comment_id: Option<thread::CommentId> = None;
-
        let mut description: Option<String> = None;
-
        let mut state: Option<State> = Some(State::Open);
-
        let mut labels = Vec::new();
-
        let mut assignees = Vec::new();
-
        let mut format = Format::default();
-
        let mut message = Message::default();
-
        let mut reply_to = None;
-
        let mut announce = true;
-
        let mut quiet = false;
-
        let mut debug = false;
-
        let mut assign_opts = AssignOptions::default();
-
        let mut label_opts = LabelOptions::default();
-
        let mut repo = None;
-

-
        while let Some(arg) = parser.next()? {
-
            match arg {
-
                Long("help") | Short('h') => {
-
                    return Err(Error::Help.into());
-
                }
+
        /// Add an assignee to the issue (may be specified multiple times).
+
        #[arg(long, short)]
+
        #[arg(value_name = "label")]
+
        #[arg(action = clap::ArgAction::Append)]
+
        add: Vec<Label>,
+

+
        /// Delete an assignee from the issue (may be specified multiple times).
+
        #[arg(long, short)]
+
        #[arg(value_name = "label")]
+
        #[arg(action = clap::ArgAction::Append)]
+
        delete: Vec<Label>,
+
    },

-
                // List options.
-
                Long("all") if op.is_none() || op == Some(OperationName::List) => {
-
                    state = None;
-
                }
-
                Long("closed") if op.is_none() || op == Some(OperationName::List) => {
-
                    state = Some(State::Closed {
-
                        reason: CloseReason::Other,
-
                    });
-
                }
-
                Long("open") if op.is_none() || op == Some(OperationName::List) => {
-
                    state = Some(State::Open);
-
                }
-
                Long("solved") if op.is_none() || op == Some(OperationName::List) => {
-
                    state = Some(State::Closed {
-
                        reason: CloseReason::Solved,
-
                    });
-
                }
+
    /// Add a comment to an issue.
+
    Comment {
+
        #[arg(value_name = "issue-id")]
+
        #[clap(value_hint = ValueHint::Dynamic(get_issue_id_hints))]
+
        id: Rev,

-
                // Open options.
-
                Long("title") if op == Some(OperationName::Open) => {
-
                    title = Some(parser.value()?.to_string_lossy().into());
-
                }
-
                Short('l') | Long("label") if matches!(op, Some(OperationName::Open)) => {
-
                    let val = parser.value()?;
-
                    let name = term::args::string(&val);
-
                    let label = Label::new(name)?;
+
        /// Message text.
+
        #[arg(long, short)]
+
        #[arg(value_name = "message")]
+
        message: Message,

-
                    labels.push(label);
-
                }
-
                Long("assign") if op == Some(OperationName::Open) => {
-
                    let val = parser.value()?;
-
                    let did = term::args::did(&val)?;
+
        #[arg(long, name = "comment-id")]
+
        reply_to: Option<Rev>,
+
    },

-
                    assignees.push(did);
-
                }
-
                Long("description") if op == Some(OperationName::Open) => {
-
                    description = Some(parser.value()?.to_string_lossy().into());
-
                }
+
    /// Show a specific issue
+
    Show {
+
        #[arg(value_name = "issue-id")]
+
        #[clap(value_hint = ValueHint::Dynamic(get_issue_id_hints))]
+
        id: Rev,

-
                // State options.
-
                Long("closed") if op == Some(OperationName::State) => {
-
                    state = Some(State::Closed {
-
                        reason: CloseReason::Other,
-
                    });
-
                }
-
                Long("open") if op == Some(OperationName::State) => {
-
                    state = Some(State::Open);
-
                }
-
                Long("solved") if op == Some(OperationName::State) => {
-
                    state = Some(State::Closed {
-
                        reason: CloseReason::Solved,
-
                    });
-
                }
+
        /// Show the issue as Rust debug output
+
        #[arg(long)]
+
        debug: bool,
+
    },

-
                // 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("to") if op == Some(OperationName::React) => {
-
                    let oid: String = parser.value()?.to_string_lossy().into();
-
                    comment_id = Some(oid.parse()?);
-
                }
+
    Cache {
+
        #[arg(value_name = "issue-id")]
+
        id: Option<Rev>,
+
    },

-
                // Show options.
-
                Long("format") if op == Some(OperationName::Show) => {
-
                    let val = parser.value()?;
-
                    let val = term::args::string(&val);
+
    State(StateArgs),
+
}

-
                    match val.as_str() {
-
                        "header" => format = Format::Header,
-
                        "full" => format = Format::Full,
-
                        _ => anyhow::bail!("unknown format '{val}'"),
-
                    }
-
                }
-
                Long("debug") if op == Some(OperationName::Show) => {
-
                    debug = true;
-
                }
+
#[derive(Parser, Debug)]
+
struct ListArgs {
+
    /// List issues assigned to <did> (default: me)
+
    #[clap(value_hint = ValueHint::Dynamic(get_assignee_did_hints))]
+
    #[arg(long, name = "did")]
+
    #[arg(default_missing_value = "me")]
+
    #[arg(num_args = 0..=1)]
+
    #[arg(require_equals = true)]
+
    assigned: Option<Assigned>,
+

+
    /// List all issues (default)
+
    #[arg(long, group = "state")]
+
    all: bool,
+

+
    /// List only open issues
+
    #[arg(long, group = "state")]
+
    open: bool,
+

+
    /// List only closed issues
+
    #[arg(long, group = "state")]
+
    closed: bool,
+

+
    /// List only solved issues
+
    #[arg(long, group = "state")]
+
    solved: bool,
+
}

-
                // Comment options.
-
                Long("message") | Short('m') if op == Some(OperationName::Comment) => {
-
                    let val = parser.value()?;
-
                    let txt = term::args::string(&val);
+
#[derive(Parser, Debug)]
+
#[clap(group(ArgGroup::new("state").required(true)))]
+
struct StateArgs {
+
    #[arg(value_name = "issue-id")]
+
    #[clap(value_hint = ValueHint::Dynamic(get_issue_id_hints))]
+
    id: Rev,

-
                    message.append(&txt);
-
                }
-
                Long("reply-to") if op == Some(OperationName::Comment) => {
-
                    let val = parser.value()?;
-
                    let rev = term::args::rev(&val)?;
+
    /// Set issue state to open
+
    #[arg(long, short, group = "state")]
+
    open: bool,

-
                    reply_to = Some(rev);
-
                }
+
    /// Set issue state to closed
+
    #[arg(long, short, group = "state")]
+
    closed: bool,

-
                // Assign options
-
                Short('a') | Long("add") if op == Some(OperationName::Assign) => {
-
                    assign_opts.add.insert(term::args::did(&parser.value()?)?);
-
                }
-
                Short('d') | Long("delete") if op == Some(OperationName::Assign) => {
-
                    assign_opts
-
                        .delete
-
                        .insert(term::args::did(&parser.value()?)?);
-
                }
-
                Long("assigned") | Short('a') if assigned.is_none() => {
-
                    if let Ok(val) = parser.value() {
-
                        let peer = term::args::did(&val)?;
-
                        assigned = Some(Assigned::Peer(peer));
-
                    } else {
-
                        assigned = Some(Assigned::Me);
-
                    }
-
                }
-

-
                // 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)?;
+
    /// Set issue state to solved
+
    #[arg(long, short, group = "state")]
+
    solved: bool,
+
}

-
                    label_opts.delete.insert(label);
-
                }
+
impl FromStr for Assigned {
+
    type Err = DidError;

-
                // Options.
-
                Long("no-announce") => {
-
                    announce = false;
-
                }
-
                Long("quiet") | Short('q') => {
-
                    quiet = true;
-
                }
-
                Long("repo") => {
-
                    let val = parser.value()?;
-
                    let rid = term::args::rid(&val)?;
+
    fn from_str(s: &str) -> Result<Self, Self::Err> {
+
        if s == "me" {
+
            Ok(Assigned::Me)
+
        } else {
+
            let value = s.parse::<Did>()?;
+
            Ok(Assigned::Peer(value))
+
        }
+
    }
+
}

-
                    repo = Some(rid);
-
                }
+
fn to_state_filter(args: ListArgs) -> Option<State> {
+
    if args.open {
+
        Some(radicle::cob::issue::State::Open)
+
    } else if args.closed {
+
        Some(State::Closed {
+
            reason: CloseReason::Other,
+
        })
+
    } else if args.solved {
+
        Some(State::Closed {
+
            reason: CloseReason::Solved,
+
        })
+
    } else {
+
        None
+
    }
+
}

-
                Value(val) if op.is_none() => match val.to_string_lossy().as_ref() {
-
                    "c" | "comment" => op = Some(OperationName::Comment),
-
                    "w" | "show" => op = Some(OperationName::Show),
-
                    "d" | "delete" => op = Some(OperationName::Delete),
-
                    "e" | "edit" => op = Some(OperationName::Edit),
-
                    "l" | "list" => op = Some(OperationName::List),
-
                    "o" | "open" => op = Some(OperationName::Open),
-
                    "r" | "react" => op = Some(OperationName::React),
-
                    "s" | "state" => op = Some(OperationName::State),
-
                    "assign" => op = Some(OperationName::Assign),
-
                    "label" => op = Some(OperationName::Label),
-
                    "cache" => op = Some(OperationName::Cache),
-

-
                    unknown => anyhow::bail!("unknown operation '{}'", unknown),
-
                },
-
                Value(val) if op.is_some() => {
-
                    let val = term::args::rev(&val)?;
-
                    id = Some(val);
-
                }
-
                _ => {
-
                    return Err(anyhow!(arg.unexpected()));
-
                }
-
            }
+
fn to_state(args: StateArgs) -> State {
+
    if args.open {
+
        radicle::cob::issue::State::Open
+
    } else if args.closed {
+
        State::Closed {
+
            reason: CloseReason::Other,
+
        }
+
    } else if args.solved {
+
        State::Closed {
+
            reason: CloseReason::Solved,
        }
+
    } else {
+
        // FIXME:
+
        unreachable!("State flag needed");
+
    }
+
}

-
        let op = match op.unwrap_or_default() {
-
            OperationName::Edit => Operation::Edit {
-
                id: id.ok_or_else(|| anyhow!("an issue must be provided"))?,
-
                title,
-
                description,
-
            },
-
            OperationName::Open => Operation::Open {
-
                title,
-
                description,
-
                labels,
-
                assignees,
-
            },
-
            OperationName::Comment => Operation::Comment {
-
                id: id.ok_or_else(|| anyhow!("an issue must be provided"))?,
-
                message,
-
                reply_to,
-
            },
-
            OperationName::Show => Operation::Show {
-
                id: id.ok_or_else(|| anyhow!("an issue must be provided"))?,
-
                format,
-
                debug,
-
            },
-
            OperationName::State => Operation::State {
-
                id: id.ok_or_else(|| anyhow!("an issue must be provided"))?,
-
                state: state.ok_or_else(|| anyhow!("a state operation must be provided"))?,
-
            },
-
            OperationName::React => Operation::React {
-
                id: id.ok_or_else(|| anyhow!("an issue must be provided"))?,
-
                reaction: reaction.ok_or_else(|| anyhow!("a reaction emoji must be provided"))?,
-
                comment_id,
-
            },
-
            OperationName::Delete => Operation::Delete {
-
                id: id.ok_or_else(|| anyhow!("an issue to remove must be provided"))?,
-
            },
-
            OperationName::Assign => Operation::Assign {
-
                id: id.ok_or_else(|| anyhow!("an issue to label must be provided"))?,
-
                opts: assign_opts,
-
            },
-
            OperationName::Label => Operation::Label {
-
                id: id.ok_or_else(|| anyhow!("an issue to label must be provided"))?,
-
                opts: label_opts,
-
            },
-
            OperationName::List => Operation::List { assigned, state },
-
            OperationName::Cache => Operation::Cache { id },
-
        };
+
pub fn get_assignee_did_hints(input: &str) -> Option<Vec<String>> {
+
    let (_, rid) = radicle::rad::cwd().ok()?;
+
    radicle::Profile::load()
+
        .ok()
+
        .and_then(|profile| profile.storage.repository(rid).ok())
+
        .and_then(|repo| {
+
            Issues::open(&repo).ok().and_then(|issues| {
+
                issues
+
                    .all()
+
                    .map(|issues| {
+
                        issues
+
                            .flat_map(|issue| {
+
                                issue.map_or(vec![], |(_, issue)| {
+
                                    issue.assignees().cloned().collect::<Vec<_>>()
+
                                })
+
                            })
+
                            .filter_map(|did| {
+
                                let did = did.to_human();
+
                                did.starts_with(input).then(|| String::from(did))
+
                            })
+
                            .collect::<Vec<_>>()
+
                    })
+
                    .ok()
+
            })
+
        })
+
}

-
        Ok((
-
            Options {
-
                op,
-
                repo,
-
                announce,
-
                quiet,
-
            },
-
            vec![],
-
        ))
-
    }
+
pub fn get_issue_id_hints(input: &str) -> Option<Vec<String>> {
+
    let (_, rid) = radicle::rad::cwd().ok()?;
+
    radicle::Profile::load()
+
        .ok()
+
        .and_then(|profile| profile.storage.repository(rid).ok())
+
        .and_then(|repo| {
+
            Issues::open(&repo).ok().and_then(|issues| {
+
                issues
+
                    .all()
+
                    .map(|issues| {
+
                        issues
+
                            .filter_map(|issue| {
+
                                if let Ok((id, _)) = issue {
+
                                    let id = id.to_string();
+
                                    if id.starts_with(input) {
+
                                        return Some(String::from(id.split_at(8).0));
+
                                    }
+
                                }
+
                                None
+
                            })
+
                            .collect::<Vec<_>>()
+
                    })
+
                    .ok()
+
            })
+
        })
}

-
pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
+
pub fn get_did_hints<R: ReadRepository + radicle::cob::Store>(input: &str) -> Option<Vec<String>> {
+
    let (_, rid) = radicle::rad::cwd().ok()?;
+
    radicle::Profile::load()
+
        .ok()
+
        .and_then(|profile| profile.storage.repository(rid).ok())
+
        .and_then(|repo| {
+
            repo.remote_ids()
+
                .map(|issues| {
+
                    issues
+
                        .filter_map(|id| {
+
                            let id = id.map(|id| Did::from(id).to_human()).ok()?;
+
                            id.starts_with(input).then_some(id)
+
                        })
+
                        .collect::<Vec<_>>()
+
                })
+
                .ok()
+
        })
+
}
+

+
// pub fn get_assignee_did_hints(input: &str) -> Option<Vec<String>> {
+
//     let profile = term::profile().ok()?;
+
//     let (_, rid) = radicle::rad::cwd().ok()?;
+
//     let repo = profile.storage.repository_mut(rid).ok()?;
+
//     let issues = Issues::open(&repo).ok()?;
+

+
//     let completions = issues
+
//         .all()
+
//         .ok()?
+
//         .flat_map(|issue| {
+
//             issue.map_or(vec![], |(_, issue)| {
+
//                 issue.assignees().cloned().collect::<Vec<_>>()
+
//             })
+
//         })
+
//         .filter_map(|did| {
+
//             let did = did.to_human();
+
//             did.starts_with(input).then(|| String::from(did))
+
//         })
+
//         .collect::<Vec<_>>();
+

+
//     Some(completions)
+
// }
+

+
// pub fn get_issue_id_hints(input: &str) -> Option<Vec<String>> {
+
//     let profile = term::profile().ok()?;
+

+
//     let (_, rid) = radicle::rad::cwd().ok()?;
+
//     let repo = profile.storage.repository_mut(rid).ok()?;
+
//     let issues = Issues::open(&repo).ok()?;
+

+
//     let completions = issues
+
//         .all()
+
//         .ok()?
+
//         .filter_map(|issue| {
+
//             if let Ok((id, _)) = issue {
+
//                 let id = id.to_string();
+
//                 if id.starts_with(input) {
+
//                     return Some(String::from(id.split_at(8).0));
+
//                 }
+
//             }
+
//             None
+
//         })
+
//         .collect::<Vec<_>>();
+

+
//     Some(completions)
+
// }
+

+
// pub fn get_did_hints(input: &str) -> Option<Vec<String>> {
+
//     let profile = term::profile().ok()?;
+
//     let (_, rid) = radicle::rad::cwd().ok()?;
+
//     let repo = profile.storage.repository_mut(rid).ok()?;
+

+
//     let ids = repo
+
//         .remote_ids()
+
//         .ok()?
+
//         .filter_map(|id| {
+
//             let id = id.map(|id| Did::from(id).to_human()).ok()?;
+
//             id.starts_with(input).then_some(id)
+
//         })
+
//         .collect::<Vec<_>>();
+

+
//     Some(ids)
+
// }
+

+
pub fn run(args: IssueArgs, ctx: impl term::Context) -> anyhow::Result<()> {
    let profile = ctx.profile()?;
-
    let rid = if let Some(rid) = options.repo {
+
    let rid = if let Some(rid) = args.repo {
        rid
    } else {
        radicle::rad::cwd().map(|(_, rid)| rid)?
    };
    let repo = profile.storage.repository_mut(rid)?;
-
    let announce = options.announce
-
        && matches!(
-
            &options.op,
-
            Operation::Open { .. }
-
                | Operation::React { .. }
-
                | Operation::State { .. }
-
                | Operation::Delete { .. }
-
                | Operation::Assign { .. }
-
                | Operation::Label { .. }
-
        );

    let mut issues = profile.issues_mut(&repo)?;

-
    match options.op {
-
        Operation::Edit {
-
            id,
-
            title,
-
            description,
-
        } => {
-
            let signer = term::signer(&profile)?;
-
            let issue = edit(&mut issues, &repo, id, title, description, &signer)?;
-
            if !options.quiet {
-
                term::issue::show(&issue, issue.id(), Format::Header, &profile)?;
+
    if let Some(command) = args.command {
+
        let announce = !args.no_announce
+
            && matches!(
+
                &command,
+
                IssueCommands::Open { .. }
+
                    | IssueCommands::React { .. }
+
                    | IssueCommands::State { .. }
+
                    | IssueCommands::Delete { .. }
+
                    | IssueCommands::Assign { .. }
+
                    | IssueCommands::Label { .. }
+
            );
+

+
        match command {
+
            IssueCommands::Delete { id } => {
+
                let signer = term::signer(&profile)?;
+
                let id = id.resolve(&repo.backend)?;
+
                issues.remove(&id, &signer)?;
            }
-
        }
-
        Operation::Open {
-
            title: Some(title),
-
            description: Some(description),
-
            labels,
-
            assignees,
-
        } => {
-
            let signer = term::signer(&profile)?;
-
            let issue = issues.create(title, description, &labels, &assignees, [], &signer)?;
-
            if !options.quiet {
-
                term::issue::show(&issue, issue.id(), Format::Header, &profile)?;
+
            IssueCommands::Edit {
+
                id,
+
                title,
+
                description,
+
            } => {
+
                let signer = term::signer(&profile)?;
+
                let issue = edit(&mut issues, &repo, id, title, description, &signer)?;
+
                if !args.quiet {
+
                    term::issue::show(&issue, issue.id(), Format::Header, &profile)?;
+
                }
            }
-
        }
-
        Operation::Comment {
-
            id,
-
            message,
-
            reply_to,
-
        } => {
-
            let signer = term::signer(&profile)?;
-
            let issue_id = id.resolve::<cob::ObjectId>(&repo.backend)?;
-
            let mut issue = issues.get_mut(&issue_id)?;
-
            let (body, reply_to) = prompt_comment(message, reply_to, &issue, &repo)?;
-
            let comment_id = issue.comment(body, reply_to, vec![], &signer)?;
-

-
            if options.quiet {
-
                term::print(comment_id);
-
            } else {
-
                let comment = issue.thread().comment(&comment_id).unwrap();
-
                term::comment::widget(&comment_id, comment, &profile).print();
+
            IssueCommands::List(list_args) => {
+
                let assigned = list_args.assigned.clone();
+
                let state = to_state_filter(list_args);
+
                list(issues, &assigned, &state, &profile)?;
            }
-
        }
-
        Operation::Show { id, format, debug } => {
-
            let id = id.resolve(&repo.backend)?;
-
            let issue = issues
-
                .get(&id)?
-
                .context("No issue with the given ID exists")?;
-
            if debug {
-
                println!("{:#?}", issue);
-
            } else {
-
                term::issue::show(&issue, &id, format, &profile)?;
+
            IssueCommands::Show { id, debug } => {
+
                let format = if args.header {
+
                    term::issue::Format::Header
+
                } else {
+
                    term::issue::Format::Full
+
                };
+

+
                let id = id.resolve(&repo.backend)?;
+

+
                let issue = issues
+
                    .get(&id)?
+
                    .context("No issue with the given ID exists")?;
+
                if debug {
+
                    println!("{:#?}", issue);
+
                } else {
+
                    term::issue::show(&issue, &id, format, &profile)?;
+
                }
            }
-
        }
-
        Operation::State { id, state } => {
-
            let signer = term::signer(&profile)?;
-
            let id = id.resolve(&repo.backend)?;
-
            let mut issue = issues.get_mut(&id)?;
-
            issue.lifecycle(state, &signer)?;
-
        }
-
        Operation::React {
-
            id,
-
            reaction,
-
            comment_id,
-
        } => {
-
            let id = id.resolve(&repo.backend)?;
-
            if let Ok(mut issue) = issues.get_mut(&id) {
+
            IssueCommands::State(state_args) => {
                let signer = term::signer(&profile)?;
-
                let comment_id = comment_id.unwrap_or_else(|| {
-
                    let (comment_id, _) = term::io::comment_select(&issue).unwrap();
-
                    *comment_id
-
                });
-
                issue.react(comment_id, reaction, true, &signer)?;
+
                let id = state_args.id.resolve(&repo.backend)?;
+
                let mut issue = issues.get_mut(&id)?;
+
                issue.lifecycle(to_state(state_args), &signer)?;
+
            }
+
            IssueCommands::Assign { id, add, delete } => {
+
                let signer = term::signer(&profile)?;
+
                let id = id.resolve(&repo.backend)?;
+
                let Ok(mut issue) = issues.get_mut(&id) else {
+
                    anyhow::bail!("Issue `{id}` not found");
+
                };
+
                let assignees = issue
+
                    .assignees()
+
                    .filter(|did| !delete.contains(did))
+
                    .chain(add.iter())
+
                    .cloned()
+
                    .collect::<Vec<_>>();
+
                issue.assign(assignees, &signer)?;
+
            }
+
            IssueCommands::Comment {
+
                id,
+
                message,
+
                reply_to,
+
            } => {
+
                let signer = term::signer(&profile)?;
+
                let issue_id = id.resolve::<cob::ObjectId>(&repo.backend)?;
+
                let mut issue = issues.get_mut(&issue_id)?;
+
                let (body, reply_to) = prompt_comment(message, reply_to, &issue, &repo)?;
+
                let comment_id = issue.comment(body, reply_to, vec![], &signer)?;
+

+
                if args.quiet {
+
                    term::print(comment_id);
+
                } else {
+
                    let comment = issue.thread().comment(&comment_id).unwrap();
+
                    term::comment::widget(&comment_id, comment, &profile).print();
+
                }
+
            }
+
            IssueCommands::React {
+
                id,
+
                comment_id,
+
                reaction,
+
            } => {
+
                let signer = term::signer(&profile)?;
+
                let id = id.resolve(&repo.backend)?;
+
                if let Ok(mut issue) = issues.get_mut(&id) {
+
                    let signer = term::signer(&profile)?;
+
                    let comment_id = comment_id.unwrap_or_else(|| {
+
                        let (comment_id, _) = term::io::comment_select(&issue).unwrap();
+
                        *comment_id
+
                    });
+
                    issue.react(comment_id, reaction, true, &signer)?;
+
                }
+
            }
+
            IssueCommands::Label { id, add, delete } => {
+
                let signer = term::signer(&profile)?;
+
                let id = id.resolve(&repo.backend)?;
+
                let Ok(mut issue) = issues.get_mut(&id) else {
+
                    anyhow::bail!("Issue `{id}` not found");
+
                };
+
                let labels = issue
+
                    .labels()
+
                    .filter(|did| !delete.contains(did))
+
                    .chain(add.iter())
+
                    .cloned()
+
                    .collect::<Vec<_>>();
+
                issue.label(labels, &signer)?;
+
            }
+
            IssueCommands::Open {
+
                ref title,
+
                ref description,
+
                ref labels,
+
                ref assignees,
+
            } => {
+
                let signer = term::signer(&profile)?;
+
                open(
+
                    title.clone(),
+
                    description.clone(),
+
                    labels.to_vec(),
+
                    assignees.to_vec(),
+
                    args.quiet,
+
                    &mut issues,
+
                    &signer,
+
                    &profile,
+
                )?;
+
            }
+
            IssueCommands::Cache { id } => {
+
                let id = id.map(|id| id.resolve(&repo.backend)).transpose()?;
+
                cache::run(id, &repo, &profile)?;
            }
        }
-
        Operation::Open {
-
            ref title,
-
            ref description,
-
            ref labels,
-
            ref assignees,
-
        } => {
-
            let signer = term::signer(&profile)?;
-
            open(
-
                title.clone(),
-
                description.clone(),
-
                labels.to_vec(),
-
                assignees.to_vec(),
-
                &options,
-
                &mut issues,
-
                &signer,
-
                &profile,
-
            )?;
-
        }
-
        Operation::Assign {
-
            id,
-
            opts: AssignOptions { add, delete },
-
        } => {
-
            let signer = term::signer(&profile)?;
-
            let id = id.resolve(&repo.backend)?;
-
            let Ok(mut issue) = issues.get_mut(&id) else {
-
                anyhow::bail!("Issue `{id}` not found");
-
            };
-
            let assignees = issue
-
                .assignees()
-
                .filter(|did| !delete.contains(did))
-
                .chain(add.iter())
-
                .cloned()
-
                .collect::<Vec<_>>();
-
            issue.assign(assignees, &signer)?;
-
        }
-
        Operation::Label {
-
            id,
-
            opts: LabelOptions { add, delete },
-
        } => {
-
            let signer = term::signer(&profile)?;
-
            let id = id.resolve(&repo.backend)?;
-
            let Ok(mut issue) = issues.get_mut(&id) else {
-
                anyhow::bail!("Issue `{id}` not found");
-
            };
-
            let labels = issue
-
                .labels()
-
                .filter(|did| !delete.contains(did))
-
                .chain(add.iter())
-
                .cloned()
-
                .collect::<Vec<_>>();
-
            issue.label(labels, &signer)?;
-
        }
-
        Operation::List { assigned, state } => {
-
            list(issues, &assigned, &state, &profile)?;
-
        }
-
        Operation::Delete { id } => {
-
            let signer = term::signer(&profile)?;
-
            let id = id.resolve(&repo.backend)?;
-
            issues.remove(&id, &signer)?;
-
        }
-
        Operation::Cache { id } => {
-
            let id = id.map(|id| id.resolve(&repo.backend)).transpose()?;
-
            cache::run(id, &repo, &profile)?;
-
        }
-
    }

-
    if announce {
-
        let mut node = Node::new(profile.socket());
-
        node::announce(rid, &mut node)?;
-
    }
+
        if announce {
+
            let mut node = Node::new(profile.socket());
+
            node::announce(rid, &mut node)?;
+
        }
+
    } else {
+
        // Default `issue` subcommand is `list`.
+
        list(issues, &None, &None, &profile)?;
+
    };

    Ok(())
}
@@ -710,7 +749,7 @@ fn open<R, G>(
    description: Option<String>,
    labels: Vec<Label>,
    assignees: Vec<Did>,
-
    options: &Options,
+
    quiet: bool,
    cache: &mut issue::Cache<issue::Issues<'_, R>, cob::cache::StoreWriter>,
    signer: &G,
    profile: &Profile,
@@ -735,7 +774,7 @@ where
        signer,
    )?;

-
    if !options.quiet {
+
    if !quiet {
        term::issue::show(&issue, issue.id(), Format::Header, profile)?;
    }
    Ok(())
modified radicle-cli/src/main.rs
@@ -3,14 +3,20 @@ use std::io::{self, Write};
use std::{io::ErrorKind, iter, process};

use anyhow::anyhow;
+
use clap::builder::styling::Style;
+
use clap::builder::Styles;
+
use clap::{CommandFactory, FromArgMatches as _, Parser, Subcommand};

-
use radicle::version::Version;
+
use clap_complete::dynamic::shells::CompleteCommand;
+
use radicle::version;
+
use radicle_cli::commands::rad_issue;
use radicle_cli::commands::*;
use radicle_cli::terminal as term;

pub const NAME: &str = "rad";
pub const PKG_VERSION: &str = env!("CARGO_PKG_VERSION");
pub const DESCRIPTION: &str = "Radicle command line interface";
+
pub const LONG_DESCRIPTION: &str = "Radicle is a distributed GIT forge.";
pub const GIT_HEAD: &str = env!("GIT_HEAD");
pub const TIMESTAMP: &str = env!("GIT_COMMIT_TIME");
pub const VERSION: Version = Version {
@@ -19,6 +25,39 @@ pub const VERSION: Version = Version {
    commit: GIT_HEAD,
    timestamp: TIMESTAMP,
};
+
pub const LONG_VERSION: &str = concat!(env!("CARGO_PKG_VERSION"), " (", env!("GIT_HEAD"), ")");
+
pub const HELP_TEMPLATE: &str = r#"
+
{before-help}{bin} {version}
+
{about-with-newline}
+
Usage: {usage}
+

+
{all-args}
+
{after-help}
+
"#;
+

+
/// Radicle command line interface
+
///
+
/// Radicle is a distributed GIT forge.
+
#[derive(Parser, Debug)]
+
#[command(name = NAME)]
+
#[command(version = VERSION)]
+
#[command(long_version = LONG_VERSION)]
+
#[command(help_template = HELP_TEMPLATE)]
+
#[command(propagate_version = true)]
+
#[command(propagate_help_template = true)]
+
#[command(styles = Styles::plain().literal(Style::new().bold()))]
+
struct CliArgs {
+
    #[command(subcommand)]
+
    pub command: Option<Commands>,
+
}
+

+
#[derive(Subcommand, Debug)]
+
enum Commands {
+
    /// Manage issues
+
    ///
+
    /// With issues you can organize your project and use it to discuss bugs and improvements.
+
    Issue(rad_issue::IssueArgs),
+
}

#[derive(Debug)]
enum Command {
@@ -225,11 +264,22 @@ fn run_other(exe: &str, args: &[OsString]) -> Result<(), Option<anyhow::Error>>
            );
        }
        "issue" => {
-
            term::run_command_args::<rad_issue::Options, _>(
-
                rad_issue::HELP,
-
                rad_issue::run,
-
                args.to_vec(),
-
            );
+
            // Use clap instead to parse all CLI args and ignore `args` passed
+
            // to `run_other`.
+
            let args_ = CliArgs::parse();
+
            if let Some(command) = args_.command {
+
                match command {
+
                    Commands::Issue(args_) => rad_issue::run(
+
                        args_,
+
                        radicle::Profile::load()
+
                            .map_err(|e| anyhow!(e))?,
+
                    )?,
+
                }
+
            }
+
        }
+
        // Used for dynamic shell completion (not user facing)
+
        "complete" => {
+
            run_completer(CliArgs::command());
        }
        "ls" => {
            term::run_command_args::<rad_ls::Options, _>(rad_ls::HELP, rad_ls::run, args.to_vec());
@@ -343,3 +393,13 @@ fn run_other(exe: &str, args: &[OsString]) -> Result<(), Option<anyhow::Error>>
    }
    Ok(())
}
+

+
/// Output shell completions
+
fn run_completer(cmd: clap::Command) -> () {
+
    let mut cmd = CompleteCommand::augment_subcommands(cmd);
+
    let matches = cmd.clone().get_matches();
+

+
    if let Ok(completions) = CompleteCommand::from_arg_matches(&matches) {
+
        completions.complete(&mut cmd);
+
    }
+
}
modified radicle-cli/src/terminal/patch.rs
@@ -108,6 +108,14 @@ impl Message {
    }
}

+
impl From<String> for Message {
+
    fn from(value: String) -> Self {
+
        let mut message = Message::default();
+
        message.append(&value);
+
        message
+
    }
+
}
+

pub const PATCH_MSG: &str = r#"
<!--
Please enter a patch message for your changes. An empty