mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
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:
co-authored by
Claude Opus 4.5
parent
1605939e2a
commit
c7f0e8014d
@@ -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
@@ -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")?
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user