use base58::ToBase58; use base64::Engine; use cid::multihash; use clap::{CommandFactory, Parser, Subcommand}; use rustyline::completion::{Completer, Pair}; use rustyline::error::ReadlineError; use rustyline::highlight::{CmdKind, Highlighter}; use rustyline::hint::Hinter; use rustyline::validate::{ValidationContext, ValidationResult, Validator}; use rustyline::{Context, Editor, Helper}; use sha2::Digest; use hidapi::HidApi; use ledger_transport_hid::TransportNativeHID; use client::{ transport::{Transport, TransportHID, TransportTcp, TransportWrapper}, vanadium_client::{NativeAppClient, VanadiumAppClient}, AtprotoAppClient, }; use std::borrow::Cow; use std::fs::File; use std::io::Read; use std::sync::Arc; #[derive(Parser, Debug)] #[command(name = "vnd-bitcoin-cli")] struct Cli { #[clap(subcommand)] command: CliCommand, } #[derive(Subcommand, Debug)] #[clap(rename_all = "snake_case")] enum CliCommand { GetDidKey { #[clap(long)] key_index: Option, #[clap(long, default_missing_value = "true", num_args = 0..=1)] display: bool, }, SignPlcOperation { #[clap(long)] key_index: Option, /// Path to operation json file #[clap(long)] operation: String, /// Path to previous operation json file #[clap(long)] previous: Option, #[clap(long, default_missing_value = "true", num_args = 0..=1)] new_plc_did: bool, }, GetCID { /// Path to operation json file #[clap(long)] operation: String, }, Exit, } // Command completer struct CommandCompleter; impl CommandCompleter { fn get_current_word<'a>(&self, line: &'a str, pos: usize) -> (usize, &'a str) { let before = &line[..pos]; // Find the last space before the cursor; if none, start at 0 let start = before.rfind(' ').map_or(0, |i| i + 1); let word = &line[start..pos]; (start, word) } } fn make_pair(s: &str) -> Pair { Pair { display: s.to_string(), replacement: s.to_string(), } } impl Completer for CommandCompleter { type Candidate = Pair; fn complete( &self, line: &str, pos: usize, _ctx: &Context<'_>, ) -> rustyline::Result<(usize, Vec)> { let prefix = line[..pos].trim_start(); // Case 1: Empty input, suggest all subcommands if prefix.is_empty() || !prefix.contains(' ') { let suggestions = Cli::command() .get_subcommands() .filter(|cmd| cmd.get_name().starts_with(prefix)) .map(|cmd| make_pair(cmd.get_name())) .collect(); return Ok((0, suggestions)); } // Case 3: Subcommand present; suggest possible arguments to complete the command let subcmd_name = prefix.split_whitespace().next().unwrap(); if let Some(subcmd) = Cli::command().find_subcommand(subcmd_name) { let (start, _) = self.get_current_word(line, pos); // Collect arguments already present in the line before the cursor let Ok(present_args) = shellwords::split(&line[..start].trim_end()) else { return Ok((0, vec![])); // no suggestions if we can't parse the line }; // replace `argument=some_value` with just `argument` for each of present_args let present_args: Vec = present_args .into_iter() .map(|arg| arg.split('=').next().unwrap().to_string()) .collect(); // Get all argument continuations let suggestions = subcmd .get_arguments() .filter_map(|arg| arg.get_long().map(|l| l.to_string())) .filter(|arg| !present_args.contains(arg)) .map(|arg| Pair { display: arg.clone(), replacement: arg, }) .collect(); return Ok((start, suggestions)); } // Default case: no suggestions Ok((0, vec![])) } } impl Validator for CommandCompleter { fn validate( &self, _ctx: &mut ValidationContext<'_>, ) -> Result { Ok(ValidationResult::Valid(None)) } } impl Highlighter for CommandCompleter { fn highlight<'l>(&self, line: &'l str, _pos: usize) -> Cow<'l, str> { Cow::Borrowed(line) } fn highlight_char(&self, _line: &str, _pos: usize, _cmd_kind: CmdKind) -> bool { false } } impl Hinter for CommandCompleter { type Hint = String; fn hint(&self, _line: &str, _pos: usize, _ctx: &Context<'_>) -> Option { None } } impl Helper for CommandCompleter {} #[derive(Parser)] #[command(name = "Vanadium", about = "Run a V-App on Vanadium")] struct Args { /// Path to the ELF file of the V-App (if not the default one) app: Option, /// Use the HID interface for a real device, instead of Speculos #[arg(long, group = "interface")] hid: bool, /// Use the native interface #[arg(long, group = "interface")] native: bool, } // a bit of a hack: we convert the prompt in a format that clap can parse // (adding a dummy command, and replacing each 'argument' with '--argument') fn prepare_prompt_for_clap(line: &str) -> Result, String> { let args = shellwords::split(line).map_err(|e| format!("Failed to parse input: {}", e))?; if args.is_empty() { return Err("Empty input".to_string()); } // dummy command, and first command unchanged let mut clap_args = vec!["dummy".to_string(), args[0].clone()]; // prepend `--` to each subsequent argument for arg in &args[1..] { clap_args.push(format!("--{}", arg)); } Ok(clap_args) } async fn handle_cli_command( app_client: &mut AtprotoAppClient, cli: &Cli, ) -> Result<(), Box> { match &cli.command { CliCommand::GetDidKey { key_index, display } => { let key = app_client .get_did_key(key_index.unwrap_or(0), *display) .await?; println!("did:key:z{}", key.to_base58()); } CliCommand::SignPlcOperation { key_index, operation, previous, new_plc_did, } => { let operation: client::PlcOperation = read_json_file(&operation)?; let previous: Option = if let Some(path) = previous { Some(read_json_file(&path)?) } else { None }; let sig = app_client .sign_plc_operation(key_index.unwrap_or(0), operation.clone(), previous) .await .map(|s| base64::prelude::BASE64_URL_SAFE_NO_PAD.encode(s))?; println!("sig: {}", sig); if *new_plc_did { let b = serde_ipld_dagcbor::to_vec(&operation.signed(sig)).unwrap(); let hash = sha2::Sha256::digest(b); let s = base32::encode(base32::Alphabet::Rfc4648Lower { padding: true }, &hash); eprintln!("did:plc:{}", &s[..24]); } } CliCommand::GetCID { operation } => { let operation: client::SignedPlcOperation = read_json_file(&operation)?; let b = serde_ipld_dagcbor::to_vec(&operation).unwrap(); let digest = sha2::Sha256::digest(b); let wrap = multihash::Multihash::wrap(0x12, &digest).unwrap(); let cid = cid::Cid::new(cid::Version::V1, 0x71, wrap).unwrap(); eprintln!("cid: {}", cid); } CliCommand::Exit => { app_client.exit().await?; return Err("Exiting".into()); } } Ok(()) } fn read_json_file(path: &str) -> Result> where T: serde::de::DeserializeOwned, { let mut file = File::open(path)?; let mut contents = String::new(); file.read_to_string(&mut contents)?; let data: T = serde_json::from_str(&contents)?; Ok(data) } #[tokio::main(flavor = "multi_thread")] async fn main() -> Result<(), Box> { let args = Args::parse(); let default_app_path = if args.native { "../target/x86_64-unknown-linux-gnu/release/vnd-atproto" } else { "../target/riscv32imc-unknown-none-elf/release/vnd-atproto" }; let app_path_str = args.app.unwrap_or(default_app_path.to_string()); let mut app_client = if args.native { AtprotoAppClient::new(Box::new( NativeAppClient::new(&app_path_str) .await .map_err(|_| "Failed to create client")?, )) } else { let transport_raw: Arc< dyn Transport> + Send + Sync, > = if args.hid { Arc::new(TransportHID::new( TransportNativeHID::new( &HidApi::new().expect("Unable to get connect to the device"), ) .unwrap(), )) } else { Arc::new( TransportTcp::new() .await .expect("Unable to get TCP transport. Is speculos running?"), ) }; let transport = TransportWrapper::new(transport_raw.clone()); let (client, _) = VanadiumAppClient::new(&app_path_str, Arc::new(transport), None) .await .map_err(|e| format!("Failed to create client: {}", e))?; AtprotoAppClient::new(Box::new(client)) }; let mut rl = Editor::::new()?; rl.set_helper(Some(CommandCompleter)); let _ = rl.load_history("history.txt"); loop { match rl.readline("> ") { Ok(line) => { if line.trim().is_empty() { continue; } rl.add_history_entry(line.as_str())?; let clap_args = match prepare_prompt_for_clap(&line) { Ok(args) => args, Err(e) => { println!("Error: {}", e); continue; } }; match Cli::try_parse_from(clap_args) { Ok(cli) => { if let Err(e) = handle_cli_command(&mut app_client, &cli).await { println!("Error: {}", e); } } Err(e) => println!("Invalid command: {}", e), } } Err(ReadlineError::Interrupted) => println!("Interrupted"), Err(ReadlineError::Eof) => { println!("Exiting"); break; } Err(err) => { println!("Error: {:?}", err); break; } } } rl.save_history("history.txt")?; app_client.exit().await?; Ok(()) }