From 79715e6b002839db443282b5c551cbf8624c1c37 Mon Sep 17 00:00:00 2001 From: itzlambda Date: Fri, 15 May 2026 14:26:40 +0530 Subject: [PATCH 01/19] feat(cli): add MissingInput and MissingPrerequisite error variants --- crates/basilica-cli/src/error.rs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/crates/basilica-cli/src/error.rs b/crates/basilica-cli/src/error.rs index 73752f16a..955181327 100644 --- a/crates/basilica-cli/src/error.rs +++ b/crates/basilica-cli/src/error.rs @@ -89,6 +89,24 @@ pub enum CliError { #[error("Invalid provider: {0}")] InvalidProvider(String), + /// A required input was not provided and we cannot prompt because we are + /// running non-interactively. `field` names the conceptual input, `hint` + /// tells the caller which flag or argument to supply, and `choices` may + /// surface the candidates that an interactive selector would have offered. + #[error("missing input: {field}")] + MissingInput { + field: String, + hint: String, + choices: crate::interactive::gate::Choices, + }, + + /// A piece of account state (e.g. a registered SSH key) that the + /// interactive flow would have set up implicitly is missing, and the CLI + /// refuses to set it up silently in non-interactive mode. `hint` names the + /// command the agent should run first. + #[error("missing prerequisite: {field}")] + MissingPrerequisite { field: String, hint: String }, + /// Everything else (using color-eyre's Report for rich errors) #[error(transparent)] Internal(#[from] Report), From c456b4f0ff1ea761cb64278503af7f861466d5fe Mon Sep 17 00:00:00 2001 From: itzlambda Date: Fri, 15 May 2026 14:28:18 +0530 Subject: [PATCH 02/19] feat(cli): add interactivity gate with ask_text/ask_select/ask_confirm --- crates/basilica-cli/src/interactive/gate.rs | 135 ++++++++++++++++++++ crates/basilica-cli/src/interactive/mod.rs | 1 + 2 files changed, 136 insertions(+) create mode 100644 crates/basilica-cli/src/interactive/gate.rs diff --git a/crates/basilica-cli/src/interactive/gate.rs b/crates/basilica-cli/src/interactive/gate.rs new file mode 100644 index 000000000..a598720d6 --- /dev/null +++ b/crates/basilica-cli/src/interactive/gate.rs @@ -0,0 +1,135 @@ +//! Interactivity gate. Every interactive prompt MUST route through this. +//! In non-interactive mode the helpers return structured errors instead of hanging. + +use crate::error::CliError; +use dialoguer::{theme::ColorfulTheme, Confirm, Input, Select}; +use std::fmt::Display; +use std::io::IsTerminal; +use std::sync::OnceLock; + +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub enum Interactivity { + Interactive, + NonInteractive, +} + +static OVERRIDE: OnceLock = OnceLock::new(); +static DETECTED: OnceLock = OnceLock::new(); + +pub fn current() -> Interactivity { + if let Some(v) = OVERRIDE.get() { + return *v; + } + *DETECTED.get_or_init(detect) +} + +fn detect() -> Interactivity { + let non_interactive = !std::io::stdin().is_terminal() + || std::env::var("BASILICA_NON_INTERACTIVE").is_ok(); + if non_interactive { + Interactivity::NonInteractive + } else { + Interactivity::Interactive + } +} + +#[cfg(test)] +pub fn set_for_test(v: Interactivity) { + let _ = OVERRIDE.set(v); +} + +/// A choice surfaced to the agent when a selector is skipped in non-interactive mode. +#[derive(Debug, Default, Clone)] +pub struct Choices(pub Vec); + +#[derive(Debug, Clone)] +pub struct Choice { + pub id: String, + pub label: String, + pub meta: serde_json::Map, +} + +/// Item passed to ask_select. Caller supplies identity + metadata explicitly +/// so we don't have to bound T: Serialize. +pub struct SelectItem<'a, T> { + pub item: &'a T, + pub id: String, + pub label: String, + pub meta: serde_json::Map, +} + +pub fn ask_text(field: &str, default: Option<&str>, hint: &str) -> Result { + match current() { + Interactivity::NonInteractive => { + if let Some(d) = default { + return Ok(d.to_string()); + } + Err(CliError::MissingInput { + field: field.to_string(), + hint: hint.to_string(), + choices: Choices::default(), + }) + } + Interactivity::Interactive => { + let theme = ColorfulTheme::default(); + let mut input = Input::::with_theme(&theme).with_prompt(field); + if let Some(d) = default { + input = input.default(d.to_string()); + } + input + .interact_text() + .map_err(|e| CliError::Internal(color_eyre::eyre::eyre!(e))) + } + } +} + +pub fn ask_select( + field: &str, + items: &[SelectItem<'_, T>], + hint: &str, +) -> Result { + match current() { + Interactivity::NonInteractive => Err(CliError::MissingInput { + field: field.to_string(), + hint: hint.to_string(), + choices: Choices( + items + .iter() + .map(|i| Choice { + id: i.id.clone(), + label: i.label.clone(), + meta: i.meta.clone(), + }) + .collect(), + ), + }), + Interactivity::Interactive => { + let labels: Vec = items.iter().map(|i| i.item.to_string()).collect(); + let theme = ColorfulTheme::default(); + Select::with_theme(&theme) + .with_prompt(field) + .items(&labels) + .default(0) + .interact() + .map_err(|e| CliError::Internal(color_eyre::eyre::eyre!(e))) + } + } +} + +pub fn ask_confirm(field: &str, default: bool, hint: &str) -> Result { + match current() { + Interactivity::NonInteractive => Err(CliError::MissingInput { + field: field.to_string(), + hint: hint.to_string(), + choices: Choices::default(), + }), + Interactivity::Interactive => { + let theme = ColorfulTheme::default(); + Confirm::with_theme(&theme) + .with_prompt(field) + .default(default) + .interact() + .map_err(|e| CliError::Internal(color_eyre::eyre::eyre!(e))) + } + } +} diff --git a/crates/basilica-cli/src/interactive/mod.rs b/crates/basilica-cli/src/interactive/mod.rs index efdda2bef..27b3841ae 100644 --- a/crates/basilica-cli/src/interactive/mod.rs +++ b/crates/basilica-cli/src/interactive/mod.rs @@ -1,5 +1,6 @@ //! Interactive UI components +pub mod gate; pub mod selector; pub use selector::*; From 83f1bc35ba93de0fbd5e689d0a41ec342af1d2bd Mon Sep 17 00:00:00 2001 From: itzlambda Date: Fri, 15 May 2026 14:29:47 +0530 Subject: [PATCH 03/19] test(cli): cover interactivity gate behavior under non-interactive mode --- Cargo.lock | 110 +++++++++++++++++++- crates/basilica-cli/Cargo.toml | 2 + crates/basilica-cli/src/interactive/gate.rs | 78 ++++++++++++++ 3 files changed, 189 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 9177b9c82..3319ac7c3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -531,6 +531,21 @@ dependencies = [ "serde_json", ] +[[package]] +name = "assert_cmd" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2aa3a22042e45de04255c7bf3626e239f450200fd0493c1e382263544b20aea6" +dependencies = [ + "anstyle", + "bstr", + "libc", + "predicates", + "predicates-core", + "predicates-tree", + "wait-timeout", +] + [[package]] name = "async-channel" version = "2.5.0" @@ -1370,6 +1385,7 @@ checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" name = "basilica-cli" version = "0.29.0" dependencies = [ + "assert_cmd", "axum 0.7.9", "base64 0.21.7", "basilica-common", @@ -1396,6 +1412,7 @@ dependencies = [ "semver 1.0.27", "serde", "serde_json", + "serial_test", "sha2 0.10.9", "shellexpand", "ssh-key", @@ -1520,7 +1537,7 @@ dependencies = [ [[package]] name = "basilica-sdk-python" -version = "0.29.0" +version = "0.29.1" dependencies = [ "basilica-sdk", "basilica-validator", @@ -1834,6 +1851,17 @@ dependencies = [ "tinyvec", ] +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "regex-automata", + "serde", +] + [[package]] name = "bumpalo" version = "3.19.1" @@ -2621,6 +2649,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "difflib" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" + [[package]] name = "digest" version = "0.9.0" @@ -5595,6 +5629,33 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "predicates" +version = "3.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ada8f2932f28a27ee7b70dd6c1c39ea0675c55a36879ab92f3a715eaa1e63cfe" +dependencies = [ + "anstyle", + "difflib", + "predicates-core", +] + +[[package]] +name = "predicates-core" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cad38746f3166b4031b1a0d39ad9f954dd291e7854fcc0eed52ee41a0b50d144" + +[[package]] +name = "predicates-tree" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0de1b847b39c8131db0467e9df1ff60e6d0562ab8e9a16e568ad0fdb372e2f2" +dependencies = [ + "predicates-core", + "termtree", +] + [[package]] name = "prettyplease" version = "0.2.37" @@ -6813,6 +6874,15 @@ dependencies = [ "yap", ] +[[package]] +name = "scc" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46e6f046b7fef48e2660c57ed794263155d713de679057f2d0c169bfc6e756cc" +dependencies = [ + "sdd", +] + [[package]] name = "schannel" version = "0.1.28" @@ -6904,6 +6974,12 @@ dependencies = [ "untrusted", ] +[[package]] +name = "sdd" +version = "3.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "490dcfcbfef26be6800d11870ff2df8774fa6e86d047e3e8c8a76b25655e41ca" + [[package]] name = "seahash" version = "4.1.0" @@ -7239,6 +7315,32 @@ dependencies = [ "serde", ] +[[package]] +name = "serial_test" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "911bd979bf1070a3f3aa7b691a3b3e9968f339ceeec89e08c280a8a22207a32f" +dependencies = [ + "futures-executor", + "futures-util", + "log", + "once_cell", + "parking_lot", + "scc", + "serial_test_derive", +] + +[[package]] +name = "serial_test_derive" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a7d91949b85b0d2fb687445e448b40d322b6b3e4af6b44a29b21d9a5f33e6d9" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "sha1" version = "0.10.6" @@ -8603,6 +8705,12 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "termtree" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" + [[package]] name = "thiserror" version = "1.0.69" diff --git a/crates/basilica-cli/Cargo.toml b/crates/basilica-cli/Cargo.toml index d0581bdf4..aea003c33 100644 --- a/crates/basilica-cli/Cargo.toml +++ b/crates/basilica-cli/Cargo.toml @@ -75,3 +75,5 @@ basilica-sdk = { path = "../basilica-sdk" } basilica-validator = { path = "../basilica-validator", features = ["client", "cli"] } [dev-dependencies] +serial_test = "3" +assert_cmd = "2" diff --git a/crates/basilica-cli/src/interactive/gate.rs b/crates/basilica-cli/src/interactive/gate.rs index a598720d6..e4c4f14b6 100644 --- a/crates/basilica-cli/src/interactive/gate.rs +++ b/crates/basilica-cli/src/interactive/gate.rs @@ -116,6 +116,84 @@ pub fn ask_select( } } +#[cfg(test)] +mod tests { + use super::*; + use crate::error::CliError; + use serial_test::serial; + + #[test] + #[serial] + fn ask_text_non_interactive_no_default_errors() { + set_for_test(Interactivity::NonInteractive); + let err = ask_text("name", None, "Pass --name").unwrap_err(); + match err { + CliError::MissingInput { field, hint, .. } => { + assert_eq!(field, "name"); + assert!(hint.contains("--name")); + } + other => panic!("expected MissingInput, got {other:?}"), + } + } + + #[test] + #[serial] + fn ask_text_non_interactive_with_default_returns_default() { + set_for_test(Interactivity::NonInteractive); + let v = ask_text("name", Some("auto-name"), "irrelevant").unwrap(); + assert_eq!(v, "auto-name"); + } + + #[test] + #[serial] + fn ask_select_non_interactive_returns_choices() { + set_for_test(Interactivity::NonInteractive); + struct Off { + name: &'static str, + } + impl std::fmt::Display for Off { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + write!(f, "{}", self.name) + } + } + let a = Off { name: "alpha" }; + let b = Off { name: "beta" }; + let items = vec![ + SelectItem { + item: &a, + id: "1".into(), + label: "alpha".into(), + meta: Default::default(), + }, + SelectItem { + item: &b, + id: "2".into(), + label: "beta".into(), + meta: Default::default(), + }, + ]; + let err = ask_select("offering", &items, "Pass --offering-id").unwrap_err(); + match err { + CliError::MissingInput { choices, .. } => assert_eq!(choices.0.len(), 2), + other => panic!("expected MissingInput, got {other:?}"), + } + } + + #[test] + #[serial] + fn ask_confirm_non_interactive_errors() { + set_for_test(Interactivity::NonInteractive); + let err = ask_confirm("replace", false, "Pass --force").unwrap_err(); + match err { + CliError::MissingInput { field, hint, .. } => { + assert_eq!(field, "replace"); + assert!(hint.contains("--force")); + } + other => panic!("expected MissingInput, got {other:?}"), + } + } +} + pub fn ask_confirm(field: &str, default: bool, hint: &str) -> Result { match current() { Interactivity::NonInteractive => Err(CliError::MissingInput { From 9626131e37244c684fcc2aec426e72d596df3129 Mon Sep 17 00:00:00 2001 From: itzlambda Date: Fri, 15 May 2026 14:31:03 +0530 Subject: [PATCH 04/19] feat(cli): render MissingInput/MissingPrerequisite errors as JSON or human text --- crates/basilica-cli/src/main.rs | 21 +-- .../basilica-cli/src/output/error_render.rs | 154 ++++++++++++++++++ crates/basilica-cli/src/output/mod.rs | 3 + 3 files changed, 166 insertions(+), 12 deletions(-) create mode 100644 crates/basilica-cli/src/output/error_render.rs diff --git a/crates/basilica-cli/src/main.rs b/crates/basilica-cli/src/main.rs index de66fc01b..8933b9d8a 100644 --- a/crates/basilica-cli/src/main.rs +++ b/crates/basilica-cli/src/main.rs @@ -71,20 +71,17 @@ async fn run_async(args: Args) -> Result<()> { ) .map_err(|e| eyre!("Failed to initialize logging: {}", e))?; + // Capture render-affecting flags before `args` is moved into `run()`. + let json = args.json; + // Run and handle errors explicitly to show suggestions if let Err(err) = args.run().await { - // Extract and format the inner error properly - match err { - basilica_cli::CliError::Internal(report) => { - // For Internal errors (which contain eyre Reports with suggestions), - // use Debug formatting to show the full error report - eprintln!("Error: {:?}", report); - } - other => { - // For other error types, use Display formatting - eprintln!("Error: {}", other); - } - } + let mode = if json { + basilica_cli::output::RenderMode::Json + } else { + basilica_cli::output::RenderMode::Human + }; + let _ = basilica_cli::output::render_error(&err, mode, &mut std::io::stderr()); std::process::exit(1); } diff --git a/crates/basilica-cli/src/output/error_render.rs b/crates/basilica-cli/src/output/error_render.rs new file mode 100644 index 000000000..1ce8b8577 --- /dev/null +++ b/crates/basilica-cli/src/output/error_render.rs @@ -0,0 +1,154 @@ +//! Render CliError to stderr. Two modes: Human (color-eyre style) and Json +//! (single object per error). Lives outside of `error.rs` so the error type +//! itself stays renderer-agnostic and does not need to implement `Serialize`. + +use crate::error::CliError; +use crate::interactive::gate::Choices; +use std::io::Write; + +#[derive(Copy, Clone, Debug)] +pub enum RenderMode { + Human, + Json, +} + +pub fn render_error(err: &CliError, mode: RenderMode, w: &mut dyn Write) -> std::io::Result<()> { + match mode { + RenderMode::Json => render_json(err, w), + RenderMode::Human => render_human(err, w), + } +} + +fn render_json(err: &CliError, w: &mut dyn Write) -> std::io::Result<()> { + let payload = match err { + CliError::MissingInput { + field, + hint, + choices, + } => serde_json::json!({ + "schema_version": 1, + "error": "missing_input", + "field": field, + "hint": hint, + "choices": choices_to_json(choices), + }), + CliError::MissingPrerequisite { field, hint } => serde_json::json!({ + "schema_version": 1, + "error": "missing_prerequisite", + "field": field, + "hint": hint, + }), + other => serde_json::json!({ + "schema_version": 1, + "error": "cli_error", + "message": other.to_string(), + }), + }; + writeln!(w, "{}", payload) +} + +fn choices_to_json(choices: &Choices) -> Vec { + choices + .0 + .iter() + .map(|c| { + let mut obj = serde_json::Map::new(); + obj.insert("id".into(), serde_json::Value::String(c.id.clone())); + obj.insert("label".into(), serde_json::Value::String(c.label.clone())); + for (k, v) in &c.meta { + obj.insert(k.clone(), v.clone()); + } + serde_json::Value::Object(obj) + }) + .collect() +} + +fn render_human(err: &CliError, w: &mut dyn Write) -> std::io::Result<()> { + match err { + CliError::MissingInput { + field, + hint, + choices, + } => { + writeln!(w, "error: missing input for '{}'", field)?; + writeln!(w, " hint: {}", hint)?; + if !choices.0.is_empty() { + writeln!(w, " choices:")?; + for c in &choices.0 { + writeln!(w, " - {} ({})", c.label, c.id)?; + } + } + Ok(()) + } + CliError::MissingPrerequisite { field, hint } => { + writeln!(w, "error: missing prerequisite for '{}'", field)?; + writeln!(w, " hint: {}", hint) + } + CliError::Internal(report) => writeln!(w, "Error: {:?}", report), + other => writeln!(w, "Error: {}", other), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::interactive::gate::Choice; + + #[test] + fn json_render_missing_input_with_choices() { + let err = CliError::MissingInput { + field: "offering_id".into(), + hint: "Pass --offering-id".into(), + choices: Choices(vec![Choice { + id: "off_a".into(), + label: "8x H100".into(), + meta: { + let mut m = serde_json::Map::new(); + m.insert("price_per_hr_usd".into(), serde_json::json!(18.40)); + m + }, + }]), + }; + let mut buf = Vec::new(); + render_error(&err, RenderMode::Json, &mut buf).unwrap(); + let v: serde_json::Value = serde_json::from_slice(&buf).unwrap(); + assert_eq!(v["error"], "missing_input"); + assert_eq!(v["field"], "offering_id"); + assert_eq!(v["choices"][0]["price_per_hr_usd"], 18.40); + assert_eq!(v["schema_version"], 1); + } + + #[test] + fn json_render_missing_prerequisite() { + let err = CliError::MissingPrerequisite { + field: "ssh_key".into(), + hint: "Run `basilica ssh-keys add` first.".into(), + }; + let mut buf = Vec::new(); + render_error(&err, RenderMode::Json, &mut buf).unwrap(); + let v: serde_json::Value = serde_json::from_slice(&buf).unwrap(); + assert_eq!(v["error"], "missing_prerequisite"); + assert_eq!(v["field"], "ssh_key"); + assert_eq!(v["schema_version"], 1); + } + + #[test] + fn human_render_missing_input_lists_choices() { + let err = CliError::MissingInput { + field: "rental".into(), + hint: "Pass .".into(), + choices: Choices(vec![Choice { + id: "rent_1".into(), + label: "alpha".into(), + meta: Default::default(), + }]), + }; + let mut buf = Vec::new(); + render_error(&err, RenderMode::Human, &mut buf).unwrap(); + let s = String::from_utf8(buf).unwrap(); + assert!(s.contains("missing input")); + assert!(s.contains("rental")); + assert!(s.contains("alpha")); + assert!(s.contains("rent_1")); + } +} diff --git a/crates/basilica-cli/src/output/mod.rs b/crates/basilica-cli/src/output/mod.rs index 5a0d33268..a11364b24 100644 --- a/crates/basilica-cli/src/output/mod.rs +++ b/crates/basilica-cli/src/output/mod.rs @@ -1,8 +1,11 @@ //! Output formatting utilities pub mod banner; +pub mod error_render; pub mod table_output; +pub use error_render::{render_error, RenderMode}; + use color_eyre::eyre::{eyre, Result}; use console::style; use rust_decimal::Decimal; From 48c048bce2a433690af0ebd1574782f21e71508f Mon Sep 17 00:00:00 2001 From: itzlambda Date: Fri, 15 May 2026 14:31:48 +0530 Subject: [PATCH 05/19] feat(cli): hide spinners in non-interactive mode --- crates/basilica-cli/src/progress/mod.rs | 45 ++++++++++++++++--------- 1 file changed, 29 insertions(+), 16 deletions(-) diff --git a/crates/basilica-cli/src/progress/mod.rs b/crates/basilica-cli/src/progress/mod.rs index 4da3dacbe..310089340 100644 --- a/crates/basilica-cli/src/progress/mod.rs +++ b/crates/basilica-cli/src/progress/mod.rs @@ -1,5 +1,6 @@ //! Progress indicators and user feedback utilities +use crate::interactive::gate::{current, Interactivity}; use indicatif::{MultiProgress, ProgressBar, ProgressStyle}; use std::collections::HashMap; use std::sync::{Arc, Mutex}; @@ -22,15 +23,21 @@ impl ProgressManager { /// Start a spinner operation with a given name and message pub fn start_spinner(&self, name: &str, message: &str) -> ProgressBar { - let spinner = ProgressBar::new_spinner(); - spinner.set_style( - ProgressStyle::default_spinner() - .template("{spinner:.cyan} {msg}") - .unwrap() - .tick_chars("⠁⠂⠄⡀⢀⠠⠐⠈ "), - ); + let spinner = match current() { + Interactivity::NonInteractive => ProgressBar::hidden(), + Interactivity::Interactive => { + let s = ProgressBar::new_spinner(); + s.set_style( + ProgressStyle::default_spinner() + .template("{spinner:.cyan} {msg}") + .unwrap() + .tick_chars("⠁⠂⠄⡀⢀⠠⠐⠈ "), + ); + s.enable_steady_tick(Duration::from_millis(120)); + s + } + }; spinner.set_message(message.to_string()); - spinner.enable_steady_tick(Duration::from_millis(120)); let pb = self.multi.add(spinner); @@ -96,15 +103,21 @@ impl Default for ProgressManager { /// Simple spinner for standalone operations pub fn create_spinner(message: &str) -> ProgressBar { - let spinner = ProgressBar::new_spinner(); - spinner.set_style( - ProgressStyle::default_spinner() - .template("{spinner:.cyan} {msg}") - .unwrap() - .tick_chars("⠁⠂⠄⡀⢀⠠⠐⠈ "), - ); + let spinner = match current() { + Interactivity::NonInteractive => ProgressBar::hidden(), + Interactivity::Interactive => { + let s = ProgressBar::new_spinner(); + s.set_style( + ProgressStyle::default_spinner() + .template("{spinner:.cyan} {msg}") + .unwrap() + .tick_chars("⠁⠂⠄⡀⢀⠠⠐⠈ "), + ); + s.enable_steady_tick(Duration::from_millis(120)); + s + } + }; spinner.set_message(message.to_string()); - spinner.enable_steady_tick(Duration::from_millis(120)); spinner } From b50b75568fa11c02bb52cc7b574938ba84b85372 Mon Sep 17 00:00:00 2001 From: itzlambda Date: Fri, 15 May 2026 14:36:34 +0530 Subject: [PATCH 06/19] feat(cli): ssh-keys add runs non-interactively with --file/--name/--force --- crates/basilica-cli/src/cli/args.rs | 11 +- crates/basilica-cli/src/cli/commands.rs | 4 + .../basilica-cli/src/cli/handlers/ssh_keys.rs | 118 +++++++++--------- crates/basilica-cli/src/interactive/gate.rs | 11 +- crates/basilica-cli/tests/non_interactive.rs | 67 ++++++++++ 5 files changed, 139 insertions(+), 72 deletions(-) create mode 100644 crates/basilica-cli/tests/non_interactive.rs diff --git a/crates/basilica-cli/src/cli/args.rs b/crates/basilica-cli/src/cli/args.rs index bb44b407d..d1c7ca78c 100644 --- a/crates/basilica-cli/src/cli/args.rs +++ b/crates/basilica-cli/src/cli/args.rs @@ -230,9 +230,14 @@ impl Args { let client = create_client(config).await?; match action { - SshKeyAction::Add { name, file } => { - handlers::ssh_keys::handle_add_ssh_key(&client, name.clone(), file.clone()) - .await?; + SshKeyAction::Add { name, file, force } => { + handlers::ssh_keys::handle_add_ssh_key( + &client, + name.clone(), + file.clone(), + *force, + ) + .await?; } SshKeyAction::List => { handlers::ssh_keys::handle_list_ssh_keys(&client, self.json).await?; diff --git a/crates/basilica-cli/src/cli/commands.rs b/crates/basilica-cli/src/cli/commands.rs index 78976f0da..73ec9083d 100644 --- a/crates/basilica-cli/src/cli/commands.rs +++ b/crates/basilica-cli/src/cli/commands.rs @@ -278,6 +278,10 @@ pub enum SshKeyAction { /// Path to SSH public key file (default: auto-detect from ~/.ssh/) #[arg(short, long, value_hint = ValueHint::FilePath)] file: Option, + + /// Overwrite an existing registered SSH key without confirmation + #[arg(long)] + force: bool, }, /// List registered SSH keys diff --git a/crates/basilica-cli/src/cli/handlers/ssh_keys.rs b/crates/basilica-cli/src/cli/handlers/ssh_keys.rs index 8fab4b19c..ac6aac9b2 100644 --- a/crates/basilica-cli/src/cli/handlers/ssh_keys.rs +++ b/crates/basilica-cli/src/cli/handlers/ssh_keys.rs @@ -1,11 +1,12 @@ //! SSH key management handlers for the Basilica CLI use crate::error::CliError; +use crate::interactive::gate::{ask_confirm, ask_select, ask_text, SelectItem}; use crate::output::{compress_path, json_output, print_success}; use crate::ssh::find_local_public_key_path; use basilica_sdk::BasilicaClient; use console::style; -use dialoguer::{theme::ColorfulTheme, Confirm, Input}; +use dialoguer::theme::ColorfulTheme; use etcetera::{choose_base_strategy, BaseStrategy}; use ssh_key::PublicKey; use std::fs; @@ -232,6 +233,7 @@ pub async fn handle_add_ssh_key( client: &BasilicaClient, name: Option, file: Option, + force: bool, ) -> Result<(), CliError> { // Step 1: Get SSH public key file path let key_path = match file { @@ -249,41 +251,51 @@ pub async fn handle_add_ssh_key( let keys = find_ssh_public_keys(); if keys.is_empty() { - // No keys found, generate one automatically - generate_ssh_key().await? + // No local keys to choose from. Generate one in interactive + // mode; in non-interactive mode refuse to mutate the user's + // ~/.ssh directory silently and tell them to pass --file. + match crate::interactive::gate::current() { + crate::interactive::gate::Interactivity::NonInteractive => { + return Err(CliError::MissingInput { + field: "ssh_public_key_path".into(), + hint: "No SSH public keys found under ~/.ssh. Pass --file to an existing SSH public key, e.g. ~/.ssh/id_ed25519.pub.".into(), + choices: crate::interactive::gate::Choices::default(), + }); + } + crate::interactive::gate::Interactivity::Interactive => { + generate_ssh_key().await? + } + } } else { - // Show interactive selection with existing keys - use dialoguer::Select; - - let options: Vec = keys + // Build SelectItems for the gate; in non-interactive mode the + // gate returns MissingInput with the candidate paths so an + // agent can pass one back as --file. + let items: Vec> = keys .iter() - .map(|path| { - path.file_name() + .map(|p| SelectItem { + item: p, + id: p.display().to_string(), + label: p + .file_name() .and_then(|n| n.to_str()) .unwrap_or("unknown") - .to_string() + .to_string(), + meta: serde_json::Map::new(), }) .collect(); - // Run interactive selection in a blocking context - let selection = tokio::task::spawn_blocking(move || { - let theme = ColorfulTheme::default(); - Select::with_theme(&theme) - .with_prompt("Select an SSH key to register") - .items(&options) - .default(0) - .interact() - }) - .await - .map_err(|e| CliError::Internal(color_eyre::eyre::eyre!("Task join error: {}", e)))? - .map_err(|e| CliError::Internal(e.into()))?; - - let path = &keys[selection]; + let idx = ask_select( + "ssh_public_key_path", + &items, + "Pass --file to choose an SSH public key file (e.g. ~/.ssh/id_ed25519.pub)", + )?; + + let path = keys[idx].clone(); println!( "{}", style(format!("Selected SSH public key: {}", path.display())).cyan() ); - path.clone() + path } } }; @@ -303,7 +315,7 @@ pub async fn handle_add_ssh_key( ))); } - // Step 3: Get name interactively if not provided + // Step 3: Get name (from flag, or via the gate) let name = match name { Some(n) => { if n.trim().is_empty() { @@ -319,17 +331,11 @@ pub async fn handle_add_ssh_key( n } None => { - let input: String = tokio::task::spawn_blocking(move || { - let theme = ColorfulTheme::default(); - Input::with_theme(&theme) - .with_prompt("Enter a name for this SSH key") - .default("default".to_string()) - .interact_text() - }) - .await - .map_err(|e| CliError::Internal(color_eyre::eyre::eyre!("Task join error: {}", e)))? - .map_err(|e| CliError::Internal(e.into()))?; - + let input = ask_text( + "name", + Some("default"), + "Pass --name