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