diff --git a/src/cli/init.rs b/src/cli/init.rs new file mode 100644 index 0000000..3616f48 --- /dev/null +++ b/src/cli/init.rs @@ -0,0 +1,937 @@ +use std::collections::HashMap; +use std::path::PathBuf; + +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; + +use crate::config::{ + AgentConfig, ChannelConfig, Config, FeishuChannelConfig, GatewayConfig, ModelConfig, + ProviderConfig, SchedulerConfig, TaggedChannelConfig, WechatChannelConfig, +}; + +/// Interactive configuration wizard for PicoBot +pub struct InitWizard { + read: BufReader, + write: tokio::io::Stdout, + config_path: PathBuf, +} + +impl InitWizard { + pub fn new() -> Self { + let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")); + Self { + read: BufReader::new(tokio::io::stdin()), + write: tokio::io::stdout(), + config_path: home.join(".picobot").join("config.json"), + } + } + + pub async fn run(&mut self, force: bool, skip_channels: bool) -> Result<(), InitError> { + let existing = self.load_existing_config(force)?; + + self.show_welcome_message(); + + // Step 1: Provider + let providers = self.configure_provider(&existing).await?; + + // Step 2: Model + let models = self.configure_model(&existing).await?; + + // Step 3: Agent + let agents = self.configure_agent(&existing, &providers, &models).await?; + + // Step 4: Channels + let channels = if !skip_channels { + self.configure_channels(&existing).await? + } else { + existing.channels.clone() + }; + + let config = self.build_config(providers, models, agents, channels, &existing); + + self.save_config(&config)?; + + self.show_completion_message(&config); + + Ok(()) + } + + fn load_existing_config(&self, force: bool) -> Result { + if self.config_path.exists() && !force { + Config::load_default().or_else(|_| Ok(Self::empty_config())) + } else { + Ok(Self::empty_config()) + } + } + + fn empty_config() -> Config { + Config { + providers: HashMap::new(), + models: HashMap::new(), + agents: HashMap::new(), + time: crate::config::TimeConfig::default(), + gateway: GatewayConfig::default(), + scheduler: SchedulerConfig::default(), + client: crate::config::ClientConfig::default(), + channels: HashMap::new(), + skills: crate::config::SkillsConfig::default(), + tools: crate::config::ToolsConfig::default(), + } + } + + fn show_welcome_message(&mut self) { + println!(); + println!("╔══════════════════════════════════════════════════════╗"); + println!("║ PicoBot Configuration Wizard ║"); + println!("╚══════════════════════════════════════════════════════╝"); + println!(); + println!("This wizard will help you configure PicoBot."); + println!("Press Enter to use default values where shown."); + println!(); + } + + // ==================== Prompt Helpers ==================== + + async fn prompt_with_default( + &mut self, + label: &str, + default: &str, + ) -> Result { + if !default.is_empty() { + println!("{} [default: {}]: ", label, default); + } else { + println!("{}: ", label); + } + self.write.flush().await?; + + let mut line = String::new(); + let bytes_read = self.read.read_line(&mut line).await?; + + if bytes_read == 0 { + return Err(InitError::InputError("EOF reached".to_string())); + } + + let input = line.trim().to_string(); + Ok(if input.is_empty() { default.to_string() } else { input }) + } + + async fn prompt_required(&mut self, label: &str) -> Result { + loop { + println!("{}: ", label); + self.write.flush().await?; + + let mut line = String::new(); + let bytes_read = self.read.read_line(&mut line).await?; + + if bytes_read == 0 { + return Err(InitError::InputError("EOF reached".to_string())); + } + + let input = line.trim().to_string(); + if !input.is_empty() { + return Ok(input); + } + println!("{} is required. Please enter a value.", label); + } + } + + async fn prompt_yes_no(&mut self, label: &str, default: bool) -> Result { + let default_str = if default { "yes" } else { "no" }; + println!("{} [yes/no, default: {}]: ", label, default_str); + self.write.flush().await?; + + let mut line = String::new(); + let bytes_read = self.read.read_line(&mut line).await?; + + if bytes_read == 0 { + return Err(InitError::InputError("EOF reached".to_string())); + } + + let input = line.trim().to_lowercase(); + if input.is_empty() { + Ok(default) + } else { + Ok(input == "yes" || input == "y" || input == "1") + } + } + + async fn prompt_select( + &mut self, + label: &str, + options: &[String], + default: usize, + ) -> Result { + for (i, opt) in options.iter().enumerate() { + println!(" {}. {}", i + 1, opt); + } + println!(); + + let default_str = (default + 1).to_string(); + let input = self.prompt_with_default(label, &default_str).await?; + + let selected: usize = input.parse().map_err(|_| { + InitError::InputError(format!("Invalid selection: {}", input)) + })?; + + if selected == 0 || selected > options.len() { + return Err(InitError::InputError(format!( + "Selection must be between 1 and {}", + options.len() + ))); + } + + Ok(selected - 1) + } + + // ==================== Step 1: Provider ==================== + + async fn configure_provider( + &mut self, + existing: &Config, + ) -> Result, InitError> { + println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); + println!("Step 1: Configure Provider (API Endpoint)"); + println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); + println!(); + + let provider_names: Vec = existing.providers.keys().cloned().collect(); + + if !provider_names.is_empty() { + println!("Existing providers:"); + for (i, name) in provider_names.iter().enumerate() { + println!(" {}. {}", i + 1, name); + } + println!(); + + println!("Options:"); + println!(" 1. Add new provider"); + println!(" 2. Modify existing provider"); + println!(" 3. Use existing provider"); + println!(" 4. Skip"); + println!(); + + let choice = self + .prompt_with_default("Select option", "1") + .await?; + + match choice.as_str() { + "1" => return self.add_provider(existing).await, + "2" => return self.modify_provider(existing).await, + "3" => { + println!("Keeping existing providers."); + return Ok(existing.providers.clone()); + } + "4" => { + println!("Skipping provider configuration."); + return Ok(existing.providers.clone()); + } + _ => { + println!("Invalid option, adding new provider."); + return self.add_provider(existing).await; + } + } + } else { + println!("No existing providers found."); + println!(); + println!("Options:"); + println!(" 1. Add new provider"); + println!(" 2. Skip"); + println!(); + + let choice = self.prompt_with_default("Select option", "1").await?; + + match choice.as_str() { + "1" => self.add_provider(existing).await, + "2" => { + println!("Skipping provider configuration."); + Ok(existing.providers.clone()) + } + _ => { + println!("Invalid option, adding new provider."); + self.add_provider(existing).await + } + } + } + } + + async fn add_provider( + &mut self, + existing: &Config, + ) -> Result, InitError> { + let provider_name = self + .prompt_with_default("Provider name", "default") + .await?; + + println!("Provider type:"); + println!(" 1. openai"); + println!(" 2. anthropic"); + println!(); + let type_choice = self.prompt_with_default("Select type", "1").await?; + let provider_type = match type_choice.as_str() { + "1" => "openai", + "2" => "anthropic", + _ => "openai", + }; + + let base_url = self.prompt_required("API base URL").await?; + + println!(); + println!("API Key is required and will be stored in config.json."); + let api_key = self.prompt_required("API Key").await?; + + let provider = ProviderConfig { + provider_type: provider_type.to_string(), + base_url, + api_key, + extra_headers: HashMap::new(), + llm_timeout_secs: 120, + memory_maintenance_timeout_secs: 600, + }; + + let mut providers = existing.providers.clone(); + providers.insert(provider_name.clone(), provider); + + println!(); + println!("Provider '{}' configured.", provider_name); + + Ok(providers) + } + + async fn modify_provider( + &mut self, + existing: &Config, + ) -> Result, InitError> { + // Select which provider to modify + let provider_names: Vec = existing.providers.keys().cloned().collect(); + println!("Select provider to modify:"); + let provider_idx = self.prompt_select("", &provider_names, 0).await?; + let selected_provider_name = &provider_names[provider_idx]; + let current_provider = existing.providers.get(selected_provider_name).unwrap(); + + println!(); + println!( + "Current config: type={}, base_url={}", + current_provider.provider_type, current_provider.base_url + ); + println!(); + + let current_type_idx = if current_provider.provider_type == "anthropic" { + 1 + } else { + 0 + }; + let type_options = vec!["openai".to_string(), "anthropic".to_string()]; + println!("Provider type:"); + let type_idx = self.prompt_select("", &type_options, current_type_idx).await?; + let provider_type = &type_options[type_idx]; + + let base_url = self + .prompt_with_default("API base URL", ¤t_provider.base_url) + .await?; + + println!(); + println!("API Key (press Enter to keep current key):"); + let api_key = self + .prompt_with_default("API Key", ¤t_provider.api_key) + .await?; + + let provider = ProviderConfig { + provider_type: provider_type.clone(), + base_url, + api_key, + extra_headers: current_provider.extra_headers.clone(), + llm_timeout_secs: current_provider.llm_timeout_secs, + memory_maintenance_timeout_secs: current_provider.memory_maintenance_timeout_secs, + }; + + let mut providers = existing.providers.clone(); + providers.insert(selected_provider_name.clone(), provider); + + println!(); + println!("Provider '{}' modified.", selected_provider_name); + + Ok(providers) + } + + // ==================== Step 2: Model ==================== + + async fn configure_model( + &mut self, + existing: &Config, + ) -> Result, InitError> { + println!(); + println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); + println!("Step 2: Configure Model"); + println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); + println!(); + + let model_names: Vec = existing.models.keys().cloned().collect(); + + if !model_names.is_empty() { + println!("Existing models:"); + for (i, name) in model_names.iter().enumerate() { + let model = existing.models.get(name).unwrap(); + println!(" {}. {} (model_id: {})", i + 1, name, model.model_id); + } + println!(); + + println!("Options:"); + println!(" 1. Use existing model"); + println!(" 2. Add new model"); + println!(" 3. Skip"); + println!(); + + let choice = self.prompt_with_default("Select option", "1").await?; + + match choice.as_str() { + "1" => { + println!("Keeping existing models."); + return Ok(existing.models.clone()); + } + "2" => return self.add_model(existing).await, + "3" => { + println!("Skipping model configuration."); + return Ok(existing.models.clone()); + } + _ => { + println!("Invalid option, keeping existing models."); + return Ok(existing.models.clone()); + } + } + } else { + println!("No existing models found."); + println!(); + println!("Options:"); + println!(" 1. Add new model"); + println!(" 2. Skip"); + println!(); + + let choice = self.prompt_with_default("Select option", "1").await?; + + match choice.as_str() { + "1" => self.add_model(existing).await, + "2" => { + println!("Skipping model configuration."); + Ok(existing.models.clone()) + } + _ => self.add_model(existing).await, + } + } + } + + async fn add_model( + &mut self, + existing: &Config, + ) -> Result, InitError> { + let model_name = self + .prompt_with_default("Model name (reference key)", "default") + .await?; + + let model_id = self.prompt_required("Model ID (actual identifier)").await?; + + let temperature_str = self + .prompt_with_default("Temperature (0.0-1.0)", "0.7") + .await?; + let temperature: Option = temperature_str + .parse() + .ok() + .filter(|v| (0.0..=1.0).contains(v)); + + let max_tokens_str = self + .prompt_with_default("Max tokens (optional)", "4096") + .await?; + let max_tokens: Option = max_tokens_str.parse().ok(); + + let model = ModelConfig { + model_id, + temperature, + max_tokens, + context_window_tokens: Some(128000), + extra: HashMap::new(), + }; + + let mut models = existing.models.clone(); + models.insert(model_name.clone(), model); + + println!(); + println!("Model '{}' configured.", model_name); + + Ok(models) + } + + // ==================== Step 3: Agent ==================== + + async fn configure_agent( + &mut self, + existing: &Config, + providers: &HashMap, + models: &HashMap, + ) -> Result, InitError> { + println!(); + println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); + println!("Step 3: Configure Agent"); + println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); + println!(); + + // Check if we have providers and models to select from + if providers.is_empty() { + println!("Warning: No providers configured. Please configure a provider first."); + return Ok(existing.agents.clone()); + } + if models.is_empty() { + println!("Warning: No models configured. Please configure a model first."); + return Ok(existing.agents.clone()); + } + + let agent_names: Vec = existing.agents.keys().cloned().collect(); + + if !agent_names.is_empty() { + println!("Existing agents:"); + for (i, name) in agent_names.iter().enumerate() { + let agent = existing.agents.get(name).unwrap(); + println!( + " {}. {} (provider: {}, model: {})", + i + 1, + name, + agent.provider, + agent.model + ); + } + println!(); + + println!("Options:"); + println!(" 1. Add new agent"); + println!(" 2. Modify existing agent"); + println!(" 3. Use existing agent"); + println!(" 4. Skip"); + println!(); + + let choice = self.prompt_with_default("Select option", "1").await?; + + match choice.as_str() { + "1" => return self.add_agent(existing, providers, models).await, + "2" => return self.modify_agent(existing, providers, models).await, + "3" => { + println!("Keeping existing agents."); + return Ok(existing.agents.clone()); + } + "4" => { + println!("Skipping agent configuration."); + return Ok(existing.agents.clone()); + } + _ => { + println!("Invalid option, adding new agent."); + return self.add_agent(existing, providers, models).await; + } + } + } else { + println!("No existing agents found."); + println!(); + println!("Options:"); + println!(" 1. Add new agent"); + println!(" 2. Skip"); + println!(); + + let choice = self.prompt_with_default("Select option", "1").await?; + + match choice.as_str() { + "1" => self.add_agent(existing, providers, models).await, + "2" => { + println!("Skipping agent configuration."); + Ok(existing.agents.clone()) + } + _ => self.add_agent(existing, providers, models).await, + } + } + } + + async fn add_agent( + &mut self, + existing: &Config, + providers: &HashMap, + models: &HashMap, + ) -> Result, InitError> { + let agent_name = self + .prompt_with_default("Agent name", "default") + .await?; + + // Select provider + let provider_names: Vec = providers.keys().cloned().collect(); + println!("Select provider:"); + let provider_idx = self.prompt_select("", &provider_names, 0).await?; + let selected_provider = &provider_names[provider_idx]; + + // Select model (independently) + let model_names: Vec = models.keys().cloned().collect(); + println!(); + println!("Select model:"); + let model_idx = self.prompt_select("", &model_names, 0).await?; + let selected_model = &model_names[model_idx]; + + let agent = AgentConfig { + provider: selected_provider.clone(), + model: selected_model.clone(), + max_tool_iterations: 100, + tool_result_max_chars: 20000, + context_tool_result_trim_chars: 2000, + }; + + let mut agents = existing.agents.clone(); + agents.insert(agent_name.clone(), agent); + + println!(); + println!( + "Agent '{}' configured (provider: {}, model: {}).", + agent_name, selected_provider, selected_model + ); + + Ok(agents) + } + + async fn modify_agent( + &mut self, + existing: &Config, + providers: &HashMap, + models: &HashMap, + ) -> Result, InitError> { + // Select which agent to modify + let agent_names: Vec = existing.agents.keys().cloned().collect(); + println!("Select agent to modify:"); + let agent_idx = self.prompt_select("", &agent_names, 0).await?; + let selected_agent_name = &agent_names[agent_idx]; + let current_agent = existing.agents.get(selected_agent_name).unwrap(); + + println!(); + println!( + "Current config: provider={}, model={}", + current_agent.provider, current_agent.model + ); + println!(); + + // Select new provider + let provider_names: Vec = providers.keys().cloned().collect(); + let current_provider_idx = provider_names + .iter() + .position(|p| p == ¤t_agent.provider) + .unwrap_or(0); + println!("Select provider:"); + let provider_idx = self.prompt_select("", &provider_names, current_provider_idx).await?; + let selected_provider = &provider_names[provider_idx]; + + // Select new model + let model_names: Vec = models.keys().cloned().collect(); + let current_model_idx = model_names + .iter() + .position(|m| m == ¤t_agent.model) + .unwrap_or(0); + println!(); + println!("Select model:"); + let model_idx = self.prompt_select("", &model_names, current_model_idx).await?; + let selected_model = &model_names[model_idx]; + + let agent = AgentConfig { + provider: selected_provider.clone(), + model: selected_model.clone(), + max_tool_iterations: current_agent.max_tool_iterations, + tool_result_max_chars: current_agent.tool_result_max_chars, + context_tool_result_trim_chars: current_agent.context_tool_result_trim_chars, + }; + + let mut agents = existing.agents.clone(); + agents.insert(selected_agent_name.clone(), agent); + + println!(); + println!( + "Agent '{}' modified (provider: {}, model: {}).", + selected_agent_name, selected_provider, selected_model + ); + + Ok(agents) + } + + // ==================== Step 4: Channels ==================== + + async fn configure_channels( + &mut self, + existing: &Config, + ) -> Result, InitError> { + println!(); + println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); + println!("Step 4: Configure Channels"); + println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); + println!(); + + // Show existing channels + if !existing.channels.is_empty() { + println!("Existing channels:"); + for (name, config) in &existing.channels { + let status = if config.enabled() { "enabled" } else { "disabled" }; + println!(" - {} ({})", name, status); + } + println!(); + } + + let mut channels = existing.channels.clone(); + + // Loop for configuring multiple channels + loop { + println!("Options:"); + println!(" 1. Add Feishu channel"); + println!(" 2. Add WeChat channel"); + println!(" 3. Skip / Done"); + println!(); + + let choice = self.prompt_with_default("Select option", "3").await?; + + match choice.as_str() { + "1" => { + let channel_config = self.configure_feishu_channel(&channels).await?; + for (name, config) in channel_config { + channels.insert(name, config); + } + } + "2" => { + let channel_config = self.configure_wechat_channel(&channels).await?; + for (name, config) in channel_config { + channels.insert(name, config); + } + } + "3" => break, + _ => { + println!("Invalid selection."); + continue; + } + } + } + + Ok(channels) + } + + async fn configure_feishu_channel( + &mut self, + existing: &HashMap, + ) -> Result, InitError> { + println!(); + println!("Configuring Feishu channel..."); + println!(); + + let channel_name = self + .prompt_with_default("Channel name", "feishu") + .await?; + + let _existing_config = existing.get(&channel_name).and_then(|c| c.as_feishu()); + + let app_id = self.prompt_required("Feishu App ID").await?; + let app_secret = self.prompt_required("Feishu App Secret").await?; + + let config = ChannelConfig::Tagged(TaggedChannelConfig::Feishu(FeishuChannelConfig { + enabled: true, + app_id, + app_secret, + allow_from: vec!["*".to_string()], + agent: "default".to_string(), + media_dir: Self::default_feishu_media_dir(), + reaction_emoji: "Typing".to_string(), + max_message_chars: 20000, + reply_context_max_chars: 20000, + })); + + let mut result = HashMap::new(); + result.insert(channel_name.clone(), config); + + println!(); + println!("Feishu channel '{}' configured.", channel_name); + + Ok(result) + } + + async fn configure_wechat_channel( + &mut self, + _existing: &HashMap, + ) -> Result, InitError> { + println!(); + println!("Configuring WeChat channel..."); + println!(); + println!("WeChat login requires scanning a QR code."); + println!("The QR code URL will be displayed after configuration."); + println!(); + + // Use default values directly + let channel_name = "wechat"; + let base_url = "https://ilinkai.weixin.qq.com"; + let cred_path = Self::default_wechat_cred_path(); + let force_login = false; + + let config = ChannelConfig::Tagged(TaggedChannelConfig::Wechat(WechatChannelConfig { + enabled: true, + allow_from: vec!["*".to_string()], + agent: "default".to_string(), + base_url: base_url.to_string(), + cred_path, + force_login, + })); + + let mut result = HashMap::new(); + result.insert(channel_name.to_string(), config); + + println!(); + println!("WeChat channel '{}' configured.", channel_name); + + // Auto login after configuration + println!(); + println!("Starting WeChat login..."); + + self.do_wechat_login(base_url, &Self::default_wechat_cred_path()).await?; + + println!(); + println!("WeChat login successful! Credentials saved."); + + Ok(result) + } + + async fn do_wechat_login(&mut self, base_url: &str, cred_path: &str) -> Result<(), InitError> { + use wechatbot::{BotOptions, WeChatBot}; + + let bot = WeChatBot::new(BotOptions { + base_url: Some(base_url.to_string()), + cred_path: Some(cred_path.to_string()), + on_qr_url: Some(Box::new(|url| { + println!(); + println!("┌──────────────────────────────────────────────────────┐"); + println!("│ WeChat QR Code │"); + println!("└──────────────────────────────────────────────────────┘"); + println!(); + println!("Scan this URL in WeChat:"); + println!("{}", url); + println!(); + println!("Waiting for confirmation..."); + })), + on_error: Some(Box::new(|error| { + eprintln!("WeChat login error: {}", error); + })), + }); + + let creds = bot.login(true).await.map_err(|e| { + InitError::WeChatError(format!("WeChat login failed: {}", e)) + })?; + + println!(); + println!( + "Logged in as: {} (account: {})", + creds.user_id, creds.account_id + ); + + Ok(()) + } + + // ==================== Build & Save ==================== + + fn build_config( + &self, + providers: HashMap, + models: HashMap, + agents: HashMap, + channels: HashMap, + existing: &Config, + ) -> Config { + Config { + providers, + models, + agents, + channels, + time: existing.time.clone(), + gateway: existing.gateway.clone(), + scheduler: existing.scheduler.clone(), + client: existing.client.clone(), + skills: existing.skills.clone(), + tools: existing.tools.clone(), + } + } + + fn save_config(&self, config: &Config) -> Result<(), InitError> { + let dir = self + .config_path + .parent() + .ok_or_else(|| InitError::IoError("Invalid config path".to_string()))?; + std::fs::create_dir_all(dir) + .map_err(|e| InitError::IoError(format!("Failed to create directory: {}", e)))?; + + let content = serde_json::to_string_pretty(config) + .map_err(|e| InitError::SerializeError(e.to_string()))?; + + std::fs::write(&self.config_path, content) + .map_err(|e| InitError::IoError(format!("Failed to write config: {}", e)))?; + + Ok(()) + } + + fn show_completion_message(&mut self, config: &Config) { + println!(); + println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); + println!("Configuration complete!"); + println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); + println!(); + println!("Config saved to: {}", self.config_path.display()); + println!(); + + println!("Summary:"); + println!(" Providers: {}", config.providers.len()); + println!(" Models: {}", config.models.len()); + println!(" Agents: {}", config.agents.len()); + println!(" Channels: {}", config.channels.len()); + println!(); + + println!("Next steps:"); + println!(" 1. Start the gateway: picobot gateway"); + println!(" 2. Connect with CLI: picobot agent"); + println!(); + println!("For more options, run: picobot --help"); + println!(); + } + + fn default_feishu_media_dir() -> String { + let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")); + home.join(".picobot/media/feishu") + .to_string_lossy() + .to_string() + } + + fn default_wechat_cred_path() -> String { + let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")); + home.join(".picobot/wechat/credentials.json") + .to_string_lossy() + .to_string() + } +} + +impl Default for InitWizard { + fn default() -> Self { + Self::new() + } +} + +#[derive(Debug)] +pub enum InitError { + IoError(String), + SerializeError(String), + InputError(String), + WeChatError(String), +} + +impl std::fmt::Display for InitError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + InitError::IoError(msg) => write!(f, "IO error: {}", msg), + InitError::SerializeError(msg) => write!(f, "Serialization error: {}", msg), + InitError::InputError(msg) => write!(f, "Input error: {}", msg), + InitError::WeChatError(msg) => write!(f, "WeChat error: {}", msg), + } + } +} + +impl std::error::Error for InitError {} + +impl From for InitError { + fn from(e: std::io::Error) -> Self { + InitError::IoError(e.to_string()) + } +} \ No newline at end of file diff --git a/src/cli/mod.rs b/src/cli/mod.rs index e582818..aff318c 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -1,5 +1,7 @@ pub mod channel; pub mod input; +pub mod init; pub use channel::CliChannel; pub use input::{InputCommand, InputEvent, InputHandler}; +pub use init::InitWizard; diff --git a/src/main.rs b/src/main.rs index a40f2da..756199d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,6 +4,15 @@ use clap::{CommandFactory, Parser}; #[command(name = "picobot")] #[command(about = "A CLI chatbot", long_about = None)] enum Command { + /// Interactive configuration wizard + Init { + /// Force overwrite existing config + #[arg(short, long)] + force: bool, + /// Only configure provider, skip channels + #[arg(long)] + skip_channels: bool, + }, /// Connect to gateway Agent { /// Gateway WebSocket URL (e.g., ws://127.0.0.1:19876/ws) @@ -31,10 +40,14 @@ async fn main() -> Result<(), Box> { if std::env::args().len() <= 1 { cmd.print_help()?; println!(); - return Ok(()); + return Ok(()) } match Command::parse() { + Command::Init { force, skip_channels } => { + let mut wizard = picobot::cli::InitWizard::new(); + wizard.run(force, skip_channels).await?; + } Command::Agent { gateway_url } => { let config = picobot::config::Config::load_default().ok(); let url = gateway_url