wip
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}