Files
optimclaw/src/setup/prompts.rs
T
5725a62c83 fix: onboarding errors reset flow and remote server auth (#185, #186) (#248)
* fix: incremental settings persistence and remote server auth (#185, #186)

Persist settings after each wizard step so failures don't lose prior
progress. Load existing settings on re-run to recover from partial
onboarding. Add manual token paste option for remote/headless servers
where browser OAuth is unreachable, and support IRONCLAW_OAUTH_CALLBACK_URL
for custom callback URLs. Color prompt output (green/red/blue prefixes).

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: replace session token paste with API key entry, address PR review

Replace option 4 in NEAR AI auth menu from session token paste to NEAR
AI Cloud API key entry (cloud.near.ai). Also address all PR review
feedback: restrict .env file permissions to 0o600, mask API key input
with secret_input, fix libsql loaded flag in try_load_existing_settings,
add ENV_MUTEX to oauth_defaults tests, and add NEARAI_API_KEY to secrets
injection.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: deduplicate keys in upsert_bootstrap_var

When the .env file contains duplicate keys (e.g. from manual editing),
only write the replacement once and skip subsequent duplicates.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: NEARAI_SESSION_TOKEN env var takes precedence over file-based tokens

Hosting providers inject session tokens via env var and expect them to
be used directly. Previously the env var was only picked up when no
session file existed and was treated as a legacy migration. Now the env
var always wins, without persisting to disk.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* docs: distinguish NEAR AI Chat and NEAR AI Cloud providers

Split documentation into two clearly named modes:
- NEAR AI Chat: Responses API at private.near.ai, session token auth
- NEAR AI Cloud: Chat Completions API at cloud-api.near.ai, API key auth

Update default base URLs so each mode points to its correct endpoint.
Update .env.example, deploy/env.example, CLAUDE.md, setup spec, and
code comments across config/llm.rs, nearai.rs, nearai_chat.rs, mod.rs.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: wizard recovery ordering — load DB before persist, fresh choices win

Previously, persist_after_step() ran after Step 1 but before
try_load_existing_settings(), bulk-upserting defaults that clobbered
prior settings. Additionally, merge_from gave stale DB values
precedence over fresh Step 1 choices.

Fix: snapshot Step 1 settings, load DB, then re-apply the snapshot.
This ensures prior progress (steps 2-7) is recovered while fresh
Step 1 choices override stale DB values.

Add two tests verifying wizard recovery merge ordering.

Addresses PR review comments from Copilot on wizard.rs:150,
wizard.rs:1607, and wizard.rs:1626.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* style: fix rustfmt formatting in config/llm.rs

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* style: collapse nested if per clippy collapsible_if lint

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: use print_success for API key confirmation, fix menu spacing

- Use print_success() for colored output consistency in api_key_login
- Fix box-drawing alignment: options 1-2 had an extra trailing space

Co-Authored-By: Claude Opus 4.6 <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-02-20 08:02:22 +00:00

385 lines
10 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Interactive prompt utilities for the setup wizard.
//!
//! Provides terminal UI components for:
//! - Single selection menus
//! - Multi-select with toggles
//! - Password/secret input (hidden)
//! - Yes/no confirmations
//! - Styled headers and step indicators
use std::io::{self, Write};
use crossterm::{
cursor,
event::{self, Event, KeyCode, KeyEvent, KeyModifiers},
execute,
style::{Color, Print, ResetColor, SetForegroundColor},
terminal::{self, ClearType},
};
use secrecy::SecretString;
/// Display a numbered menu and get user selection.
///
/// Returns the index (0-based) of the selected option.
/// Pressing Enter without input selects the first option (index 0).
///
/// # Example
///
/// ```ignore
/// let choice = select_one("Choose an option:", &["Option A", "Option B"]);
/// ```
pub fn select_one(prompt: &str, options: &[&str]) -> io::Result<usize> {
let mut stdout = io::stdout();
// Print prompt
writeln!(stdout, "{}", prompt)?;
writeln!(stdout)?;
// Print options
for (i, option) in options.iter().enumerate() {
writeln!(stdout, " [{}] {}", i + 1, option)?;
}
writeln!(stdout)?;
loop {
print!("> ");
stdout.flush()?;
let mut input = String::new();
io::stdin().read_line(&mut input)?;
let input = input.trim();
// Handle empty input as first option
if input.is_empty() {
return Ok(0);
}
// Parse number
if let Ok(num) = input.parse::<usize>()
&& num >= 1
&& num <= options.len()
{
return Ok(num - 1);
}
writeln!(
stdout,
"Invalid choice. Please enter a number 1-{}.",
options.len()
)?;
}
}
/// Multi-select with space to toggle, enter to confirm.
///
/// `options` is a slice of (label, initially_selected) tuples.
/// Returns indices of selected options.
///
/// # Example
///
/// ```ignore
/// let selected = select_many("Select channels:", &[
/// ("CLI/TUI", true),
/// ("HTTP webhook", false),
/// ("Telegram", false),
/// ])?;
/// ```
pub fn select_many(prompt: &str, options: &[(&str, bool)]) -> io::Result<Vec<usize>> {
if options.is_empty() {
return Ok(vec![]);
}
let mut stdout = io::stdout();
let mut selected: Vec<bool> = options.iter().map(|(_, s)| *s).collect();
let mut cursor_pos = 0;
terminal::enable_raw_mode()?;
execute!(stdout, cursor::Hide)?;
let result = (|| {
loop {
// Clear and redraw
execute!(stdout, cursor::MoveToColumn(0))?;
writeln!(stdout, "{}\r", prompt)?;
writeln!(stdout, "\r")?;
writeln!(
stdout,
" (Use arrow keys to navigate, space to toggle, enter to confirm)\r"
)?;
writeln!(stdout, "\r")?;
for (i, (label, _)) in options.iter().enumerate() {
let checkbox = if selected[i] { "[x]" } else { "[ ]" };
let prefix = if i == cursor_pos { ">" } else { " " };
if i == cursor_pos {
execute!(stdout, SetForegroundColor(Color::Cyan))?;
writeln!(stdout, " {} {} {}\r", prefix, checkbox, label)?;
execute!(stdout, ResetColor)?;
} else {
writeln!(stdout, " {} {} {}\r", prefix, checkbox, label)?;
}
}
stdout.flush()?;
// Read key
if let Event::Key(KeyEvent {
code, modifiers, ..
}) = event::read()?
{
match code {
KeyCode::Up => {
cursor_pos = cursor_pos.saturating_sub(1);
}
KeyCode::Down => {
if cursor_pos < options.len() - 1 {
cursor_pos += 1;
}
}
KeyCode::Char(' ') => {
selected[cursor_pos] = !selected[cursor_pos];
}
KeyCode::Enter => {
break;
}
KeyCode::Char('c') if modifiers.contains(KeyModifiers::CONTROL) => {
return Err(io::Error::new(io::ErrorKind::Interrupted, "Ctrl-C"));
}
_ => {}
}
// Move cursor up to redraw
execute!(
stdout,
cursor::MoveUp((options.len() + 4) as u16),
terminal::Clear(ClearType::FromCursorDown)
)?;
}
}
Ok(())
})();
// Cleanup
execute!(stdout, cursor::Show)?;
terminal::disable_raw_mode()?;
writeln!(stdout)?;
result?;
Ok(selected
.iter()
.enumerate()
.filter_map(|(i, &s)| if s { Some(i) } else { None })
.collect())
}
/// Password/secret input with hidden characters.
///
/// # Example
///
/// ```ignore
/// let token = secret_input("Bot token")?;
/// ```
pub fn secret_input(prompt: &str) -> io::Result<SecretString> {
let mut stdout = io::stdout();
print!("{}: ", prompt);
stdout.flush()?;
terminal::enable_raw_mode()?;
let result = read_secret_line();
terminal::disable_raw_mode()?;
writeln!(stdout)?;
result
}
fn read_secret_line() -> io::Result<SecretString> {
let mut input = String::new();
let mut stdout = io::stdout();
loop {
if let Event::Key(KeyEvent {
code, modifiers, ..
}) = event::read()?
{
match code {
KeyCode::Enter => {
break;
}
KeyCode::Backspace => {
if !input.is_empty() {
input.pop();
execute!(stdout, Print("\x08 \x08"))?;
stdout.flush()?;
}
}
KeyCode::Char('c') if modifiers.contains(KeyModifiers::CONTROL) => {
return Err(io::Error::new(io::ErrorKind::Interrupted, "Ctrl-C"));
}
KeyCode::Char(c) => {
input.push(c);
execute!(stdout, Print('*'))?;
stdout.flush()?;
}
_ => {}
}
}
}
Ok(SecretString::from(input))
}
/// Yes/no confirmation prompt.
///
/// # Example
///
/// ```ignore
/// if confirm("Enable Telegram channel?", false)? {
/// // ...
/// }
/// ```
pub fn confirm(prompt: &str, default: bool) -> io::Result<bool> {
let mut stdout = io::stdout();
let hint = if default { "[Y/n]" } else { "[y/N]" };
print!("{} {} ", prompt, hint);
stdout.flush()?;
let mut input = String::new();
io::stdin().read_line(&mut input)?;
let input = input.trim().to_lowercase();
Ok(match input.as_str() {
"" => default,
"y" | "yes" => true,
"n" | "no" => false,
_ => default,
})
}
/// Print a styled header box.
///
/// # Example
///
/// ```ignore
/// print_header("IronClaw Setup Wizard");
/// ```
pub fn print_header(text: &str) {
let width = text.len() + 4;
let border = "─".repeat(width);
println!();
println!("╭{}╮", border);
println!("│ {} │", text);
println!("╰{}╯", border);
println!();
}
/// Print a step indicator.
///
/// # Example
///
/// ```ignore
/// print_step(1, 3, "NEAR AI Authentication");
/// // Output: Step 1/3: NEAR AI Authentication
/// // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
/// ```
pub fn print_step(current: usize, total: usize, name: &str) {
println!("Step {}/{}: {}", current, total, name);
println!("{}", "━".repeat(32));
println!();
}
/// Print a success message with green checkmark.
pub fn print_success(message: &str) {
let mut stdout = io::stdout();
let _ = execute!(stdout, SetForegroundColor(Color::Green));
print!("✓");
let _ = execute!(stdout, ResetColor);
println!(" {}", message);
}
/// Print an error message with red X.
pub fn print_error(message: &str) {
let mut stderr = io::stderr();
let _ = execute!(stderr, SetForegroundColor(Color::Red));
eprint!("✗");
let _ = execute!(stderr, ResetColor);
eprintln!(" {}", message);
}
/// Print an info message with blue info icon.
pub fn print_info(message: &str) {
let mut stdout = io::stdout();
let _ = execute!(stdout, SetForegroundColor(Color::Blue));
print!("");
let _ = execute!(stdout, ResetColor);
println!(" {}", message);
}
/// Read a simple line of input with a prompt.
pub fn input(prompt: &str) -> io::Result<String> {
let mut stdout = io::stdout();
print!("{}: ", prompt);
stdout.flush()?;
let mut input = String::new();
io::stdin().read_line(&mut input)?;
Ok(input.trim().to_string())
}
/// Read an optional line of input (empty returns None).
pub fn optional_input(prompt: &str, hint: Option<&str>) -> io::Result<Option<String>> {
let mut stdout = io::stdout();
if let Some(h) = hint {
print!("{} ({}): ", prompt, h);
} else {
print!("{}: ", prompt);
}
stdout.flush()?;
let mut input = String::new();
io::stdin().read_line(&mut input)?;
let input = input.trim();
if input.is_empty() {
Ok(None)
} else {
Ok(Some(input.to_string()))
}
}
#[cfg(test)]
mod tests {
// Interactive tests are difficult to unit test, but we can test the non-interactive parts.
#[test]
fn test_header_length_calculation() {
// Just verify it doesn't panic with various inputs
super::print_header("Test");
super::print_header("A longer header text");
super::print_header("");
}
#[test]
fn test_step_indicator() {
super::print_step(1, 3, "Test Step");
super::print_step(3, 3, "Final Step");
}
#[test]
fn test_print_functions_do_not_panic() {
super::print_success("operation completed");
super::print_error("something went wrong");
super::print_info("here is some information");
// Also test with empty strings
super::print_success("");
super::print_error("");
super::print_info("");
}
}