rho-cli 0.1.29

Rho CLI tools for encrypted agent collaboration, dataset publishing, controlled runs, and result release workflows
Documentation
//! `rho nostr` — controller key, directory publishing, resolve, send, listen.
//! Thin CLI over the shared nostr_relay client; the same paths back the desktop.

use std::env;
use std::fs;
use std::path::PathBuf;

use crate::RhoResult;
use crate::normalize_actor_id;
use crate::nostr::NostrIdentity;
use crate::nostr_relay::RelayClient;
use crate::providers::parse_identity_id;

const DEFAULT_RELAY_URL: &str = "wss://relay.biovault.net";

pub fn usage() -> ! {
    eprintln!(
        "usage:\n  \
         rho nostr key [--show]                 create/show this profile's controller key\n  \
         rho nostr publish-identity [--relay <ws-url>]\n  \
         rho nostr resolve <rho-id|handle> [--relay <ws-url>]\n  \
         rho nostr send <rho-id|handle> --message <text> [--relay <ws-url>]\n  \
         rho nostr listen [--relay <ws-url>]\n  \
         rho nostr publish-repo <owner/repo> [--rho-repo-id <id>] [--web-url <url>] [--description <text>] [--relay <ws-url>]\n  \
         rho nostr list-repos [--relay <ws-url>]"
    );
    std::process::exit(2);
}

pub fn run(args: &[String]) -> RhoResult<()> {
    let Some(command) = args.first().map(String::as_str) else {
        usage();
    };
    match command {
        "key" => key(&args[1..]),
        "publish-identity" => publish_identity(&args[1..]),
        "resolve" => resolve(&args[1..]),
        "send" => send(&args[1..]),
        "listen" => listen(&args[1..]),
        "publish-repo" => publish_repo(&args[1..]),
        "list-repos" => list_repos(&args[1..]),
        _ => usage(),
    }
}

fn publish_repo(args: &[String]) -> RhoResult<()> {
    let slug = first_positional(args).ok_or("usage: rho nostr publish-repo <owner/repo>")?;
    let me = active_identity()?;
    let handle = parse_identity_id(&me)?.handle;
    let identity = load_or_create_identity(&handle)?;
    let web_url =
        arg_value(args, "--web-url").unwrap_or_else(|| format!("https://github.com/{slug}"));
    let rho_repo_id = arg_value(args, "--rho-repo-id").unwrap_or_default();
    let description = arg_value(args, "--description").unwrap_or_default();
    let relay = relay_url(args);
    block_on(async {
        let client = RelayClient::connect(&identity, &relay).await?;
        let id = client
            .publish_repo(&identity, &slug, &rho_repo_id, &me, &web_url, &description)
            .await?;
        println!("published repo record {slug} ({id}) to {relay}");
        Ok(())
    })
}

fn list_repos(args: &[String]) -> RhoResult<()> {
    let identity = ephemeral_identity()?;
    let relay = relay_url(args);
    block_on(async {
        let client = RelayClient::connect(&identity, &relay).await?;
        let repos = client.list_repos().await?;
        if repos.is_empty() {
            println!("no repos published to {relay}");
        }
        for repo in repos {
            println!("{}  {}", repo.slug, repo.description);
        }
        Ok(())
    })
}

fn key(args: &[String]) -> RhoResult<()> {
    let handle = active_handle()?;
    let path = controller_key_path(&handle)?;
    if has_flag(args, "--show") && !path.is_file() {
        return Err(format!("no controller key yet for {handle}; run `rho nostr key`").into());
    }
    let identity = load_or_create_identity(&handle)?;
    println!("handle: {handle}");
    println!("nostr pubkey: {}", identity.pubkey_hex());
    println!("key file: {}", path.display());
    Ok(())
}

fn publish_identity(args: &[String]) -> RhoResult<()> {
    let rho_id = active_identity()?;
    let handle = parse_identity_id(&rho_id)?.handle;
    let identity = load_or_create_identity(&handle)?;
    let relay = relay_url(args);
    block_on(async {
        let client = RelayClient::connect(&identity, &relay).await?;
        let id = client
            .publish_identity(&identity, &rho_id, &handle, &relay)
            .await?;
        println!("published identity record {rho_id} ({id}) to {relay}");
        Ok(())
    })
}

fn resolve(args: &[String]) -> RhoResult<()> {
    let target = first_positional(args).ok_or("usage: rho nostr resolve <rho-id|handle>")?;
    let rho_id = to_rho_id(&target);
    let identity = ephemeral_identity()?;
    let relay = relay_url(args);
    block_on(async {
        let client = RelayClient::connect(&identity, &relay).await?;
        match client.resolve(&rho_id).await? {
            Some(record) => {
                println!("{}", serde_json::to_string_pretty(&record)?);
                Ok(())
            }
            None => Err(format!("{rho_id} not found in directory at {relay}").into()),
        }
    })
}

