From c7f0e8014de67912650bdd6e6f96198a43f51893 Mon Sep 17 00:00:00 2001 From: Illia Polosukhin Date: Wed, 4 Feb 2026 09:42:41 -0800 Subject: [PATCH] Add interactive setup wizard for first-run configuration Introduces `near-agent setup` command that guides users through: - NEAR AI authentication (reuses existing OAuth flow) - Model selection (fetches from API or shows defaults) - Channel configuration (HTTP webhook, Telegram) Features: - First-run detection: auto-runs wizard if no session exists - Respects existing settings: shows current model with keep/change option - Saves channel secrets to ~/.near-agent/secrets/ with 0600 permissions - Validates Telegram bot tokens via API before saving Also fixes default NEARAI_BASE_URL to use cloud-api.near.ai (api.near.ai returns 410 Gone). Co-Authored-By: Claude Opus 4.5 --- src/cli/mod.rs | 16 ++ src/config.rs | 2 +- src/lib.rs | 1 + src/main.rs | 39 ++++- src/settings.rs | 30 ++++ src/setup/channels.rs | 284 +++++++++++++++++++++++++++++++++ src/setup/mod.rs | 23 +++ src/setup/prompts.rs | 355 ++++++++++++++++++++++++++++++++++++++++++ src/setup/wizard.rs | 347 +++++++++++++++++++++++++++++++++++++++++ 9 files changed, 1093 insertions(+), 4 deletions(-) create mode 100644 src/setup/channels.rs create mode 100644 src/setup/mod.rs create mode 100644 src/setup/prompts.rs create mode 100644 src/setup/wizard.rs diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 8e277796..adc0a009 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -2,6 +2,7 @@ //! //! Provides subcommands for: //! - Running the agent (`run`) +//! - Interactive setup wizard (`setup`) //! - Managing WASM tools (`tool install`, `tool list`, `tool remove`) //! - Managing secrets (`secret set`, `secret list`, `secret remove`) @@ -38,6 +39,10 @@ pub struct Cli { /// Configuration file path (optional, uses env vars by default) #[arg(short, long, global = true)] pub config: Option, + + /// Skip first-run setup check + #[arg(long, global = true)] + pub no_setup: bool, } #[derive(Subcommand, Debug)] @@ -45,6 +50,17 @@ pub enum Command { /// Run the agent (default if no subcommand given) Run, + /// Interactive setup wizard + Setup { + /// Skip authentication (use existing session) + #[arg(long)] + skip_auth: bool, + + /// Reconfigure channels only + #[arg(long)] + channels_only: bool, + }, + /// Manage WASM tools #[command(subcommand)] Tool(ToolCommand), diff --git a/src/config.rs b/src/config.rs index b2b35529..1d9e8d07 100644 --- a/src/config.rs +++ b/src/config.rs @@ -149,7 +149,7 @@ impl LlmConfig { .to_string() }), base_url: optional_env("NEARAI_BASE_URL")? - .unwrap_or_else(|| "https://api.near.ai".to_string()), + .unwrap_or_else(|| "https://cloud-api.near.ai".to_string()), auth_base_url: optional_env("NEARAI_AUTH_URL")? .unwrap_or_else(|| "https://private.near.ai".to_string()), session_path: optional_env("NEARAI_SESSION_PATH")? diff --git a/src/lib.rs b/src/lib.rs index b90c7be5..3541d8a5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -51,6 +51,7 @@ pub mod llm; pub mod safety; pub mod secrets; pub mod settings; +pub mod setup; pub mod tools; pub mod workspace; diff --git a/src/main.rs b/src/main.rs index f93084fa..1d5f8cd4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -16,6 +16,8 @@ use near_agent::{ history::Store, llm::{SessionConfig, create_llm_provider, create_session_manager}, safety::SafetyLayer, + settings::Settings, + setup::{SetupConfig, SetupWizard}, tools::{ ToolRegistry, wasm::{WasmToolLoader, WasmToolRuntime}, @@ -39,14 +41,45 @@ async fn main() -> anyhow::Result<()> { return run_tool_command(tool_cmd.clone()).await; } + Some(Command::Setup { + skip_auth, + channels_only, + }) => { + // Load .env before running setup wizard + let _ = dotenvy::dotenv(); + + // Run setup wizard + let config = SetupConfig { + skip_auth: *skip_auth, + channels_only: *channels_only, + }; + let mut wizard = SetupWizard::with_config(config); + wizard.run().await?; + return Ok(()); + } None | Some(Command::Run) => { // Continue to run agent } } - // Load configuration first (before any logging setup) - // so we can do auth before TUI starts - let _ = dotenvy::dotenv(); // Load .env if present + // Load .env if present + let _ = dotenvy::dotenv(); + + // First-run detection: if setup hasn't been completed and user didn't skip it, + // automatically run the setup wizard + if !cli.no_setup { + let settings = Settings::load(); + let session_path = near_agent::llm::session::default_session_path(); + + if !settings.setup_completed && !session_path.exists() { + println!("First run detected. Starting setup wizard..."); + println!(); + let mut wizard = SetupWizard::new(); + wizard.run().await?; + } + } + + // Load configuration (after potential setup) let config = Config::from_env()?; // Initialize session manager and authenticate BEFORE TUI setup diff --git a/src/settings.rs b/src/settings.rs index 651ab513..3846bf3e 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -12,6 +12,34 @@ pub struct Settings { /// Currently selected model. #[serde(default)] pub selected_model: Option, + + /// Whether setup wizard has been completed. + #[serde(default)] + pub setup_completed: bool, + + /// Channel configuration. + #[serde(default)] + pub channels: ChannelSettings, +} + +/// Channel-specific settings. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct ChannelSettings { + /// Whether HTTP webhook channel is enabled. + #[serde(default)] + pub http_enabled: bool, + + /// HTTP webhook port (if enabled). + #[serde(default)] + pub http_port: Option, + + /// Whether Telegram channel is enabled. + #[serde(default)] + pub telegram_enabled: bool, + + /// Whether Slack channel is enabled. + #[serde(default)] + pub slack_enabled: bool, } impl Settings { @@ -80,6 +108,7 @@ mod tests { let settings = Settings { selected_model: Some("claude-3-5-sonnet-20241022".to_string()), + ..Default::default() }; settings.save_to(&path).unwrap(); @@ -101,6 +130,7 @@ mod tests { let settings = Settings { selected_model: Some("my-model".to_string()), + ..Default::default() }; assert_eq!(settings.model_or("default-model"), "my-model".to_string()); } diff --git a/src/setup/channels.rs b/src/setup/channels.rs new file mode 100644 index 00000000..c8b753f4 --- /dev/null +++ b/src/setup/channels.rs @@ -0,0 +1,284 @@ +//! Channel-specific setup flows. +//! +//! Each channel (Telegram, HTTP, etc.) has its own setup function that: +//! 1. Displays setup instructions +//! 2. Collects configuration (tokens, ports, etc.) +//! 3. Validates the configuration +//! 4. Saves secrets securely + +use std::io; +use std::path::PathBuf; + +use reqwest::Client; +use secrecy::{ExposeSecret, SecretString}; +use serde::{Deserialize, Serialize}; + +use crate::setup::prompts::{ + confirm, optional_input, print_error, print_info, print_success, secret_input, +}; + +/// Result of Telegram setup. +#[derive(Debug, Clone)] +pub struct TelegramSetupResult { + pub enabled: bool, + pub bot_username: Option, +} + +/// Telegram Bot API response for getMe. +#[derive(Debug, Deserialize)] +struct TelegramGetMeResponse { + ok: bool, + result: Option, +} + +#[derive(Debug, Deserialize)] +struct TelegramUser { + username: Option, + #[allow(dead_code)] + first_name: String, +} + +/// Set up Telegram bot channel. +/// +/// Guides the user through: +/// 1. Creating a bot with @BotFather +/// 2. Entering the bot token +/// 3. Validating the token +/// 4. Saving the token to secrets +pub async fn setup_telegram() -> io::Result { + println!("Telegram Setup:"); + println!(); + print_info("To create a Telegram bot:"); + print_info("1. Open Telegram and message @BotFather"); + print_info("2. Send /newbot and follow the prompts"); + print_info("3. Copy the bot token (looks like 123456:ABC-DEF...)"); + println!(); + + let token = secret_input("Bot token (from @BotFather)")?; + + // Validate the token + print_info("Validating bot token..."); + + match validate_telegram_token(&token).await { + Ok(username) => { + print_success(&format!( + "Bot validated: @{}", + username.as_deref().unwrap_or("unknown") + )); + + // Save to secrets file + if let Err(e) = save_channel_secret("telegram_bot_token", &token) { + print_error(&format!("Failed to save token: {}", e)); + return Err(io::Error::new(io::ErrorKind::Other, e.to_string())); + } + + print_success("Token saved to ~/.near-agent/secrets/telegram_bot_token"); + + Ok(TelegramSetupResult { + enabled: true, + bot_username: username, + }) + } + Err(e) => { + print_error(&format!("Token validation failed: {}", e)); + + if confirm("Try again?", true)? { + // Recursive retry + Box::pin(setup_telegram()).await + } else { + Ok(TelegramSetupResult { + enabled: false, + bot_username: None, + }) + } + } + } +} + +/// Validate a Telegram bot token by calling the getMe API. +/// +/// Returns the bot's username if valid. +pub async fn validate_telegram_token(token: &SecretString) -> Result, String> { + let client = Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .build() + .map_err(|e| format!("Failed to create HTTP client: {}", e))?; + + let url = format!( + "https://api.telegram.org/bot{}/getMe", + token.expose_secret() + ); + + let response = client + .get(&url) + .send() + .await + .map_err(|e| format!("Request failed: {}", e))?; + + if !response.status().is_success() { + return Err(format!("API returned status {}", response.status())); + } + + let body: TelegramGetMeResponse = response + .json() + .await + .map_err(|e| format!("Failed to parse response: {}", e))?; + + if body.ok { + Ok(body.result.and_then(|u| u.username)) + } else { + Err("Telegram API returned error".to_string()) + } +} + +/// Result of HTTP webhook setup. +#[derive(Debug, Clone)] +pub struct HttpSetupResult { + pub enabled: bool, + pub port: u16, + pub host: String, +} + +/// Set up HTTP webhook channel. +pub fn setup_http() -> io::Result { + println!("HTTP Webhook Setup:"); + println!(); + print_info("The HTTP webhook allows external services to send messages to the agent."); + println!(); + + let port_str = optional_input("Port", Some("default: 8080"))?; + let port: u16 = + port_str.as_deref().unwrap_or("8080").parse().map_err(|e| { + io::Error::new(io::ErrorKind::InvalidInput, format!("Invalid port: {}", e)) + })?; + + if port < 1024 { + print_info("Note: Ports below 1024 may require root privileges"); + } + + let host = + optional_input("Host", Some("default: 0.0.0.0"))?.unwrap_or_else(|| "0.0.0.0".to_string()); + + // Generate a webhook secret + if confirm("Generate a webhook secret for authentication?", true)? { + let secret = generate_webhook_secret(); + save_channel_secret("http_webhook_secret", &SecretString::from(secret.clone()))?; + print_success("Webhook secret generated and saved"); + print_info(&format!( + "Secret: {} (store this for your webhook clients)", + secret + )); + } + + print_success(&format!("HTTP webhook will listen on {}:{}", host, port)); + + Ok(HttpSetupResult { + enabled: true, + port, + host, + }) +} + +/// Generate a random webhook secret. +fn generate_webhook_secret() -> String { + use rand::RngCore; + let mut rng = rand::thread_rng(); + let mut bytes = [0u8; 32]; + rng.fill_bytes(&mut bytes); + // Encode as hex manually (avoid adding hex crate dependency) + bytes.iter().map(|b| format!("{:02x}", b)).collect() +} + +/// Get the secrets directory path. +pub fn secrets_dir() -> PathBuf { + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(".near-agent") + .join("secrets") +} + +/// Save a channel secret to the secrets directory. +/// +/// Secrets are stored as individual files with restricted permissions. +pub fn save_channel_secret(name: &str, value: &SecretString) -> io::Result<()> { + let dir = secrets_dir(); + std::fs::create_dir_all(&dir)?; + + let path = dir.join(name); + + // Write the secret + std::fs::write(&path, value.expose_secret())?; + + // Set restrictive permissions on Unix + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = std::fs::metadata(&path)?.permissions(); + perms.set_mode(0o600); // Owner read/write only + std::fs::set_permissions(&path, perms)?; + } + + Ok(()) +} + +/// Load a channel secret from the secrets directory. +#[allow(dead_code)] +pub fn load_channel_secret(name: &str) -> io::Result> { + let path = secrets_dir().join(name); + + if !path.exists() { + return Ok(None); + } + + let contents = std::fs::read_to_string(&path)?; + Ok(Some(SecretString::from(contents.trim().to_string()))) +} + +/// Check if a channel secret exists. +#[allow(dead_code)] +pub fn has_channel_secret(name: &str) -> bool { + secrets_dir().join(name).exists() +} + +/// Delete a channel secret. +#[allow(dead_code)] +pub fn delete_channel_secret(name: &str) -> io::Result { + let path = secrets_dir().join(name); + if path.exists() { + std::fs::remove_file(&path)?; + Ok(true) + } else { + Ok(false) + } +} + +/// Channel secrets configuration (persisted to settings). +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct ChannelSecretsConfig { + /// Whether Telegram has a saved token. + pub telegram_configured: bool, + /// Whether HTTP webhook has a saved secret. + pub http_configured: bool, +} + +impl ChannelSecretsConfig { + /// Load from the secrets directory. + #[allow(dead_code)] + pub fn from_secrets_dir() -> Self { + Self { + telegram_configured: has_channel_secret("telegram_bot_token"), + http_configured: has_channel_secret("http_webhook_secret"), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_generate_webhook_secret() { + let secret = generate_webhook_secret(); + assert_eq!(secret.len(), 64); // 32 bytes = 64 hex chars + } +} diff --git a/src/setup/mod.rs b/src/setup/mod.rs new file mode 100644 index 00000000..34417f82 --- /dev/null +++ b/src/setup/mod.rs @@ -0,0 +1,23 @@ +//! Interactive setup wizard for NEAR Agent. +//! +//! Provides a guided setup experience for: +//! - NEAR AI authentication +//! - Model selection +//! - Channel configuration (HTTP, Telegram, etc.) +//! +//! # Example +//! +//! ```ignore +//! use near_agent::setup::SetupWizard; +//! +//! let mut wizard = SetupWizard::new(); +//! wizard.run().await?; +//! ``` + +mod channels; +mod prompts; +mod wizard; + +pub use channels::{setup_http, setup_telegram, validate_telegram_token}; +pub use prompts::{confirm, print_header, print_step, secret_input, select_many, select_one}; +pub use wizard::{SetupConfig, SetupWizard}; diff --git a/src/setup/prompts.rs b/src/setup/prompts.rs new file mode 100644 index 00000000..7a581cef --- /dev/null +++ b/src/setup/prompts.rs @@ -0,0 +1,355 @@ +//! 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. +/// +/// # Example +/// +/// ```ignore +/// let choice = select_one("Choose an option:", &["Option A", "Option B"]); +/// ``` +pub fn select_one(prompt: &str, options: &[&str]) -> io::Result { + 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::() { + if 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> { + let mut stdout = io::stdout(); + let mut selected: Vec = 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 { + 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 { + 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 { + 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("NEAR Agent 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 checkmark. +pub fn print_success(message: &str) { + println!("✓ {}", message); +} + +/// Print an error message. +pub fn print_error(message: &str) { + eprintln!("✗ {}", message); +} + +/// Print an info message. +pub fn print_info(message: &str) { + println!(" {}", message); +} + +/// Read a simple line of input with a prompt. +pub fn input(prompt: &str) -> io::Result { + 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> { + 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"); + } +} diff --git a/src/setup/wizard.rs b/src/setup/wizard.rs new file mode 100644 index 00000000..c41ac592 --- /dev/null +++ b/src/setup/wizard.rs @@ -0,0 +1,347 @@ +//! Main setup wizard orchestration. +//! +//! The wizard guides users through: +//! 1. NEAR AI authentication +//! 2. Model selection +//! 3. Channel configuration + +use std::sync::Arc; + +use crate::llm::{SessionConfig, SessionManager}; +use crate::settings::Settings; +use crate::setup::channels::{setup_http, setup_telegram}; +use crate::setup::prompts::{ + input, print_header, print_info, print_step, print_success, select_many, select_one, +}; + +/// Setup wizard error. +#[derive(Debug, thiserror::Error)] +pub enum SetupError { + #[error("I/O error: {0}")] + Io(#[from] std::io::Error), + + #[error("Authentication error: {0}")] + Auth(String), + + #[error("User cancelled")] + Cancelled, +} + +/// Setup wizard configuration. +#[derive(Debug, Clone, Default)] +pub struct SetupConfig { + /// Skip authentication step (use existing session). + pub skip_auth: bool, + /// Only reconfigure channels. + pub channels_only: bool, +} + +/// Interactive setup wizard for NEAR Agent. +pub struct SetupWizard { + config: SetupConfig, + settings: Settings, + session_manager: Option>, +} + +impl SetupWizard { + /// Create a new setup wizard. + pub fn new() -> Self { + Self { + config: SetupConfig::default(), + settings: Settings::load(), + session_manager: None, + } + } + + /// Create a wizard with custom configuration. + pub fn with_config(config: SetupConfig) -> Self { + Self { + config, + settings: Settings::load(), + session_manager: None, + } + } + + /// Set the session manager (for reusing existing auth). + pub fn with_session(mut self, session: Arc) -> Self { + self.session_manager = Some(session); + self + } + + /// Run the setup wizard. + pub async fn run(&mut self) -> Result<(), SetupError> { + print_header("NEAR Agent Setup Wizard"); + + let total_steps = if self.config.channels_only { 1 } else { 3 }; + let mut current_step = 1; + + // Step 1: Authentication (unless skipped or channels-only) + if !self.config.channels_only && !self.config.skip_auth { + print_step(current_step, total_steps, "NEAR AI Authentication"); + self.step_authentication().await?; + current_step += 1; + } + + // Step 2: Model selection (unless channels-only) + if !self.config.channels_only { + print_step(current_step, total_steps, "Model Selection"); + self.step_model_selection().await?; + current_step += 1; + } + + // Step 3: Channel configuration + print_step(current_step, total_steps, "Channel Configuration"); + self.step_channels().await?; + + // Save settings and print summary + self.save_and_summarize()?; + + Ok(()) + } + + /// Step 1: NEAR AI authentication. + async fn step_authentication(&mut self) -> Result<(), SetupError> { + // Check if we already have a session + if let Some(ref session) = self.session_manager { + if session.has_token().await { + print_info("Existing session found. Validating..."); + match session.ensure_authenticated().await { + Ok(()) => { + print_success("Session valid"); + return Ok(()); + } + Err(e) => { + print_info(&format!("Session invalid: {}. Re-authenticating...", e)); + } + } + } + } + + // Create session manager if we don't have one + let session = if let Some(ref s) = self.session_manager { + Arc::clone(s) + } else { + let config = SessionConfig::default(); + Arc::new(SessionManager::new(config)) + }; + + // Trigger authentication flow + session + .ensure_authenticated() + .await + .map_err(|e| SetupError::Auth(e.to_string()))?; + + self.session_manager = Some(session); + Ok(()) + } + + /// Step 2: Model selection. + async fn step_model_selection(&mut self) -> Result<(), SetupError> { + // Show current model if already configured + if let Some(ref current) = self.settings.selected_model { + print_info(&format!("Current model: {}", current)); + println!(); + + let options = ["Keep current model", "Change model"]; + let choice = select_one("What would you like to do?", &options)?; + + if choice == 0 { + print_success(&format!("Keeping {}", current)); + return Ok(()); + } + } + + // Try to fetch available models + let models = if let Some(ref session) = self.session_manager { + self.fetch_available_models(session).await + } else { + vec![] + }; + + // Default models if we couldn't fetch + let default_models = [ + ( + "fireworks::accounts/fireworks/models/llama4-maverick-instruct-basic", + "Llama 4 Maverick (default, fast)", + ), + ( + "anthropic::claude-sonnet-4-20250514", + "Claude Sonnet 4 (best quality)", + ), + ("openai::gpt-4o", "GPT-4o"), + ]; + + println!("Available models:"); + println!(); + + let options: Vec<&str> = if models.is_empty() { + default_models.iter().map(|(_, desc)| *desc).collect() + } else { + models.iter().map(|m| m.as_str()).collect() + }; + + // Add custom option + let mut all_options = options.clone(); + all_options.push("Custom model ID"); + + let choice = select_one("Select a model:", &all_options)?; + + let selected_model = if choice == all_options.len() - 1 { + // Custom model + input("Enter model ID")? + } else if models.is_empty() { + default_models[choice].0.to_string() + } else { + models[choice].clone() + }; + + self.settings.selected_model = Some(selected_model.clone()); + print_success(&format!("Selected {}", selected_model)); + + Ok(()) + } + + /// Fetch available models from the API. + async fn fetch_available_models(&self, session: &Arc) -> Vec { + // Create a temporary LLM provider to fetch models + use crate::config::LlmConfig; + use crate::llm::create_llm_provider; + + // Read base URL from env, fallback to cloud-api.near.ai + let base_url = std::env::var("NEARAI_BASE_URL") + .unwrap_or_else(|_| "https://cloud-api.near.ai".to_string()); + let auth_base_url = std::env::var("NEARAI_AUTH_URL") + .unwrap_or_else(|_| "https://private.near.ai".to_string()); + + let config = LlmConfig { + nearai: crate::config::NearAiConfig { + model: "dummy".to_string(), // Not used for listing + base_url, + auth_base_url, + session_path: crate::llm::session::default_session_path(), + api_mode: crate::config::NearAiApiMode::Responses, + api_key: None, + }, + }; + + match create_llm_provider(&config, Arc::clone(session)) { + Ok(provider) => match provider.list_models().await { + Ok(models) => models, + Err(e) => { + print_info(&format!("Could not fetch models: {}. Using defaults.", e)); + vec![] + } + }, + Err(e) => { + print_info(&format!( + "Could not initialize provider: {}. Using defaults.", + e + )); + vec![] + } + } + } + + /// Step 3: Channel configuration. + async fn step_channels(&mut self) -> Result<(), SetupError> { + let options = [ + ("CLI/TUI (always enabled)", true), + ("HTTP webhook", self.settings.channels.http_enabled), + ("Telegram", self.settings.channels.telegram_enabled), + ]; + + let selected = select_many("Which channels do you want to enable?", &options)?; + + // HTTP is index 1 + if selected.contains(&1) { + println!(); + let result = setup_http()?; + self.settings.channels.http_enabled = result.enabled; + self.settings.channels.http_port = Some(result.port); + } else { + self.settings.channels.http_enabled = false; + } + + // Telegram is index 2 + if selected.contains(&2) { + println!(); + let result = setup_telegram().await?; + self.settings.channels.telegram_enabled = result.enabled; + } else { + self.settings.channels.telegram_enabled = false; + } + + Ok(()) + } + + /// Save settings and print summary. + fn save_and_summarize(&mut self) -> Result<(), SetupError> { + self.settings.setup_completed = true; + + self.settings.save().map_err(|e| { + SetupError::Io(std::io::Error::new( + std::io::ErrorKind::Other, + format!("Failed to save settings: {}", e), + )) + })?; + + println!(); + print_success("Configuration saved to ~/.near-agent/"); + println!(); + + // Print summary + println!("Configuration Summary:"); + println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); + + if let Some(ref model) = self.settings.selected_model { + println!(" Model: {}", model); + } + + println!(" Channels:"); + println!(" - CLI/TUI: enabled"); + + if self.settings.channels.http_enabled { + let port = self.settings.channels.http_port.unwrap_or(8080); + println!(" - HTTP: enabled (port {})", port); + } + + if self.settings.channels.telegram_enabled { + println!(" - Telegram: enabled"); + } + + println!(); + println!("To start the agent, run:"); + println!(" near-agent"); + println!(); + + Ok(()) + } +} + +impl Default for SetupWizard { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_wizard_creation() { + let wizard = SetupWizard::new(); + assert!(!wizard.config.skip_auth); + assert!(!wizard.config.channels_only); + } + + #[test] + fn test_wizard_with_config() { + let config = SetupConfig { + skip_auth: true, + channels_only: false, + }; + let wizard = SetupWizard::with_config(config); + assert!(wizard.config.skip_auth); + } +}