Radish alpha
h
rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5
Radicle Heartwood Protocol & Stack
Radicle
Git
Multiprofile: Profile management for Radicle CLI
Draft did:key:z6MkihpD...TvYG opened 8 months ago

This change introduces a first-class profile management command to the Radicle CLI, enabling clean separation and safe switching of per-user configuration and keys by copying selected profile data into the root ~/.radicle/ directory (no symlinks).

Summary

  • New command: rad profile
  • Subcommands: new, switch, list, current, remove
  • new: creates profiles/, activates it, and clears the root so rad auth can initialize cleanly
  • switch: saves current root back to the active profile; then copies the target profile’s config.json and keys/* into ~/.radicle/
  • Replace-in-place copy semantics to avoid partial states; cross-platform behavior

Documentation

  • crates/radicle-cli/examples/rad-profile.md
4 files changed +445 -0 55cdd880 829b7cba
modified crates/radicle-cli/src/commands.rs
@@ -60,3 +60,5 @@ pub mod rad_unfollow;
pub mod rad_unseed;
#[path = "commands/watch.rs"]
pub mod rad_watch;
+
#[path = "commands/profile.rs"]
+
pub mod rad_profile;
modified crates/radicle-cli/src/commands/help.rs
@@ -39,6 +39,7 @@ const COMMANDS: &[Help] = &[
    rad_remote::HELP,
    rad_stats::HELP,
    rad_sync::HELP,
+
    rad_profile::HELP
];

#[derive(Default)]
added crates/radicle-cli/src/commands/profile.rs
@@ -0,0 +1,435 @@
+
use std::env;
+
use std::ffi::OsString;
+
use std::fs;
+
use std::io;
+
use std::path::{Path, PathBuf};
+

+
use lexopt::prelude::*;
+
use lexopt::Parser;
+
use thiserror::Error;
+

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

+
pub const HELP: Help = Help {
+
    name: "profile",
+
    description: "Manage Radicle CLI profiles (config.json and keys/* per user)",
+
    version: env!("RADICLE_VERSION"),
+
    usage: r#"
+
Usage
+

+
    rad profile new <name> [--from <name>] [--force]
+
    rad profile switch <name|default> [--print-env]
+
    rad profile list
+
    rad profile current
+
    rad profile remove <name> [-y]
+

+
Options
+

+
    --from <name>     Create from existing profile (for: new)
+
    --force, -f       Overwrite existing profile or reserved name (for: new)
+
    --print-env       Print shell export for RAD_PROFILE (for: switch)
+
    --yes, -y         Confirm removal (for: remove)
+
    --help            Print help
+

+
Notes
+

+
    • 'default' is the pseudo-profile representing the root (~/.radicle).
+
      'rad profile switch default' persists the current root into the active profile,
+
      clears the root, and unsets the active profile marker.
+
"#,
+
};
+

+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+
enum OperationName {
+
    New,
+
    Switch,
+
    List,
+
    Current,
+
    Remove,
+
}
+

+
#[derive(Debug)]
+
struct Operation {
+
    name: OperationName,
+
    target: Option<String>,
+
    from: Option<String>,
+
    force: bool,
+
    print_env: bool,
+
    yes: bool,
+
}
+

+
#[derive(Debug)]
+
pub struct Options(Operation);
+

+
#[derive(Error, Debug)]
+
pub enum ProfileError {
+
    #[error("home directory not available")]
+
    Home,
+
    #[error("profiles root not found: {0}")]
+
    ProfilesRoot(PathBuf),
+
    #[error("active profile marker error: {0}")]
+
    ActiveMarker(PathBuf),
+
    #[error("profile not found: {0}")]
+
    NotFound(String),
+
    #[error("profile already exists: {0}")]
+
    AlreadyExists(String),
+
    #[error("cannot remove active profile: {0}")]
+
    RemoveActive(String),
+
    #[error("cannot remove pseudo-profile 'default'")]
+
    RemoveDefault,
+
    #[error("confirmation required, use -y/--yes")]
+
    ConfirmationRequired,
+
    #[error("io error: {0}")]
+
    Io(#[from] io::Error),
+
    #[error("invalid state: {0}")]
+
    InvalidState(&'static str),
+
}
+

+
type Result<T> = std::result::Result<T, ProfileError>;
+

+
impl Args for Options {
+
    fn from_args(v: Vec<OsString>) -> anyhow::Result<(Self, Vec<OsString>)> {
+
        let mut p = Parser::from_args(v);
+
        let mut op = None;
+
        let mut target = None;
+
        let mut from = None;
+
        let mut force = false;
+
        let mut print_env = false;
+
        let mut yes = false;
+

+
        while let Some(arg) = p.next()? {
+
            match arg {
+
                Value(val) if op.is_none() => {
+
                    let s = term::args::string(&val);
+
                    op = Some(match s.as_str() {
+
                        "new" => OperationName::New,
+
                        "switch" => OperationName::Switch,
+
                        "list" => OperationName::List,
+
                        "current" => OperationName::Current,
+
                        "remove" => OperationName::Remove,
+
                        other => anyhow::bail!("unknown subcommand: {other}"),
+
                    });
+
                }
+
                Value(val) => {
+
                    target = Some(term::args::string(&val));
+
                }
+
                Long("from") => {
+
                    from = Some(term::args::string(&p.value()?));
+
                }
+
                Long("force") | Short('f') => {
+
                    force = true;
+
                }
+
                Long("print-env") => {
+
                    print_env = true;
+
                }
+
                Long("yes") | Short('y') => {
+
                    yes = true;
+
                }
+
                Long("help") | Short('h') => return Err(term::args::Error::Help.into()),
+
                _ => anyhow::bail!("unexpected argument"),
+
            }
+
        }
+

+
        let name = op.ok_or_else(|| anyhow::anyhow!("missing subcommand"))?;
+
        if matches!(name, OperationName::New | OperationName::Switch | OperationName::Remove)
+
            && target.is_none()
+
        {
+
            anyhow::bail!("missing <name>");
+
        }
+

+
        Ok((
+
            Options(Operation {
+
                name,
+
                target,
+
                from,
+
                force,
+
                print_env,
+
                yes,
+
            }),
+
            vec![],
+
        ))
+
    }
+
}
+

+
pub fn run(opts: Options, ctx: impl term::Context) -> anyhow::Result<()> {
+
    run_inner(opts, ctx).map_err(|e| anyhow::anyhow!(e))
+
}
+

+
fn run_inner(opts: Options, ctx: impl term::Context) -> Result<()> {
+
    let home = resolve_home(ctx)?;
+

+
    match opts.0.name {
+
        OperationName::New => {
+
            let name = opts.0.target.as_ref().unwrap();
+
            if name == "default" && !opts.0.force {
+
                return Err(ProfileError::AlreadyExists("default".into()));
+
            }
+
            create_profile(&home, name, opts.0.from.as_deref(), opts.0.force)?;
+
            write_active(&home, name)?;
+
            clear_root_for_auth(&home)?;
+
            term::success!("Profile {} created", term::format::tertiary(name));
+
        }
+
        OperationName::Switch => {
+
            let name = opts.0.target.as_ref().unwrap();
+
            if name == "default" {
+
                if let Some(current) = read_active(&home)? {
+
                    persist_root_into_profile(&home, &current)?;
+
                }
+
                clear_root_for_auth(&home)?;
+
                clear_active(&home)?;
+
                if opts.0.print_env {
+
                    println!("unset RAD_PROFILE");
+
                } else {
+
                    term::success!("Switched to {}", term::format::tertiary("default"));
+
                }
+
            } else {
+
                let active = read_active(&home)?;
+
                if let Some(current) = active.as_deref() {
+
                    persist_root_into_profile(&home, current)?;
+
                }
+
                copy_profile_into_root(&home, name)?;
+
                write_active(&home, name)?;
+
                if opts.0.print_env {
+
                    println!("export RAD_PROFILE={name}");
+
                } else {
+
                    term::success!("Switched to {}", term::format::tertiary(name));
+
                }
+
            }
+
        }
+
        OperationName::List => {
+
            list_profiles(&home)?;
+
        }
+
        OperationName::Current => {
+
            let cur = read_active(&home)?.unwrap_or_else(|| "default".to_string());
+
            println!("{cur}");
+
        }
+
        OperationName::Remove => {
+
            let name = opts.0.target.as_ref().unwrap();
+
            if name == "default" {
+
                return Err(ProfileError::RemoveDefault);
+
            }
+
            let cur = read_active(&home)?;
+
            if cur.as_deref() == Some(name) {
+
                return Err(ProfileError::RemoveActive(name.clone()));
+
            }
+
            if !opts.0.yes {
+
                return Err(ProfileError::ConfirmationRequired);
+
            }
+
            remove_profile(&home, name)?;
+
            term::success!("Profile {} removed", term::format::tertiary(name));
+
        }
+
    }
+
    Ok(())
+
}
+

+

+
fn resolve_home(ctx: impl term::Context) -> Result<PathBuf> {
+
    if let Ok(p) = ctx.profile() {
+
        return Ok(p.home().path().to_path_buf());
+
    }
+
    if let Ok(rad_home) = env::var("RAD_HOME") {
+
        return Ok(PathBuf::from(rad_home));
+
    }
+
    if let Ok(home) = env::var("HOME") {
+
        return Ok(PathBuf::from(home).join(".radicle"));
+
    }
+
    Err(ProfileError::Home)
+
}
+

+
fn profiles_root(home: &Path) -> PathBuf {
+
    home.join("profiles")
+
}
+

+
fn active_file(home: &Path) -> PathBuf {
+
    home.join(".active_profile")
+
}
+

+
fn root_config(home: &Path) -> PathBuf {
+
    home.join("config.json")
+
}
+

+
fn root_keys(home: &Path) -> PathBuf {
+
    home.join("keys")
+
}
+

+
fn profile_dir(home: &Path, name: &str) -> PathBuf {
+
    profiles_root(home).join(name)
+
}
+

+
fn profile_config(home: &Path, name: &str) -> PathBuf {
+
    profile_dir(home, name).join("config.json")
+
}
+

+
fn profile_keys(home: &Path, name: &str) -> PathBuf {
+
    profile_dir(home, name).join("keys")
+
}
+

+
fn ensure_profiles_root(home: &Path) -> Result<()> {
+
    fs::create_dir_all(profiles_root(home))?;
+
    Ok(())
+
}
+

+
fn create_profile(home: &Path, name: &str, from: Option<&str>, force: bool) -> Result<()> {
+
    ensure_profiles_root(home)?;
+
    let dst_dir = profile_dir(home, name);
+
    if dst_dir.exists() && !force {
+
        return Err(ProfileError::AlreadyExists(name.to_string()));
+
    }
+
    fs::create_dir_all(&dst_dir)?;
+
    fs::create_dir_all(profile_keys(home, name))?;
+
    if let Some(src) = from {
+
        let src_cfg = profile_config(home, src);
+
        let src_keys = profile_keys(home, src);
+
        let dst_cfg = profile_config(home, name);
+
        let dst_keys = profile_keys(home, name);
+
        if src_cfg.exists() {
+
            if let Some(parent) = dst_cfg.parent() {
+
                fs::create_dir_all(parent)?;
+
            }
+
            fs::copy(src_cfg, dst_cfg)?;
+
        }
+
        if src_keys.exists() {
+
            copy_dir(&src_keys, &dst_keys)?;
+
        }
+
    }
+
    Ok(())
+
}
+

+
fn remove_profile(home: &Path, name: &str) -> Result<()> {
+
    let dir = profile_dir(home, name);
+
    if !dir.exists() {
+
        return Err(ProfileError::NotFound(name.to_string()));
+
    }
+
    fs::remove_dir_all(dir)?;
+
    Ok(())
+
}
+

+
fn read_active(home: &Path) -> Result<Option<String>> {
+
    let path = active_file(home);
+
    match fs::read_to_string(&path) {
+
        Ok(s) => Ok(Some(s.trim().to_string())),
+
        Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
+
        Err(_) => Err(ProfileError::ActiveMarker(path)),
+
    }
+
}
+

+
fn write_active(home: &Path, name: &str) -> Result<()> {
+
    let path = active_file(home);
+
    if let Some(parent) = path.parent() {
+
        fs::create_dir_all(parent)?;
+
    }
+
    fs::write(path, format!("{name}\n"))?;
+
    Ok(())
+
}
+

+
fn clear_active(home: &Path) -> Result<()> {
+
    let path = active_file(home);
+
    if path.exists() {
+
        fs::remove_file(path)?;
+
    }
+
    Ok(())
+
}
+

+
fn persist_root_into_profile(home: &Path, name: &str) -> Result<()> {
+
    let src_cfg = root_config(home);
+
    let src_keys = root_keys(home);
+
    let dst_cfg = profile_config(home, name);
+
    let dst_keys = profile_keys(home, name);
+

+
    if let Some(parent) = dst_cfg.parent() {
+
        fs::create_dir_all(parent)?;
+
    }
+
    fs::create_dir_all(&dst_keys)?;
+

+
    if src_cfg.exists() {
+
        fs::copy(src_cfg, dst_cfg)?;
+
    }
+
    if src_keys.exists() {
+
        copy_dir(&src_keys, &dst_keys)?;
+
    }
+
    Ok(())
+
}
+

+
fn copy_profile_into_root(home: &Path, name: &str) -> Result<()> {
+
    let src_cfg = profile_config(home, name);
+
    let src_keys = profile_keys(home, name);
+
    let dst_cfg = root_config(home);
+
    let dst_keys = root_keys(home);
+

+
    if dst_cfg.exists() {
+
        fs::remove_file(&dst_cfg)?;
+
    }
+
    if dst_keys.exists() {
+
        fs::remove_dir_all(&dst_keys)?;
+
    }
+
    if src_cfg.exists() {
+
        if let Some(parent) = dst_cfg.parent() {
+
            fs::create_dir_all(parent)?;
+
        }
+
        fs::copy(src_cfg, dst_cfg)?;
+
    }
+
    if src_keys.exists() {
+
        fs::create_dir_all(&dst_keys)?;
+
        copy_dir(&src_keys, &dst_keys)?;
+
    }
+
    Ok(())
+
}
+

+
fn clear_root_for_auth(home: &Path) -> Result<()> {
+
    let cfg = root_config(home);
+
    let keys = root_keys(home);
+
    if cfg.exists() {
+
        fs::remove_file(cfg)?;
+
    }
+
    if keys.exists() {
+
        fs::remove_dir_all(keys)?;
+
    }
+
    Ok(())
+
}
+

+
fn list_profiles(home: &Path) -> Result<()> {
+
    ensure_profiles_root(home)?;
+
    let active = read_active(home)?;
+
    let mut names = Vec::<String>::new();
+
    for entry in fs::read_dir(profiles_root(home))? {
+
        let e = entry?;
+
        if e.file_type()?.is_dir() {
+
            if let Some(n) = e.file_name().to_str() {
+
                names.push(n.to_string());
+
            }
+
        }
+
    }
+
    names.sort();
+

+
    
+
    if active.is_none() {
+
        println!("* default");
+
    } else {
+
        println!("  default");
+
    }
+

+
    for n in names {
+
        if active.as_deref() == Some(n.as_str()) {
+
            println!("* {n}");
+
        } else {
+
            println!("  {n}");
+
        }
+
    }
+
    Ok(())
+
}
+

+
fn copy_dir(src: &Path, dst: &Path) -> Result<()> {
+
    fs::create_dir_all(dst)?;
+
    for entry in fs::read_dir(src)? {
+
        let e = entry?;
+
        let ty = e.file_type()?;
+
        let to = dst.join(e.file_name());
+
        if ty.is_dir() {
+
            copy_dir(&e.path(), &to)?;
+
        } else {
+
            fs::copy(e.path(), &to)?;
+
        }
+
    }
+
    Ok(())
+
}

\ No newline at end of file
modified crates/radicle-cli/src/main.rs
@@ -331,6 +331,13 @@ fn run_other(exe: &str, args: &[OsString]) -> Result<(), Option<anyhow::Error>>
            rad_stats::run,
            args.to_vec(),
        ),
+
        "profile" => {
+
            term::run_command_args::<rad_profile::Options, _>(
+
                rad_profile::HELP,
+
                rad_profile::run,
+
                args.to_vec(),
+
            );
+
        }
        "watch" => term::run_command_args::<rad_watch::Options, _>(
            rad_watch::HELP,
            rad_watch::run,