fn send(args: &[String]) -> RhoResult<()> {
    let target =
        first_positional(args).ok_or("usage: rho nostr send <rho-id|handle> --message <text>")?;
    let text = arg_value(args, "--message").ok_or("missing required argument: --message")?;
    let me = active_identity()?;
    let handle = parse_identity_id(&me)?.handle;
    let identity = load_or_create_identity(&handle)?;
    let to_id = to_rho_id(&target);
    let relay = relay_url(args);
    block_on(async {
        let client = RelayClient::connect(&identity, &relay).await?;
        let record = client.resolve(&to_id).await?.ok_or_else(|| {
            format!("{to_id} not found in directory; ask them to publish-identity")
        })?;
        let id = client
            .send_message(&identity, &record.controller_pubkey, &me, &to_id, &text)
            .await?;
        println!("sent message to {to_id} ({id})");
        Ok(())
    })
}

fn listen(args: &[String]) -> RhoResult<()> {
    let me = active_identity()?;
    let handle = parse_identity_id(&me)?.handle;
    let identity = load_or_create_identity(&handle)?;
    let relay = relay_url(args);
    println!("listening for messages to {me} on {relay} (ctrl-c to stop)");
    block_on(async {
        let client = RelayClient::connect(&identity, &relay).await?;
        client
            .listen(&identity, |message| {
                println!(
                    "[{}] {}: {}",
                    message.created_at, message.from_rho_id, message.text
                );
            })
            .await
    })
}

// --- helpers ---------------------------------------------------------------

fn block_on<F: std::future::Future>(fut: F) -> F::Output {
    tokio::runtime::Builder::new_current_thread()
        .enable_all()
        .build()
        .expect("failed to build tokio runtime")
        .block_on(fut)
}

fn relay_url(args: &[String]) -> String {
    arg_value(args, "--relay")
        .or_else(|| env::var("RHO_RELAY_URL").ok())
        .filter(|value| !value.trim().is_empty())
        .unwrap_or_else(|| DEFAULT_RELAY_URL.to_string())
}

fn to_rho_id(input: &str) -> String {
    let trimmed = input.trim().trim_start_matches('@');
    if trimmed.starts_with("rho://") {
        trimmed.to_string()
    } else if trimmed.contains('/') {
        // already provider-qualified, e.g. "github/madhavajay"
        format!("rho://id/{trimmed}")
    } else {
        format!("rho://id/github/{trimmed}")
    }
}

fn rho_home() -> RhoResult<PathBuf> {
    if let Ok(value) = env::var("RHO_HOME")
        && !value.is_empty()
    {
        return Ok(PathBuf::from(value));
    }
    let home = env::var("HOME").map_err(|_| "HOME is not set and RHO_HOME was not provided")?;
    Ok(PathBuf::from(home).join("rho"))
}

fn active_identity() -> RhoResult<String> {
    let identity = env::var("RHO_IDENTITY")
        .ok()
        .map(|value| value.trim().to_string())
        .filter(|value| !value.is_empty())
        .ok_or("RHO_IDENTITY is not set (pass --identity or set the profile)")?;
    normalize_actor_id(&identity)
}

fn active_handle() -> RhoResult<String> {
    Ok(parse_identity_id(&active_identity()?)?.handle)
}

fn controller_key_path(handle: &str) -> RhoResult<PathBuf> {
    Ok(rho_home()?
        .join("keys")
        .join("nostr")
        .join(format!("{handle}.hex")))
}

fn load_or_create_identity(handle: &str) -> RhoResult<NostrIdentity> {
    let path = controller_key_path(handle)?;
    if path.is_file() {
        let secret = fs::read_to_string(&path)?;
        return NostrIdentity::from_secret_hex(secret.trim());
    }
    let identity = NostrIdentity::generate();
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent)?;
    }
    fs::write(&path, identity.secret_hex())?;
    restrict_permissions(&path);
    Ok(identity)
}

// A throwaway key for read-only operations (resolve) where we still need a signer.
fn ephemeral_identity() -> RhoResult<NostrIdentity> {
    Ok(NostrIdentity::generate())
}

#[cfg(unix)]
fn restrict_permissions(path: &std::path::Path) {
    use std::os::unix::fs::PermissionsExt;
    let _ = fs::set_permissions(path, fs::Permissions::from_mode(0o600));
}

#[cfg(not(unix))]
fn restrict_permissions(_path: &std::path::Path) {}

fn has_flag(args: &[String], flag: &str) -> bool {
    args.iter().any(|arg| arg == flag)
}

fn arg_value(args: &[String], flag: &str) -> Option<String> {
    args.iter()
        .position(|arg| arg == flag)
        .and_then(|index| args.get(index + 1))
        .cloned()
}

fn first_positional(args: &[String]) -> Option<String> {
    let mut iter = args.iter();
    while let Some(arg) = iter.next() {
        if arg.starts_with("--") {
            // skip flag value (all our flags take a value)
            iter.next();
            continue;
        }
        return Some(arg.clone());
    }
    None
}