rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5 heartwood13fd2ed92139426d31f3973c7e3024265fc5107a
{
"request": "trigger",
"version": 1,
"event_type": "patch",
"repository": {
"id": "rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5",
"name": "heartwood",
"description": "Radicle Heartwood Protocol & Stack",
"private": false,
"default_branch": "master",
"delegates": [
"did:key:z6MksFqXN3Yhqk8pTJdUGLwATkRfQvwZXPqR2qMEhbS9wzpT",
"did:key:z6MktaNvN1KVFMkSRAiN4qK5yvX1zuEEaseeX5sffhzPZRZW",
"did:key:z6MkireRatUThvd3qzfKht1S44wpm4FEWSSa4PRMTSQZ3voM",
"did:key:z6MkgFq6z5fkF2hioLLSNu1zP2qEL1aHXHZzGH1FLFGAnBGz",
"did:key:z6MkkPvBfjP4bQmco5Dm7UGsX2ruDBieEHi8n9DVJWX5sTEz"
]
},
"action": "Created",
"patch": {
"id": "9702eda6cd9b7f8f49f9a17d976a465f5df8c193",
"author": {
"id": "did:key:z6MkihpDMDu7ZYo6ma4M3Stf4fZF7Q1Jjuysn9GaRAvoTvYG",
"alias": "LAA"
},
"title": "Multiprofile: Profile management for Radicle CLI",
"state": {
"status": "open",
"conflicts": []
},
"before": "55cdd880bfee08124d5b6a38cc05036402c7ab6e",
"after": "13fd2ed92139426d31f3973c7e3024265fc5107a",
"commits": [
"13fd2ed92139426d31f3973c7e3024265fc5107a"
],
"target": "55cdd880bfee08124d5b6a38cc05036402c7ab6e",
"labels": [],
"assignees": [],
"revisions": [
{
"id": "9702eda6cd9b7f8f49f9a17d976a465f5df8c193",
"author": {
"id": "did:key:z6MkihpDMDu7ZYo6ma4M3Stf4fZF7Q1Jjuysn9GaRAvoTvYG",
"alias": "LAA"
},
"description": "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).\n\nSummary\n- New command: rad profile\n- Subcommands: new, switch, list, current, remove\n- new: creates profiles/<name>, activates it, and clears the root so rad auth can initialize cleanly\n- switch: saves current root back to the active profile; then copies the target profile\u2019s config.json and keys/* into ~/.radicle/\n- Replace-in-place copy semantics to avoid partial states; cross-platform behavior\n\nDocumentation\n- crates/radicle-cli/examples/rad-profile.md",
"base": "55cdd880bfee08124d5b6a38cc05036402c7ab6e",
"oid": "13fd2ed92139426d31f3973c7e3024265fc5107a",
"timestamp": 1756340885
}
]
}
}
{
"response": "triggered",
"run_id": {
"id": "06245a11-59c1-4167-87f8-78af1f46f5ec"
},
"info_url": "https://cci.rad.levitte.org//06245a11-59c1-4167-87f8-78af1f46f5ec.html"
}
Started at: 2025-08-28 03:03:09.442556+02:00
Commands:
$ rad clone rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5 .
✓ Creating checkout in ./...
✓ Remote cloudhead@z6MksFqXN3Yhqk8pTJdUGLwATkRfQvwZXPqR2qMEhbS9wzpT added
✓ Remote-tracking branch cloudhead@z6MksFqXN3Yhqk8pTJdUGLwATkRfQvwZXPqR2qMEhbS9wzpT/master created for z6MksFqXN3Yhqk8pTJdUGLwATkRfQvwZXPqR2qMEhbS9wzpT
✓ Remote cloudhead@z6MktaNvN1KVFMkSRAiN4qK5yvX1zuEEaseeX5sffhzPZRZW added
✓ Remote-tracking branch cloudhead@z6MktaNvN1KVFMkSRAiN4qK5yvX1zuEEaseeX5sffhzPZRZW/master created for z6MktaNvN1KVFMkSRAiN4qK5yvX1zuEEaseeX5sffhzPZRZW
✓ Remote fintohaps@z6MkireRatUThvd3qzfKht1S44wpm4FEWSSa4PRMTSQZ3voM added
✓ Remote-tracking branch fintohaps@z6MkireRatUThvd3qzfKht1S44wpm4FEWSSa4PRMTSQZ3voM/master created for z6MkireRatUThvd3qzfKht1S44wpm4FEWSSa4PRMTSQZ3voM
✓ Remote erikli@z6MkgFq6z5fkF2hioLLSNu1zP2qEL1aHXHZzGH1FLFGAnBGz added
✓ Remote-tracking branch erikli@z6MkgFq6z5fkF2hioLLSNu1zP2qEL1aHXHZzGH1FLFGAnBGz/master created for z6MkgFq6z5fkF2hioLLSNu1zP2qEL1aHXHZzGH1FLFGAnBGz
✓ Remote lorenz@z6MkkPvBfjP4bQmco5Dm7UGsX2ruDBieEHi8n9DVJWX5sTEz added
✓ Remote-tracking branch lorenz@z6MkkPvBfjP4bQmco5Dm7UGsX2ruDBieEHi8n9DVJWX5sTEz/master created for z6MkkPvBfjP4bQmco5Dm7UGsX2ruDBieEHi8n9DVJWX5sTEz
✓ Repository successfully cloned under /opt/radcis/ci.rad.levitte.org/cci/state/06245a11-59c1-4167-87f8-78af1f46f5ec/w/
╭────────────────────────────────────╮
│ heartwood │
│ Radicle Heartwood Protocol & Stack │
│ 121 issues · 13 patches │
╰────────────────────────────────────╯
Run `cd ./.` to go to the repository directory.
Exit code: 0
$ rad patch checkout 9702eda6cd9b7f8f49f9a17d976a465f5df8c193
✓ Switched to branch patch/9702eda at revision 9702eda
✓ Branch patch/9702eda setup to track rad/patches/9702eda6cd9b7f8f49f9a17d976a465f5df8c193
Exit code: 0
$ git config advice.detachedHead false
Exit code: 0
$ git checkout 13fd2ed92139426d31f3973c7e3024265fc5107a
HEAD is now at 13fd2ed9 Multiprofile: add 'rad profile' command (copy-on-switch, no symlinks); wire into CLI
Exit code: 0
$ git show 13fd2ed92139426d31f3973c7e3024265fc5107a
commit 13fd2ed92139426d31f3973c7e3024265fc5107a
Author: anon <anon@rad>
Date: Thu Aug 28 03:27:36 2025 +0300
Multiprofile: add 'rad profile' command (copy-on-switch, no symlinks); wire into CLI
diff --git a/crates/radicle-cli/src/commands.rs b/crates/radicle-cli/src/commands.rs
index e4df12c1..aff3f00f 100644
--- a/crates/radicle-cli/src/commands.rs
+++ b/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;
diff --git a/crates/radicle-cli/src/commands/help.rs b/crates/radicle-cli/src/commands/help.rs
index a1117f48..371acd7d 100644
--- a/crates/radicle-cli/src/commands/help.rs
+++ b/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)]
diff --git a/crates/radicle-cli/src/commands/profile.rs b/crates/radicle-cli/src/commands/profile.rs
new file mode 100644
index 00000000..050d3de4
--- /dev/null
+++ b/crates/radicle-cli/src/commands/profile.rs
@@ -0,0 +1,342 @@
+use std::ffi::OsString;
+use std::fs;
+use std::path::{Path, PathBuf};
+
+use anyhow::{anyhow, Context as _};
+use lexopt::prelude::*;
+use radicle::profile;
+
+use crate::terminal as term;
+use crate::terminal::{args, Args, Context, 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>
+ rad profile switch <name> [--print-env]
+ rad profile list
+ rad profile current
+ rad profile remove <name> [-y]
+
+Options
+
+ --print-env Print shell export for RAD_PROFILE (for: switch)
+ --yes, -y Confirm removal (for: remove)
+ --help Print help
+"#,
+};
+
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
+enum OpTag {
+ #[default]
+ List,
+ New,
+ Switch,
+ Current,
+ Remove,
+}
+
+#[derive(Debug)]
+enum Op {
+ New { name: String },
+ Switch { name: String, print_env: bool },
+ List,
+ Current,
+ Remove { name: String, yes: bool },
+}
+
+#[derive(Debug)]
+pub struct Options {
+ op: Op,
+}
+
+impl Args for Options {
+ fn from_args(v: Vec<OsString>) -> anyhow::Result<(Self, Vec<OsString>)> {
+ let mut p = lexopt::Parser::from_args(v);
+ let mut tag: Option<OpTag> = None;
+ let mut name: Option<String> = None;
+ let mut print_env = false;
+ let mut yes = false;
+
+ while let Some(arg) = p.next()? {
+ match arg {
+ Long("help") | Short('h') => return Err(args::Error::Help.into()),
+ Long("print-env") => print_env = true,
+ Long("yes") | Short('y') => yes = true,
+ Value(val) if tag.is_none() => match val.to_string_lossy().as_ref() {
+ "new" => tag = Some(OpTag::New),
+ "switch" | "use" => tag = Some(OpTag::Switch),
+ "list" | "ls" => tag = Some(OpTag::List),
+ "current" | "cur" => tag = Some(OpTag::Current),
+ "remove" | "rm" => tag = Some(OpTag::Remove),
+ u => anyhow::bail!("unknown operation '{}'", u),
+ },
+ Value(val) if name.is_none() => name = Some(args::string(&val)),
+ _ => return Err(anyhow!(arg.unexpected())),
+ }
+ }
+
+ let op = match tag.unwrap_or_default() {
+ OpTag::New => Op::New {
+ name: name.ok_or(anyhow!("name required, see `rad profile new --help`"))?,
+ },
+ OpTag::Switch => Op::Switch {
+ name: name.ok_or(anyhow!("name required, see `rad profile switch --help`"))?,
+ print_env,
+ },
+ OpTag::List => Op::List,
+ OpTag::Current => Op::Current,
+ OpTag::Remove => Op::Remove {
+ name: name.ok_or(anyhow!("name required, see `rad profile remove --help`"))?,
+ yes,
+ },
+ };
+
+ Ok((Options { op }, vec![]))
+ }
+}
+
+pub fn run(options: Options, _ctx: impl Context) -> anyhow::Result<()> {
+ ensure_roots()?;
+ match options.op {
+ Op::New { name } => {
+ create_profile(&name)?;
+ set_active(&name)?;
+ clear_root()?;
+ term::success!("Profile {} created and activated; root cleared", term::format::tertiary(&name));
+ }
+ Op::Switch { name, print_env } => {
+ let cur = active()?;
+ if cur != name {
+ ensure_profile_dir(&cur)?;
+ save_root_into(&cur)?;
+ apply_profile_to_root(&name)?;
+ set_active(&name)?;
+ } else {
+ apply_profile_to_root(&name)?;
+ }
+ if print_env {
+ println!("export RAD_PROFILE={}", name);
+ } else {
+ term::success!("Switched to {}", term::format::tertiary(&name));
+ }
+ }
+ Op::List => list_profiles()?,
+ Op::Current => {
+ println!("{}", active()?);
+ }
+ Op::Remove { name, yes } => {
+ remove_profile(&name, yes)?;
+ term::success!("Profile {} removed", term::format::tertiary(&name));
+ }
+ }
+ Ok(())
+}
+
+fn rad_home() -> anyhow::Result<PathBuf> {
+ Ok(profile::home()?.path().to_path_buf())
+}
+
+fn profiles_root() -> anyhow::Result<PathBuf> {
+ Ok(rad_home()?.join("profiles"))
+}
+
+fn active_file() -> anyhow::Result<PathBuf> {
+ Ok(rad_home()?.join(".active_profile"))
+}
+
+fn cfg_root() -> anyhow::Result<PathBuf> {
+ Ok(rad_home()?.join("config.json"))
+}
+
+fn keys_root() -> anyhow::Result<PathBuf> {
+ Ok(rad_home()?.join("keys"))
+}
+
+fn profile_dir(name: &str) -> anyhow::Result<PathBuf> {
+ Ok(profiles_root()?.join(name))
+}
+
+fn profile_cfg(name: &str) -> anyhow::Result<PathBuf> {
+ Ok(profile_dir(name)?.join("config.json"))
+}
+
+fn profile_keys(name: &str) -> anyhow::Result<PathBuf> {
+ Ok(profile_dir(name)?.join("keys"))
+}
+
+fn exists(p: &Path) -> bool {
+ fs::symlink_metadata(p).is_ok()
+}
+
+fn ensure_roots() -> anyhow::Result<()> {
+ let pr = profiles_root()?;
+ if !pr.exists() {
+ fs::create_dir_all(&pr)?;
+ }
+ Ok(())
+}
+
+fn ensure_profile_dir(name: &str) -> anyhow::Result<()> {
+ let dir = profile_dir(name)?;
+ if !dir.exists() {
+ fs::create_dir_all(profile_keys(name)?)?;
+ } else {
+ fs::create_dir_all(profile_keys(name)?)?;
+ }
+ Ok(())
+}
+
+fn create_profile(name: &str) -> anyhow::Result<()> {
+ let dir = profile_dir(name)?;
+ if dir.exists() {
+ anyhow::bail!("profile already exists");
+ }
+ fs::create_dir_all(profile_keys(name)?)?;
+ Ok(())
+}
+
+fn set_active(name: &str) -> anyhow::Result<()> {
+ fs::write(active_file()?, format!("{}\n", name))?;
+ Ok(())
+}
+
+fn active() -> anyhow::Result<String> {
+ if let Ok(v) = std::env::var("RAD_PROFILE") {
+ return Ok(v);
+ }
+ let f = active_file()?;
+ if exists(&f) {
+ let s = fs::read_to_string(f)?;
+ let s = s.trim();
+ if !s.is_empty() {
+ return Ok(s.to_owned());
+ }
+ }
+ Ok("default".to_owned())
+}
+
+fn clear_root() -> anyhow::Result<()> {
+ let c = cfg_root()?;
+ if exists(&c) {
+ fs::remove_file(&c).ok();
+ }
+ let k = keys_root()?;
+ if exists(&k) {
+ fs::remove_dir_all(&k).ok();
+ }
+ Ok(())
+}
+
+fn copy_file_atomic(src: &Path, dst: &Path) -> anyhow::Result<()> {
+ if !exists(src) {
+ anyhow::bail!("missing {}", src.display());
+ }
+ if let Some(p) = dst.parent() {
+ fs::create_dir_all(p)?;
+ }
+ let tmp = dst.with_extension("tmp");
+ fs::copy(src, &tmp).with_context(|| format!("copy {} -> {}", src.display(), tmp.display()))?;
+ if exists(dst) {
+ fs::remove_file(dst).ok();
+ }
+ fs::rename(&tmp, dst)?;
+ Ok(())
+}
+
+fn copy_dir_replace(src: &Path, dst: &Path) -> anyhow::Result<()> {
+ if !exists(src) {
+ anyhow::bail!("missing {}", src.display());
+ }
+ if exists(dst) {
+ fs::remove_dir_all(dst)?;
+ }
+ fs::create_dir_all(dst)?;
+ for e in fs::read_dir(src)? {
+ let e = e?;
+ let ft = e.file_type()?;
+ let sp = e.path();
+ let dp = dst.join(e.file_name());
+ if ft.is_dir() {
+ copy_dir_replace(&sp, &dp)?;
+ } else if ft.is_file() {
+ copy_file_atomic(&sp, &dp)?;
+ }
+ }
+ Ok(())
+}
+
+fn save_root_into(name: &str) -> anyhow::Result<()> {
+ let csrc = cfg_root()?;
+ let ksrc = keys_root()?;
+ if !exists(&csrc) && !exists(&ksrc) {
+ return Ok(());
+ }
+ ensure_profile_dir(name)?;
+ let cdst = profile_cfg(name)?;
+ let kdst = profile_keys(name)?;
+ if exists(&csrc) {
+ copy_file_atomic(&csrc, &cdst)?;
+ }
+ if exists(&ksrc) {
+ copy_dir_replace(&ksrc, &kdst)?;
+ }
+ Ok(())
+}
+
+fn apply_profile_to_root(name: &str) -> anyhow::Result<()> {
+ let pd = profile_dir(name)?;
+ if !pd.exists() {
+ anyhow::bail!("profile '{}' not found", name);
+ }
+ let pc = profile_cfg(name)?;
+ let pk = profile_keys(name)?;
+ clear_root()?;
+ if exists(&pc) {
+ copy_file_atomic(&pc, &cfg_root()?)?;
+ }
+ if exists(&pk) {
+ copy_dir_replace(&pk, &keys_root()?)?;
+ }
+ Ok(())
+}
+
+fn list_profiles() -> anyhow::Result<()> {
+ let cur = active()?;
+ let mut v = Vec::new();
+ for e in fs::read_dir(profiles_root()?)? {
+ let e = e?;
+ if e.file_type()?.is_dir() {
+ v.push(e.file_name().to_string_lossy().into_owned());
+ }
+ }
+ v.sort();
+ for n in v {
+ if n == cur {
+ println!("* {}", n);
+ } else {
+ println!(" {}", n);
+ }
+ }
+ Ok(())
+}
+
+fn remove_profile(name: &str, yes: bool) -> anyhow::Result<()> {
+ let cur = active().unwrap_or_else(|_| "default".to_owned());
+ if name == cur {
+ anyhow::bail!("cannot remove the active profile");
+ }
+ let dir = profile_dir(name)?;
+ if !dir.exists() {
+ anyhow::bail!("profile '{}' not found", name);
+ }
+ if !yes {
+ anyhow::bail!("refusing to remove without -y");
+ }
+ fs::remove_dir_all(dir)?;
+ Ok(())
+}
\ No newline at end of file
diff --git a/crates/radicle-cli/src/main.rs b/crates/radicle-cli/src/main.rs
index 4f136d8a..5bd77dfc 100644
--- a/crates/radicle-cli/src/main.rs
+++ b/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,
Exit code: 0
shell: 'cargo --version rustc --version cargo fmt --check cargo clippy --all-targets --workspace -- --deny warnings cargo build --all-targets --workspace cargo doc --workspace --no-deps cargo test --workspace --no-fail-fast '
Commands:
$ podman run --name 06245a11-59c1-4167-87f8-78af1f46f5ec -v /opt/radcis/ci.rad.levitte.org/cci/state/06245a11-59c1-4167-87f8-78af1f46f5ec/s:/06245a11-59c1-4167-87f8-78af1f46f5ec/s:ro -v /opt/radcis/ci.rad.levitte.org/cci/state/06245a11-59c1-4167-87f8-78af1f46f5ec/w:/06245a11-59c1-4167-87f8-78af1f46f5ec/w -w /06245a11-59c1-4167-87f8-78af1f46f5ec/w -v /opt/radcis/ci.rad.levitte.org/.radicle:/${id}/.radicle:ro -e RAD_HOME=/${id}/.radicle rust:bookworm bash /06245a11-59c1-4167-87f8-78af1f46f5ec/s/script.sh
time="2025-08-28T03:03:11+02:00" level=error msg="User-selected graph driver \"overlay\" overwritten by graph driver \"vfs\" from database - delete libpod local files (\"/opt/radcis/ci.rad.levitte.org/.local/share/containers/storage\") to resolve. May prevent use of images created by other tools"
time="2025-08-28T03:03:11+02:00" level=error msg="User-selected graph driver \"overlay\" overwritten by graph driver \"vfs\" from database - delete libpod local files (\"/opt/radcis/ci.rad.levitte.org/.local/share/containers/storage\") to resolve. May prevent use of images created by other tools"
+ cargo --version
info: syncing channel updates for '1.88-x86_64-unknown-linux-gnu'
info: latest update on 2025-06-26, rust version 1.88.0 (6b00bc388 2025-06-23)
info: downloading component 'cargo'
info: downloading component 'clippy'
info: downloading component 'rust-docs'
info: downloading component 'rust-src'
info: downloading component 'rust-std'
info: downloading component 'rustc'
info: downloading component 'rustfmt'
info: installing component 'cargo'
info: installing component 'clippy'
info: installing component 'rust-docs'
info: installing component 'rust-src'
info: installing component 'rust-std'
info: installing component 'rustc'
info: installing component 'rustfmt'
cargo 1.88.0 (873a06493 2025-05-10)
+ rustc --version
rustc 1.88.0 (6b00bc388 2025-06-23)
+ cargo fmt --check
Diff in /06245a11-59c1-4167-87f8-78af1f46f5ec/w/crates/radicle-cli/src/commands/help.rs:39:
rad_remote::HELP,
rad_stats::HELP,
rad_sync::HELP,
- rad_profile::HELP
+ rad_profile::HELP,
];
#[derive(Default)]
Diff in /06245a11-59c1-4167-87f8-78af1f46f5ec/w/crates/radicle-cli/src/commands/profile.rs:107:
create_profile(&name)?;
set_active(&name)?;
clear_root()?;
- term::success!("Profile {} created and activated; root cleared", term::format::tertiary(&name));
+ term::success!(
+ "Profile {} created and activated; root cleared",
+ term::format::tertiary(&name)
+ );
}
Op::Switch { name, print_env } => {
let cur = active()?;
Diff in /06245a11-59c1-4167-87f8-78af1f46f5ec/w/crates/radicle-cli/src/commands/profile.rs:340:
fs::remove_dir_all(dir)?;
Ok(())
}
+
Diff in /06245a11-59c1-4167-87f8-78af1f46f5ec/w/crates/radicle-cli/src/commands.rs:40:
pub mod rad_patch;
#[path = "commands/path.rs"]
pub mod rad_path;
+#[path = "commands/profile.rs"]
+pub mod rad_profile;
#[path = "commands/publish.rs"]
pub mod rad_publish;
#[path = "commands/remote.rs"]
Diff in /06245a11-59c1-4167-87f8-78af1f46f5ec/w/crates/radicle-cli/src/commands.rs:60:
pub mod rad_unseed;
#[path = "commands/watch.rs"]
pub mod rad_watch;
-#[path = "commands/profile.rs"]
-pub mod rad_profile;
Exit code: 1
{
"response": "finished",
"result": "failure"
}