1use base58::ToBase58; 2use clap::{CommandFactory, Parser, Subcommand}; 3use rustyline::completion::{Completer, Pair}; 4use rustyline::error::ReadlineError; 5use rustyline::highlight::{CmdKind, Highlighter}; 6use rustyline::hint::Hinter; 7use rustyline::validate::{ValidationContext, ValidationResult, Validator}; 8use rustyline::{Context, Editor, Helper}; 9 10use hidapi::HidApi; 11use ledger_transport_hid::TransportNativeHID; 12 13use client::{ 14 transport::{Transport, TransportHID, TransportTcp, TransportWrapper}, 15 vanadium_client::{NativeAppClient, VanadiumAppClient}, 16 AtprotoAppClient, 17}; 18 19use std::borrow::Cow; 20use std::sync::Arc; 21 22#[derive(Parser, Debug)] 23#[command(name = "vnd-bitcoin-cli")] 24struct Cli { 25 #[clap(subcommand)] 26 command: CliCommand, 27} 28 29#[derive(Subcommand, Debug)] 30#[clap(rename_all = "snake_case")] 31enum CliCommand { 32 GetDidKey { 33 #[clap(long)] 34 key_index: Option<u32>, 35 #[clap(long, default_missing_value = "true", num_args = 0..=1)] 36 display: bool, 37 }, 38 Exit, 39} 40 41// Command completer 42struct CommandCompleter; 43 44impl CommandCompleter { 45 fn get_current_word<'a>(&self, line: &'a str, pos: usize) -> (usize, &'a str) { 46 let before = &line[..pos]; 47 // Find the last space before the cursor; if none, start at 0 48 let start = before.rfind(' ').map_or(0, |i| i + 1); 49 let word = &line[start..pos]; 50 (start, word) 51 } 52} 53 54fn make_pair(s: &str) -> Pair { 55 Pair { 56 display: s.to_string(), 57 replacement: s.to_string(), 58 } 59} 60 61impl Completer for CommandCompleter { 62 type Candidate = Pair; 63 64 fn complete( 65 &self, 66 line: &str, 67 pos: usize, 68 _ctx: &Context<'_>, 69 ) -> rustyline::Result<(usize, Vec<Pair>)> { 70 let prefix = line[..pos].trim_start(); 71 72 // Case 1: Empty input, suggest all subcommands 73 if prefix.is_empty() || !prefix.contains(' ') { 74 let suggestions = Cli::command() 75 .get_subcommands() 76 .filter(|cmd| cmd.get_name().starts_with(prefix)) 77 .map(|cmd| make_pair(cmd.get_name())) 78 .collect(); 79 return Ok((0, suggestions)); 80 } 81 82 // Case 3: Subcommand present; suggest possible arguments to complete the command 83 let subcmd_name = prefix.split_whitespace().next().unwrap(); 84 if let Some(subcmd) = Cli::command().find_subcommand(subcmd_name) { 85 let (start, _) = self.get_current_word(line, pos); 86 87 // Collect arguments already present in the line before the cursor 88 let Ok(present_args) = shellwords::split(&line[..start].trim_end()) else { 89 return Ok((0, vec![])); // no suggestions if we can't parse the line 90 }; 91 92 // replace `argument=some_value` with just `argument` for each of present_args 93 let present_args: Vec<String> = present_args 94 .into_iter() 95 .map(|arg| arg.split('=').next().unwrap().to_string()) 96 .collect(); 97 98 // Get all argument continuations 99 let suggestions = subcmd 100 .get_arguments() 101 .filter_map(|arg| arg.get_long().map(|l| l.to_string())) 102 .filter(|arg| !present_args.contains(arg)) 103 .map(|arg| Pair { 104 display: arg.clone(), 105 replacement: arg, 106 }) 107 .collect(); 108 return Ok((start, suggestions)); 109 } 110 111 // Default case: no suggestions 112 Ok((0, vec![])) 113 } 114} 115 116impl Validator for CommandCompleter { 117 fn validate( 118 &self, 119 _ctx: &mut ValidationContext<'_>, 120 ) -> Result<ValidationResult, ReadlineError> { 121 Ok(ValidationResult::Valid(None)) 122 } 123} 124 125impl Highlighter for CommandCompleter { 126 fn highlight<'l>(&self, line: &'l str, _pos: usize) -> Cow<'l, str> { 127 Cow::Borrowed(line) 128 } 129 130 fn highlight_char(&self, _line: &str, _pos: usize, _cmd_kind: CmdKind) -> bool { 131 false 132 } 133} 134 135impl Hinter for CommandCompleter { 136 type Hint = String; 137 138 fn hint(&self, _line: &str, _pos: usize, _ctx: &Context<'_>) -> Option<String> { 139 None 140 } 141} 142 143impl Helper for CommandCompleter {} 144 145#[derive(Parser)] 146#[command(name = "Vanadium", about = "Run a V-App on Vanadium")] 147struct Args { 148 /// Path to the ELF file of the V-App (if not the default one) 149 app: Option<String>, 150 151 /// Use the HID interface for a real device, instead of Speculos 152 #[arg(long, group = "interface")] 153 hid: bool, 154 155 /// Use the native interface 156 #[arg(long, group = "interface")] 157 native: bool, 158} 159 160// a bit of a hack: we convert the prompt in a format that clap can parse 161// (adding a dummy command, and replacing each 'argument' with '--argument') 162fn prepare_prompt_for_clap(line: &str) -> Result<Vec<String>, String> { 163 let args = shellwords::split(line).map_err(|e| format!("Failed to parse input: {}", e))?; 164 if args.is_empty() { 165 return Err("Empty input".to_string()); 166 } 167 168 // dummy command, and first command unchanged 169 let mut clap_args = vec!["dummy".to_string(), args[0].clone()]; 170 171 // prepend `--` to each subsequent argument 172 for arg in &args[1..] { 173 clap_args.push(format!("--{}", arg)); 174 } 175 Ok(clap_args) 176} 177 178async fn handle_cli_command( 179 app_client: &mut AtprotoAppClient, 180 cli: &Cli, 181) -> Result<(), Box<dyn std::error::Error>> { 182 match &cli.command { 183 CliCommand::GetDidKey { key_index, display } => { 184 let key = app_client 185 .get_did_key(key_index.unwrap_or(0), *display) 186 .await?; 187 println!("did:key:z{}", key.to_base58()); 188 } 189 CliCommand::Exit => { 190 return Err("Exiting".into()); 191 } 192 } 193 Ok(()) 194} 195 196#[tokio::main(flavor = "multi_thread")] 197async fn main() -> Result<(), Box<dyn std::error::Error>> { 198 let args = Args::parse(); 199 200 let default_app_path = if args.native { 201 "../target/x86_64-unknown-linux-gnu/release/vnd-atproto" 202 } else { 203 "../target/riscv32imc-unknown-none-elf/release/vnd-atproto" 204 }; 205 206 let app_path_str = args.app.unwrap_or(default_app_path.to_string()); 207 208 let mut app_client = if args.native { 209 AtprotoAppClient::new(Box::new( 210 NativeAppClient::new(&app_path_str) 211 .await 212 .map_err(|_| "Failed to create client")?, 213 )) 214 } else { 215 let transport_raw: Arc< 216 dyn Transport<Error = Box<dyn std::error::Error + Send + Sync>> + Send + Sync, 217 > = if args.hid { 218 Arc::new(TransportHID::new( 219 TransportNativeHID::new( 220 &HidApi::new().expect("Unable to get connect to the device"), 221 ) 222 .unwrap(), 223 )) 224 } else { 225 Arc::new( 226 TransportTcp::new() 227 .await 228 .expect("Unable to get TCP transport. Is speculos running?"), 229 ) 230 }; 231 let transport = TransportWrapper::new(transport_raw.clone()); 232 233 let (client, _) = VanadiumAppClient::new(&app_path_str, Arc::new(transport), None) 234 .await 235 .map_err(|e| format!("Failed to create client: {}", e))?; 236 237 AtprotoAppClient::new(Box::new(client)) 238 }; 239 240 let mut rl = Editor::<CommandCompleter, rustyline::history::DefaultHistory>::new()?; 241 rl.set_helper(Some(CommandCompleter)); 242 243 let _ = rl.load_history("history.txt"); 244 245 loop { 246 match rl.readline("> ") { 247 Ok(line) => { 248 if line.trim().is_empty() { 249 continue; 250 } 251 rl.add_history_entry(line.as_str())?; 252 253 let clap_args = match prepare_prompt_for_clap(&line) { 254 Ok(args) => args, 255 Err(e) => { 256 println!("Error: {}", e); 257 continue; 258 } 259 }; 260 261 match Cli::try_parse_from(clap_args) { 262 Ok(cli) => { 263 if let Err(e) = handle_cli_command(&mut app_client, &cli).await { 264 println!("Error: {}", e); 265 } 266 } 267 Err(e) => println!("Invalid command: {}", e), 268 } 269 } 270 Err(ReadlineError::Interrupted) => println!("Interrupted"), 271 Err(ReadlineError::Eof) => { 272 println!("Exiting"); 273 break; 274 } 275 Err(err) => { 276 println!("Error: {:?}", err); 277 break; 278 } 279 } 280 } 281 282 rl.save_history("history.txt")?; 283 284 app_client.exit().await?; 285 286 Ok(()) 287}