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 <[email protected]>
This commit is contained in:
Illia Polosukhin
2026-02-04 09:42:41 -08:00
co-authored by Claude Opus 4.5
parent 1605939e2a
commit c7f0e8014d
9 changed files with 1093 additions and 4 deletions
+16
View File
@@ -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<std::path::PathBuf>,
/// 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),
+1 -1
View File
@@ -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")?
+1
View File
@@ -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;
+36 -3
View File
@@ -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
+30
View File
@@ -12,6 +12,34 @@ pub struct Settings {
/// Currently selected model.
#[serde(default)]
pub selected_model: Option<String>,
/// 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<u16>,
/// 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());
}
+284
View File
@@ -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<String>,
}
/// Telegram Bot API response for getMe.
#[derive(Debug, Deserialize)]
struct TelegramGetMeResponse {
ok: bool,
result: Option<TelegramUser>,
}
#[derive(Debug, Deserialize)]
struct TelegramUser {
username: Option<String>,
#[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<TelegramSetupResult> {
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<Option<String>, 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<HttpSetupResult> {
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<Option<SecretString>> {
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<bool> {
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
}
}
+23
View File
@@ -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};
+355
View File
@@ -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<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>() {
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<Vec<usize>> {
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("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<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");
}
}
+347
View File
@@ -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<Arc<SessionManager>>,
}
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<SessionManager>) -> 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<SessionManager>) -> Vec<String> {
// 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);
}
}