{"repo": "bahtya/kestrel-agent", "files": {"src/commands/setup.rs": {"a8b74d3814794dc39e778d44448b6787b51e406b": {"t": 1785746247.41588, "n": 72852, "h": "e66232224a8603d5", "c": "//! Setup command — interactive wizard for configuring Kestrel.\n//!\n//! Supports back-navigation via a state-machine loop, Quick/Full setup modes,\n//! input validation, and enhanced configuration summaries.\n\nuse anyhow::{bail, Context, Result};\nuse console::Term;\nuse dialoguer::{Confirm, Input, Password as PasswordInput, Select};\nuse kestrel_config::{\n loader, paths,\n schema::{Config, FeishuConfig, ProviderEntry, TelegramConfig, WebSocketConfig, WeixinConfig},\n};\nuse owo_colors::OwoColorize;\nuse std::net::SocketAddr;\nuse std::path::Path;\nuse std::time::Duration;\n\n// ── Provider display info ──────────────────────────────────────\n\nstruct ProviderInfo {\n key: &'static str,\n display: &'static str,\n default_model: &'static str,\n default_url: &'static str,\n}\n\nconst PROVIDERS: &[ProviderInfo] = &[\n ProviderInfo {\n key: \"openai\",\n display: \"★ OpenAI\",\n default_model: \"gpt-4o\",\n default_url: \"https://api.openai.com/v1\",\n },\n ProviderInfo {\n key: \"anthropic\",\n display: \"★ Anthropic\",\n default_model: \"claude-sonnet-4-20250514\",\n default_url: \"https://api.anthropic.com\",\n },\n ProviderInfo {\n key: \"openrouter\",\n display: \"★ OpenRouter (multi-model)\",\n default_model: \"anthropic/claude-sonnet-4-20250514\",\n default_url: \"https://openrouter.ai/api/v1\",\n },\n ProviderInfo {\n key: \"ollama\",\n display: \" Ollama (local models)\",\n default_model: \"llama3\",\n default_url: \"http://localhost:11434\",\n },\n ProviderInfo {\n key: \"deepseek\",\n display: \" DeepSeek\",\n default_model: \"deepseek-chat\",\n default_url: \"https://api.deepseek.com\",\n },\n ProviderInfo {\n key: \"gemini\",\n display: \" Gemini\",\n default_model: \"gemini-2.5-pro\",\n default_url: \"https://generativelanguage.googleapis.com/v1beta\",\n },\n ProviderInfo {\n key: \"groq\",\n display: \" Groq\",\n default_model: \"llama-3.3-70b-versatile\",\n default_url: \"https://api.groq.com/openai/v1\",\n },\n ProviderInfo {\n key: \"moonshot\",\n display: \" Moonshot (月之暗面)\",\n default_model: \"moonshot-v1-8k\",\n default_url: \"https://api.moonshot.cn/v1\",\n },\n ProviderInfo {\n key: \"minimax\",\n display: \" MiniMax\",\n default_model: \"MiniMax-Text-01\",\n default_url: \"https://api.minimax.chat/v1\",\n },\n ProviderInfo {\n key: \"github_copilot\",\n display: \" GitHub Copilot\",\n default_model: \"gpt-4o\",\n default_url: \"https://api.githubcopilot.com\",\n },\n ProviderInfo {\n key: \"openai_codex\",\n display: \" OpenAI Codex\",\n default_model: \"codex-mini\",\n default_url: \"https://api.openai.com/v1\",\n },\n ProviderInfo {\n key: \"glm_coding_plan\",\n display: \" GLM Coding Plan (智谱)\",\n default_model: \"glm-5.1\",\n default_url: \"https://open.bigmodel.cn/api/coding/paas/v4\",\n },\n];\n\nconst TOTAL_CONFIG_STEPS: usize = 5;\n\nconst BACK_OPTION: &str = \"↩ Go back\";\n\n// ── Wizard step state machine ──────────────────────────────────\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\nenum WizardStep {\n Provider, // Step 1\n Telegram, // Step 2\n Feishu, // Step 3\n WebSocket, // Step 4\n WeChat, // Step 5\n Review, // Final (no step number)\n}\n\nimpl WizardStep {\n fn step_number(self) -> usize {\n match self {\n Self::Provider => 1,\n Self::Telegram => 2,\n Self::Feishu => 3,\n Self::WebSocket => 4,\n Self::WeChat => 5,\n Self::Review => 0,\n }\n }\n\n fn title(self) -> &'static str {\n match self {\n Self::Provider => \"Provider Configuration\",\n Self::Telegram => \"Telegram Channel\",\n Self::Feishu => \"Feishu / Lark Channel\",\n Self::WebSocket => \"WebSocket Port\",\n Self::WeChat => \"WeChat Channel\",\n Self::Review => \"Review & Save\",\n }\n }\n\n fn all() -> &'static [WizardStep] {\n &[\n WizardStep::Provider,\n WizardStep::Telegram,\n WizardStep::Feishu,\n WizardStep::WebSocket,\n WizardStep::WeChat,\n ]\n }\n}\n\n/// Whether a channel step was configured or skipped.\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\nenum ChannelStatus {\n Configured,\n Skipped,\n}\n\n// ── Trait for interactive I/O (enables testability) ────────────\n\ntrait WizardIo {\n fn confirm(&self, prompt: &str, default: bool) -> Result;\n fn select(&self, prompt: &str, items: &[&str], default: usize) -> Result;\n fn input_with_default(&self, prompt: &str, default: &str) -> Result;\n fn input_allow_empty(&self, prompt: &str) -> Result;\n fn password(&self, prompt: &str) -> Result;\n fn write_line(&self, line: &str) -> Result<()>;\n\n /// Show a confirmation with back option. Returns `None` if user chose back.\n fn confirm_or_back(&self, prompt: &str, default: bool) -> Result> {\n let items = if default {\n &[\"Yes\", \"Skip (keep current)\", BACK_OPTION] as &[&str]\n } else {\n &[\"Skip (keep current)\", \"Yes\", BACK_OPTION] as &[&str]\n };\n let default_idx = 0;\n let choice = self.select(prompt, items, default_idx)?;\n match choice {\n idx if items[idx] == BACK_OPTION => Ok(None),\n idx if items[idx] == \"Yes\" => Ok(Some(true)),\n _ => Ok(Some(false)),\n }\n }\n}\n\n// ── Production implementation using Term + dialoguer ───────────\n\nstruct TermWizard<'a> {\n term: &'a Term,\n}\n\nimpl<'a> TermWizard<'a> {\n fn new(term: &'a Term) -> Self {\n Self { term }\n }\n}\n\nimpl WizardIo for TermWizard<'_> {\n fn confirm(&self, prompt: &str, default: bool) -> Result {\n Ok(Confirm::new()\n .with_prompt(prompt)\n .default(default)\n .interact_on(self.term)?)\n }\n\n fn select(&self, prompt: &str, items: &[&str], default: usize) -> Result {\n Ok(Select::new()\n .with_prompt(prompt)\n .items(items)\n .default(default)\n .interact_on(self.term)?)\n }\n\n fn input_with_default(&self, prompt: &str, default: &str) -> Result {\n Ok(Input::new()\n .with_prompt(prompt)\n .default(default.to_string())\n .interact_text_on(self.term)?)\n }\n\n fn input_allow_empty(&self, prompt: &str) -> Result {\n Ok(Input::::new()\n .with_prompt(prompt)\n .allow_empty(true)\n .interact_text_on(self.term)?)\n }\n\n fn password(&self, prompt: &str) -> Result {\n Ok(PasswordInput::new()\n .with_prompt(prompt)\n .interact_on(self.term)?)\n }\n\n fn write_line(&self, line: &str) -> Result<()> {\n self.term.write_line(line)?;\n Ok(())\n }\n}\n\n// ── Entry point ────────────────────────────────────────────────\n\npub fn run(_config: Config) -> Result<()> {\n let term = Term::stdout();\n if !term.is_term() {\n bail!(\n \"Setup requires an interactive terminal. \\\n Run this command in a terminal, not in a pipe or CI environment.\"\n );\n }\n let io = TermWizard::new(&term);\n let config_path = paths::get_config_path()?;\n\n // Set up Ctrl+C handler for graceful exit\n ctrlc::set_handler(|| {\n println!();\n println!(\n \" {} Setup interrupted. Progress has not been saved.\",\n \"!\".yellow()\n );\n println!(\" Run `kestrel setup` again to start over.\");\n std::process::exit(1);\n })\n .ok(); // Ignore error if handler already set\n\n run_wizard(&io, &config_path)\n}\n\n// ── Wizard flow (state machine) ────────────────────────────────\n\nfn run_wizard(io: &dyn WizardIo, config_path: &Path) -> Result<()> {\n let is_first_run = !config_path.exists();\n print_banner(io, is_first_run)?;\n\n // ── Load existing config or start fresh ──────────────────────\n let mut config = if config_path.exists() {\n match load_existing_config(config_path) {\n Ok(existing) => {\n show_config_summary_simple(io, &existing)?;\n if io.confirm(\"Update existing config?\", true)? {\n existing\n } else {\n io.write_line(&format!(\n \" {} Keeping config at {}.\",\n \"✓\".green(),\n config_path.display()\n ))?;\n return Ok(());\n }\n }\n Err(e) => {\n io.write_line(&format!(\n \" {} Could not parse existing config: {}\",\n \"!\".yellow(),\n e\n ))?;\n if io.confirm(\"Start fresh with defaults?\", true)? {\n Config::default()\n } else {\n bail!(\"Setup cancelled.\");\n }\n }\n }\n } else {\n Config::default()\n };\n\n // ── State machine: Review-centric navigation ────────────────\n // After banner + config load, user lands on Review menu.\n // They can freely jump to any step, configure it, and return to Review.\n let mut channel_status = build_channel_status(&config);\n let mut current_step = WizardStep::Review;\n\n loop {\n match current_step {\n WizardStep::Provider => {\n print_step(io, current_step)?;\n match configure_provider(io, &mut config)? {\n StepAction::Back => {}\n StepAction::Continue => {\n channel_status[0] = ChannelStatus::Configured;\n }\n }\n current_step = WizardStep::Review;\n }\n WizardStep::Telegram => {\n print_step(io, current_step)?;\n let has_existing = config.channels.telegram.is_some();\n match configure_channel_step(\n io,\n &mut config,\n \"Telegram\",\n has_existing,\n configure_telegram,\n &mut channel_status[1],\n )? {\n StepAction::Back => {}\n StepAction::Continue => {}\n }\n current_step = WizardStep::Review;\n }\n WizardStep::Feishu => {\n print_step(io, current_step)?;\n let has_existing = config.channels.feishu.as_ref().is_some_and(|f| f.enabled);\n match configure_channel_step(\n io,\n &mut config,\n \"Feishu / Lark\",\n has_existing,\n configure_feishu,\n &mut channel_status[2],\n )? {\n StepAction::Back => {}\n StepAction::Continue => {}\n }\n current_step = WizardStep::Review;\n }\n WizardStep::WebSocket => {\n print_step(io, current_step)?;\n let has_existing = config\n .channels\n .websocket\n .as_ref()\n .is_some_and(|w| w.enabled);\n match configure_channel_step(\n io,\n &mut config,\n \"WebSocket\",\n has_existing,\n configure_websocket,\n &mut channel_status[3],\n )? {\n StepAction::Back => {}\n StepAction::Continue => {}\n }\n current_step = WizardStep::Review;\n }\n WizardStep::WeChat => {\n print_step(io, current_step)?;\n let has_existing = config.channels.weixin.is_some();\n match configure_channel_step(\n io,\n &mut config,\n \"WeChat\",\n has_existing,\n configure_weixin,\n &mut channel_status[4],\n )? {\n StepAction::Back => {}\n StepAction::Continue => {}\n }\n current_step = WizardStep::Review;\n }\n WizardStep::Review => {\n // Refresh status from actual config state\n channel_status = build_channel_status(&config);\n print_review(io, &config, &channel_status)?;\n\n let review_items = build_review_items(&channel_status);\n let review_refs: Vec<&str> = review_items.iter().map(|s| s.as_str()).collect();\n let default_save = review_items.len() - 1;\n let choice = io.select(\n \"Select a step to configure, or save\",\n &review_refs,\n default_save,\n )?;\n\n if choice == default_save {\n if save_config(io, &config, config_path)? {\n print_next_steps(io, &config)?;\n return Ok(());\n }\n // User cancelled save, stay in review\n } else if choice < WizardStep::all().len() {\n current_step = WizardStep::all()[choice];\n }\n }\n }\n }\n}\n\n/// Build channel status from actual config state (not just wizard tracking).\nfn build_channel_status(config: &Config) -> [ChannelStatus; 5] {\n [\n if config.agent.provider.is_some() {\n ChannelStatus::Configured\n } else {\n ChannelStatus::Skipped\n },\n if config.channels.telegram.is_some() {\n ChannelStatus::Configured\n } else {\n ChannelStatus::Skipped\n },\n if config.channels.feishu.as_ref().is_some_and(|f| f.enabled) {\n ChannelStatus::Configured\n } else {\n ChannelStatus::Skipped\n },\n if config\n .channels\n .websocket\n .as_ref()\n .is_some_and(|w| w.enabled)\n {\n ChannelStatus::Configured\n } else {\n ChannelStatus::Skipped\n },\n if config.channels.weixin.is_some() {\n ChannelStatus::Configured\n } else {\n ChannelStatus::Skipped\n },\n ]\n}\n\nfn build_review_items(channel_status: &[ChannelStatus; 5]) -> Vec {\n let mut items: Vec = WizardStep::all()\n .iter()\n .enumerate()\n .map(|(i, s)| {\n let num = s.step_number();\n let title = s.title();\n let status = match channel_status[i] {\n ChannelStatus::Configured => \"configured\".to_string(),\n ChannelStatus::Skipped => \"not configured\".to_string(),\n };\n format!(\"{}. {} ({})\", num, title, status)\n })\n .collect();\n items.push(\"Save configuration\".to_string());\n items\n}\n\n/// Result of a configure step: continue forward or go back.\nenum StepAction {\n Continue,\n Back,\n}\n\n/// Generic wrapper for channel configuration steps with back support.\nfn configure_channel_step(\n io: &dyn WizardIo,\n config: &mut Config,\n channel_name: &str,\n has_existing: bool,\n configure_fn: fn(&dyn WizardIo, &mut Config) -> Result,\n status: &mut ChannelStatus,\n) -> Result {\n let prompt = if has_existing {\n format!(\"Update {} configuration?\", channel_name)\n } else {\n format!(\"Set up {}?\", channel_name)\n };\n\n match io.confirm_or_back(&prompt, has_existing)? {\n None => return Ok(StepAction::Back),\n Some(false) => {\n io.write_line(\" Kept current configuration.\")?;\n *status = ChannelStatus::Skipped;\n return Ok(StepAction::Continue);\n }\n Some(true) => {}\n }\n\n match configure_fn(io, config) {\n Ok(true) => {\n *status = ChannelStatus::Configured;\n Ok(StepAction::Continue)\n }\n Ok(false) => {\n // Configure fn handled skip internally\n *status = ChannelStatus::Skipped;\n Ok(StepAction::Continue)\n }\n Err(e) => Err(e),\n }\n}\n\n// ── Banner & step printing ─────────────────────────────────────\n\nfn print_banner(io: &dyn WizardIo, is_first_run: bool) -> Result<()> {\n io.write_line(\"\")?;\n if is_first_run {\n io.write_line(&format!(\n \" {} {}\",\n \"▸\".cyan(),\n \"Kestrel 初始化配置向导\".bold().cyan()\n ))?;\n io.write_line(\"\")?;\n io.write_line(\" Welcome to Kestrel! Let's set up your configuration.\")?;\n io.write_line(\" You can re-run `kestrel setup` anytime to make changes.\")?;\n } else {\n io.write_line(&format!(\n \" {} {}\",\n \"▸\".cyan(),\n \"Kestrel 配置更新向导\".bold().cyan()\n ))?;\n io.write_line(\"\")?;\n io.write_line(\" Update your existing configuration.\")?;\n }\n io.write_line(\"\")?;\n Ok(())\n}\n\nfn print_step(io: &dyn WizardIo, step: WizardStep) -> Result<()> {\n io.write_line(\"\")?;\n let num = step.step_number();\n io.write_line(&format!(\n \" {} Step {}/{}: {}\",\n \"▸\".cyan(),\n num,\n TOTAL_CONFIG_STEPS,\n step.title().bold()\n ))?;\n io.write_line(&format!(\" {}\", \"─\".repeat(40).dimmed()))?;\n Ok(())\n}\n\nfn print_review(\n io: &dyn WizardIo,\n config: &Config,\n channel_status: &[ChannelStatus; 5],\n) -> Result<()> {\n io.write_line(\"\")?;\n io.write_line(&format!(\n \" {} {}\",\n \"▸\".cyan(),\n \"Review & Save\".bold().cyan()\n ))?;\n io.write_line(&format!(\" {}\", \"─\".repeat(50).dimmed()))?;\n io.write_line(\"\")?;\n show_config_summary(io, config, channel_status)?;\n io.write_line(\"\")?;\n Ok(())\n}\n\nfn load_existing_config(path: &Path) -> Result {\n let content = std::fs::read_to_string(path)\n .with_context(|| format!(\"Failed to read {}\", path.display()))?;\n let config: Config =\n toml::from_str(&content).with_context(|| format!(\"Failed to parse {}\", path.display()))?;\n Ok(config)\n}\n\n// ── Token masking & config summary ─────────────────────────────\n\nfn mask_token(token: &str) -> String {\n if token.is_empty() {\n \"(not set)\".to_string()\n } else if token.len() <= 8 {\n \"(masked)\".to_string()\n } else {\n format!(\"{}…{}\", &token[..3], &token[token.len() - 4..])\n }\n}\n\nfn show_config_summary(\n io: &dyn WizardIo,\n config: &Config,\n channel_status: &[ChannelStatus; 5],\n) -> Result<()> {\n let provider = config.agent.provider.as_deref().unwrap_or(\"default\");\n io.write_line(&format!(\" Provider: {}\", provider))?;\n io.write_line(&format!(\" Model: {}\", config.agent.model))?;\n\n // Show base URL\n if let Some(url) = config\n .agent\n .provider\n .as_deref()\n .and_then(|p| get_provider_url(config, p))\n {\n io.write_line(&format!(\" Base URL: {}\", url))?;\n }\n\n // Show API key status\n let key_status = config\n .agent\n .provider\n .as_deref()\n .and_then(|p| get_provider_api_key(config, p))\n .map(mask_token)\n .unwrap_or_else(|| \"(not set)\".to_string());\n io.write_line(&format!(\" API Key: {}\", key_status))?;\n\n io.write_line(&format!(\" Temperature: {}\", config.agent.temperature))?;\n io.write_line(&format!(\" Max tokens: {}\", config.agent.max_tokens))?;\n\n io.write_line(\"\")?;\n io.write_line(\" Channels:\")?;\n\n let channel_names = [\"Telegram\", \"Feishu\", \"WebSocket\", \"WeChat\"];\n for (i, name) in channel_names.iter().enumerate() {\n let status_str = match channel_status[i + 1] {\n ChannelStatus::Configured => \"✓ configured\".green().to_string(),\n ChannelStatus::Skipped => \"✗ skipped\".dimmed().to_string(),\n };\n io.write_line(&format!(\" {}: {}\", name, status_str))?;\n }\n\n // Show details for configured channels\n if let Some(ref tg) = config.channels.telegram {\n if !tg.token.is_empty() {\n io.write_line(&format!(\" Token: {}\", mask_token(&tg.token)))?;\n }\n }\n\n if let Some(ref ws) = config.channels.websocket {\n if ws.enabled {\n io.write_line(&format!(\" Address: {}\", ws.listen_addr))?;\n }\n }\n\n if let Some(ref wx) = config.channels.weixin {\n if wx.enabled {\n let acct = wx.account_id.as_deref().unwrap_or(\"(unknown)\");\n io.write_line(&format!(\" Account: {}\", mask_token(acct)))?;\n }\n }\n\n if let Some(ref fs) = config.channels.feishu {\n if fs.enabled {\n let app_id = fs.app_id.as_deref().unwrap_or(\"(unknown)\");\n io.write_line(&format!(\" App ID: {}\", mask_token(app_id)))?;\n }\n }\n\n Ok(())\n}\n\n/// Legacy summary without channel status (used for existing config review).\nfn show_config_summary_simple(io: &dyn WizardIo, config: &Config) -> Result<()> {\n let provider = config.agent.provider.as_deref().unwrap_or(\"default\");\n io.write_line(&format!(\" Provider: {}\", provider))?;\n io.write_line(&format!(\" Model: {}\", config.agent.model))?;\n io.write_line(&format!(\" Temperature: {}\", config.agent.temperature))?;\n io.write_line(&format!(\" Max tokens: {}\", config.agent.max_tokens))?;\n io.write_line(&format!(\" Streaming: {}\", config.agent.streaming))?;\n\n if let Some(ref tg) = config.channels.telegram {\n io.write_line(&format!(\" Telegram: {}\", mask_token(&tg.token)))?;\n }\n\n if let Some(ref fs) = config.channels.feishu {\n if fs.enabled {\n let app_id = fs.app_id.as_deref().unwrap_or(\"(not set)\");\n io.write_line(&format!(\" Feishu: {}\", mask_token(app_id)))?;\n }\n }\n\n if let Some(ref ws) = config.channels.websocket {\n if ws.enabled {\n io.write_line(&format!(\" WebSocket: {}\", ws.listen_addr))?;\n }\n }\n\n if let Some(ref wx) = config.channels.weixin {\n let status = if wx.enabled {\n mask_token(wx.bot_token.as_deref().unwrap_or(\"\"))\n } else {\n \"disabled\".to_string()\n };\n io.write_line(&format!(\" WeChat: {}\", status))?;\n }\n\n Ok(())\n}\n\nfn save_config(io: &dyn WizardIo, config: &Config, config_path: &Path) -> Result {\n io.write_line(&format!(\" Config path: {}\", config_path.display()))?;\n\n if !io.confirm(\"Write this configuration?\", true)? {\n io.write_line(&format!(\" {} Save cancelled.\", \"!\".yellow()))?;\n return Ok(false);\n }\n\n let home = config_path\n .parent()\n .context(\"Config path must have a parent directory\")?;\n\n std::fs::create_dir_all(home)\n .with_context(|| format!(\"Failed to create config home: {}\", home.display()))?;\n\n loader::save_config(config, config_path)?;\n\n for dir in [\"skills\", \"sessions\", \"learning\"] {\n let path = home.join(dir);\n std::fs::create_dir_all(&path)\n .with_context(|| format!(\"Failed to create directory: {}\", path.display()))?;\n }\n\n io.write_line(\"\")?;\n io.write_line(&format!(\n \" {} Configuration saved to {}\",\n \"✓\".green(),\n config_path.display()\n ))?;\n io.write_line(&format!(\n \" {} Created directories: skills, sessions, learning\",\n \"✓\".green()\n ))?;\n io.write_line(&format!(\" {} Setup complete!\", \"✓\".green()))?;\n\n Ok(true)\n}\n\nfn print_next_steps(io: &dyn WizardIo, config: &Config) -> Result<()> {\n io.write_line(\"\")?;\n io.write_line(&format!(\" {} Next steps:\", \"▸\".cyan()))?;\n io.write_line(\"\")?;\n io.write_line(\" 1. Try it out:\")?;\n io.write_line(\" kestrel agent\")?;\n io.write_line(\"\")?;\n\n let has_channel = config.channels.telegram.is_some()\n || config.channels.feishu.is_some()\n || config.channels.weixin.is_some()\n || config.channels.websocket.is_some();\n\n if has_channel {\n io.write_line(\" 2. Start the gateway (connect to chat platforms):\")?;\n io.write_line(\" kestrel gateway\")?;\n io.write_line(\"\")?;\n }\n\n if config.channels.websocket.is_some() {\n io.write_line(\" 3. Start the API server:\")?;\n io.write_line(\" kestrel serve\")?;\n io.write_line(\"\")?;\n }\n\n io.write_line(\" Check system health:\")?;\n io.write_line(\" kestrel doctor\")?;\n io.write_line(\"\")?;\n io.write_line(\" Re-run setup anytime:\")?;\n io.write_line(\" kestrel setup\")?;\n io.write_line(\"\")?;\n Ok(())\n}\n\n// ── Input validation ───────────────────────────────────────────\n\nfn validate_api_key(provider: &str, key: &str) -> Result {\n let key = key.trim().to_string();\n if key.is_empty() {\n bail!(\"API key cannot be empty\");\n }\n match provider {\n \"openai\" | \"openai_codex\" if !key.starts_with(\"sk-\") => {\n bail!(\"OpenAI API keys typically start with 'sk-'\");\n }\n \"anthropic\" if !key.starts_with(\"sk-ant-\") => {\n bail!(\"Anthropic API keys typically start with 'sk-ant-'\");\n }\n _ => {}\n }\n Ok(key)\n}\n\nfn validate_url(input: &str) -> Result {\n let url = input.trim().to_string();\n if url.is_empty() {\n return Ok(url);\n }\n if !url.starts_with(\"http://\") && !url.starts_with(\"https://\") {\n bail!(\"URL must start with http:// or https://\");\n }\n Ok(url)\n}\n\n// ── Connectivity validation ────────────────────────────────────\n\n/// Result of a connectivity check.\n#[cfg_attr(test, allow(dead_code))]\nenum ConnectivityResult {\n /// Successfully connected and authenticated.\n Ok {\n latency_ms: u64,\n models: Vec,\n },\n /// Connected to server but authentication failed (HTTP 401/403).\n AuthFailed(String),\n /// Server unreachable or other network error.\n Unreachable(String),\n}\n\n/// Test API key validity by making a real request to the provider.\n///\n/// Most OpenAI-compatible providers support `GET /models` with Bearer auth.\n/// For Anthropic we use `GET /v1/models` with `x-api-key` header.\n#[cfg_attr(test, allow(dead_code))]\nfn test_api_key_connectivity(\n provider_key: &str,\n base_url: &str,\n api_key: &str,\n) -> ConnectivityResult {\n let rt = match tokio::runtime::Runtime::new() {\n Ok(rt) => rt,\n Err(e) => return ConnectivityResult::Unreachable(format!(\"Runtime error: {}\", e)),\n };\n\n rt.block_on(async {\n let client = reqwest::Client::builder()\n .timeout(Duration::from_secs(10))\n .build();\n\n let client = match client {\n Ok(c) => c,\n Err(e) => return ConnectivityResult::Unreachable(format!(\"Client error: {}\", e)),\n };\n\n let start = std::time::Instant::now();\n\n // Build the request based on provider type\n let (display_url, req) = build_validation_request(provider_key, base_url, api_key, &client);\n\n match req {\n Ok(request) => match client.execute(request).await {\n Ok(resp) => {\n let latency = start.elapsed().as_millis() as u64;\n let status = resp.status();\n\n if status.is_success() {\n let models = parse_models_from_response(provider_key, resp).await;\n ConnectivityResult::Ok {\n latency_ms: latency,\n models,\n }\n } else if status.as_u16() == 401 || status.as_u16() == 403 {\n let body = resp.text().await.unwrap_or_default();\n let msg = extract_error_message(&body)\n .unwrap_or_else(|| format!(\"HTTP {} — API key may be invalid\", status));\n ConnectivityResult::AuthFailed(msg)\n } else {\n // Other HTTP errors — report with status code so user can diagnose\n let msg = format!(\"Server returned HTTP {} for {}\", status, display_url);\n ConnectivityResult::Unreachable(msg)\n }\n }\n Err(e) => {\n let msg = if e.is_timeout() {\n \"Connection timed out (10s)\".to_string()\n } else if e.is_connect() {\n format!(\"Cannot connect to {}\", display_url)\n } else {\n format!(\"{}\", e)\n };\n ConnectivityResult::Unreachable(msg)\n }\n },\n Err(e) => ConnectivityResult::Unreachable(format!(\"Request error: {}\", e)),\n }\n })\n}\n\n/// Build the validation HTTP request based on provider type.\n/// Returns (display_url, request_result) where display_url is safe to show in error messages.\n#[cfg_attr(test, allow(dead_code))]\nfn build_validation_request(\n provider_key: &str,\n base_url: &str,\n api_key: &str,\n client: &reqwest::Client,\n) -> (String, Result) {\n let (display_url, req_builder) = match provider_key {\n \"anthropic\" => {\n let url = format!(\"{}/v1/models\", base_url.trim_end_matches('/'));\n let req = client\n .get(&url)\n .header(\"x-api-key\", api_key)\n .header(\"anthropic-version\", \"2023-06-01\");\n (url, req.build())\n }\n \"gemini\" => {\n let url = format!(\"{}?key={}\", base_url.trim_end_matches('/'), api_key);\n // Don't expose API key in error messages\n let display = format!(\"{}/models\", base_url.trim_end_matches('/'));\n let req = client.get(&url);\n (display, req.build())\n }\n // OpenAI-compatible providers: openai, openrouter, deepseek, groq, moonshot, minimax,\n // github_copilot, openai_codex, glm_coding_plan, etc.\n _ => {\n let base = base_url.trim_end_matches('/');\n let url = format!(\"{}/models\", base);\n let req = client\n .get(&url)\n .header(\"Authorization\", format!(\"Bearer {}\", api_key));\n (url, req.build())\n }\n };\n (display_url, req_builder)\n}\n\n/// Try to extract model names from the validation response.\n#[cfg_attr(test, allow(dead_code))]\nasync fn parse_models_from_response(provider_key: &str, resp: reqwest::Response) -> Vec {\n let body = resp.text().await.unwrap_or_default();\n\n if provider_key == \"gemini\" {\n // Gemini returns { \"models\": [ { \"name\": \"models/gemini-2.5-pro\", ... } ] }\n if let Ok(val) = serde_json::from_str::(&body) {\n if let Some(models) = val.get(\"models\").and_then(|m| m.as_array()) {\n return models\n .iter()\n .filter_map(|m| {\n m.get(\"name\")\n .and_then(|n| n.as_str())\n .map(|n| n.trim_start_matches(\"models/\").to_string())\n })\n .take(5)\n .collect();\n }\n }\n return vec![];\n }\n\n if provider_key == \"anthropic\" {\n // Anthropic /v1/models returns { \"data\": [ { \"id\": \"claude-...\", ... } ] }\n return parse_openai_models(&body);\n }\n\n // OpenAI-compatible: { \"data\": [ { \"id\": \"gpt-4o\", ... } ] }\n parse_openai_models(&body)\n}\n\n/// Parse model IDs from an OpenAI-style { \"data\": [ ... ] } response.\nfn parse_openai_models(body: &str) -> Vec {\n if let Ok(val) = serde_json::from_str::(body) {\n if let Some(data) = val.get(\"data\").and_then(|d| d.as_array()) {\n return data\n .iter()\n .filter_map(|m| m.get(\"id\").and_then(|id| id.as_str()).map(String::from))\n .take(5)\n .collect();\n }\n }\n vec![]\n}\n\n/// Extract a human-readable error message from an error response body.\nfn extract_error_message(body: &str) -> Option {\n if let Ok(val) = serde_json::from_str::(body) {\n // { \"error\": { \"message\": \"...\" } } — OpenAI and Anthropic format\n if let Some(msg) = val\n .get(\"error\")\n .and_then(|e| e.get(\"message\"))\n .and_then(|m| m.as_str())\n {\n return Some(msg.to_string());\n }\n // Fallback: { \"message\": \"...\" }\n if let Some(msg) = val.get(\"message\").and_then(|m| m.as_str()) {\n return Some(msg.to_string());\n }\n }\n None\n}\n\n/// Run connectivity validation after provider config is entered.\n/// Shows results to user and asks if they want to continue on failure.\n/// Accepts a connectivity test fn (for testability).\nfn run_connectivity_check(\n io: &dyn WizardIo,\n provider_key: &str,\n base_url: &str,\n api_key: &str,\n test_fn: fn(&str, &str, &str) -> ConnectivityResult,\n) -> Result<()> {\n // Skip for Ollama (local, no API key needed)\n if provider_key == \"ollama\" {\n return Ok(());\n }\n\n io.write_line(\"\")?;\n io.write_line(&format!(\" {} Validating API key…\", \"⟳\".cyan()))?;\n\n match test_fn(provider_key, base_url, api_key) {\n ConnectivityResult::Ok { latency_ms, models } => {\n if models.is_empty() {\n io.write_line(&format!(\n \" {} Connected! (latency: {}ms)\",\n \"✓\".green(),\n latency_ms\n ))?;\n } else {\n io.write_line(&format!(\n \" {} Connected! (latency: {}ms)\",\n \"✓\".green(),\n latency_ms\n ))?;\n let models_str = models.join(\", \");\n io.write_line(&format!(\" Available models: {}\", models_str.dimmed()))?;\n }\n }\n ConnectivityResult::AuthFailed(msg) => {\n io.write_line(&format!(\n \" {} API key validation failed: {}\",\n \"✗\".red(),\n msg\n ))?;\n if !io.confirm(\" Continue with this key anyway?\", false)? {\n bail!(\"Provider configuration cancelled.\");\n }\n }\n ConnectivityResult::Unreachable(msg) => {\n io.write_line(&format!(\n \" {} Could not verify API key: {}\",\n \"⚠\".yellow(),\n msg\n ))?;\n io.write_line(&format!(\n \" {}\",\n \"The server may be unreachable from this network.\".dimmed()\n ))?;\n if !io.confirm(\" Continue anyway?\", true)? {\n bail!(\"Provider configuration cancelled.\");\n }\n }\n }\n\n Ok(())\n}\n\n// ── Provider configuration ─────────────────────────────────────\n\nfn configure_provider(io: &dyn WizardIo, config: &mut Config) -> Result {\n #[cfg(test)]\n let test_fn: fn(&str, &str, &str) -> ConnectivityResult = mock_connectivity_ok;\n #[cfg(not(test))]\n let test_fn: fn(&str, &str, &str) -> ConnectivityResult = test_api_key_connectivity;\n configure_provider_inner(io, config, test_fn)\n}\n\nfn configure_provider_inner(\n io: &dyn WizardIo,\n config: &mut Config,\n connectivity_fn: fn(&str, &str, &str) -> ConnectivityResult,\n) -> Result {\n let display_names: Vec<&str> = PROVIDERS.iter().map(|p| p.display).collect();\n\n let default_idx = config\n .agent\n .provider\n .as_deref()\n .and_then(|p| PROVIDERS.iter().position(|info| info.key == p))\n .unwrap_or(0); // default to OpenAI (first)\n\n let provider_idx = io.select(\"Select LLM provider\", &display_names, default_idx)?;\n\n let provider_key = PROVIDERS[provider_idx].key;\n config.agent.provider = Some(provider_key.to_string());\n\n let default_model = PROVIDERS[provider_idx].default_model;\n let current_model = if config.agent.model.is_empty() {\n default_model\n } else {\n &config.agent.model\n };\n\n let model: String = io.input_with_default(\"Model name\", current_model)?;\n config.agent.model = model.trim().to_string();\n\n let default_url = PROVIDERS[provider_idx].default_url;\n let current_url = get_provider_url(config, provider_key).unwrap_or(default_url);\n\n if !current_url.is_empty() {\n let base_url: String = io.input_with_default(\"Base URL\", current_url)?;\n let base_url = validate_url(&base_url)?;\n set_provider_url(config, provider_key, &base_url);\n } else {\n let base_url: String = io.input_allow_empty(\"Base URL (leave empty for default)\")?;\n let base_url = validate_url(&base_url)?;\n if !base_url.is_empty() {\n set_provider_url(config, provider_key, &base_url);\n }\n }\n\n loop {\n let api_key: String = io.password(\"API key\")?;\n match validate_api_key(provider_key, &api_key) {\n Ok(key) => {\n set_provider_api_key(config, provider_key, &key);\n break;\n }\n Err(e) => {\n io.write_line(&format!(\" {} {}\", \"!\".yellow(), e))?;\n if !io.confirm(\"Try again?\", true)? {\n // Accept whatever was entered\n let key = api_key.trim().to_string();\n if !key.is_empty() {\n set_provider_api_key(config, provider_key, &key);\n }\n break;\n }\n }\n }\n }\n\n // Connectivity validation\n let base_url = get_provider_url(config, provider_key)\n .unwrap_or(PROVIDERS[provider_idx].default_url)\n .to_string();\n let api_key = get_provider_api_key(config, provider_key)\n .unwrap_or(\"\")\n .to_string();\n if !api_key.is_empty() && !base_url.is_empty() {\n run_connectivity_check(io, provider_key, &base_url, &api_key, connectivity_fn)?;\n }\n\n Ok(StepAction::Continue)\n}\n\n// ── Channel configurations ─────────────────────────────────────\n\n/// Configure Telegram. Returns true if configured, false if skipped.\nfn configure_telegram(io: &dyn WizardIo, config: &mut Config) -> Result {\n let current_token = config\n .channels\n .telegram\n .as_ref()\n .map(|tg| tg.token.as_str())\n .unwrap_or(\"\");\n\n let token: String = if current_token.is_empty() {\n io.input_allow_empty(\"Bot token (from @BotFather)\")?\n } else {\n io.input_with_default(\"Bot token (from @BotFather)\", current_token)?\n };\n\n let token = token.trim().to_string();\n if token.is_empty() {\n io.write_line(\" No token provided, skipping Telegram.\")?;\n return Ok(false);\n }\n\n let allowed: String = loop {\n let input: String =\n io.input_allow_empty(\"Allowed user IDs (comma-separated, leave empty for all)\")?;\n if input.trim().is_empty() {\n break String::new();\n }\n let ids: Vec<&str> = input\n .split(',')\n .map(|s| s.trim())\n .filter(|s| !s.is_empty())\n .collect();\n let mut valid = true;\n for id in &ids {\n if id.parse::().is_err() {\n io.write_line(&format!(\n \" {} '{}' is not a valid user ID (must be a positive integer).\",\n \"!\".yellow(),\n id\n ))?;\n valid = false;\n break;\n }\n }\n if valid {\n break input;\n }\n };\n\n let allowed_users: Vec = if allowed.trim().is_empty() {\n Vec::new()\n } else {\n allowed\n .split(',')\n .map(|s| s.trim().to_string())\n .filter(|s| !s.is_empty())\n .collect()\n };\n\n let (admin_users, enabled, streaming, proxy) = config\n .channels\n .telegram\n .as_ref()\n .map(|tg| {\n (\n tg.admin_users.clone(),\n tg.enabled,\n tg.streaming,\n tg.proxy.clone(),\n )\n })\n .unwrap_or((Vec::new(), true, true, None));\n\n config.channels.telegram = Some(TelegramConfig {\n token,\n allowed_users,\n admin_users,\n enabled,\n streaming,\n proxy,\n });\n\n io.write_line(&format!(\" {} Telegram configured.\", \"✓\".green()))?;\n Ok(true)\n}\n\n/// Configure Feishu. Returns true if configured, false if skipped.\nfn configure_feishu(io: &dyn WizardIo, config: &mut Config) -> Result {\n let domain_options = [\"Feishu (飞书)\", \"Lark (international)\"];\n let idx = io.select(\"Select platform\", &domain_options, 0)?;\n let domain = if idx == 1 { \"lark\" } else { \"feishu\" };\n\n let rt = tokio::runtime::Builder::new_current_thread()\n .enable_all()\n .build()\n .context(\"Failed to create tokio runtime\")?;\n\n let result = match rt.block_on(super::feishu_onboarding::run_onboarding(domain)) {\n Ok(r) => r,\n Err(e) => {\n io.write_line(&format!(\" {} Feishu setup skipped: {}\", \"!\".yellow(), e))?;\n return Ok(false);\n }\n };\n\n let existing_proxy = config\n .channels\n .feishu\n .as_ref()\n .and_then(|f| f.proxy.clone());\n\n config.channels.feishu = Some(FeishuConfig {\n app_id: Some(result.app_id),\n app_secret: Some(result.app_secret),\n enabled: true,\n proxy: existing_proxy,\n ..Default::default()\n });\n\n io.write_line(&format!(\" {} Feishu configured.\", \"✓\".green()))?;\n Ok(true)\n}\n\n/// Configure WebSocket. Returns true if configured, false if skipped.\nfn configure_websocket(io: &dyn WizardIo, config: &mut Config) -> Result {\n let default_addr = config\n .channels\n .websocket\n .as_ref()\n .map(|ws| ws.listen_addr.as_str())\n .unwrap_or(\"127.0.0.1:8090\");\n\n let listen_addr: String = loop {\n let input = io.input_with_default(\"Listen address\", default_addr)?;\n match input.parse::() {\n Ok(_) => break input,\n Err(e) => {\n io.write_line(&format!(\n \" {} Invalid address '{}': {}\",\n \"!\".yellow(),\n input,\n e\n ))?;\n }\n }\n };\n\n config.channels.websocket = Some(WebSocketConfig {\n enabled: true,\n listen_addr,\n auth: Default::default(),\n max_clients: 100,\n max_message_size: 1048576,\n });\n\n io.write_line(&format!(\" {} WebSocket configured.\", \"✓\".green()))?;\n Ok(true)\n}\n\n/// Configure WeChat. Returns true if configured, false if skipped.\nfn configure_weixin(io: &dyn WizardIo, config: &mut Config) -> Result {\n let choices = &[\n \"Scan QR code with WeChat (recommended)\",\n \"Enter credentials manually\",\n \"Skip\",\n ];\n let choice = io.select(\"How would you like to configure WeChat?\", choices, 0)?;\n\n match choice {\n 0 => {\n io.write_line(\n \" QR scan setup requires running `kestrel setup weixin` in a terminal.\",\n )?;\n io.write_line(\" Please run that command separately, then return to this wizard.\")?;\n if let Some(ref mut wx) = config.channels.weixin {\n if wx.account_id.is_some() && wx.bot_token.is_some() {\n wx.enabled = true;\n io.write_line(&format!(\n \" {} Existing WeChat credentials detected and enabled.\",\n \"✓\".green()\n ))?;\n return Ok(true);\n }\n }\n if config.channels.weixin.is_none() {\n io.write_line(\n \" No WeChat credentials found yet. Run `kestrel setup weixin` first.\",\n )?;\n }\n Ok(false)\n }\n 1 => {\n let current_account = config\n .channels\n .weixin\n .as_ref()\n .and_then(|w| w.account_id.as_deref())\n .unwrap_or(\"\");\n let account_id: String = if current_account.is_empty() {\n io.input_allow_empty(\"iLink account ID (e.g. wxid_xxx@im.bot)\")?\n } else {\n io.input_with_default(\"iLink account ID\", current_account)?\n };\n\n if account_id.trim().is_empty() {\n io.write_line(\" No account ID provided, skipping WeChat.\")?;\n return Ok(false);\n }\n\n let current_token = config\n .channels\n .weixin\n .as_ref()\n .and_then(|w| w.bot_token.as_deref())\n .unwrap_or(\"\");\n let bot_token: String = if current_token.is_empty() {\n io.input_allow_empty(\"iLink bot token\")?\n } else {\n io.input_with_default(\"iLink bot token\", current_token)?\n };\n\n if bot_token.trim().is_empty() {\n io.write_line(\" No bot token provided, skipping WeChat.\")?;\n return Ok(false);\n }\n\n let (\n app_id,\n app_secret,\n old_token,\n encoding_aes_key,\n base_url,\n cdn_base_url,\n dm_policy,\n group_policy,\n allowed_users,\n group_allowed_users,\n ) = config\n .channels\n .weixin\n .as_ref()\n .map(|w| {\n (\n w.app_id.clone(),\n w.app_secret.clone(),\n w.token.clone(),\n w.encoding_aes_key.clone(),\n w.base_url.clone(),\n w.cdn_base_url.clone(),\n w.dm_policy.clone(),\n w.group_policy.clone(),\n w.allowed_users.clone(),\n w.group_allowed_users.clone(),\n )\n })\n .unwrap_or_default();\n\n config.channels.weixin = Some(WeixinConfig {\n account_id: Some(account_id.trim().to_string()),\n bot_token: Some(bot_token.trim().to_string()),\n app_id,\n app_secret,\n token: old_token,\n encoding_aes_key,\n base_url,\n cdn_base_url,\n dm_policy,\n group_policy,\n allowed_users,\n group_allowed_users,\n enabled: true,\n });\n\n io.write_line(&format!(\" {} WeChat configured.\", \"✓\".green()))?;\n Ok(true)\n }\n _ => {\n io.write_line(\" Skipped.\")?;\n Ok(false)\n }\n }\n}\n\n// ── Provider field helpers ─────────────────────────────────────\n\nfn get_provider_entry_mut<'a>(\n config: &'a mut Config,\n provider: &str,\n) -> Option<&'a mut ProviderEntry> {\n match provider {\n \"anthropic\" => config.providers.anthropic.as_mut(),\n \"openai\" => config.providers.openai.as_mut(),\n \"openrouter\" => config.providers.openrouter.as_mut(),\n \"ollama\" => config.providers.ollama.as_mut(),\n \"deepseek\" => config.providers.deepseek.as_mut(),\n \"gemini\" => config.providers.gemini.as_mut(),\n \"groq\" => config.providers.groq.as_mut(),\n \"moonshot\" => config.providers.moonshot.as_mut(),\n \"minimax\" => config.providers.minimax.as_mut(),\n \"github_copilot\" => config.providers.github_copilot.as_mut(),\n \"openai_codex\" => config.providers.openai_codex.as_mut(),\n \"opencode_go\" => config.providers.opencode_go.as_mut(),\n \"glm_coding_plan\" => config.providers.glm_coding_plan.as_mut(),\n _ => None,\n }\n}\n\nfn ensure_provider_entry(config: &mut Config, provider: &str) {\n match provider {\n \"anthropic\" => {\n config\n .providers\n .anthropic\n .get_or_insert_with(ProviderEntry::default);\n }\n \"openai\" => {\n config\n .providers\n .openai\n .get_or_insert_with(ProviderEntry::default);\n }\n \"openrouter\" => {\n config\n .providers\n .openrouter\n .get_or_insert_with(ProviderEntry::default);\n }\n \"ollama\" => {\n config\n .providers\n .ollama\n .get_or_insert_with(ProviderEntry::default);\n }\n \"deepseek\" => {\n config\n .providers\n .deepseek\n .get_or_insert_with(ProviderEntry::default);\n }\n \"gemini\" => {\n config\n .providers\n .gemini\n .get_or_insert_with(ProviderEntry::default);\n }\n \"groq\" => {\n config\n .providers\n .groq\n .get_or_insert_with(ProviderEntry::default);\n }\n \"moonshot\" => {\n config\n .providers\n .moonshot\n .get_or_insert_with(ProviderEntry::default);\n }\n \"minimax\" => {\n config\n .providers\n .minimax\n .get_or_insert_with(ProviderEntry::default);\n }\n \"github_copilot\" => {\n config\n .providers\n .github_copilot\n .get_or_insert_with(ProviderEntry::default);\n }\n \"openai_codex\" => {\n config\n .providers\n .openai_codex\n .get_or_insert_with(ProviderEntry::default);\n }\n \"opencode_go\" => {\n config\n .providers\n .opencode_go\n .get_or_insert_with(ProviderEntry::default);\n }\n \"glm_coding_plan\" => {\n config\n .providers\n .glm_coding_plan\n .get_or_insert_with(ProviderEntry::default);\n }\n _ => {}\n }\n}\n\nfn get_provider_url<'a>(config: &'a Config, provider: &str) -> Option<&'a str> {\n let entry = match provider {\n \"anthropic\" => config.providers.anthropic.as_ref(),\n \"openai\" => config.providers.openai.as_ref(),\n \"openrouter\" => config.providers.openrouter.as_ref(),\n \"ollama\" => config.providers.ollama.as_ref(),\n \"deepseek\" => config.providers.deepseek.as_ref(),\n \"gemini\" => config.providers.gemini.as_ref(),\n \"groq\" => config.providers.groq.as_ref(),\n \"moonshot\" => config.providers.moonshot.as_ref(),\n \"minimax\" => config.providers.minimax.as_ref(),\n \"github_copilot\" => config.providers.github_copilot.as_ref(),\n \"openai_codex\" => config.providers.openai_codex.as_ref(),\n \"opencode_go\" => config.providers.opencode_go.as_ref(),\n \"glm_coding_plan\" => config.providers.glm_coding_plan.as_ref(),\n _ => None,\n };\n entry.and_then(|e| e.base_url.as_deref())\n}\n\nfn get_provider_api_key<'a>(config: &'a Config, provider: &str) -> Option<&'a str> {\n let entry = match provider {\n \"anthropic\" => config.providers.anthropic.as_ref(),\n \"openai\" => config.providers.openai.as_ref(),\n \"openrouter\" => config.providers.openrouter.as_ref(),\n \"ollama\" => config.providers.ollama.as_ref(),\n \"deepseek\" => config.providers.deepseek.as_ref(),\n \"gemini\" => config.providers.gemini.as_ref(),\n \"groq\" => config.providers.groq.as_ref(),\n \"moonshot\" => config.providers.moonshot.as_ref(),\n \"minimax\" => config.providers.minimax.as_ref(),\n \"github_copilot\" => config.providers.github_copilot.as_ref(),\n \"openai_codex\" => config.providers.openai_codex.as_ref(),\n \"opencode_go\" => config.providers.opencode_go.as_ref(),\n \"glm_coding_plan\" => config.providers.glm_coding_plan.as_ref(),\n _ => None,\n };\n entry.and_then(|e| e.api_key.as_deref())\n}\n\nfn set_provider_url(config: &mut Config, provider: &str, url: &str) {\n ensure_provider_entry(config, provider);\n if let Some(entry) = get_provider_entry_mut(config, provider) {\n entry.base_url = if url.is_empty() {\n None\n } else {\n Some(url.to_string())\n };\n }\n}\n\nfn set_provider_api_key(config: &mut Config, provider: &str, key: &str) {\n ensure_provider_entry(config, provider);\n if let Some(entry) = get_provider_entry_mut(config, provider) {\n entry.api_key = if key.is_empty() {\n None\n } else {\n Some(key.to_string())\n };\n }\n}\n\n// ── Test helpers (cfg(test) only) ─────────────────────────────\n\n#[cfg(test)]\nfn mock_connectivity_ok(\n _provider_key: &str,\n _base_url: &str,\n _api_key: &str,\n) -> ConnectivityResult {\n ConnectivityResult::Ok {\n latency_ms: 42,\n models: vec![\"gpt-4o\".to_string(), \"gpt-4o-mini\".to_string()],\n }\n}\n\n// ── Tests ──────────────────────────────────────────────────────\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use std::cell::RefCell;\n use std::collections::VecDeque;\n\n fn template_toml() -> String {\n toml::to_string(&Config::default()).unwrap()\n }\n\n // ── Mock wizard IO for flow testing ─────────────────────────\n\n #[derive(Debug, Clone)]\n enum MockAction {\n Confirm {\n prompt_contains: &'static str,\n result: bool,\n },\n Select {\n result: usize,\n },\n Input {\n result: &'static str,\n },\n Password {\n result: &'static str,\n },\n }\n\n struct MockWizard {\n actions: RefCell>,\n output: RefCell,\n }\n\n impl MockWizard {\n fn new(actions: Vec) -> Self {\n Self {\n actions: RefCell::new(actions.into()),\n output: RefCell::new(String::new()),\n }\n }\n\n fn output(&self) -> String {\n self.output.borrow().clone()\n }\n }\n\n impl WizardIo for MockWizard {\n fn confirm(&self, prompt: &str, _default: bool) -> Result {\n let action = self.actions.borrow_mut().pop_front();\n match action {\n Some(MockAction::Confirm {\n prompt_contains,\n result,\n }) => {\n assert!(\n prompt.contains(prompt_contains),\n \"confirm prompt '{}' did not contain '{}'\",\n prompt,\n prompt_contains\n );\n Ok(result)\n }\n _ => panic!(\"unexpected confirm call, prompt: {}\", prompt),\n }\n }\n\n fn select(&self, _prompt: &str, _items: &[&str], _default: usize) -> Result {\n let action = self.actions.borrow_mut().pop_front();\n match action {\n Some(MockAction::Select { result }) => Ok(result),\n _ => panic!(\"unexpected select call\"),\n }\n }\n\n fn input_with_default(&self, _prompt: &str, _default: &str) -> Result {\n let action = self.actions.borrow_mut().pop_front();\n match action {\n Some(MockAction::Input { result }) => Ok(result.to_string()),\n _ => panic!(\"unexpected input_with_default call\"),\n }\n }\n\n fn input_allow_empty(&self, _prompt: &str) -> Result {\n let action = self.actions.borrow_mut().pop_front();\n match action {\n Some(MockAction::Input { result }) => Ok(result.to_string()),\n _ => panic!(\"unexpected input_allow_empty call\"),\n }\n }\n\n fn password(&self, _prompt: &str) -> Result {\n let action = self.actions.borrow_mut().pop_front();\n match action {\n Some(MockAction::Password { result }) => Ok(result.to_string()),\n _ => panic!(\"unexpected password call\"),\n }\n }\n\n fn write_line(&self, line: &str) -> Result<()> {\n self.output.borrow_mut().push_str(line);\n self.output.borrow_mut().push('\\n');\n Ok(())\n }\n }\n\n // ── Unit tests ──────────────────────────────────────────────\n\n #[test]\n fn setup_creates_template_config_when_config_is_missing() {\n let tmp = tempfile::tempdir().unwrap();\n let config_path = tmp.path().join(\"config.toml\");\n\n let config = Config::default();\n let home = config_path.parent().unwrap();\n std::fs::create_dir_all(home).unwrap();\n loader::save_config(&config, &config_path).unwrap();\n\n assert_eq!(\n std::fs::read_to_string(&config_path).unwrap(),\n template_toml()\n );\n assert!(config_path.exists());\n }\n\n #[test]\n fn provider_helpers_set_and_get_fields() {\n let mut config = Config::default();\n set_provider_url(&mut config, \"openai\", \"https://custom.api/v1\");\n set_provider_api_key(&mut config, \"openai\", \"sk-test-key\");\n\n let entry = config.providers.openai.as_ref().unwrap();\n assert_eq!(entry.base_url.as_deref(), Some(\"https://custom.api/v1\"));\n assert_eq!(entry.api_key.as_deref(), Some(\"sk-test-key\"));\n }\n\n #[test]\n fn provider_helpers_handle_unknown_provider() {\n let mut config = Config::default();\n set_provider_url(&mut config, \"nonexistent\", \"https://example.com\");\n assert!(get_provider_url(&config, \"nonexistent\").is_none());\n }\n\n #[test]\n fn wizard_keeps_existing_config() {\n let tmp = tempfile::tempdir().unwrap();\n let config_path = tmp.path().join(\"config.toml\");\n\n let existing = Config::default();\n std::fs::create_dir_all(config_path.parent().unwrap()).unwrap();\n loader::save_config(&existing, &config_path).unwrap();\n\n let mock = MockWizard::new(vec![MockAction::Confirm {\n prompt_contains: \"Update\",\n result: false,\n }]);\n\n run_wizard(&mock, &config_path).unwrap();\n\n assert_eq!(\n std::fs::read_to_string(&config_path).unwrap(),\n template_toml()\n );\n }\n\n #[test]\n fn wizard_fresh_quick_setup() {\n let tmp = tempfile::tempdir().unwrap();\n let config_path = tmp.path().join(\"config.toml\");\n\n let mock = MockWizard::new(vec![\n // Review: select Provider (index 0)\n MockAction::Select { result: 0 },\n // Step 1: Provider\n MockAction::Select { result: 0 }, // openai (first)\n MockAction::Input { result: \"gpt-4o\" },\n MockAction::Input {\n result: \"https://api.openai.com/v1\",\n },\n MockAction::Password {\n result: \"sk-test-key\",\n },\n // Review: Save (index 5 = last)\n MockAction::Select { result: 5 },\n MockAction::Confirm {\n prompt_contains: \"Write\",\n result: true,\n },\n ]);\n\n run_wizard(&mock, &config_path).unwrap();\n\n assert!(config_path.exists());\n let saved: Config =\n toml::from_str(&std::fs::read_to_string(&config_path).unwrap()).unwrap();\n assert_eq!(saved.agent.provider.as_deref(), Some(\"openai\"));\n assert_eq!(saved.agent.model, \"gpt-4o\");\n }\n\n #[test]\n fn wizard_setup_only_feishu() {\n // Test: user only configures Feishu, skips everything else\n let tmp = tempfile::tempdir().unwrap();\n let config_path = tmp.path().join(\"config.toml\");\n\n let mock = MockWizard::new(vec![\n // Review: save directly without configuring anything\n MockAction::Select { result: 5 }, // Save\n MockAction::Confirm {\n prompt_contains: \"Write\",\n result: true,\n },\n ]);\n\n run_wizard(&mock, &config_path).unwrap();\n\n assert!(config_path.exists());\n // Default config should be saved\n let saved: Config =\n toml::from_str(&std::fs::read_to_string(&config_path).unwrap()).unwrap();\n assert!(saved.agent.provider.is_none());\n }\n\n #[test]\n fn wizard_overwrite_existing_config() {\n let tmp = tempfile::tempdir().unwrap();\n let config_path = tmp.path().join(\"config.toml\");\n\n let existing = Config::default();\n std::fs::create_dir_all(config_path.parent().unwrap()).unwrap();\n loader::save_config(&existing, &config_path).unwrap();\n\n let mock = MockWizard::new(vec![\n // Update existing\n MockAction::Confirm {\n prompt_contains: \"Update\",\n result: true,\n },\n // Review: select Provider (index 0)\n MockAction::Select { result: 0 },\n // Step 1: Provider — select anthropic (index 1)\n MockAction::Select { result: 1 },\n MockAction::Input {\n result: \"claude-sonnet-4-20250514\",\n },\n MockAction::Input {\n result: \"https://api.anthropic.com\",\n },\n MockAction::Password {\n result: \"sk-ant-test\",\n },\n // Back to Review: Save (index 5)\n MockAction::Select { result: 5 },\n MockAction::Confirm {\n prompt_contains: \"Write\",\n result: true,\n },\n ]);\n\n run_wizard(&mock, &config_path).unwrap();\n\n let saved: Config =\n toml::from_str(&std::fs::read_to_string(&config_path).unwrap()).unwrap();\n assert_eq!(saved.agent.provider.as_deref(), Some(\"anthropic\"));\n assert_eq!(saved.agent.model, \"claude-sonnet-4-20250514\");\n }\n\n #[test]\n fn token_masking_short_tokens() {\n assert_eq!(mask_token(\"ab\"), \"(masked)\");\n assert_eq!(mask_token(\"\"), \"(not set)\");\n }\n\n #[test]\n fn token_masking_long_token() {\n let masked = mask_token(\"sk-proj-abcdefghijklmnop-b2cF\");\n assert!(masked.starts_with(\"sk-\"));\n assert!(masked.ends_with(\"b2cF\"));\n assert!(masked.contains(\"…\"));\n }\n\n #[test]\n fn validate_api_key_openai() {\n assert!(validate_api_key(\"openai\", \"sk-test123\").is_ok());\n assert!(validate_api_key(\"openai\", \"bad-key\").is_err());\n }\n\n #[test]\n fn validate_api_key_anthropic() {\n assert!(validate_api_key(\"anthropic\", \"sk-ant-test123\").is_ok());\n assert!(validate_api_key(\"anthropic\", \"bad-key\").is_err());\n }\n\n #[test]\n fn validate_api_key_other_providers() {\n assert!(validate_api_key(\"ollama\", \"any-key\").is_ok());\n }\n\n #[test]\n fn validate_api_key_empty() {\n assert!(validate_api_key(\"openai\", \"\").is_err());\n }\n\n #[test]\n fn validate_url_valid() {\n assert_eq!(\n validate_url(\"https://api.openai.com/v1\").unwrap(),\n \"https://api.openai.com/v1\"\n );\n assert_eq!(\n validate_url(\"http://localhost:11434\").unwrap(),\n \"http://localhost:11434\"\n );\n }\n\n #[test]\n fn validate_url_empty() {\n assert_eq!(validate_url(\"\").unwrap(), \"\");\n }\n\n #[test]\n fn validate_url_invalid() {\n assert!(validate_url(\"not-a-url\").is_err());\n assert!(validate_url(\"ftp://example.com\").is_err());\n }\n\n #[test]\n fn wizard_step_ordering() {\n assert_eq!(WizardStep::Provider.step_number(), 1);\n assert_eq!(WizardStep::Review.step_number(), 0);\n assert_eq!(WizardStep::all().len(), 5);\n assert_eq!(WizardStep::all()[0], WizardStep::Provider);\n assert_eq!(WizardStep::all()[4], WizardStep::WeChat);\n }\n\n fn tg(token: &str) -> TelegramConfig {\n TelegramConfig {\n token: token.to_string(),\n allowed_users: Vec::new(),\n admin_users: Vec::new(),\n enabled: true,\n streaming: false,\n proxy: None,\n }\n }\n\n #[test]\n fn token_masking_empty_token() {\n let mut config = Config::default();\n config.channels.telegram = Some(tg(\"\"));\n\n let mock = MockWizard::new(vec![]);\n show_config_summary_simple(&mock, &config).unwrap();\n\n let output = mock.output();\n assert!(output.contains(\"(not set)\"));\n }\n\n #[test]\n fn configure_weixin_qr_enables_existing_credentials() {\n let mock = MockWizard::new(vec![MockAction::Select { result: 0 }]);\n let mut config = Config::default();\n config.channels.weixin = Some(WeixinConfig {\n app_id: None,\n app_secret: None,\n token: None,\n encoding_aes_key: None,\n account_id: Some(\"wxid_existing@im.bot\".to_string()),\n bot_token: Some(\"tok_existing\".to_string()),\n base_url: None,\n cdn_base_url: None,\n dm_policy: \"open\".to_string(),\n group_policy: \"disabled\".to_string(),\n allowed_users: vec![],\n group_allowed_users: vec![],\n enabled: false,\n });\n\n configure_weixin(&mock, &mut config).unwrap();\n\n let weixin = config.channels.weixin.unwrap();\n assert!(weixin.enabled);\n assert_eq!(weixin.account_id.as_deref(), Some(\"wxid_existing@im.bot\"));\n assert_eq!(weixin.bot_token.as_deref(), Some(\"tok_existing\"));\n assert!(mock.output().contains(\"detected and enabled\"));\n }\n\n #[test]\n fn configure_weixin_manual_preserves_existing_fields() {\n let mock = MockWizard::new(vec![\n MockAction::Select { result: 1 },\n MockAction::Input {\n result: \"wxid_new@im.bot\",\n },\n MockAction::Input { result: \"tok_new\" },\n ]);\n let mut config = Config::default();\n config.channels.weixin = Some(WeixinConfig {\n app_id: Some(\"app_existing\".to_string()),\n app_secret: Some(\"secret_existing\".to_string()),\n token: Some(\"verify_token\".to_string()),\n encoding_aes_key: Some(\"aes_key\".to_string()),\n account_id: Some(\"wxid_old@im.bot\".to_string()),\n bot_token: Some(\"tok_old\".to_string()),\n base_url: Some(\"https://ilinkai.weixin.qq.com\".to_string()),\n cdn_base_url: Some(\"https://novac2c.cdn.weixin.qq.com/c2c\".to_string()),\n dm_policy: \"allowlist\".to_string(),\n group_policy: \"open\".to_string(),\n allowed_users: vec![\"u1\".to_string()],\n group_allowed_users: vec![\"g1\".to_string()],\n enabled: false,\n });\n\n configure_weixin(&mock, &mut config).unwrap();\n\n let weixin = config.channels.weixin.unwrap();\n assert_eq!(weixin.account_id.as_deref(), Some(\"wxid_new@im.bot\"));\n assert_eq!(weixin.bot_token.as_deref(), Some(\"tok_new\"));\n assert_eq!(weixin.app_id.as_deref(), Some(\"app_existing\"));\n assert_eq!(weixin.app_secret.as_deref(), Some(\"secret_existing\"));\n assert_eq!(weixin.token.as_deref(), Some(\"verify_token\"));\n assert_eq!(weixin.encoding_aes_key.as_deref(), Some(\"aes_key\"));\n assert_eq!(\n weixin.base_url.as_deref(),\n Some(\"https://ilinkai.weixin.qq.com\")\n );\n assert_eq!(\n weixin.cdn_base_url.as_deref(),\n Some(\"https://novac2c.cdn.weixin.qq.com/c2c\")\n );\n assert_eq!(weixin.dm_policy, \"allowlist\");\n assert_eq!(weixin.group_policy, \"open\");\n assert_eq!(weixin.allowed_users, vec![\"u1\"]);\n assert_eq!(weixin.group_allowed_users, vec![\"g1\"]);\n assert!(weixin.enabled);\n }\n\n // ── Connectivity validation tests ──────────────────────────\n\n fn mock_connectivity_auth_fail(\n _provider_key: &str,\n _base_url: &str,\n _api_key: &str,\n ) -> ConnectivityResult {\n ConnectivityResult::AuthFailed(\"HTTP 401 Unauthorized\".to_string())\n }\n\n fn mock_connectivity_unreachable(\n _provider_key: &str,\n _base_url: &str,\n _api_key: &str,\n ) -> ConnectivityResult {\n ConnectivityResult::Unreachable(\"Connection timed out\".to_string())\n }\n\n #[test]\n fn connectivity_check_ok_shows_models() {\n let mock = MockWizard::new(vec![]);\n let result = run_connectivity_check(\n &mock,\n \"openai\",\n \"https://api.openai.com/v1\",\n \"sk-test-key\",\n mock_connectivity_ok,\n );\n assert!(result.is_ok());\n let output = mock.output();\n assert!(output.contains(\"Connected!\"));\n assert!(output.contains(\"gpt-4o\"));\n }\n\n #[test]\n fn connectivity_check_auth_fail_asks_continue() {\n let mock = MockWizard::new(vec![MockAction::Confirm {\n prompt_contains: \"Continue\",\n result: true,\n }]);\n let result = run_connectivity_check(\n &mock,\n \"openai\",\n \"https://api.openai.com/v1\",\n \"bad-key\",\n mock_connectivity_auth_fail,\n );\n assert!(result.is_ok());\n let output = mock.output();\n assert!(output.contains(\"validation failed\"));\n }\n\n #[test]\n fn connectivity_check_auth_fail_user_cancels() {\n let mock = MockWizard::new(vec![MockAction::Confirm {\n prompt_contains: \"Continue\",\n result: false,\n }]);\n let result = run_connectivity_check(\n &mock,\n \"openai\",\n \"https://api.openai.com/v1\",\n \"bad-key\",\n mock_connectivity_auth_fail,\n );\n assert!(result.is_err());\n }\n\n #[test]\n fn connectivity_check_unreachable_asks_continue() {\n let mock = MockWizard::new(vec![MockAction::Confirm {\n prompt_contains: \"Continue\",\n result: true,\n }]);\n let result = run_connectivity_check(\n &mock,\n \"openai\",\n \"https://api.openai.com/v1\",\n \"sk-test\",\n mock_connectivity_unreachable,\n );\n assert!(result.is_ok());\n let output = mock.output();\n assert!(output.contains(\"Could not verify\"));\n }\n\n #[test]\n fn connectivity_check_skips_ollama() {\n let mock = MockWizard::new(vec![]);\n let result = run_connectivity_check(\n &mock,\n \"ollama\",\n \"http://localhost:11434\",\n \"\",\n mock_connectivity_ok,\n );\n assert!(result.is_ok());\n // No output for Ollama (skipped)\n assert!(mock.output().is_empty());\n }\n\n #[test]\n fn parse_models_from_openai_response() {\n let body = r#\"{\"data\":[{\"id\":\"gpt-4o\"},{\"id\":\"gpt-4o-mini\"},{\"id\":\"gpt-3.5-turbo\"}]}\"#;\n let models = parse_openai_models(body);\n assert_eq!(models, vec![\"gpt-4o\", \"gpt-4o-mini\", \"gpt-3.5-turbo\"]);\n }\n\n #[test]\n fn parse_models_empty_response() {\n let models = parse_openai_models(\"not json\");\n assert!(models.is_empty());\n }\n\n #[test]\n fn extract_error_message_from_openai_format() {\n let body = r#\"{\"error\":{\"message\":\"Incorrect API key\",\"type\":\"invalid_request_error\"}}\"#;\n let msg = extract_error_message(body).unwrap();\n assert_eq!(msg, \"Incorrect API key\");\n }\n\n #[test]\n fn extract_error_message_no_message() {\n let body = r#\"{\"status\":\"ok\"}\"#;\n assert!(extract_error_message(body).is_none());\n }\n\n #[test]\n fn configure_provider_with_connectivity() {\n let mock = MockWizard::new(vec![\n MockAction::Select { result: 0 }, // openai\n MockAction::Input { result: \"gpt-4o\" },\n MockAction::Input {\n result: \"https://api.openai.com/v1\",\n },\n MockAction::Password {\n result: \"sk-testkey123456\",\n },\n ]);\n\n let mut config = Config::default();\n configure_provider_inner(&mock, &mut config, mock_connectivity_ok).unwrap();\n\n assert_eq!(config.agent.provider.as_deref(), Some(\"openai\"));\n assert_eq!(config.agent.model, \"gpt-4o\");\n let output = mock.output();\n assert!(output.contains(\"Connected!\"));\n }\n}\n"}, "a0d389097b461856896ceb019fbac3d0874bc042": {"t": 1785761066.2635505, "n": 72852, "h": "e66232224a8603d5", "c": "//! Setup command — interactive wizard for configuring Kestrel.\n//!\n//! Supports back-navigation via a state-machine loop, Quick/Full setup modes,\n//! input validation, and enhanced configuration summaries.\n\nuse anyhow::{bail, Context, Result};\nuse console::Term;\nuse dialoguer::{Confirm, Input, Password as PasswordInput, Select};\nuse kestrel_config::{\n loader, paths,\n schema::{Config, FeishuConfig, ProviderEntry, TelegramConfig, WebSocketConfig, WeixinConfig},\n};\nuse owo_colors::OwoColorize;\nuse std::net::SocketAddr;\nuse std::path::Path;\nuse std::time::Duration;\n\n// ── Provider display info ──────────────────────────────────────\n\nstruct ProviderInfo {\n key: &'static str,\n display: &'static str,\n default_model: &'static str,\n default_url: &'static str,\n}\n\nconst PROVIDERS: &[ProviderInfo] = &[\n ProviderInfo {\n key: \"openai\",\n display: \"★ OpenAI\",\n default_model: \"gpt-4o\",\n default_url: \"https://api.openai.com/v1\",\n },\n ProviderInfo {\n key: \"anthropic\",\n display: \"★ Anthropic\",\n default_model: \"claude-sonnet-4-20250514\",\n default_url: \"https://api.anthropic.com\",\n },\n ProviderInfo {\n key: \"openrouter\",\n display: \"★ OpenRouter (multi-model)\",\n default_model: \"anthropic/claude-sonnet-4-20250514\",\n default_url: \"https://openrouter.ai/api/v1\",\n },\n ProviderInfo {\n key: \"ollama\",\n display: \" Ollama (local models)\",\n default_model: \"llama3\",\n default_url: \"http://localhost:11434\",\n },\n ProviderInfo {\n key: \"deepseek\",\n display: \" DeepSeek\",\n default_model: \"deepseek-chat\",\n default_url: \"https://api.deepseek.com\",\n },\n ProviderInfo {\n key: \"gemini\",\n display: \" Gemini\",\n default_model: \"gemini-2.5-pro\",\n default_url: \"https://generativelanguage.googleapis.com/v1beta\",\n },\n ProviderInfo {\n key: \"groq\",\n display: \" Groq\",\n default_model: \"llama-3.3-70b-versatile\",\n default_url: \"https://api.groq.com/openai/v1\",\n },\n ProviderInfo {\n key: \"moonshot\",\n display: \" Moonshot (月之暗面)\",\n default_model: \"moonshot-v1-8k\",\n default_url: \"https://api.moonshot.cn/v1\",\n },\n ProviderInfo {\n key: \"minimax\",\n display: \" MiniMax\",\n default_model: \"MiniMax-Text-01\",\n default_url: \"https://api.minimax.chat/v1\",\n },\n ProviderInfo {\n key: \"github_copilot\",\n display: \" GitHub Copilot\",\n default_model: \"gpt-4o\",\n default_url: \"https://api.githubcopilot.com\",\n },\n ProviderInfo {\n key: \"openai_codex\",\n display: \" OpenAI Codex\",\n default_model: \"codex-mini\",\n default_url: \"https://api.openai.com/v1\",\n },\n ProviderInfo {\n key: \"glm_coding_plan\",\n display: \" GLM Coding Plan (智谱)\",\n default_model: \"glm-5.1\",\n default_url: \"https://open.bigmodel.cn/api/coding/paas/v4\",\n },\n];\n\nconst TOTAL_CONFIG_STEPS: usize = 5;\n\nconst BACK_OPTION: &str = \"↩ Go back\";\n\n// ── Wizard step state machine ──────────────────────────────────\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\nenum WizardStep {\n Provider, // Step 1\n Telegram, // Step 2\n Feishu, // Step 3\n WebSocket, // Step 4\n WeChat, // Step 5\n Review, // Final (no step number)\n}\n\nimpl WizardStep {\n fn step_number(self) -> usize {\n match self {\n Self::Provider => 1,\n Self::Telegram => 2,\n Self::Feishu => 3,\n Self::WebSocket => 4,\n Self::WeChat => 5,\n Self::Review => 0,\n }\n }\n\n fn title(self) -> &'static str {\n match self {\n Self::Provider => \"Provider Configuration\",\n Self::Telegram => \"Telegram Channel\",\n Self::Feishu => \"Feishu / Lark Channel\",\n Self::WebSocket => \"WebSocket Port\",\n Self::WeChat => \"WeChat Channel\",\n Self::Review => \"Review & Save\",\n }\n }\n\n fn all() -> &'static [WizardStep] {\n &[\n WizardStep::Provider,\n WizardStep::Telegram,\n WizardStep::Feishu,\n WizardStep::WebSocket,\n WizardStep::WeChat,\n ]\n }\n}\n\n/// Whether a channel step was configured or skipped.\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\nenum ChannelStatus {\n Configured,\n Skipped,\n}\n\n// ── Trait for interactive I/O (enables testability) ────────────\n\ntrait WizardIo {\n fn confirm(&self, prompt: &str, default: bool) -> Result;\n fn select(&self, prompt: &str, items: &[&str], default: usize) -> Result;\n fn input_with_default(&self, prompt: &str, default: &str) -> Result;\n fn input_allow_empty(&self, prompt: &str) -> Result;\n fn password(&self, prompt: &str) -> Result;\n fn write_line(&self, line: &str) -> Result<()>;\n\n /// Show a confirmation with back option. Returns `None` if user chose back.\n fn confirm_or_back(&self, prompt: &str, default: bool) -> Result> {\n let items = if default {\n &[\"Yes\", \"Skip (keep current)\", BACK_OPTION] as &[&str]\n } else {\n &[\"Skip (keep current)\", \"Yes\", BACK_OPTION] as &[&str]\n };\n let default_idx = 0;\n let choice = self.select(prompt, items, default_idx)?;\n match choice {\n idx if items[idx] == BACK_OPTION => Ok(None),\n idx if items[idx] == \"Yes\" => Ok(Some(true)),\n _ => Ok(Some(false)),\n }\n }\n}\n\n// ── Production implementation using Term + dialoguer ───────────\n\nstruct TermWizard<'a> {\n term: &'a Term,\n}\n\nimpl<'a> TermWizard<'a> {\n fn new(term: &'a Term) -> Self {\n Self { term }\n }\n}\n\nimpl WizardIo for TermWizard<'_> {\n fn confirm(&self, prompt: &str, default: bool) -> Result {\n Ok(Confirm::new()\n .with_prompt(prompt)\n .default(default)\n .interact_on(self.term)?)\n }\n\n fn select(&self, prompt: &str, items: &[&str], default: usize) -> Result {\n Ok(Select::new()\n .with_prompt(prompt)\n .items(items)\n .default(default)\n .interact_on(self.term)?)\n }\n\n fn input_with_default(&self, prompt: &str, default: &str) -> Result {\n Ok(Input::new()\n .with_prompt(prompt)\n .default(default.to_string())\n .interact_text_on(self.term)?)\n }\n\n fn input_allow_empty(&self, prompt: &str) -> Result {\n Ok(Input::::new()\n .with_prompt(prompt)\n .allow_empty(true)\n .interact_text_on(self.term)?)\n }\n\n fn password(&self, prompt: &str) -> Result {\n Ok(PasswordInput::new()\n .with_prompt(prompt)\n .interact_on(self.term)?)\n }\n\n fn write_line(&self, line: &str) -> Result<()> {\n self.term.write_line(line)?;\n Ok(())\n }\n}\n\n// ── Entry point ────────────────────────────────────────────────\n\npub fn run(_config: Config) -> Result<()> {\n let term = Term::stdout();\n if !term.is_term() {\n bail!(\n \"Setup requires an interactive terminal. \\\n Run this command in a terminal, not in a pipe or CI environment.\"\n );\n }\n let io = TermWizard::new(&term);\n let config_path = paths::get_config_path()?;\n\n // Set up Ctrl+C handler for graceful exit\n ctrlc::set_handler(|| {\n println!();\n println!(\n \" {} Setup interrupted. Progress has not been saved.\",\n \"!\".yellow()\n );\n println!(\" Run `kestrel setup` again to start over.\");\n std::process::exit(1);\n })\n .ok(); // Ignore error if handler already set\n\n run_wizard(&io, &config_path)\n}\n\n// ── Wizard flow (state machine) ────────────────────────────────\n\nfn run_wizard(io: &dyn WizardIo, config_path: &Path) -> Result<()> {\n let is_first_run = !config_path.exists();\n print_banner(io, is_first_run)?;\n\n // ── Load existing config or start fresh ──────────────────────\n let mut config = if config_path.exists() {\n match load_existing_config(config_path) {\n Ok(existing) => {\n show_config_summary_simple(io, &existing)?;\n if io.confirm(\"Update existing config?\", true)? {\n existing\n } else {\n io.write_line(&format!(\n \" {} Keeping config at {}.\",\n \"✓\".green(),\n config_path.display()\n ))?;\n return Ok(());\n }\n }\n Err(e) => {\n io.write_line(&format!(\n \" {} Could not parse existing config: {}\",\n \"!\".yellow(),\n e\n ))?;\n if io.confirm(\"Start fresh with defaults?\", true)? {\n Config::default()\n } else {\n bail!(\"Setup cancelled.\");\n }\n }\n }\n } else {\n Config::default()\n };\n\n // ── State machine: Review-centric navigation ────────────────\n // After banner + config load, user lands on Review menu.\n // They can freely jump to any step, configure it, and return to Review.\n let mut channel_status = build_channel_status(&config);\n let mut current_step = WizardStep::Review;\n\n loop {\n match current_step {\n WizardStep::Provider => {\n print_step(io, current_step)?;\n match configure_provider(io, &mut config)? {\n StepAction::Back => {}\n StepAction::Continue => {\n channel_status[0] = ChannelStatus::Configured;\n }\n }\n current_step = WizardStep::Review;\n }\n WizardStep::Telegram => {\n print_step(io, current_step)?;\n let has_existing = config.channels.telegram.is_some();\n match configure_channel_step(\n io,\n &mut config,\n \"Telegram\",\n has_existing,\n configure_telegram,\n &mut channel_status[1],\n )? {\n StepAction::Back => {}\n StepAction::Continue => {}\n }\n current_step = WizardStep::Review;\n }\n WizardStep::Feishu => {\n print_step(io, current_step)?;\n let has_existing = config.channels.feishu.as_ref().is_some_and(|f| f.enabled);\n match configure_channel_step(\n io,\n &mut config,\n \"Feishu / Lark\",\n has_existing,\n configure_feishu,\n &mut channel_status[2],\n )? {\n StepAction::Back => {}\n StepAction::Continue => {}\n }\n current_step = WizardStep::Review;\n }\n WizardStep::WebSocket => {\n print_step(io, current_step)?;\n let has_existing = config\n .channels\n .websocket\n .as_ref()\n .is_some_and(|w| w.enabled);\n match configure_channel_step(\n io,\n &mut config,\n \"WebSocket\",\n has_existing,\n configure_websocket,\n &mut channel_status[3],\n )? {\n StepAction::Back => {}\n StepAction::Continue => {}\n }\n current_step = WizardStep::Review;\n }\n WizardStep::WeChat => {\n print_step(io, current_step)?;\n let has_existing = config.channels.weixin.is_some();\n match configure_channel_step(\n io,\n &mut config,\n \"WeChat\",\n has_existing,\n configure_weixin,\n &mut channel_status[4],\n )? {\n StepAction::Back => {}\n StepAction::Continue => {}\n }\n current_step = WizardStep::Review;\n }\n WizardStep::Review => {\n // Refresh status from actual config state\n channel_status = build_channel_status(&config);\n print_review(io, &config, &channel_status)?;\n\n let review_items = build_review_items(&channel_status);\n let review_refs: Vec<&str> = review_items.iter().map(|s| s.as_str()).collect();\n let default_save = review_items.len() - 1;\n let choice = io.select(\n \"Select a step to configure, or save\",\n &review_refs,\n default_save,\n )?;\n\n if choice == default_save {\n if save_config(io, &config, config_path)? {\n print_next_steps(io, &config)?;\n return Ok(());\n }\n // User cancelled save, stay in review\n } else if choice < WizardStep::all().len() {\n current_step = WizardStep::all()[choice];\n }\n }\n }\n }\n}\n\n/// Build channel status from actual config state (not just wizard tracking).\nfn build_channel_status(config: &Config) -> [ChannelStatus; 5] {\n [\n if config.agent.provider.is_some() {\n ChannelStatus::Configured\n } else {\n ChannelStatus::Skipped\n },\n if config.channels.telegram.is_some() {\n ChannelStatus::Configured\n } else {\n ChannelStatus::Skipped\n },\n if config.channels.feishu.as_ref().is_some_and(|f| f.enabled) {\n ChannelStatus::Configured\n } else {\n ChannelStatus::Skipped\n },\n if config\n .channels\n .websocket\n .as_ref()\n .is_some_and(|w| w.enabled)\n {\n ChannelStatus::Configured\n } else {\n ChannelStatus::Skipped\n },\n if config.channels.weixin.is_some() {\n ChannelStatus::Configured\n } else {\n ChannelStatus::Skipped\n },\n ]\n}\n\nfn build_review_items(channel_status: &[ChannelStatus; 5]) -> Vec {\n let mut items: Vec = WizardStep::all()\n .iter()\n .enumerate()\n .map(|(i, s)| {\n let num = s.step_number();\n let title = s.title();\n let status = match channel_status[i] {\n ChannelStatus::Configured => \"configured\".to_string(),\n ChannelStatus::Skipped => \"not configured\".to_string(),\n };\n format!(\"{}. {} ({})\", num, title, status)\n })\n .collect();\n items.push(\"Save configuration\".to_string());\n items\n}\n\n/// Result of a configure step: continue forward or go back.\nenum StepAction {\n Continue,\n Back,\n}\n\n/// Generic wrapper for channel configuration steps with back support.\nfn configure_channel_step(\n io: &dyn WizardIo,\n config: &mut Config,\n channel_name: &str,\n has_existing: bool,\n configure_fn: fn(&dyn WizardIo, &mut Config) -> Result,\n status: &mut ChannelStatus,\n) -> Result {\n let prompt = if has_existing {\n format!(\"Update {} configuration?\", channel_name)\n } else {\n format!(\"Set up {}?\", channel_name)\n };\n\n match io.confirm_or_back(&prompt, has_existing)? {\n None => return Ok(StepAction::Back),\n Some(false) => {\n io.write_line(\" Kept current configuration.\")?;\n *status = ChannelStatus::Skipped;\n return Ok(StepAction::Continue);\n }\n Some(true) => {}\n }\n\n match configure_fn(io, config) {\n Ok(true) => {\n *status = ChannelStatus::Configured;\n Ok(StepAction::Continue)\n }\n Ok(false) => {\n // Configure fn handled skip internally\n *status = ChannelStatus::Skipped;\n Ok(StepAction::Continue)\n }\n Err(e) => Err(e),\n }\n}\n\n// ── Banner & step printing ─────────────────────────────────────\n\nfn print_banner(io: &dyn WizardIo, is_first_run: bool) -> Result<()> {\n io.write_line(\"\")?;\n if is_first_run {\n io.write_line(&format!(\n \" {} {}\",\n \"▸\".cyan(),\n \"Kestrel 初始化配置向导\".bold().cyan()\n ))?;\n io.write_line(\"\")?;\n io.write_line(\" Welcome to Kestrel! Let's set up your configuration.\")?;\n io.write_line(\" You can re-run `kestrel setup` anytime to make changes.\")?;\n } else {\n io.write_line(&format!(\n \" {} {}\",\n \"▸\".cyan(),\n \"Kestrel 配置更新向导\".bold().cyan()\n ))?;\n io.write_line(\"\")?;\n io.write_line(\" Update your existing configuration.\")?;\n }\n io.write_line(\"\")?;\n Ok(())\n}\n\nfn print_step(io: &dyn WizardIo, step: WizardStep) -> Result<()> {\n io.write_line(\"\")?;\n let num = step.step_number();\n io.write_line(&format!(\n \" {} Step {}/{}: {}\",\n \"▸\".cyan(),\n num,\n TOTAL_CONFIG_STEPS,\n step.title().bold()\n ))?;\n io.write_line(&format!(\" {}\", \"─\".repeat(40).dimmed()))?;\n Ok(())\n}\n\nfn print_review(\n io: &dyn WizardIo,\n config: &Config,\n channel_status: &[ChannelStatus; 5],\n) -> Result<()> {\n io.write_line(\"\")?;\n io.write_line(&format!(\n \" {} {}\",\n \"▸\".cyan(),\n \"Review & Save\".bold().cyan()\n ))?;\n io.write_line(&format!(\" {}\", \"─\".repeat(50).dimmed()))?;\n io.write_line(\"\")?;\n show_config_summary(io, config, channel_status)?;\n io.write_line(\"\")?;\n Ok(())\n}\n\nfn load_existing_config(path: &Path) -> Result {\n let content = std::fs::read_to_string(path)\n .with_context(|| format!(\"Failed to read {}\", path.display()))?;\n let config: Config =\n toml::from_str(&content).with_context(|| format!(\"Failed to parse {}\", path.display()))?;\n Ok(config)\n}\n\n// ── Token masking & config summary ─────────────────────────────\n\nfn mask_token(token: &str) -> String {\n if token.is_empty() {\n \"(not set)\".to_string()\n } else if token.len() <= 8 {\n \"(masked)\".to_string()\n } else {\n format!(\"{}…{}\", &token[..3], &token[token.len() - 4..])\n }\n}\n\nfn show_config_summary(\n io: &dyn WizardIo,\n config: &Config,\n channel_status: &[ChannelStatus; 5],\n) -> Result<()> {\n let provider = config.agent.provider.as_deref().unwrap_or(\"default\");\n io.write_line(&format!(\" Provider: {}\", provider))?;\n io.write_line(&format!(\" Model: {}\", config.agent.model))?;\n\n // Show base URL\n if let Some(url) = config\n .agent\n .provider\n .as_deref()\n .and_then(|p| get_provider_url(config, p))\n {\n io.write_line(&format!(\" Base URL: {}\", url))?;\n }\n\n // Show API key status\n let key_status = config\n .agent\n .provider\n .as_deref()\n .and_then(|p| get_provider_api_key(config, p))\n .map(mask_token)\n .unwrap_or_else(|| \"(not set)\".to_string());\n io.write_line(&format!(\" API Key: {}\", key_status))?;\n\n io.write_line(&format!(\" Temperature: {}\", config.agent.temperature))?;\n io.write_line(&format!(\" Max tokens: {}\", config.agent.max_tokens))?;\n\n io.write_line(\"\")?;\n io.write_line(\" Channels:\")?;\n\n let channel_names = [\"Telegram\", \"Feishu\", \"WebSocket\", \"WeChat\"];\n for (i, name) in channel_names.iter().enumerate() {\n let status_str = match channel_status[i + 1] {\n ChannelStatus::Configured => \"✓ configured\".green().to_string(),\n ChannelStatus::Skipped => \"✗ skipped\".dimmed().to_string(),\n };\n io.write_line(&format!(\" {}: {}\", name, status_str))?;\n }\n\n // Show details for configured channels\n if let Some(ref tg) = config.channels.telegram {\n if !tg.token.is_empty() {\n io.write_line(&format!(\" Token: {}\", mask_token(&tg.token)))?;\n }\n }\n\n if let Some(ref ws) = config.channels.websocket {\n if ws.enabled {\n io.write_line(&format!(\" Address: {}\", ws.listen_addr))?;\n }\n }\n\n if let Some(ref wx) = config.channels.weixin {\n if wx.enabled {\n let acct = wx.account_id.as_deref().unwrap_or(\"(unknown)\");\n io.write_line(&format!(\" Account: {}\", mask_token(acct)))?;\n }\n }\n\n if let Some(ref fs) = config.channels.feishu {\n if fs.enabled {\n let app_id = fs.app_id.as_deref().unwrap_or(\"(unknown)\");\n io.write_line(&format!(\" App ID: {}\", mask_token(app_id)))?;\n }\n }\n\n Ok(())\n}\n\n/// Legacy summary without channel status (used for existing config review).\nfn show_config_summary_simple(io: &dyn WizardIo, config: &Config) -> Result<()> {\n let provider = config.agent.provider.as_deref().unwrap_or(\"default\");\n io.write_line(&format!(\" Provider: {}\", provider))?;\n io.write_line(&format!(\" Model: {}\", config.agent.model))?;\n io.write_line(&format!(\" Temperature: {}\", config.agent.temperature))?;\n io.write_line(&format!(\" Max tokens: {}\", config.agent.max_tokens))?;\n io.write_line(&format!(\" Streaming: {}\", config.agent.streaming))?;\n\n if let Some(ref tg) = config.channels.telegram {\n io.write_line(&format!(\" Telegram: {}\", mask_token(&tg.token)))?;\n }\n\n if let Some(ref fs) = config.channels.feishu {\n if fs.enabled {\n let app_id = fs.app_id.as_deref().unwrap_or(\"(not set)\");\n io.write_line(&format!(\" Feishu: {}\", mask_token(app_id)))?;\n }\n }\n\n if let Some(ref ws) = config.channels.websocket {\n if ws.enabled {\n io.write_line(&format!(\" WebSocket: {}\", ws.listen_addr))?;\n }\n }\n\n if let Some(ref wx) = config.channels.weixin {\n let status = if wx.enabled {\n mask_token(wx.bot_token.as_deref().unwrap_or(\"\"))\n } else {\n \"disabled\".to_string()\n };\n io.write_line(&format!(\" WeChat: {}\", status))?;\n }\n\n Ok(())\n}\n\nfn save_config(io: &dyn WizardIo, config: &Config, config_path: &Path) -> Result {\n io.write_line(&format!(\" Config path: {}\", config_path.display()))?;\n\n if !io.confirm(\"Write this configuration?\", true)? {\n io.write_line(&format!(\" {} Save cancelled.\", \"!\".yellow()))?;\n return Ok(false);\n }\n\n let home = config_path\n .parent()\n .context(\"Config path must have a parent directory\")?;\n\n std::fs::create_dir_all(home)\n .with_context(|| format!(\"Failed to create config home: {}\", home.display()))?;\n\n loader::save_config(config, config_path)?;\n\n for dir in [\"skills\", \"sessions\", \"learning\"] {\n let path = home.join(dir);\n std::fs::create_dir_all(&path)\n .with_context(|| format!(\"Failed to create directory: {}\", path.display()))?;\n }\n\n io.write_line(\"\")?;\n io.write_line(&format!(\n \" {} Configuration saved to {}\",\n \"✓\".green(),\n config_path.display()\n ))?;\n io.write_line(&format!(\n \" {} Created directories: skills, sessions, learning\",\n \"✓\".green()\n ))?;\n io.write_line(&format!(\" {} Setup complete!\", \"✓\".green()))?;\n\n Ok(true)\n}\n\nfn print_next_steps(io: &dyn WizardIo, config: &Config) -> Result<()> {\n io.write_line(\"\")?;\n io.write_line(&format!(\" {} Next steps:\", \"▸\".cyan()))?;\n io.write_line(\"\")?;\n io.write_line(\" 1. Try it out:\")?;\n io.write_line(\" kestrel agent\")?;\n io.write_line(\"\")?;\n\n let has_channel = config.channels.telegram.is_some()\n || config.channels.feishu.is_some()\n || config.channels.weixin.is_some()\n || config.channels.websocket.is_some();\n\n if has_channel {\n io.write_line(\" 2. Start the gateway (connect to chat platforms):\")?;\n io.write_line(\" kestrel gateway\")?;\n io.write_line(\"\")?;\n }\n\n if config.channels.websocket.is_some() {\n io.write_line(\" 3. Start the API server:\")?;\n io.write_line(\" kestrel serve\")?;\n io.write_line(\"\")?;\n }\n\n io.write_line(\" Check system health:\")?;\n io.write_line(\" kestrel doctor\")?;\n io.write_line(\"\")?;\n io.write_line(\" Re-run setup anytime:\")?;\n io.write_line(\" kestrel setup\")?;\n io.write_line(\"\")?;\n Ok(())\n}\n\n// ── Input validation ───────────────────────────────────────────\n\nfn validate_api_key(provider: &str, key: &str) -> Result {\n let key = key.trim().to_string();\n if key.is_empty() {\n bail!(\"API key cannot be empty\");\n }\n match provider {\n \"openai\" | \"openai_codex\" if !key.starts_with(\"sk-\") => {\n bail!(\"OpenAI API keys typically start with 'sk-'\");\n }\n \"anthropic\" if !key.starts_with(\"sk-ant-\") => {\n bail!(\"Anthropic API keys typically start with 'sk-ant-'\");\n }\n _ => {}\n }\n Ok(key)\n}\n\nfn validate_url(input: &str) -> Result {\n let url = input.trim().to_string();\n if url.is_empty() {\n return Ok(url);\n }\n if !url.starts_with(\"http://\") && !url.starts_with(\"https://\") {\n bail!(\"URL must start with http:// or https://\");\n }\n Ok(url)\n}\n\n// ── Connectivity validation ────────────────────────────────────\n\n/// Result of a connectivity check.\n#[cfg_attr(test, allow(dead_code))]\nenum ConnectivityResult {\n /// Successfully connected and authenticated.\n Ok {\n latency_ms: u64,\n models: Vec,\n },\n /// Connected to server but authentication failed (HTTP 401/403).\n AuthFailed(String),\n /// Server unreachable or other network error.\n Unreachable(String),\n}\n\n/// Test API key validity by making a real request to the provider.\n///\n/// Most OpenAI-compatible providers support `GET /models` with Bearer auth.\n/// For Anthropic we use `GET /v1/models` with `x-api-key` header.\n#[cfg_attr(test, allow(dead_code))]\nfn test_api_key_connectivity(\n provider_key: &str,\n base_url: &str,\n api_key: &str,\n) -> ConnectivityResult {\n let rt = match tokio::runtime::Runtime::new() {\n Ok(rt) => rt,\n Err(e) => return ConnectivityResult::Unreachable(format!(\"Runtime error: {}\", e)),\n };\n\n rt.block_on(async {\n let client = reqwest::Client::builder()\n .timeout(Duration::from_secs(10))\n .build();\n\n let client = match client {\n Ok(c) => c,\n Err(e) => return ConnectivityResult::Unreachable(format!(\"Client error: {}\", e)),\n };\n\n let start = std::time::Instant::now();\n\n // Build the request based on provider type\n let (display_url, req) = build_validation_request(provider_key, base_url, api_key, &client);\n\n match req {\n Ok(request) => match client.execute(request).await {\n Ok(resp) => {\n let latency = start.elapsed().as_millis() as u64;\n let status = resp.status();\n\n if status.is_success() {\n let models = parse_models_from_response(provider_key, resp).await;\n ConnectivityResult::Ok {\n latency_ms: latency,\n models,\n }\n } else if status.as_u16() == 401 || status.as_u16() == 403 {\n let body = resp.text().await.unwrap_or_default();\n let msg = extract_error_message(&body)\n .unwrap_or_else(|| format!(\"HTTP {} — API key may be invalid\", status));\n ConnectivityResult::AuthFailed(msg)\n } else {\n // Other HTTP errors — report with status code so user can diagnose\n let msg = format!(\"Server returned HTTP {} for {}\", status, display_url);\n ConnectivityResult::Unreachable(msg)\n }\n }\n Err(e) => {\n let msg = if e.is_timeout() {\n \"Connection timed out (10s)\".to_string()\n } else if e.is_connect() {\n format!(\"Cannot connect to {}\", display_url)\n } else {\n format!(\"{}\", e)\n };\n ConnectivityResult::Unreachable(msg)\n }\n },\n Err(e) => ConnectivityResult::Unreachable(format!(\"Request error: {}\", e)),\n }\n })\n}\n\n/// Build the validation HTTP request based on provider type.\n/// Returns (display_url, request_result) where display_url is safe to show in error messages.\n#[cfg_attr(test, allow(dead_code))]\nfn build_validation_request(\n provider_key: &str,\n base_url: &str,\n api_key: &str,\n client: &reqwest::Client,\n) -> (String, Result) {\n let (display_url, req_builder) = match provider_key {\n \"anthropic\" => {\n let url = format!(\"{}/v1/models\", base_url.trim_end_matches('/'));\n let req = client\n .get(&url)\n .header(\"x-api-key\", api_key)\n .header(\"anthropic-version\", \"2023-06-01\");\n (url, req.build())\n }\n \"gemini\" => {\n let url = format!(\"{}?key={}\", base_url.trim_end_matches('/'), api_key);\n // Don't expose API key in error messages\n let display = format!(\"{}/models\", base_url.trim_end_matches('/'));\n let req = client.get(&url);\n (display, req.build())\n }\n // OpenAI-compatible providers: openai, openrouter, deepseek, groq, moonshot, minimax,\n // github_copilot, openai_codex, glm_coding_plan, etc.\n _ => {\n let base = base_url.trim_end_matches('/');\n let url = format!(\"{}/models\", base);\n let req = client\n .get(&url)\n .header(\"Authorization\", format!(\"Bearer {}\", api_key));\n (url, req.build())\n }\n };\n (display_url, req_builder)\n}\n\n/// Try to extract model names from the validation response.\n#[cfg_attr(test, allow(dead_code))]\nasync fn parse_models_from_response(provider_key: &str, resp: reqwest::Response) -> Vec {\n let body = resp.text().await.unwrap_or_default();\n\n if provider_key == \"gemini\" {\n // Gemini returns { \"models\": [ { \"name\": \"models/gemini-2.5-pro\", ... } ] }\n if let Ok(val) = serde_json::from_str::(&body) {\n if let Some(models) = val.get(\"models\").and_then(|m| m.as_array()) {\n return models\n .iter()\n .filter_map(|m| {\n m.get(\"name\")\n .and_then(|n| n.as_str())\n .map(|n| n.trim_start_matches(\"models/\").to_string())\n })\n .take(5)\n .collect();\n }\n }\n return vec![];\n }\n\n if provider_key == \"anthropic\" {\n // Anthropic /v1/models returns { \"data\": [ { \"id\": \"claude-...\", ... } ] }\n return parse_openai_models(&body);\n }\n\n // OpenAI-compatible: { \"data\": [ { \"id\": \"gpt-4o\", ... } ] }\n parse_openai_models(&body)\n}\n\n/// Parse model IDs from an OpenAI-style { \"data\": [ ... ] } response.\nfn parse_openai_models(body: &str) -> Vec {\n if let Ok(val) = serde_json::from_str::(body) {\n if let Some(data) = val.get(\"data\").and_then(|d| d.as_array()) {\n return data\n .iter()\n .filter_map(|m| m.get(\"id\").and_then(|id| id.as_str()).map(String::from))\n .take(5)\n .collect();\n }\n }\n vec![]\n}\n\n/// Extract a human-readable error message from an error response body.\nfn extract_error_message(body: &str) -> Option {\n if let Ok(val) = serde_json::from_str::(body) {\n // { \"error\": { \"message\": \"...\" } } — OpenAI and Anthropic format\n if let Some(msg) = val\n .get(\"error\")\n .and_then(|e| e.get(\"message\"))\n .and_then(|m| m.as_str())\n {\n return Some(msg.to_string());\n }\n // Fallback: { \"message\": \"...\" }\n if let Some(msg) = val.get(\"message\").and_then(|m| m.as_str()) {\n return Some(msg.to_string());\n }\n }\n None\n}\n\n/// Run connectivity validation after provider config is entered.\n/// Shows results to user and asks if they want to continue on failure.\n/// Accepts a connectivity test fn (for testability).\nfn run_connectivity_check(\n io: &dyn WizardIo,\n provider_key: &str,\n base_url: &str,\n api_key: &str,\n test_fn: fn(&str, &str, &str) -> ConnectivityResult,\n) -> Result<()> {\n // Skip for Ollama (local, no API key needed)\n if provider_key == \"ollama\" {\n return Ok(());\n }\n\n io.write_line(\"\")?;\n io.write_line(&format!(\" {} Validating API key…\", \"⟳\".cyan()))?;\n\n match test_fn(provider_key, base_url, api_key) {\n ConnectivityResult::Ok { latency_ms, models } => {\n if models.is_empty() {\n io.write_line(&format!(\n \" {} Connected! (latency: {}ms)\",\n \"✓\".green(),\n latency_ms\n ))?;\n } else {\n io.write_line(&format!(\n \" {} Connected! (latency: {}ms)\",\n \"✓\".green(),\n latency_ms\n ))?;\n let models_str = models.join(\", \");\n io.write_line(&format!(\" Available models: {}\", models_str.dimmed()))?;\n }\n }\n ConnectivityResult::AuthFailed(msg) => {\n io.write_line(&format!(\n \" {} API key validation failed: {}\",\n \"✗\".red(),\n msg\n ))?;\n if !io.confirm(\" Continue with this key anyway?\", false)? {\n bail!(\"Provider configuration cancelled.\");\n }\n }\n ConnectivityResult::Unreachable(msg) => {\n io.write_line(&format!(\n \" {} Could not verify API key: {}\",\n \"⚠\".yellow(),\n msg\n ))?;\n io.write_line(&format!(\n \" {}\",\n \"The server may be unreachable from this network.\".dimmed()\n ))?;\n if !io.confirm(\" Continue anyway?\", true)? {\n bail!(\"Provider configuration cancelled.\");\n }\n }\n }\n\n Ok(())\n}\n\n// ── Provider configuration ─────────────────────────────────────\n\nfn configure_provider(io: &dyn WizardIo, config: &mut Config) -> Result {\n #[cfg(test)]\n let test_fn: fn(&str, &str, &str) -> ConnectivityResult = mock_connectivity_ok;\n #[cfg(not(test))]\n let test_fn: fn(&str, &str, &str) -> ConnectivityResult = test_api_key_connectivity;\n configure_provider_inner(io, config, test_fn)\n}\n\nfn configure_provider_inner(\n io: &dyn WizardIo,\n config: &mut Config,\n connectivity_fn: fn(&str, &str, &str) -> ConnectivityResult,\n) -> Result {\n let display_names: Vec<&str> = PROVIDERS.iter().map(|p| p.display).collect();\n\n let default_idx = config\n .agent\n .provider\n .as_deref()\n .and_then(|p| PROVIDERS.iter().position(|info| info.key == p))\n .unwrap_or(0); // default to OpenAI (first)\n\n let provider_idx = io.select(\"Select LLM provider\", &display_names, default_idx)?;\n\n let provider_key = PROVIDERS[provider_idx].key;\n config.agent.provider = Some(provider_key.to_string());\n\n let default_model = PROVIDERS[provider_idx].default_model;\n let current_model = if config.agent.model.is_empty() {\n default_model\n } else {\n &config.agent.model\n };\n\n let model: String = io.input_with_default(\"Model name\", current_model)?;\n config.agent.model = model.trim().to_string();\n\n let default_url = PROVIDERS[provider_idx].default_url;\n let current_url = get_provider_url(config, provider_key).unwrap_or(default_url);\n\n if !current_url.is_empty() {\n let base_url: String = io.input_with_default(\"Base URL\", current_url)?;\n let base_url = validate_url(&base_url)?;\n set_provider_url(config, provider_key, &base_url);\n } else {\n let base_url: String = io.input_allow_empty(\"Base URL (leave empty for default)\")?;\n let base_url = validate_url(&base_url)?;\n if !base_url.is_empty() {\n set_provider_url(config, provider_key, &base_url);\n }\n }\n\n loop {\n let api_key: String = io.password(\"API key\")?;\n match validate_api_key(provider_key, &api_key) {\n Ok(key) => {\n set_provider_api_key(config, provider_key, &key);\n break;\n }\n Err(e) => {\n io.write_line(&format!(\" {} {}\", \"!\".yellow(), e))?;\n if !io.confirm(\"Try again?\", true)? {\n // Accept whatever was entered\n let key = api_key.trim().to_string();\n if !key.is_empty() {\n set_provider_api_key(config, provider_key, &key);\n }\n break;\n }\n }\n }\n }\n\n // Connectivity validation\n let base_url = get_provider_url(config, provider_key)\n .unwrap_or(PROVIDERS[provider_idx].default_url)\n .to_string();\n let api_key = get_provider_api_key(config, provider_key)\n .unwrap_or(\"\")\n .to_string();\n if !api_key.is_empty() && !base_url.is_empty() {\n run_connectivity_check(io, provider_key, &base_url, &api_key, connectivity_fn)?;\n }\n\n Ok(StepAction::Continue)\n}\n\n// ── Channel configurations ─────────────────────────────────────\n\n/// Configure Telegram. Returns true if configured, false if skipped.\nfn configure_telegram(io: &dyn WizardIo, config: &mut Config) -> Result {\n let current_token = config\n .channels\n .telegram\n .as_ref()\n .map(|tg| tg.token.as_str())\n .unwrap_or(\"\");\n\n let token: String = if current_token.is_empty() {\n io.input_allow_empty(\"Bot token (from @BotFather)\")?\n } else {\n io.input_with_default(\"Bot token (from @BotFather)\", current_token)?\n };\n\n let token = token.trim().to_string();\n if token.is_empty() {\n io.write_line(\" No token provided, skipping Telegram.\")?;\n return Ok(false);\n }\n\n let allowed: String = loop {\n let input: String =\n io.input_allow_empty(\"Allowed user IDs (comma-separated, leave empty for all)\")?;\n if input.trim().is_empty() {\n break String::new();\n }\n let ids: Vec<&str> = input\n .split(',')\n .map(|s| s.trim())\n .filter(|s| !s.is_empty())\n .collect();\n let mut valid = true;\n for id in &ids {\n if id.parse::().is_err() {\n io.write_line(&format!(\n \" {} '{}' is not a valid user ID (must be a positive integer).\",\n \"!\".yellow(),\n id\n ))?;\n valid = false;\n break;\n }\n }\n if valid {\n break input;\n }\n };\n\n let allowed_users: Vec = if allowed.trim().is_empty() {\n Vec::new()\n } else {\n allowed\n .split(',')\n .map(|s| s.trim().to_string())\n .filter(|s| !s.is_empty())\n .collect()\n };\n\n let (admin_users, enabled, streaming, proxy) = config\n .channels\n .telegram\n .as_ref()\n .map(|tg| {\n (\n tg.admin_users.clone(),\n tg.enabled,\n tg.streaming,\n tg.proxy.clone(),\n )\n })\n .unwrap_or((Vec::new(), true, true, None));\n\n config.channels.telegram = Some(TelegramConfig {\n token,\n allowed_users,\n admin_users,\n enabled,\n streaming,\n proxy,\n });\n\n io.write_line(&format!(\" {} Telegram configured.\", \"✓\".green()))?;\n Ok(true)\n}\n\n/// Configure Feishu. Returns true if configured, false if skipped.\nfn configure_feishu(io: &dyn WizardIo, config: &mut Config) -> Result {\n let domain_options = [\"Feishu (飞书)\", \"Lark (international)\"];\n let idx = io.select(\"Select platform\", &domain_options, 0)?;\n let domain = if idx == 1 { \"lark\" } else { \"feishu\" };\n\n let rt = tokio::runtime::Builder::new_current_thread()\n .enable_all()\n .build()\n .context(\"Failed to create tokio runtime\")?;\n\n let result = match rt.block_on(super::feishu_onboarding::run_onboarding(domain)) {\n Ok(r) => r,\n Err(e) => {\n io.write_line(&format!(\" {} Feishu setup skipped: {}\", \"!\".yellow(), e))?;\n return Ok(false);\n }\n };\n\n let existing_proxy = config\n .channels\n .feishu\n .as_ref()\n .and_then(|f| f.proxy.clone());\n\n config.channels.feishu = Some(FeishuConfig {\n app_id: Some(result.app_id),\n app_secret: Some(result.app_secret),\n enabled: true,\n proxy: existing_proxy,\n ..Default::default()\n });\n\n io.write_line(&format!(\" {} Feishu configured.\", \"✓\".green()))?;\n Ok(true)\n}\n\n/// Configure WebSocket. Returns true if configured, false if skipped.\nfn configure_websocket(io: &dyn WizardIo, config: &mut Config) -> Result {\n let default_addr = config\n .channels\n .websocket\n .as_ref()\n .map(|ws| ws.listen_addr.as_str())\n .unwrap_or(\"127.0.0.1:8090\");\n\n let listen_addr: String = loop {\n let input = io.input_with_default(\"Listen address\", default_addr)?;\n match input.parse::() {\n Ok(_) => break input,\n Err(e) => {\n io.write_line(&format!(\n \" {} Invalid address '{}': {}\",\n \"!\".yellow(),\n input,\n e\n ))?;\n }\n }\n };\n\n config.channels.websocket = Some(WebSocketConfig {\n enabled: true,\n listen_addr,\n auth: Default::default(),\n max_clients: 100,\n max_message_size: 1048576,\n });\n\n io.write_line(&format!(\" {} WebSocket configured.\", \"✓\".green()))?;\n Ok(true)\n}\n\n/// Configure WeChat. Returns true if configured, false if skipped.\nfn configure_weixin(io: &dyn WizardIo, config: &mut Config) -> Result {\n let choices = &[\n \"Scan QR code with WeChat (recommended)\",\n \"Enter credentials manually\",\n \"Skip\",\n ];\n let choice = io.select(\"How would you like to configure WeChat?\", choices, 0)?;\n\n match choice {\n 0 => {\n io.write_line(\n \" QR scan setup requires running `kestrel setup weixin` in a terminal.\",\n )?;\n io.write_line(\" Please run that command separately, then return to this wizard.\")?;\n if let Some(ref mut wx) = config.channels.weixin {\n if wx.account_id.is_some() && wx.bot_token.is_some() {\n wx.enabled = true;\n io.write_line(&format!(\n \" {} Existing WeChat credentials detected and enabled.\",\n \"✓\".green()\n ))?;\n return Ok(true);\n }\n }\n if config.channels.weixin.is_none() {\n io.write_line(\n \" No WeChat credentials found yet. Run `kestrel setup weixin` first.\",\n )?;\n }\n Ok(false)\n }\n 1 => {\n let current_account = config\n .channels\n .weixin\n .as_ref()\n .and_then(|w| w.account_id.as_deref())\n .unwrap_or(\"\");\n let account_id: String = if current_account.is_empty() {\n io.input_allow_empty(\"iLink account ID (e.g. wxid_xxx@im.bot)\")?\n } else {\n io.input_with_default(\"iLink account ID\", current_account)?\n };\n\n if account_id.trim().is_empty() {\n io.write_line(\" No account ID provided, skipping WeChat.\")?;\n return Ok(false);\n }\n\n let current_token = config\n .channels\n .weixin\n .as_ref()\n .and_then(|w| w.bot_token.as_deref())\n .unwrap_or(\"\");\n let bot_token: String = if current_token.is_empty() {\n io.input_allow_empty(\"iLink bot token\")?\n } else {\n io.input_with_default(\"iLink bot token\", current_token)?\n };\n\n if bot_token.trim().is_empty() {\n io.write_line(\" No bot token provided, skipping WeChat.\")?;\n return Ok(false);\n }\n\n let (\n app_id,\n app_secret,\n old_token,\n encoding_aes_key,\n base_url,\n cdn_base_url,\n dm_policy,\n group_policy,\n allowed_users,\n group_allowed_users,\n ) = config\n .channels\n .weixin\n .as_ref()\n .map(|w| {\n (\n w.app_id.clone(),\n w.app_secret.clone(),\n w.token.clone(),\n w.encoding_aes_key.clone(),\n w.base_url.clone(),\n w.cdn_base_url.clone(),\n w.dm_policy.clone(),\n w.group_policy.clone(),\n w.allowed_users.clone(),\n w.group_allowed_users.clone(),\n )\n })\n .unwrap_or_default();\n\n config.channels.weixin = Some(WeixinConfig {\n account_id: Some(account_id.trim().to_string()),\n bot_token: Some(bot_token.trim().to_string()),\n app_id,\n app_secret,\n token: old_token,\n encoding_aes_key,\n base_url,\n cdn_base_url,\n dm_policy,\n group_policy,\n allowed_users,\n group_allowed_users,\n enabled: true,\n });\n\n io.write_line(&format!(\" {} WeChat configured.\", \"✓\".green()))?;\n Ok(true)\n }\n _ => {\n io.write_line(\" Skipped.\")?;\n Ok(false)\n }\n }\n}\n\n// ── Provider field helpers ─────────────────────────────────────\n\nfn get_provider_entry_mut<'a>(\n config: &'a mut Config,\n provider: &str,\n) -> Option<&'a mut ProviderEntry> {\n match provider {\n \"anthropic\" => config.providers.anthropic.as_mut(),\n \"openai\" => config.providers.openai.as_mut(),\n \"openrouter\" => config.providers.openrouter.as_mut(),\n \"ollama\" => config.providers.ollama.as_mut(),\n \"deepseek\" => config.providers.deepseek.as_mut(),\n \"gemini\" => config.providers.gemini.as_mut(),\n \"groq\" => config.providers.groq.as_mut(),\n \"moonshot\" => config.providers.moonshot.as_mut(),\n \"minimax\" => config.providers.minimax.as_mut(),\n \"github_copilot\" => config.providers.github_copilot.as_mut(),\n \"openai_codex\" => config.providers.openai_codex.as_mut(),\n \"opencode_go\" => config.providers.opencode_go.as_mut(),\n \"glm_coding_plan\" => config.providers.glm_coding_plan.as_mut(),\n _ => None,\n }\n}\n\nfn ensure_provider_entry(config: &mut Config, provider: &str) {\n match provider {\n \"anthropic\" => {\n config\n .providers\n .anthropic\n .get_or_insert_with(ProviderEntry::default);\n }\n \"openai\" => {\n config\n .providers\n .openai\n .get_or_insert_with(ProviderEntry::default);\n }\n \"openrouter\" => {\n config\n .providers\n .openrouter\n .get_or_insert_with(ProviderEntry::default);\n }\n \"ollama\" => {\n config\n .providers\n .ollama\n .get_or_insert_with(ProviderEntry::default);\n }\n \"deepseek\" => {\n config\n .providers\n .deepseek\n .get_or_insert_with(ProviderEntry::default);\n }\n \"gemini\" => {\n config\n .providers\n .gemini\n .get_or_insert_with(ProviderEntry::default);\n }\n \"groq\" => {\n config\n .providers\n .groq\n .get_or_insert_with(ProviderEntry::default);\n }\n \"moonshot\" => {\n config\n .providers\n .moonshot\n .get_or_insert_with(ProviderEntry::default);\n }\n \"minimax\" => {\n config\n .providers\n .minimax\n .get_or_insert_with(ProviderEntry::default);\n }\n \"github_copilot\" => {\n config\n .providers\n .github_copilot\n .get_or_insert_with(ProviderEntry::default);\n }\n \"openai_codex\" => {\n config\n .providers\n .openai_codex\n .get_or_insert_with(ProviderEntry::default);\n }\n \"opencode_go\" => {\n config\n .providers\n .opencode_go\n .get_or_insert_with(ProviderEntry::default);\n }\n \"glm_coding_plan\" => {\n config\n .providers\n .glm_coding_plan\n .get_or_insert_with(ProviderEntry::default);\n }\n _ => {}\n }\n}\n\nfn get_provider_url<'a>(config: &'a Config, provider: &str) -> Option<&'a str> {\n let entry = match provider {\n \"anthropic\" => config.providers.anthropic.as_ref(),\n \"openai\" => config.providers.openai.as_ref(),\n \"openrouter\" => config.providers.openrouter.as_ref(),\n \"ollama\" => config.providers.ollama.as_ref(),\n \"deepseek\" => config.providers.deepseek.as_ref(),\n \"gemini\" => config.providers.gemini.as_ref(),\n \"groq\" => config.providers.groq.as_ref(),\n \"moonshot\" => config.providers.moonshot.as_ref(),\n \"minimax\" => config.providers.minimax.as_ref(),\n \"github_copilot\" => config.providers.github_copilot.as_ref(),\n \"openai_codex\" => config.providers.openai_codex.as_ref(),\n \"opencode_go\" => config.providers.opencode_go.as_ref(),\n \"glm_coding_plan\" => config.providers.glm_coding_plan.as_ref(),\n _ => None,\n };\n entry.and_then(|e| e.base_url.as_deref())\n}\n\nfn get_provider_api_key<'a>(config: &'a Config, provider: &str) -> Option<&'a str> {\n let entry = match provider {\n \"anthropic\" => config.providers.anthropic.as_ref(),\n \"openai\" => config.providers.openai.as_ref(),\n \"openrouter\" => config.providers.openrouter.as_ref(),\n \"ollama\" => config.providers.ollama.as_ref(),\n \"deepseek\" => config.providers.deepseek.as_ref(),\n \"gemini\" => config.providers.gemini.as_ref(),\n \"groq\" => config.providers.groq.as_ref(),\n \"moonshot\" => config.providers.moonshot.as_ref(),\n \"minimax\" => config.providers.minimax.as_ref(),\n \"github_copilot\" => config.providers.github_copilot.as_ref(),\n \"openai_codex\" => config.providers.openai_codex.as_ref(),\n \"opencode_go\" => config.providers.opencode_go.as_ref(),\n \"glm_coding_plan\" => config.providers.glm_coding_plan.as_ref(),\n _ => None,\n };\n entry.and_then(|e| e.api_key.as_deref())\n}\n\nfn set_provider_url(config: &mut Config, provider: &str, url: &str) {\n ensure_provider_entry(config, provider);\n if let Some(entry) = get_provider_entry_mut(config, provider) {\n entry.base_url = if url.is_empty() {\n None\n } else {\n Some(url.to_string())\n };\n }\n}\n\nfn set_provider_api_key(config: &mut Config, provider: &str, key: &str) {\n ensure_provider_entry(config, provider);\n if let Some(entry) = get_provider_entry_mut(config, provider) {\n entry.api_key = if key.is_empty() {\n None\n } else {\n Some(key.to_string())\n };\n }\n}\n\n// ── Test helpers (cfg(test) only) ─────────────────────────────\n\n#[cfg(test)]\nfn mock_connectivity_ok(\n _provider_key: &str,\n _base_url: &str,\n _api_key: &str,\n) -> ConnectivityResult {\n ConnectivityResult::Ok {\n latency_ms: 42,\n models: vec![\"gpt-4o\".to_string(), \"gpt-4o-mini\".to_string()],\n }\n}\n\n// ── Tests ──────────────────────────────────────────────────────\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use std::cell::RefCell;\n use std::collections::VecDeque;\n\n fn template_toml() -> String {\n toml::to_string(&Config::default()).unwrap()\n }\n\n // ── Mock wizard IO for flow testing ─────────────────────────\n\n #[derive(Debug, Clone)]\n enum MockAction {\n Confirm {\n prompt_contains: &'static str,\n result: bool,\n },\n Select {\n result: usize,\n },\n Input {\n result: &'static str,\n },\n Password {\n result: &'static str,\n },\n }\n\n struct MockWizard {\n actions: RefCell>,\n output: RefCell,\n }\n\n impl MockWizard {\n fn new(actions: Vec) -> Self {\n Self {\n actions: RefCell::new(actions.into()),\n output: RefCell::new(String::new()),\n }\n }\n\n fn output(&self) -> String {\n self.output.borrow().clone()\n }\n }\n\n impl WizardIo for MockWizard {\n fn confirm(&self, prompt: &str, _default: bool) -> Result {\n let action = self.actions.borrow_mut().pop_front();\n match action {\n Some(MockAction::Confirm {\n prompt_contains,\n result,\n }) => {\n assert!(\n prompt.contains(prompt_contains),\n \"confirm prompt '{}' did not contain '{}'\",\n prompt,\n prompt_contains\n );\n Ok(result)\n }\n _ => panic!(\"unexpected confirm call, prompt: {}\", prompt),\n }\n }\n\n fn select(&self, _prompt: &str, _items: &[&str], _default: usize) -> Result {\n let action = self.actions.borrow_mut().pop_front();\n match action {\n Some(MockAction::Select { result }) => Ok(result),\n _ => panic!(\"unexpected select call\"),\n }\n }\n\n fn input_with_default(&self, _prompt: &str, _default: &str) -> Result {\n let action = self.actions.borrow_mut().pop_front();\n match action {\n Some(MockAction::Input { result }) => Ok(result.to_string()),\n _ => panic!(\"unexpected input_with_default call\"),\n }\n }\n\n fn input_allow_empty(&self, _prompt: &str) -> Result {\n let action = self.actions.borrow_mut().pop_front();\n match action {\n Some(MockAction::Input { result }) => Ok(result.to_string()),\n _ => panic!(\"unexpected input_allow_empty call\"),\n }\n }\n\n fn password(&self, _prompt: &str) -> Result {\n let action = self.actions.borrow_mut().pop_front();\n match action {\n Some(MockAction::Password { result }) => Ok(result.to_string()),\n _ => panic!(\"unexpected password call\"),\n }\n }\n\n fn write_line(&self, line: &str) -> Result<()> {\n self.output.borrow_mut().push_str(line);\n self.output.borrow_mut().push('\\n');\n Ok(())\n }\n }\n\n // ── Unit tests ──────────────────────────────────────────────\n\n #[test]\n fn setup_creates_template_config_when_config_is_missing() {\n let tmp = tempfile::tempdir().unwrap();\n let config_path = tmp.path().join(\"config.toml\");\n\n let config = Config::default();\n let home = config_path.parent().unwrap();\n std::fs::create_dir_all(home).unwrap();\n loader::save_config(&config, &config_path).unwrap();\n\n assert_eq!(\n std::fs::read_to_string(&config_path).unwrap(),\n template_toml()\n );\n assert!(config_path.exists());\n }\n\n #[test]\n fn provider_helpers_set_and_get_fields() {\n let mut config = Config::default();\n set_provider_url(&mut config, \"openai\", \"https://custom.api/v1\");\n set_provider_api_key(&mut config, \"openai\", \"sk-test-key\");\n\n let entry = config.providers.openai.as_ref().unwrap();\n assert_eq!(entry.base_url.as_deref(), Some(\"https://custom.api/v1\"));\n assert_eq!(entry.api_key.as_deref(), Some(\"sk-test-key\"));\n }\n\n #[test]\n fn provider_helpers_handle_unknown_provider() {\n let mut config = Config::default();\n set_provider_url(&mut config, \"nonexistent\", \"https://example.com\");\n assert!(get_provider_url(&config, \"nonexistent\").is_none());\n }\n\n #[test]\n fn wizard_keeps_existing_config() {\n let tmp = tempfile::tempdir().unwrap();\n let config_path = tmp.path().join(\"config.toml\");\n\n let existing = Config::default();\n std::fs::create_dir_all(config_path.parent().unwrap()).unwrap();\n loader::save_config(&existing, &config_path).unwrap();\n\n let mock = MockWizard::new(vec![MockAction::Confirm {\n prompt_contains: \"Update\",\n result: false,\n }]);\n\n run_wizard(&mock, &config_path).unwrap();\n\n assert_eq!(\n std::fs::read_to_string(&config_path).unwrap(),\n template_toml()\n );\n }\n\n #[test]\n fn wizard_fresh_quick_setup() {\n let tmp = tempfile::tempdir().unwrap();\n let config_path = tmp.path().join(\"config.toml\");\n\n let mock = MockWizard::new(vec![\n // Review: select Provider (index 0)\n MockAction::Select { result: 0 },\n // Step 1: Provider\n MockAction::Select { result: 0 }, // openai (first)\n MockAction::Input { result: \"gpt-4o\" },\n MockAction::Input {\n result: \"https://api.openai.com/v1\",\n },\n MockAction::Password {\n result: \"sk-test-key\",\n },\n // Review: Save (index 5 = last)\n MockAction::Select { result: 5 },\n MockAction::Confirm {\n prompt_contains: \"Write\",\n result: true,\n },\n ]);\n\n run_wizard(&mock, &config_path).unwrap();\n\n assert!(config_path.exists());\n let saved: Config =\n toml::from_str(&std::fs::read_to_string(&config_path).unwrap()).unwrap();\n assert_eq!(saved.agent.provider.as_deref(), Some(\"openai\"));\n assert_eq!(saved.agent.model, \"gpt-4o\");\n }\n\n #[test]\n fn wizard_setup_only_feishu() {\n // Test: user only configures Feishu, skips everything else\n let tmp = tempfile::tempdir().unwrap();\n let config_path = tmp.path().join(\"config.toml\");\n\n let mock = MockWizard::new(vec![\n // Review: save directly without configuring anything\n MockAction::Select { result: 5 }, // Save\n MockAction::Confirm {\n prompt_contains: \"Write\",\n result: true,\n },\n ]);\n\n run_wizard(&mock, &config_path).unwrap();\n\n assert!(config_path.exists());\n // Default config should be saved\n let saved: Config =\n toml::from_str(&std::fs::read_to_string(&config_path).unwrap()).unwrap();\n assert!(saved.agent.provider.is_none());\n }\n\n #[test]\n fn wizard_overwrite_existing_config() {\n let tmp = tempfile::tempdir().unwrap();\n let config_path = tmp.path().join(\"config.toml\");\n\n let existing = Config::default();\n std::fs::create_dir_all(config_path.parent().unwrap()).unwrap();\n loader::save_config(&existing, &config_path).unwrap();\n\n let mock = MockWizard::new(vec![\n // Update existing\n MockAction::Confirm {\n prompt_contains: \"Update\",\n result: true,\n },\n // Review: select Provider (index 0)\n MockAction::Select { result: 0 },\n // Step 1: Provider — select anthropic (index 1)\n MockAction::Select { result: 1 },\n MockAction::Input {\n result: \"claude-sonnet-4-20250514\",\n },\n MockAction::Input {\n result: \"https://api.anthropic.com\",\n },\n MockAction::Password {\n result: \"sk-ant-test\",\n },\n // Back to Review: Save (index 5)\n MockAction::Select { result: 5 },\n MockAction::Confirm {\n prompt_contains: \"Write\",\n result: true,\n },\n ]);\n\n run_wizard(&mock, &config_path).unwrap();\n\n let saved: Config =\n toml::from_str(&std::fs::read_to_string(&config_path).unwrap()).unwrap();\n assert_eq!(saved.agent.provider.as_deref(), Some(\"anthropic\"));\n assert_eq!(saved.agent.model, \"claude-sonnet-4-20250514\");\n }\n\n #[test]\n fn token_masking_short_tokens() {\n assert_eq!(mask_token(\"ab\"), \"(masked)\");\n assert_eq!(mask_token(\"\"), \"(not set)\");\n }\n\n #[test]\n fn token_masking_long_token() {\n let masked = mask_token(\"sk-proj-abcdefghijklmnop-b2cF\");\n assert!(masked.starts_with(\"sk-\"));\n assert!(masked.ends_with(\"b2cF\"));\n assert!(masked.contains(\"…\"));\n }\n\n #[test]\n fn validate_api_key_openai() {\n assert!(validate_api_key(\"openai\", \"sk-test123\").is_ok());\n assert!(validate_api_key(\"openai\", \"bad-key\").is_err());\n }\n\n #[test]\n fn validate_api_key_anthropic() {\n assert!(validate_api_key(\"anthropic\", \"sk-ant-test123\").is_ok());\n assert!(validate_api_key(\"anthropic\", \"bad-key\").is_err());\n }\n\n #[test]\n fn validate_api_key_other_providers() {\n assert!(validate_api_key(\"ollama\", \"any-key\").is_ok());\n }\n\n #[test]\n fn validate_api_key_empty() {\n assert!(validate_api_key(\"openai\", \"\").is_err());\n }\n\n #[test]\n fn validate_url_valid() {\n assert_eq!(\n validate_url(\"https://api.openai.com/v1\").unwrap(),\n \"https://api.openai.com/v1\"\n );\n assert_eq!(\n validate_url(\"http://localhost:11434\").unwrap(),\n \"http://localhost:11434\"\n );\n }\n\n #[test]\n fn validate_url_empty() {\n assert_eq!(validate_url(\"\").unwrap(), \"\");\n }\n\n #[test]\n fn validate_url_invalid() {\n assert!(validate_url(\"not-a-url\").is_err());\n assert!(validate_url(\"ftp://example.com\").is_err());\n }\n\n #[test]\n fn wizard_step_ordering() {\n assert_eq!(WizardStep::Provider.step_number(), 1);\n assert_eq!(WizardStep::Review.step_number(), 0);\n assert_eq!(WizardStep::all().len(), 5);\n assert_eq!(WizardStep::all()[0], WizardStep::Provider);\n assert_eq!(WizardStep::all()[4], WizardStep::WeChat);\n }\n\n fn tg(token: &str) -> TelegramConfig {\n TelegramConfig {\n token: token.to_string(),\n allowed_users: Vec::new(),\n admin_users: Vec::new(),\n enabled: true,\n streaming: false,\n proxy: None,\n }\n }\n\n #[test]\n fn token_masking_empty_token() {\n let mut config = Config::default();\n config.channels.telegram = Some(tg(\"\"));\n\n let mock = MockWizard::new(vec![]);\n show_config_summary_simple(&mock, &config).unwrap();\n\n let output = mock.output();\n assert!(output.contains(\"(not set)\"));\n }\n\n #[test]\n fn configure_weixin_qr_enables_existing_credentials() {\n let mock = MockWizard::new(vec![MockAction::Select { result: 0 }]);\n let mut config = Config::default();\n config.channels.weixin = Some(WeixinConfig {\n app_id: None,\n app_secret: None,\n token: None,\n encoding_aes_key: None,\n account_id: Some(\"wxid_existing@im.bot\".to_string()),\n bot_token: Some(\"tok_existing\".to_string()),\n base_url: None,\n cdn_base_url: None,\n dm_policy: \"open\".to_string(),\n group_policy: \"disabled\".to_string(),\n allowed_users: vec![],\n group_allowed_users: vec![],\n enabled: false,\n });\n\n configure_weixin(&mock, &mut config).unwrap();\n\n let weixin = config.channels.weixin.unwrap();\n assert!(weixin.enabled);\n assert_eq!(weixin.account_id.as_deref(), Some(\"wxid_existing@im.bot\"));\n assert_eq!(weixin.bot_token.as_deref(), Some(\"tok_existing\"));\n assert!(mock.output().contains(\"detected and enabled\"));\n }\n\n #[test]\n fn configure_weixin_manual_preserves_existing_fields() {\n let mock = MockWizard::new(vec![\n MockAction::Select { result: 1 },\n MockAction::Input {\n result: \"wxid_new@im.bot\",\n },\n MockAction::Input { result: \"tok_new\" },\n ]);\n let mut config = Config::default();\n config.channels.weixin = Some(WeixinConfig {\n app_id: Some(\"app_existing\".to_string()),\n app_secret: Some(\"secret_existing\".to_string()),\n token: Some(\"verify_token\".to_string()),\n encoding_aes_key: Some(\"aes_key\".to_string()),\n account_id: Some(\"wxid_old@im.bot\".to_string()),\n bot_token: Some(\"tok_old\".to_string()),\n base_url: Some(\"https://ilinkai.weixin.qq.com\".to_string()),\n cdn_base_url: Some(\"https://novac2c.cdn.weixin.qq.com/c2c\".to_string()),\n dm_policy: \"allowlist\".to_string(),\n group_policy: \"open\".to_string(),\n allowed_users: vec![\"u1\".to_string()],\n group_allowed_users: vec![\"g1\".to_string()],\n enabled: false,\n });\n\n configure_weixin(&mock, &mut config).unwrap();\n\n let weixin = config.channels.weixin.unwrap();\n assert_eq!(weixin.account_id.as_deref(), Some(\"wxid_new@im.bot\"));\n assert_eq!(weixin.bot_token.as_deref(), Some(\"tok_new\"));\n assert_eq!(weixin.app_id.as_deref(), Some(\"app_existing\"));\n assert_eq!(weixin.app_secret.as_deref(), Some(\"secret_existing\"));\n assert_eq!(weixin.token.as_deref(), Some(\"verify_token\"));\n assert_eq!(weixin.encoding_aes_key.as_deref(), Some(\"aes_key\"));\n assert_eq!(\n weixin.base_url.as_deref(),\n Some(\"https://ilinkai.weixin.qq.com\")\n );\n assert_eq!(\n weixin.cdn_base_url.as_deref(),\n Some(\"https://novac2c.cdn.weixin.qq.com/c2c\")\n );\n assert_eq!(weixin.dm_policy, \"allowlist\");\n assert_eq!(weixin.group_policy, \"open\");\n assert_eq!(weixin.allowed_users, vec![\"u1\"]);\n assert_eq!(weixin.group_allowed_users, vec![\"g1\"]);\n assert!(weixin.enabled);\n }\n\n // ── Connectivity validation tests ──────────────────────────\n\n fn mock_connectivity_auth_fail(\n _provider_key: &str,\n _base_url: &str,\n _api_key: &str,\n ) -> ConnectivityResult {\n ConnectivityResult::AuthFailed(\"HTTP 401 Unauthorized\".to_string())\n }\n\n fn mock_connectivity_unreachable(\n _provider_key: &str,\n _base_url: &str,\n _api_key: &str,\n ) -> ConnectivityResult {\n ConnectivityResult::Unreachable(\"Connection timed out\".to_string())\n }\n\n #[test]\n fn connectivity_check_ok_shows_models() {\n let mock = MockWizard::new(vec![]);\n let result = run_connectivity_check(\n &mock,\n \"openai\",\n \"https://api.openai.com/v1\",\n \"sk-test-key\",\n mock_connectivity_ok,\n );\n assert!(result.is_ok());\n let output = mock.output();\n assert!(output.contains(\"Connected!\"));\n assert!(output.contains(\"gpt-4o\"));\n }\n\n #[test]\n fn connectivity_check_auth_fail_asks_continue() {\n let mock = MockWizard::new(vec![MockAction::Confirm {\n prompt_contains: \"Continue\",\n result: true,\n }]);\n let result = run_connectivity_check(\n &mock,\n \"openai\",\n \"https://api.openai.com/v1\",\n \"bad-key\",\n mock_connectivity_auth_fail,\n );\n assert!(result.is_ok());\n let output = mock.output();\n assert!(output.contains(\"validation failed\"));\n }\n\n #[test]\n fn connectivity_check_auth_fail_user_cancels() {\n let mock = MockWizard::new(vec![MockAction::Confirm {\n prompt_contains: \"Continue\",\n result: false,\n }]);\n let result = run_connectivity_check(\n &mock,\n \"openai\",\n \"https://api.openai.com/v1\",\n \"bad-key\",\n mock_connectivity_auth_fail,\n );\n assert!(result.is_err());\n }\n\n #[test]\n fn connectivity_check_unreachable_asks_continue() {\n let mock = MockWizard::new(vec![MockAction::Confirm {\n prompt_contains: \"Continue\",\n result: true,\n }]);\n let result = run_connectivity_check(\n &mock,\n \"openai\",\n \"https://api.openai.com/v1\",\n \"sk-test\",\n mock_connectivity_unreachable,\n );\n assert!(result.is_ok());\n let output = mock.output();\n assert!(output.contains(\"Could not verify\"));\n }\n\n #[test]\n fn connectivity_check_skips_ollama() {\n let mock = MockWizard::new(vec![]);\n let result = run_connectivity_check(\n &mock,\n \"ollama\",\n \"http://localhost:11434\",\n \"\",\n mock_connectivity_ok,\n );\n assert!(result.is_ok());\n // No output for Ollama (skipped)\n assert!(mock.output().is_empty());\n }\n\n #[test]\n fn parse_models_from_openai_response() {\n let body = r#\"{\"data\":[{\"id\":\"gpt-4o\"},{\"id\":\"gpt-4o-mini\"},{\"id\":\"gpt-3.5-turbo\"}]}\"#;\n let models = parse_openai_models(body);\n assert_eq!(models, vec![\"gpt-4o\", \"gpt-4o-mini\", \"gpt-3.5-turbo\"]);\n }\n\n #[test]\n fn parse_models_empty_response() {\n let models = parse_openai_models(\"not json\");\n assert!(models.is_empty());\n }\n\n #[test]\n fn extract_error_message_from_openai_format() {\n let body = r#\"{\"error\":{\"message\":\"Incorrect API key\",\"type\":\"invalid_request_error\"}}\"#;\n let msg = extract_error_message(body).unwrap();\n assert_eq!(msg, \"Incorrect API key\");\n }\n\n #[test]\n fn extract_error_message_no_message() {\n let body = r#\"{\"status\":\"ok\"}\"#;\n assert!(extract_error_message(body).is_none());\n }\n\n #[test]\n fn configure_provider_with_connectivity() {\n let mock = MockWizard::new(vec![\n MockAction::Select { result: 0 }, // openai\n MockAction::Input { result: \"gpt-4o\" },\n MockAction::Input {\n result: \"https://api.openai.com/v1\",\n },\n MockAction::Password {\n result: \"sk-testkey123456\",\n },\n ]);\n\n let mut config = Config::default();\n configure_provider_inner(&mock, &mut config, mock_connectivity_ok).unwrap();\n\n assert_eq!(config.agent.provider.as_deref(), Some(\"openai\"));\n assert_eq!(config.agent.model, \"gpt-4o\");\n let output = mock.output();\n assert!(output.contains(\"Connected!\"));\n }\n}\n"}, "5c0888384b3bbda04014484682f04a14fa03729d": {"t": 1785761067.7952986, "n": 72708, "h": "3ed982a619fc2823", "c": "//! Setup command — interactive wizard for configuring Kestrel.\n//!\n//! Supports back-navigation via a state-machine loop, Quick/Full setup modes,\n//! input validation, and enhanced configuration summaries.\n\nuse anyhow::{bail, Context, Result};\nuse console::Term;\nuse dialoguer::{Confirm, Input, Password as PasswordInput, Select};\nuse kestrel_config::{\n loader, paths,\n schema::{Config, FeishuConfig, ProviderEntry, TelegramConfig, WebSocketConfig, WeixinConfig},\n};\nuse owo_colors::OwoColorize;\nuse std::net::SocketAddr;\nuse std::path::Path;\nuse std::time::Duration;\n\n// ── Provider display info ──────────────────────────────────────\n\nstruct ProviderInfo {\n key: &'static str,\n display: &'static str,\n default_model: &'static str,\n default_url: &'static str,\n}\n\nconst PROVIDERS: &[ProviderInfo] = &[\n ProviderInfo {\n key: \"openai\",\n display: \"★ OpenAI\",\n default_model: \"gpt-4o\",\n default_url: \"https://api.openai.com/v1\",\n },\n ProviderInfo {\n key: \"anthropic\",\n display: \"★ Anthropic\",\n default_model: \"claude-sonnet-4-20250514\",\n default_url: \"https://api.anthropic.com\",\n },\n ProviderInfo {\n key: \"openrouter\",\n display: \"★ OpenRouter (multi-model)\",\n default_model: \"anthropic/claude-sonnet-4-20250514\",\n default_url: \"https://openrouter.ai/api/v1\",\n },\n ProviderInfo {\n key: \"ollama\",\n display: \" Ollama (local models)\",\n default_model: \"llama3\",\n default_url: \"http://localhost:11434\",\n },\n ProviderInfo {\n key: \"deepseek\",\n display: \" DeepSeek\",\n default_model: \"deepseek-chat\",\n default_url: \"https://api.deepseek.com\",\n },\n ProviderInfo {\n key: \"gemini\",\n display: \" Gemini\",\n default_model: \"gemini-2.5-pro\",\n default_url: \"https://generativelanguage.googleapis.com/v1beta\",\n },\n ProviderInfo {\n key: \"groq\",\n display: \" Groq\",\n default_model: \"llama-3.3-70b-versatile\",\n default_url: \"https://api.groq.com/openai/v1\",\n },\n ProviderInfo {\n key: \"moonshot\",\n display: \" Moonshot (月之暗面)\",\n default_model: \"moonshot-v1-8k\",\n default_url: \"https://api.moonshot.cn/v1\",\n },\n ProviderInfo {\n key: \"minimax\",\n display: \" MiniMax\",\n default_model: \"MiniMax-Text-01\",\n default_url: \"https://api.minimax.chat/v1\",\n },\n ProviderInfo {\n key: \"github_copilot\",\n display: \" GitHub Copilot\",\n default_model: \"gpt-4o\",\n default_url: \"https://api.githubcopilot.com\",\n },\n ProviderInfo {\n key: \"openai_codex\",\n display: \" OpenAI Codex\",\n default_model: \"codex-mini\",\n default_url: \"https://api.openai.com/v1\",\n },\n ProviderInfo {\n key: \"glm_coding_plan\",\n display: \" GLM Coding Plan (智谱)\",\n default_model: \"glm-5.1\",\n default_url: \"https://open.bigmodel.cn/api/coding/paas/v4\",\n },\n];\n\nconst TOTAL_CONFIG_STEPS: usize = 5;\n\nconst BACK_OPTION: &str = \"↩ Go back\";\n\n// ── Wizard step state machine ──────────────────────────────────\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\nenum WizardStep {\n Provider, // Step 1\n Telegram, // Step 2\n Feishu, // Step 3\n WebSocket, // Step 4\n WeChat, // Step 5\n Review, // Final (no step number)\n}\n\nimpl WizardStep {\n fn step_number(self) -> usize {\n match self {\n Self::Provider => 1,\n Self::Telegram => 2,\n Self::Feishu => 3,\n Self::WebSocket => 4,\n Self::WeChat => 5,\n Self::Review => 0,\n }\n }\n\n fn title(self) -> &'static str {\n match self {\n Self::Provider => \"Provider Configuration\",\n Self::Telegram => \"Telegram Channel\",\n Self::Feishu => \"Feishu / Lark Channel\",\n Self::WebSocket => \"WebSocket Port\",\n Self::WeChat => \"WeChat Channel\",\n Self::Review => \"Review & Save\",\n }\n }\n\n fn all() -> &'static [WizardStep] {\n &[\n WizardStep::Provider,\n WizardStep::Telegram,\n WizardStep::Feishu,\n WizardStep::WebSocket,\n WizardStep::WeChat,\n ]\n }\n}\n\n/// Whether a channel step was configured or skipped.\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\nenum ChannelStatus {\n Configured,\n Skipped,\n}\n\n// ── Trait for interactive I/O (enables testability) ────────────\n\ntrait WizardIo {\n fn confirm(&self, prompt: &str, default: bool) -> Result;\n fn select(&self, prompt: &str, items: &[&str], default: usize) -> Result;\n fn input_with_default(&self, prompt: &str, default: &str) -> Result;\n fn input_allow_empty(&self, prompt: &str) -> Result;\n fn password(&self, prompt: &str) -> Result;\n fn write_line(&self, line: &str) -> Result<()>;\n\n /// Show a confirmation with back option. Returns `None` if user chose back.\n fn confirm_or_back(&self, prompt: &str, default: bool) -> Result> {\n let items = if default {\n &[\"Yes\", \"Skip (keep current)\", BACK_OPTION] as &[&str]\n } else {\n &[\"Skip (keep current)\", \"Yes\", BACK_OPTION] as &[&str]\n };\n let default_idx = 0;\n let choice = self.select(prompt, items, default_idx)?;\n match choice {\n idx if items[idx] == BACK_OPTION => Ok(None),\n idx if items[idx] == \"Yes\" => Ok(Some(true)),\n _ => Ok(Some(false)),\n }\n }\n}\n\n// ── Production implementation using Term + dialoguer ───────────\n\nstruct TermWizard<'a> {\n term: &'a Term,\n}\n\nimpl<'a> TermWizard<'a> {\n fn new(term: &'a Term) -> Self {\n Self { term }\n }\n}\n\nimpl WizardIo for TermWizard<'_> {\n fn confirm(&self, prompt: &str, default: bool) -> Result {\n Ok(Confirm::new()\n .with_prompt(prompt)\n .default(default)\n .interact_on(self.term)?)\n }\n\n fn select(&self, prompt: &str, items: &[&str], default: usize) -> Result {\n Ok(Select::new()\n .with_prompt(prompt)\n .items(items)\n .default(default)\n .interact_on(self.term)?)\n }\n\n fn input_with_default(&self, prompt: &str, default: &str) -> Result {\n Ok(Input::new()\n .with_prompt(prompt)\n .default(default.to_string())\n .interact_text_on(self.term)?)\n }\n\n fn input_allow_empty(&self, prompt: &str) -> Result {\n Ok(Input::::new()\n .with_prompt(prompt)\n .allow_empty(true)\n .interact_text_on(self.term)?)\n }\n\n fn password(&self, prompt: &str) -> Result {\n Ok(PasswordInput::new()\n .with_prompt(prompt)\n .interact_on(self.term)?)\n }\n\n fn write_line(&self, line: &str) -> Result<()> {\n self.term.write_line(line)?;\n Ok(())\n }\n}\n\n// ── Entry point ────────────────────────────────────────────────\n\npub fn run(_config: Config) -> Result<()> {\n let term = Term::stdout();\n if !term.is_term() {\n bail!(\n \"Setup requires an interactive terminal. \\\n Run this command in a terminal, not in a pipe or CI environment.\"\n );\n }\n let io = TermWizard::new(&term);\n let config_path = paths::get_config_path()?;\n\n // Set up Ctrl+C handler for graceful exit\n ctrlc::set_handler(|| {\n println!();\n println!(\n \" {} Setup interrupted. Progress has not been saved.\",\n \"!\".yellow()\n );\n println!(\" Run `kestrel setup` again to start over.\");\n std::process::exit(1);\n })\n .ok(); // Ignore error if handler already set\n\n run_wizard(&io, &config_path)\n}\n\n// ── Wizard flow (state machine) ────────────────────────────────\n\nfn run_wizard(io: &dyn WizardIo, config_path: &Path) -> Result<()> {\n let is_first_run = !config_path.exists();\n print_banner(io, is_first_run)?;\n\n // ── Load existing config or start fresh ──────────────────────\n let mut config = if config_path.exists() {\n match load_existing_config(config_path) {\n Ok(existing) => {\n show_config_summary_simple(io, &existing)?;\n if io.confirm(\"Update existing config?\", true)? {\n existing\n } else {\n io.write_line(&format!(\n \" {} Keeping config at {}.\",\n \"✓\".green(),\n config_path.display()\n ))?;\n return Ok(());\n }\n }\n Err(e) => {\n io.write_line(&format!(\n \" {} Could not parse existing config: {}\",\n \"!\".yellow(),\n e\n ))?;\n if io.confirm(\"Start fresh with defaults?\", true)? {\n Config::default()\n } else {\n bail!(\"Setup cancelled.\");\n }\n }\n }\n } else {\n Config::default()\n };\n\n // ── State machine: Review-centric navigation ────────────────\n // After banner + config load, user lands on Review menu.\n // They can freely jump to any step, configure it, and return to Review.\n let mut channel_status = build_channel_status(&config);\n let mut current_step = WizardStep::Review;\n\n loop {\n match current_step {\n WizardStep::Provider => {\n print_step(io, current_step)?;\n match configure_provider(io, &mut config)? {\n StepAction::Back => {}\n StepAction::Continue => {\n channel_status[0] = ChannelStatus::Configured;\n }\n }\n current_step = WizardStep::Review;\n }\n WizardStep::Telegram => {\n print_step(io, current_step)?;\n let has_existing = config.channels.telegram.is_some();\n match configure_channel_step(\n io,\n &mut config,\n \"Telegram\",\n has_existing,\n configure_telegram,\n &mut channel_status[1],\n )? {\n StepAction::Back => {}\n StepAction::Continue => {}\n }\n current_step = WizardStep::Review;\n }\n WizardStep::Feishu => {\n print_step(io, current_step)?;\n let has_existing = config.channels.feishu.as_ref().is_some_and(|f| f.enabled);\n match configure_channel_step(\n io,\n &mut config,\n \"Feishu / Lark\",\n has_existing,\n configure_feishu,\n &mut channel_status[2],\n )? {\n StepAction::Back => {}\n StepAction::Continue => {}\n }\n current_step = WizardStep::Review;\n }\n WizardStep::WebSocket => {\n print_step(io, current_step)?;\n let has_existing = config\n .channels\n .websocket\n .as_ref()\n .is_some_and(|w| w.enabled);\n match configure_channel_step(\n io,\n &mut config,\n \"WebSocket\",\n has_existing,\n configure_websocket,\n &mut channel_status[3],\n )? {\n StepAction::Back => {}\n StepAction::Continue => {}\n }\n current_step = WizardStep::Review;\n }\n WizardStep::WeChat => {\n print_step(io, current_step)?;\n let has_existing = config.channels.weixin.is_some();\n match configure_channel_step(\n io,\n &mut config,\n \"WeChat\",\n has_existing,\n configure_weixin,\n &mut channel_status[4],\n )? {\n StepAction::Back => {}\n StepAction::Continue => {}\n }\n current_step = WizardStep::Review;\n }\n WizardStep::Review => {\n // Refresh status from actual config state\n channel_status = build_channel_status(&config);\n print_review(io, &config, &channel_status)?;\n\n let review_items = build_review_items(&channel_status);\n let review_refs: Vec<&str> = review_items.iter().map(|s| s.as_str()).collect();\n let default_save = review_items.len() - 1;\n let choice = io.select(\n \"Select a step to configure, or save\",\n &review_refs,\n default_save,\n )?;\n\n if choice == default_save {\n if save_config(io, &config, config_path)? {\n print_next_steps(io, &config)?;\n return Ok(());\n }\n // User cancelled save, stay in review\n } else if choice < WizardStep::all().len() {\n current_step = WizardStep::all()[choice];\n }\n }\n }\n }\n}\n\n/// Build channel status from actual config state (not just wizard tracking).\nfn build_channel_status(config: &Config) -> [ChannelStatus; 5] {\n [\n if config.agent.provider.is_some() {\n ChannelStatus::Configured\n } else {\n ChannelStatus::Skipped\n },\n if config.channels.telegram.is_some() {\n ChannelStatus::Configured\n } else {\n ChannelStatus::Skipped\n },\n if config.channels.feishu.as_ref().is_some_and(|f| f.enabled) {\n ChannelStatus::Configured\n } else {\n ChannelStatus::Skipped\n },\n if config\n .channels\n .websocket\n .as_ref()\n .is_some_and(|w| w.enabled)\n {\n ChannelStatus::Configured\n } else {\n ChannelStatus::Skipped\n },\n if config.channels.weixin.is_some() {\n ChannelStatus::Configured\n } else {\n ChannelStatus::Skipped\n },\n ]\n}\n\nfn build_review_items(channel_status: &[ChannelStatus; 5]) -> Vec {\n let mut items: Vec = WizardStep::all()\n .iter()\n .enumerate()\n .map(|(i, s)| {\n let num = s.step_number();\n let title = s.title();\n let status = match channel_status[i] {\n ChannelStatus::Configured => \"configured\".to_string(),\n ChannelStatus::Skipped => \"not configured\".to_string(),\n };\n format!(\"{}. {} ({})\", num, title, status)\n })\n .collect();\n items.push(\"Save configuration\".to_string());\n items\n}\n\n/// Result of a configure step: continue forward or go back.\nenum StepAction {\n Continue,\n Back,\n}\n\n/// Generic wrapper for channel configuration steps with back support.\nfn configure_channel_step(\n io: &dyn WizardIo,\n config: &mut Config,\n channel_name: &str,\n has_existing: bool,\n configure_fn: fn(&dyn WizardIo, &mut Config) -> Result,\n status: &mut ChannelStatus,\n) -> Result {\n let prompt = if has_existing {\n format!(\"Update {} configuration?\", channel_name)\n } else {\n format!(\"Set up {}?\", channel_name)\n };\n\n match io.confirm_or_back(&prompt, has_existing)? {\n None => return Ok(StepAction::Back),\n Some(false) => {\n io.write_line(\" Kept current configuration.\")?;\n *status = ChannelStatus::Skipped;\n return Ok(StepAction::Continue);\n }\n Some(true) => {}\n }\n\n match configure_fn(io, config) {\n Ok(true) => {\n *status = ChannelStatus::Configured;\n Ok(StepAction::Continue)\n }\n Ok(false) => {\n // Configure fn handled skip internally\n *status = ChannelStatus::Skipped;\n Ok(StepAction::Continue)\n }\n Err(e) => Err(e),\n }\n}\n\n// ── Banner & step printing ─────────────────────────────────────\n\nfn print_banner(io: &dyn WizardIo, is_first_run: bool) -> Result<()> {\n io.write_line(\"\")?;\n if is_first_run {\n io.write_line(&format!(\n \" {} {}\",\n \"▸\".cyan(),\n \"Kestrel 初始化配置向导\".bold().cyan()\n ))?;\n io.write_line(\"\")?;\n io.write_line(\" Welcome to Kestrel! Let's set up your configuration.\")?;\n io.write_line(\" You can re-run `kestrel setup` anytime to make changes.\")?;\n } else {\n io.write_line(&format!(\n \" {} {}\",\n \"▸\".cyan(),\n \"Kestrel 配置更新向导\".bold().cyan()\n ))?;\n io.write_line(\"\")?;\n io.write_line(\" Update your existing configuration.\")?;\n }\n io.write_line(\"\")?;\n Ok(())\n}\n\nfn print_step(io: &dyn WizardIo, step: WizardStep) -> Result<()> {\n io.write_line(\"\")?;\n let num = step.step_number();\n io.write_line(&format!(\n \" {} Step {}/{}: {}\",\n \"▸\".cyan(),\n num,\n TOTAL_CONFIG_STEPS,\n step.title().bold()\n ))?;\n io.write_line(&format!(\" {}\", \"─\".repeat(40).dimmed()))?;\n Ok(())\n}\n\nfn print_review(\n io: &dyn WizardIo,\n config: &Config,\n channel_status: &[ChannelStatus; 5],\n) -> Result<()> {\n io.write_line(\"\")?;\n io.write_line(&format!(\n \" {} {}\",\n \"▸\".cyan(),\n \"Review & Save\".bold().cyan()\n ))?;\n io.write_line(&format!(\" {}\", \"─\".repeat(50).dimmed()))?;\n io.write_line(\"\")?;\n show_config_summary(io, config, channel_status)?;\n io.write_line(\"\")?;\n Ok(())\n}\n\nfn load_existing_config(path: &Path) -> Result {\n let content = std::fs::read_to_string(path)\n .with_context(|| format!(\"Failed to read {}\", path.display()))?;\n let config: Config =\n toml::from_str(&content).with_context(|| format!(\"Failed to parse {}\", path.display()))?;\n Ok(config)\n}\n\n// ── Token masking & config summary ─────────────────────────────\n\nfn mask_token(token: &str) -> String {\n if token.is_empty() {\n \"(not set)\".to_string()\n } else if token.len() <= 8 {\n \"(masked)\".to_string()\n } else {\n format!(\"{}…{}\", &token[..3], &token[token.len() - 4..])\n }\n}\n\nfn show_config_summary(\n io: &dyn WizardIo,\n config: &Config,\n channel_status: &[ChannelStatus; 5],\n) -> Result<()> {\n let provider = config.agent.provider.as_deref().unwrap_or(\"default\");\n io.write_line(&format!(\" Provider: {}\", provider))?;\n io.write_line(&format!(\" Model: {}\", config.agent.model))?;\n\n // Show base URL\n if let Some(url) = config\n .agent\n .provider\n .as_deref()\n .and_then(|p| get_provider_url(config, p))\n {\n io.write_line(&format!(\" Base URL: {}\", url))?;\n }\n\n // Show API key status\n let key_status = config\n .agent\n .provider\n .as_deref()\n .and_then(|p| get_provider_api_key(config, p))\n .map(mask_token)\n .unwrap_or_else(|| \"(not set)\".to_string());\n io.write_line(&format!(\" API Key: {}\", key_status))?;\n\n io.write_line(&format!(\" Temperature: {}\", config.agent.temperature))?;\n io.write_line(&format!(\" Max tokens: {}\", config.agent.max_tokens))?;\n\n io.write_line(\"\")?;\n io.write_line(\" Channels:\")?;\n\n let channel_names = [\"Telegram\", \"Feishu\", \"WebSocket\", \"WeChat\"];\n for (i, name) in channel_names.iter().enumerate() {\n let status_str = match channel_status[i + 1] {\n ChannelStatus::Configured => \"✓ configured\".green().to_string(),\n ChannelStatus::Skipped => \"✗ skipped\".dimmed().to_string(),\n };\n io.write_line(&format!(\" {}: {}\", name, status_str))?;\n }\n\n // Show details for configured channels\n if let Some(ref tg) = config.channels.telegram {\n if !tg.token.is_empty() {\n io.write_line(&format!(\" Token: {}\", mask_token(&tg.token)))?;\n }\n }\n\n if let Some(ref ws) = config.channels.websocket {\n if ws.enabled {\n io.write_line(&format!(\" Address: {}\", ws.listen_addr))?;\n }\n }\n\n if let Some(ref wx) = config.channels.weixin {\n if wx.enabled {\n let acct = wx.account_id.as_deref().unwrap_or(\"(unknown)\");\n io.write_line(&format!(\" Account: {}\", mask_token(acct)))?;\n }\n }\n\n if let Some(ref fs) = config.channels.feishu {\n if fs.enabled {\n let app_id = fs.app_id.as_deref().unwrap_or(\"(unknown)\");\n io.write_line(&format!(\" App ID: {}\", mask_token(app_id)))?;\n }\n }\n\n Ok(())\n}\n\n/// Legacy summary without channel status (used for existing config review).\nfn show_config_summary_simple(io: &dyn WizardIo, config: &Config) -> Result<()> {\n let provider = config.agent.provider.as_deref().unwrap_or(\"default\");\n io.write_line(&format!(\" Provider: {}\", provider))?;\n io.write_line(&format!(\" Model: {}\", config.agent.model))?;\n io.write_line(&format!(\" Temperature: {}\", config.agent.temperature))?;\n io.write_line(&format!(\" Max tokens: {}\", config.agent.max_tokens))?;\n io.write_line(&format!(\" Streaming: {}\", config.agent.streaming))?;\n\n if let Some(ref tg) = config.channels.telegram {\n io.write_line(&format!(\" Telegram: {}\", mask_token(&tg.token)))?;\n }\n\n if let Some(ref fs) = config.channels.feishu {\n if fs.enabled {\n let app_id = fs.app_id.as_deref().unwrap_or(\"(not set)\");\n io.write_line(&format!(\" Feishu: {}\", mask_token(app_id)))?;\n }\n }\n\n if let Some(ref ws) = config.channels.websocket {\n if ws.enabled {\n io.write_line(&format!(\" WebSocket: {}\", ws.listen_addr))?;\n }\n }\n\n if let Some(ref wx) = config.channels.weixin {\n let status = if wx.enabled {\n mask_token(wx.bot_token.as_deref().unwrap_or(\"\"))\n } else {\n \"disabled\".to_string()\n };\n io.write_line(&format!(\" WeChat: {}\", status))?;\n }\n\n Ok(())\n}\n\nfn save_config(io: &dyn WizardIo, config: &Config, config_path: &Path) -> Result {\n io.write_line(&format!(\" Config path: {}\", config_path.display()))?;\n\n if !io.confirm(\"Write this configuration?\", true)? {\n io.write_line(&format!(\" {} Save cancelled.\", \"!\".yellow()))?;\n return Ok(false);\n }\n\n let home = config_path\n .parent()\n .context(\"Config path must have a parent directory\")?;\n\n std::fs::create_dir_all(home)\n .with_context(|| format!(\"Failed to create config home: {}\", home.display()))?;\n\n loader::save_config(config, config_path)?;\n\n for dir in [\"skills\", \"sessions\", \"learning\"] {\n let path = home.join(dir);\n std::fs::create_dir_all(&path)\n .with_context(|| format!(\"Failed to create directory: {}\", path.display()))?;\n }\n\n io.write_line(\"\")?;\n io.write_line(&format!(\n \" {} Configuration saved to {}\",\n \"✓\".green(),\n config_path.display()\n ))?;\n io.write_line(&format!(\n \" {} Created directories: skills, sessions, learning\",\n \"✓\".green()\n ))?;\n io.write_line(&format!(\" {} Setup complete!\", \"✓\".green()))?;\n\n Ok(true)\n}\n\nfn print_next_steps(io: &dyn WizardIo, config: &Config) -> Result<()> {\n io.write_line(\"\")?;\n io.write_line(&format!(\" {} Next steps:\", \"▸\".cyan()))?;\n io.write_line(\"\")?;\n io.write_line(\" 1. Try it out:\")?;\n io.write_line(\" kestrel agent\")?;\n io.write_line(\"\")?;\n\n let has_channel = config.channels.telegram.is_some()\n || config.channels.feishu.is_some()\n || config.channels.weixin.is_some()\n || config.channels.websocket.is_some();\n\n if has_channel {\n io.write_line(\" 2. Start the gateway (connect to chat platforms):\")?;\n io.write_line(\" kestrel gateway\")?;\n io.write_line(\"\")?;\n }\n\n if config.channels.websocket.is_some() {\n io.write_line(\" 3. Start the API server:\")?;\n io.write_line(\" kestrel serve\")?;\n io.write_line(\"\")?;\n }\n\n io.write_line(\" Check system health:\")?;\n io.write_line(\" kestrel doctor\")?;\n io.write_line(\"\")?;\n io.write_line(\" Re-run setup anytime:\")?;\n io.write_line(\" kestrel setup\")?;\n io.write_line(\"\")?;\n Ok(())\n}\n\n// ── Input validation ───────────────────────────────────────────\n\nfn validate_api_key(provider: &str, key: &str) -> Result {\n let key = key.trim().to_string();\n if key.is_empty() {\n bail!(\"API key cannot be empty\");\n }\n match provider {\n \"openai\" | \"openai_codex\" if !key.starts_with(\"sk-\") => {\n bail!(\"OpenAI API keys typically start with 'sk-'\");\n }\n \"anthropic\" if !key.starts_with(\"sk-ant-\") => {\n bail!(\"Anthropic API keys typically start with 'sk-ant-'\");\n }\n _ => {}\n }\n Ok(key)\n}\n\nfn validate_url(input: &str) -> Result {\n let url = input.trim().to_string();\n if url.is_empty() {\n return Ok(url);\n }\n if !url.starts_with(\"http://\") && !url.starts_with(\"https://\") {\n bail!(\"URL must start with http:// or https://\");\n }\n Ok(url)\n}\n\n// ── Connectivity validation ────────────────────────────────────\n\n/// Result of a connectivity check.\nenum ConnectivityResult {\n /// Successfully connected and authenticated.\n Ok {\n latency_ms: u64,\n models: Vec,\n },\n /// Connected to server but authentication failed (HTTP 401/403).\n AuthFailed(String),\n /// Server unreachable or other network error.\n Unreachable(String),\n}\n\n/// Test API key validity by making a real request to the provider.\n///\n/// Most OpenAI-compatible providers support `GET /models` with Bearer auth.\n/// For Anthropic we use `GET /v1/models` with `x-api-key` header.\nfn test_api_key_connectivity(\n provider_key: &str,\n base_url: &str,\n api_key: &str,\n) -> ConnectivityResult {\n let rt = match tokio::runtime::Runtime::new() {\n Ok(rt) => rt,\n Err(e) => return ConnectivityResult::Unreachable(format!(\"Runtime error: {}\", e)),\n };\n\n rt.block_on(async {\n let client = reqwest::Client::builder()\n .timeout(Duration::from_secs(10))\n .build();\n\n let client = match client {\n Ok(c) => c,\n Err(e) => return ConnectivityResult::Unreachable(format!(\"Client error: {}\", e)),\n };\n\n let start = std::time::Instant::now();\n\n // Build the request based on provider type\n let (display_url, req) = build_validation_request(provider_key, base_url, api_key, &client);\n\n match req {\n Ok(request) => match client.execute(request).await {\n Ok(resp) => {\n let latency = start.elapsed().as_millis() as u64;\n let status = resp.status();\n\n if status.is_success() {\n let models = parse_models_from_response(provider_key, resp).await;\n ConnectivityResult::Ok {\n latency_ms: latency,\n models,\n }\n } else if status.as_u16() == 401 || status.as_u16() == 403 {\n let body = resp.text().await.unwrap_or_default();\n let msg = extract_error_message(&body)\n .unwrap_or_else(|| format!(\"HTTP {} — API key may be invalid\", status));\n ConnectivityResult::AuthFailed(msg)\n } else {\n // Other HTTP errors — report with status code so user can diagnose\n let msg = format!(\"Server returned HTTP {} for {}\", status, display_url);\n ConnectivityResult::Unreachable(msg)\n }\n }\n Err(e) => {\n let msg = if e.is_timeout() {\n \"Connection timed out (10s)\".to_string()\n } else if e.is_connect() {\n format!(\"Cannot connect to {}\", display_url)\n } else {\n format!(\"{}\", e)\n };\n ConnectivityResult::Unreachable(msg)\n }\n },\n Err(e) => ConnectivityResult::Unreachable(format!(\"Request error: {}\", e)),\n }\n })\n}\n\n/// Build the validation HTTP request based on provider type.\n/// Returns (display_url, request_result) where display_url is safe to show in error messages.\nfn build_validation_request(\n provider_key: &str,\n base_url: &str,\n api_key: &str,\n client: &reqwest::Client,\n) -> (String, Result) {\n let (display_url, req_builder) = match provider_key {\n \"anthropic\" => {\n let url = format!(\"{}/v1/models\", base_url.trim_end_matches('/'));\n let req = client\n .get(&url)\n .header(\"x-api-key\", api_key)\n .header(\"anthropic-version\", \"2023-06-01\");\n (url, req.build())\n }\n \"gemini\" => {\n let url = format!(\"{}?key={}\", base_url.trim_end_matches('/'), api_key);\n // Don't expose API key in error messages\n let display = format!(\"{}/models\", base_url.trim_end_matches('/'));\n let req = client.get(&url);\n (display, req.build())\n }\n // OpenAI-compatible providers: openai, openrouter, deepseek, groq, moonshot, minimax,\n // github_copilot, openai_codex, glm_coding_plan, etc.\n _ => {\n let base = base_url.trim_end_matches('/');\n let url = format!(\"{}/models\", base);\n let req = client\n .get(&url)\n .header(\"Authorization\", format!(\"Bearer {}\", api_key));\n (url, req.build())\n }\n };\n (display_url, req_builder)\n}\n\n/// Try to extract model names from the validation response.\nasync fn parse_models_from_response(provider_key: &str, resp: reqwest::Response) -> Vec {\n let body = resp.text().await.unwrap_or_default();\n\n if provider_key == \"gemini\" {\n // Gemini returns { \"models\": [ { \"name\": \"models/gemini-2.5-pro\", ... } ] }\n if let Ok(val) = serde_json::from_str::(&body) {\n if let Some(models) = val.get(\"models\").and_then(|m| m.as_array()) {\n return models\n .iter()\n .filter_map(|m| {\n m.get(\"name\")\n .and_then(|n| n.as_str())\n .map(|n| n.trim_start_matches(\"models/\").to_string())\n })\n .take(5)\n .collect();\n }\n }\n return vec![];\n }\n\n if provider_key == \"anthropic\" {\n // Anthropic /v1/models returns { \"data\": [ { \"id\": \"claude-...\", ... } ] }\n return parse_openai_models(&body);\n }\n\n // OpenAI-compatible: { \"data\": [ { \"id\": \"gpt-4o\", ... } ] }\n parse_openai_models(&body)\n}\n\n/// Parse model IDs from an OpenAI-style { \"data\": [ ... ] } response.\nfn parse_openai_models(body: &str) -> Vec {\n if let Ok(val) = serde_json::from_str::(body) {\n if let Some(data) = val.get(\"data\").and_then(|d| d.as_array()) {\n return data\n .iter()\n .filter_map(|m| m.get(\"id\").and_then(|id| id.as_str()).map(String::from))\n .take(5)\n .collect();\n }\n }\n vec![]\n}\n\n/// Extract a human-readable error message from an error response body.\nfn extract_error_message(body: &str) -> Option {\n if let Ok(val) = serde_json::from_str::(body) {\n // { \"error\": { \"message\": \"...\" } } — OpenAI and Anthropic format\n if let Some(msg) = val\n .get(\"error\")\n .and_then(|e| e.get(\"message\"))\n .and_then(|m| m.as_str())\n {\n return Some(msg.to_string());\n }\n // Fallback: { \"message\": \"...\" }\n if let Some(msg) = val.get(\"message\").and_then(|m| m.as_str()) {\n return Some(msg.to_string());\n }\n }\n None\n}\n\n/// Run connectivity validation after provider config is entered.\n/// Shows results to user and asks if they want to continue on failure.\n/// Accepts a connectivity test fn (for testability).\nfn run_connectivity_check(\n io: &dyn WizardIo,\n provider_key: &str,\n base_url: &str,\n api_key: &str,\n test_fn: fn(&str, &str, &str) -> ConnectivityResult,\n) -> Result<()> {\n // Skip for Ollama (local, no API key needed)\n if provider_key == \"ollama\" {\n return Ok(());\n }\n\n io.write_line(\"\")?;\n io.write_line(&format!(\" {} Validating API key…\", \"⟳\".cyan()))?;\n\n match test_fn(provider_key, base_url, api_key) {\n ConnectivityResult::Ok { latency_ms, models } => {\n if models.is_empty() {\n io.write_line(&format!(\n \" {} Connected! (latency: {}ms)\",\n \"✓\".green(),\n latency_ms\n ))?;\n } else {\n io.write_line(&format!(\n \" {} Connected! (latency: {}ms)\",\n \"✓\".green(),\n latency_ms\n ))?;\n let models_str = models.join(\", \");\n io.write_line(&format!(\" Available models: {}\", models_str.dimmed()))?;\n }\n }\n ConnectivityResult::AuthFailed(msg) => {\n io.write_line(&format!(\n \" {} API key validation failed: {}\",\n \"✗\".red(),\n msg\n ))?;\n if !io.confirm(\" Continue with this key anyway?\", false)? {\n bail!(\"Provider configuration cancelled.\");\n }\n }\n ConnectivityResult::Unreachable(msg) => {\n io.write_line(&format!(\n \" {} Could not verify API key: {}\",\n \"⚠\".yellow(),\n msg\n ))?;\n io.write_line(&format!(\n \" {}\",\n \"The server may be unreachable from this network.\".dimmed()\n ))?;\n if !io.confirm(\" Continue anyway?\", true)? {\n bail!(\"Provider configuration cancelled.\");\n }\n }\n }\n\n Ok(())\n}\n\n// ── Provider configuration ─────────────────────────────────────\n\nfn configure_provider(io: &dyn WizardIo, config: &mut Config) -> Result {\n #[cfg(test)]\n let test_fn: fn(&str, &str, &str) -> ConnectivityResult = mock_connectivity_ok;\n #[cfg(not(test))]\n let test_fn: fn(&str, &str, &str) -> ConnectivityResult = test_api_key_connectivity;\n configure_provider_inner(io, config, test_fn)\n}\n\nfn configure_provider_inner(\n io: &dyn WizardIo,\n config: &mut Config,\n connectivity_fn: fn(&str, &str, &str) -> ConnectivityResult,\n) -> Result {\n let display_names: Vec<&str> = PROVIDERS.iter().map(|p| p.display).collect();\n\n let default_idx = config\n .agent\n .provider\n .as_deref()\n .and_then(|p| PROVIDERS.iter().position(|info| info.key == p))\n .unwrap_or(0); // default to OpenAI (first)\n\n let provider_idx = io.select(\"Select LLM provider\", &display_names, default_idx)?;\n\n let provider_key = PROVIDERS[provider_idx].key;\n config.agent.provider = Some(provider_key.to_string());\n\n let default_model = PROVIDERS[provider_idx].default_model;\n let current_model = if config.agent.model.is_empty() {\n default_model\n } else {\n &config.agent.model\n };\n\n let model: String = io.input_with_default(\"Model name\", current_model)?;\n config.agent.model = model.trim().to_string();\n\n let default_url = PROVIDERS[provider_idx].default_url;\n let current_url = get_provider_url(config, provider_key).unwrap_or(default_url);\n\n if !current_url.is_empty() {\n let base_url: String = io.input_with_default(\"Base URL\", current_url)?;\n let base_url = validate_url(&base_url)?;\n set_provider_url(config, provider_key, &base_url);\n } else {\n let base_url: String = io.input_allow_empty(\"Base URL (leave empty for default)\")?;\n let base_url = validate_url(&base_url)?;\n if !base_url.is_empty() {\n set_provider_url(config, provider_key, &base_url);\n }\n }\n\n loop {\n let api_key: String = io.password(\"API key\")?;\n match validate_api_key(provider_key, &api_key) {\n Ok(key) => {\n set_provider_api_key(config, provider_key, &key);\n break;\n }\n Err(e) => {\n io.write_line(&format!(\" {} {}\", \"!\".yellow(), e))?;\n if !io.confirm(\"Try again?\", true)? {\n // Accept whatever was entered\n let key = api_key.trim().to_string();\n if !key.is_empty() {\n set_provider_api_key(config, provider_key, &key);\n }\n break;\n }\n }\n }\n }\n\n // Connectivity validation\n let base_url = get_provider_url(config, provider_key)\n .unwrap_or(PROVIDERS[provider_idx].default_url)\n .to_string();\n let api_key = get_provider_api_key(config, provider_key)\n .unwrap_or(\"\")\n .to_string();\n if !api_key.is_empty() && !base_url.is_empty() {\n run_connectivity_check(io, provider_key, &base_url, &api_key, connectivity_fn)?;\n }\n\n Ok(StepAction::Continue)\n}\n\n// ── Channel configurations ─────────────────────────────────────\n\n/// Configure Telegram. Returns true if configured, false if skipped.\nfn configure_telegram(io: &dyn WizardIo, config: &mut Config) -> Result {\n let current_token = config\n .channels\n .telegram\n .as_ref()\n .map(|tg| tg.token.as_str())\n .unwrap_or(\"\");\n\n let token: String = if current_token.is_empty() {\n io.input_allow_empty(\"Bot token (from @BotFather)\")?\n } else {\n io.input_with_default(\"Bot token (from @BotFather)\", current_token)?\n };\n\n let token = token.trim().to_string();\n if token.is_empty() {\n io.write_line(\" No token provided, skipping Telegram.\")?;\n return Ok(false);\n }\n\n let allowed: String = loop {\n let input: String =\n io.input_allow_empty(\"Allowed user IDs (comma-separated, leave empty for all)\")?;\n if input.trim().is_empty() {\n break String::new();\n }\n let ids: Vec<&str> = input\n .split(',')\n .map(|s| s.trim())\n .filter(|s| !s.is_empty())\n .collect();\n let mut valid = true;\n for id in &ids {\n if id.parse::().is_err() {\n io.write_line(&format!(\n \" {} '{}' is not a valid user ID (must be a positive integer).\",\n \"!\".yellow(),\n id\n ))?;\n valid = false;\n break;\n }\n }\n if valid {\n break input;\n }\n };\n\n let allowed_users: Vec = if allowed.trim().is_empty() {\n Vec::new()\n } else {\n allowed\n .split(',')\n .map(|s| s.trim().to_string())\n .filter(|s| !s.is_empty())\n .collect()\n };\n\n let (admin_users, enabled, streaming, proxy) = config\n .channels\n .telegram\n .as_ref()\n .map(|tg| {\n (\n tg.admin_users.clone(),\n tg.enabled,\n tg.streaming,\n tg.proxy.clone(),\n )\n })\n .unwrap_or((Vec::new(), true, true, None));\n\n config.channels.telegram = Some(TelegramConfig {\n token,\n allowed_users,\n admin_users,\n enabled,\n streaming,\n proxy,\n });\n\n io.write_line(&format!(\" {} Telegram configured.\", \"✓\".green()))?;\n Ok(true)\n}\n\n/// Configure Feishu. Returns true if configured, false if skipped.\nfn configure_feishu(io: &dyn WizardIo, config: &mut Config) -> Result {\n let domain_options = [\"Feishu (飞书)\", \"Lark (international)\"];\n let idx = io.select(\"Select platform\", &domain_options, 0)?;\n let domain = if idx == 1 { \"lark\" } else { \"feishu\" };\n\n let rt = tokio::runtime::Builder::new_current_thread()\n .enable_all()\n .build()\n .context(\"Failed to create tokio runtime\")?;\n\n let result = match rt.block_on(super::feishu_onboarding::run_onboarding(domain)) {\n Ok(r) => r,\n Err(e) => {\n io.write_line(&format!(\" {} Feishu setup skipped: {}\", \"!\".yellow(), e))?;\n return Ok(false);\n }\n };\n\n let existing_proxy = config\n .channels\n .feishu\n .as_ref()\n .and_then(|f| f.proxy.clone());\n\n config.channels.feishu = Some(FeishuConfig {\n app_id: Some(result.app_id),\n app_secret: Some(result.app_secret),\n enabled: true,\n proxy: existing_proxy,\n ..Default::default()\n });\n\n io.write_line(&format!(\" {} Feishu configured.\", \"✓\".green()))?;\n Ok(true)\n}\n\n/// Configure WebSocket. Returns true if configured, false if skipped.\nfn configure_websocket(io: &dyn WizardIo, config: &mut Config) -> Result {\n let default_addr = config\n .channels\n .websocket\n .as_ref()\n .map(|ws| ws.listen_addr.as_str())\n .unwrap_or(\"127.0.0.1:8090\");\n\n let listen_addr: String = loop {\n let input = io.input_with_default(\"Listen address\", default_addr)?;\n match input.parse::() {\n Ok(_) => break input,\n Err(e) => {\n io.write_line(&format!(\n \" {} Invalid address '{}': {}\",\n \"!\".yellow(),\n input,\n e\n ))?;\n }\n }\n };\n\n config.channels.websocket = Some(WebSocketConfig {\n enabled: true,\n listen_addr,\n auth: Default::default(),\n max_clients: 100,\n max_message_size: 1048576,\n });\n\n io.write_line(&format!(\" {} WebSocket configured.\", \"✓\".green()))?;\n Ok(true)\n}\n\n/// Configure WeChat. Returns true if configured, false if skipped.\nfn configure_weixin(io: &dyn WizardIo, config: &mut Config) -> Result {\n let choices = &[\n \"Scan QR code with WeChat (recommended)\",\n \"Enter credentials manually\",\n \"Skip\",\n ];\n let choice = io.select(\"How would you like to configure WeChat?\", choices, 0)?;\n\n match choice {\n 0 => {\n io.write_line(\n \" QR scan setup requires running `kestrel setup weixin` in a terminal.\",\n )?;\n io.write_line(\" Please run that command separately, then return to this wizard.\")?;\n if let Some(ref mut wx) = config.channels.weixin {\n if wx.account_id.is_some() && wx.bot_token.is_some() {\n wx.enabled = true;\n io.write_line(&format!(\n \" {} Existing WeChat credentials detected and enabled.\",\n \"✓\".green()\n ))?;\n return Ok(true);\n }\n }\n if config.channels.weixin.is_none() {\n io.write_line(\n \" No WeChat credentials found yet. Run `kestrel setup weixin` first.\",\n )?;\n }\n Ok(false)\n }\n 1 => {\n let current_account = config\n .channels\n .weixin\n .as_ref()\n .and_then(|w| w.account_id.as_deref())\n .unwrap_or(\"\");\n let account_id: String = if current_account.is_empty() {\n io.input_allow_empty(\"iLink account ID (e.g. wxid_xxx@im.bot)\")?\n } else {\n io.input_with_default(\"iLink account ID\", current_account)?\n };\n\n if account_id.trim().is_empty() {\n io.write_line(\" No account ID provided, skipping WeChat.\")?;\n return Ok(false);\n }\n\n let current_token = config\n .channels\n .weixin\n .as_ref()\n .and_then(|w| w.bot_token.as_deref())\n .unwrap_or(\"\");\n let bot_token: String = if current_token.is_empty() {\n io.input_allow_empty(\"iLink bot token\")?\n } else {\n io.input_with_default(\"iLink bot token\", current_token)?\n };\n\n if bot_token.trim().is_empty() {\n io.write_line(\" No bot token provided, skipping WeChat.\")?;\n return Ok(false);\n }\n\n let (\n app_id,\n app_secret,\n old_token,\n encoding_aes_key,\n base_url,\n cdn_base_url,\n dm_policy,\n group_policy,\n allowed_users,\n group_allowed_users,\n ) = config\n .channels\n .weixin\n .as_ref()\n .map(|w| {\n (\n w.app_id.clone(),\n w.app_secret.clone(),\n w.token.clone(),\n w.encoding_aes_key.clone(),\n w.base_url.clone(),\n w.cdn_base_url.clone(),\n w.dm_policy.clone(),\n w.group_policy.clone(),\n w.allowed_users.clone(),\n w.group_allowed_users.clone(),\n )\n })\n .unwrap_or_default();\n\n config.channels.weixin = Some(WeixinConfig {\n account_id: Some(account_id.trim().to_string()),\n bot_token: Some(bot_token.trim().to_string()),\n app_id,\n app_secret,\n token: old_token,\n encoding_aes_key,\n base_url,\n cdn_base_url,\n dm_policy,\n group_policy,\n allowed_users,\n group_allowed_users,\n enabled: true,\n });\n\n io.write_line(&format!(\" {} WeChat configured.\", \"✓\".green()))?;\n Ok(true)\n }\n _ => {\n io.write_line(\" Skipped.\")?;\n Ok(false)\n }\n }\n}\n\n// ── Provider field helpers ─────────────────────────────────────\n\nfn get_provider_entry_mut<'a>(\n config: &'a mut Config,\n provider: &str,\n) -> Option<&'a mut ProviderEntry> {\n match provider {\n \"anthropic\" => config.providers.anthropic.as_mut(),\n \"openai\" => config.providers.openai.as_mut(),\n \"openrouter\" => config.providers.openrouter.as_mut(),\n \"ollama\" => config.providers.ollama.as_mut(),\n \"deepseek\" => config.providers.deepseek.as_mut(),\n \"gemini\" => config.providers.gemini.as_mut(),\n \"groq\" => config.providers.groq.as_mut(),\n \"moonshot\" => config.providers.moonshot.as_mut(),\n \"minimax\" => config.providers.minimax.as_mut(),\n \"github_copilot\" => config.providers.github_copilot.as_mut(),\n \"openai_codex\" => config.providers.openai_codex.as_mut(),\n \"opencode_go\" => config.providers.opencode_go.as_mut(),\n \"glm_coding_plan\" => config.providers.glm_coding_plan.as_mut(),\n _ => None,\n }\n}\n\nfn ensure_provider_entry(config: &mut Config, provider: &str) {\n match provider {\n \"anthropic\" => {\n config\n .providers\n .anthropic\n .get_or_insert_with(ProviderEntry::default);\n }\n \"openai\" => {\n config\n .providers\n .openai\n .get_or_insert_with(ProviderEntry::default);\n }\n \"openrouter\" => {\n config\n .providers\n .openrouter\n .get_or_insert_with(ProviderEntry::default);\n }\n \"ollama\" => {\n config\n .providers\n .ollama\n .get_or_insert_with(ProviderEntry::default);\n }\n \"deepseek\" => {\n config\n .providers\n .deepseek\n .get_or_insert_with(ProviderEntry::default);\n }\n \"gemini\" => {\n config\n .providers\n .gemini\n .get_or_insert_with(ProviderEntry::default);\n }\n \"groq\" => {\n config\n .providers\n .groq\n .get_or_insert_with(ProviderEntry::default);\n }\n \"moonshot\" => {\n config\n .providers\n .moonshot\n .get_or_insert_with(ProviderEntry::default);\n }\n \"minimax\" => {\n config\n .providers\n .minimax\n .get_or_insert_with(ProviderEntry::default);\n }\n \"github_copilot\" => {\n config\n .providers\n .github_copilot\n .get_or_insert_with(ProviderEntry::default);\n }\n \"openai_codex\" => {\n config\n .providers\n .openai_codex\n .get_or_insert_with(ProviderEntry::default);\n }\n \"opencode_go\" => {\n config\n .providers\n .opencode_go\n .get_or_insert_with(ProviderEntry::default);\n }\n \"glm_coding_plan\" => {\n config\n .providers\n .glm_coding_plan\n .get_or_insert_with(ProviderEntry::default);\n }\n _ => {}\n }\n}\n\nfn get_provider_url<'a>(config: &'a Config, provider: &str) -> Option<&'a str> {\n let entry = match provider {\n \"anthropic\" => config.providers.anthropic.as_ref(),\n \"openai\" => config.providers.openai.as_ref(),\n \"openrouter\" => config.providers.openrouter.as_ref(),\n \"ollama\" => config.providers.ollama.as_ref(),\n \"deepseek\" => config.providers.deepseek.as_ref(),\n \"gemini\" => config.providers.gemini.as_ref(),\n \"groq\" => config.providers.groq.as_ref(),\n \"moonshot\" => config.providers.moonshot.as_ref(),\n \"minimax\" => config.providers.minimax.as_ref(),\n \"github_copilot\" => config.providers.github_copilot.as_ref(),\n \"openai_codex\" => config.providers.openai_codex.as_ref(),\n \"opencode_go\" => config.providers.opencode_go.as_ref(),\n \"glm_coding_plan\" => config.providers.glm_coding_plan.as_ref(),\n _ => None,\n };\n entry.and_then(|e| e.base_url.as_deref())\n}\n\nfn get_provider_api_key<'a>(config: &'a Config, provider: &str) -> Option<&'a str> {\n let entry = match provider {\n \"anthropic\" => config.providers.anthropic.as_ref(),\n \"openai\" => config.providers.openai.as_ref(),\n \"openrouter\" => config.providers.openrouter.as_ref(),\n \"ollama\" => config.providers.ollama.as_ref(),\n \"deepseek\" => config.providers.deepseek.as_ref(),\n \"gemini\" => config.providers.gemini.as_ref(),\n \"groq\" => config.providers.groq.as_ref(),\n \"moonshot\" => config.providers.moonshot.as_ref(),\n \"minimax\" => config.providers.minimax.as_ref(),\n \"github_copilot\" => config.providers.github_copilot.as_ref(),\n \"openai_codex\" => config.providers.openai_codex.as_ref(),\n \"opencode_go\" => config.providers.opencode_go.as_ref(),\n \"glm_coding_plan\" => config.providers.glm_coding_plan.as_ref(),\n _ => None,\n };\n entry.and_then(|e| e.api_key.as_deref())\n}\n\nfn set_provider_url(config: &mut Config, provider: &str, url: &str) {\n ensure_provider_entry(config, provider);\n if let Some(entry) = get_provider_entry_mut(config, provider) {\n entry.base_url = if url.is_empty() {\n None\n } else {\n Some(url.to_string())\n };\n }\n}\n\nfn set_provider_api_key(config: &mut Config, provider: &str, key: &str) {\n ensure_provider_entry(config, provider);\n if let Some(entry) = get_provider_entry_mut(config, provider) {\n entry.api_key = if key.is_empty() {\n None\n } else {\n Some(key.to_string())\n };\n }\n}\n\n// ── Test helpers (cfg(test) only) ─────────────────────────────\n\n#[cfg(test)]\nfn mock_connectivity_ok(\n _provider_key: &str,\n _base_url: &str,\n _api_key: &str,\n) -> ConnectivityResult {\n ConnectivityResult::Ok {\n latency_ms: 42,\n models: vec![\"gpt-4o\".to_string(), \"gpt-4o-mini\".to_string()],\n }\n}\n\n// ── Tests ──────────────────────────────────────────────────────\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use std::cell::RefCell;\n use std::collections::VecDeque;\n\n fn template_toml() -> String {\n toml::to_string(&Config::default()).unwrap()\n }\n\n // ── Mock wizard IO for flow testing ─────────────────────────\n\n #[derive(Debug, Clone)]\n enum MockAction {\n Confirm {\n prompt_contains: &'static str,\n result: bool,\n },\n Select {\n result: usize,\n },\n Input {\n result: &'static str,\n },\n Password {\n result: &'static str,\n },\n }\n\n struct MockWizard {\n actions: RefCell>,\n output: RefCell,\n }\n\n impl MockWizard {\n fn new(actions: Vec) -> Self {\n Self {\n actions: RefCell::new(actions.into()),\n output: RefCell::new(String::new()),\n }\n }\n\n fn output(&self) -> String {\n self.output.borrow().clone()\n }\n }\n\n impl WizardIo for MockWizard {\n fn confirm(&self, prompt: &str, _default: bool) -> Result {\n let action = self.actions.borrow_mut().pop_front();\n match action {\n Some(MockAction::Confirm {\n prompt_contains,\n result,\n }) => {\n assert!(\n prompt.contains(prompt_contains),\n \"confirm prompt '{}' did not contain '{}'\",\n prompt,\n prompt_contains\n );\n Ok(result)\n }\n _ => panic!(\"unexpected confirm call, prompt: {}\", prompt),\n }\n }\n\n fn select(&self, _prompt: &str, _items: &[&str], _default: usize) -> Result {\n let action = self.actions.borrow_mut().pop_front();\n match action {\n Some(MockAction::Select { result }) => Ok(result),\n _ => panic!(\"unexpected select call\"),\n }\n }\n\n fn input_with_default(&self, _prompt: &str, _default: &str) -> Result {\n let action = self.actions.borrow_mut().pop_front();\n match action {\n Some(MockAction::Input { result }) => Ok(result.to_string()),\n _ => panic!(\"unexpected input_with_default call\"),\n }\n }\n\n fn input_allow_empty(&self, _prompt: &str) -> Result {\n let action = self.actions.borrow_mut().pop_front();\n match action {\n Some(MockAction::Input { result }) => Ok(result.to_string()),\n _ => panic!(\"unexpected input_allow_empty call\"),\n }\n }\n\n fn password(&self, _prompt: &str) -> Result {\n let action = self.actions.borrow_mut().pop_front();\n match action {\n Some(MockAction::Password { result }) => Ok(result.to_string()),\n _ => panic!(\"unexpected password call\"),\n }\n }\n\n fn write_line(&self, line: &str) -> Result<()> {\n self.output.borrow_mut().push_str(line);\n self.output.borrow_mut().push('\\n');\n Ok(())\n }\n }\n\n // ── Unit tests ──────────────────────────────────────────────\n\n #[test]\n fn setup_creates_template_config_when_config_is_missing() {\n let tmp = tempfile::tempdir().unwrap();\n let config_path = tmp.path().join(\"config.toml\");\n\n let config = Config::default();\n let home = config_path.parent().unwrap();\n std::fs::create_dir_all(home).unwrap();\n loader::save_config(&config, &config_path).unwrap();\n\n assert_eq!(\n std::fs::read_to_string(&config_path).unwrap(),\n template_toml()\n );\n assert!(config_path.exists());\n }\n\n #[test]\n fn provider_helpers_set_and_get_fields() {\n let mut config = Config::default();\n set_provider_url(&mut config, \"openai\", \"https://custom.api/v1\");\n set_provider_api_key(&mut config, \"openai\", \"sk-test-key\");\n\n let entry = config.providers.openai.as_ref().unwrap();\n assert_eq!(entry.base_url.as_deref(), Some(\"https://custom.api/v1\"));\n assert_eq!(entry.api_key.as_deref(), Some(\"sk-test-key\"));\n }\n\n #[test]\n fn provider_helpers_handle_unknown_provider() {\n let mut config = Config::default();\n set_provider_url(&mut config, \"nonexistent\", \"https://example.com\");\n assert!(get_provider_url(&config, \"nonexistent\").is_none());\n }\n\n #[test]\n fn wizard_keeps_existing_config() {\n let tmp = tempfile::tempdir().unwrap();\n let config_path = tmp.path().join(\"config.toml\");\n\n let existing = Config::default();\n std::fs::create_dir_all(config_path.parent().unwrap()).unwrap();\n loader::save_config(&existing, &config_path).unwrap();\n\n let mock = MockWizard::new(vec![MockAction::Confirm {\n prompt_contains: \"Update\",\n result: false,\n }]);\n\n run_wizard(&mock, &config_path).unwrap();\n\n assert_eq!(\n std::fs::read_to_string(&config_path).unwrap(),\n template_toml()\n );\n }\n\n #[test]\n fn wizard_fresh_quick_setup() {\n let tmp = tempfile::tempdir().unwrap();\n let config_path = tmp.path().join(\"config.toml\");\n\n let mock = MockWizard::new(vec![\n // Review: select Provider (index 0)\n MockAction::Select { result: 0 },\n // Step 1: Provider\n MockAction::Select { result: 0 }, // openai (first)\n MockAction::Input { result: \"gpt-4o\" },\n MockAction::Input {\n result: \"https://api.openai.com/v1\",\n },\n MockAction::Password {\n result: \"sk-test-key\",\n },\n // Review: Save (index 5 = last)\n MockAction::Select { result: 5 },\n MockAction::Confirm {\n prompt_contains: \"Write\",\n result: true,\n },\n ]);\n\n run_wizard(&mock, &config_path).unwrap();\n\n assert!(config_path.exists());\n let saved: Config =\n toml::from_str(&std::fs::read_to_string(&config_path).unwrap()).unwrap();\n assert_eq!(saved.agent.provider.as_deref(), Some(\"openai\"));\n assert_eq!(saved.agent.model, \"gpt-4o\");\n }\n\n #[test]\n fn wizard_setup_only_feishu() {\n // Test: user only configures Feishu, skips everything else\n let tmp = tempfile::tempdir().unwrap();\n let config_path = tmp.path().join(\"config.toml\");\n\n let mock = MockWizard::new(vec![\n // Review: save directly without configuring anything\n MockAction::Select { result: 5 }, // Save\n MockAction::Confirm {\n prompt_contains: \"Write\",\n result: true,\n },\n ]);\n\n run_wizard(&mock, &config_path).unwrap();\n\n assert!(config_path.exists());\n // Default config should be saved\n let saved: Config =\n toml::from_str(&std::fs::read_to_string(&config_path).unwrap()).unwrap();\n assert!(saved.agent.provider.is_none());\n }\n\n #[test]\n fn wizard_overwrite_existing_config() {\n let tmp = tempfile::tempdir().unwrap();\n let config_path = tmp.path().join(\"config.toml\");\n\n let existing = Config::default();\n std::fs::create_dir_all(config_path.parent().unwrap()).unwrap();\n loader::save_config(&existing, &config_path).unwrap();\n\n let mock = MockWizard::new(vec![\n // Update existing\n MockAction::Confirm {\n prompt_contains: \"Update\",\n result: true,\n },\n // Review: select Provider (index 0)\n MockAction::Select { result: 0 },\n // Step 1: Provider — select anthropic (index 1)\n MockAction::Select { result: 1 },\n MockAction::Input {\n result: \"claude-sonnet-4-20250514\",\n },\n MockAction::Input {\n result: \"https://api.anthropic.com\",\n },\n MockAction::Password {\n result: \"sk-ant-test\",\n },\n // Back to Review: Save (index 5)\n MockAction::Select { result: 5 },\n MockAction::Confirm {\n prompt_contains: \"Write\",\n result: true,\n },\n ]);\n\n run_wizard(&mock, &config_path).unwrap();\n\n let saved: Config =\n toml::from_str(&std::fs::read_to_string(&config_path).unwrap()).unwrap();\n assert_eq!(saved.agent.provider.as_deref(), Some(\"anthropic\"));\n assert_eq!(saved.agent.model, \"claude-sonnet-4-20250514\");\n }\n\n #[test]\n fn token_masking_short_tokens() {\n assert_eq!(mask_token(\"ab\"), \"(masked)\");\n assert_eq!(mask_token(\"\"), \"(not set)\");\n }\n\n #[test]\n fn token_masking_long_token() {\n let masked = mask_token(\"sk-proj-abcdefghijklmnop-b2cF\");\n assert!(masked.starts_with(\"sk-\"));\n assert!(masked.ends_with(\"b2cF\"));\n assert!(masked.contains(\"…\"));\n }\n\n #[test]\n fn validate_api_key_openai() {\n assert!(validate_api_key(\"openai\", \"sk-test123\").is_ok());\n assert!(validate_api_key(\"openai\", \"bad-key\").is_err());\n }\n\n #[test]\n fn validate_api_key_anthropic() {\n assert!(validate_api_key(\"anthropic\", \"sk-ant-test123\").is_ok());\n assert!(validate_api_key(\"anthropic\", \"bad-key\").is_err());\n }\n\n #[test]\n fn validate_api_key_other_providers() {\n assert!(validate_api_key(\"ollama\", \"any-key\").is_ok());\n }\n\n #[test]\n fn validate_api_key_empty() {\n assert!(validate_api_key(\"openai\", \"\").is_err());\n }\n\n #[test]\n fn validate_url_valid() {\n assert_eq!(\n validate_url(\"https://api.openai.com/v1\").unwrap(),\n \"https://api.openai.com/v1\"\n );\n assert_eq!(\n validate_url(\"http://localhost:11434\").unwrap(),\n \"http://localhost:11434\"\n );\n }\n\n #[test]\n fn validate_url_empty() {\n assert_eq!(validate_url(\"\").unwrap(), \"\");\n }\n\n #[test]\n fn validate_url_invalid() {\n assert!(validate_url(\"not-a-url\").is_err());\n assert!(validate_url(\"ftp://example.com\").is_err());\n }\n\n #[test]\n fn wizard_step_ordering() {\n assert_eq!(WizardStep::Provider.step_number(), 1);\n assert_eq!(WizardStep::Review.step_number(), 0);\n assert_eq!(WizardStep::all().len(), 5);\n assert_eq!(WizardStep::all()[0], WizardStep::Provider);\n assert_eq!(WizardStep::all()[4], WizardStep::WeChat);\n }\n\n fn tg(token: &str) -> TelegramConfig {\n TelegramConfig {\n token: token.to_string(),\n allowed_users: Vec::new(),\n admin_users: Vec::new(),\n enabled: true,\n streaming: false,\n proxy: None,\n }\n }\n\n #[test]\n fn token_masking_empty_token() {\n let mut config = Config::default();\n config.channels.telegram = Some(tg(\"\"));\n\n let mock = MockWizard::new(vec![]);\n show_config_summary_simple(&mock, &config).unwrap();\n\n let output = mock.output();\n assert!(output.contains(\"(not set)\"));\n }\n\n #[test]\n fn configure_weixin_qr_enables_existing_credentials() {\n let mock = MockWizard::new(vec![MockAction::Select { result: 0 }]);\n let mut config = Config::default();\n config.channels.weixin = Some(WeixinConfig {\n app_id: None,\n app_secret: None,\n token: None,\n encoding_aes_key: None,\n account_id: Some(\"wxid_existing@im.bot\".to_string()),\n bot_token: Some(\"tok_existing\".to_string()),\n base_url: None,\n cdn_base_url: None,\n dm_policy: \"open\".to_string(),\n group_policy: \"disabled\".to_string(),\n allowed_users: vec![],\n group_allowed_users: vec![],\n enabled: false,\n });\n\n configure_weixin(&mock, &mut config).unwrap();\n\n let weixin = config.channels.weixin.unwrap();\n assert!(weixin.enabled);\n assert_eq!(weixin.account_id.as_deref(), Some(\"wxid_existing@im.bot\"));\n assert_eq!(weixin.bot_token.as_deref(), Some(\"tok_existing\"));\n assert!(mock.output().contains(\"detected and enabled\"));\n }\n\n #[test]\n fn configure_weixin_manual_preserves_existing_fields() {\n let mock = MockWizard::new(vec![\n MockAction::Select { result: 1 },\n MockAction::Input {\n result: \"wxid_new@im.bot\",\n },\n MockAction::Input { result: \"tok_new\" },\n ]);\n let mut config = Config::default();\n config.channels.weixin = Some(WeixinConfig {\n app_id: Some(\"app_existing\".to_string()),\n app_secret: Some(\"secret_existing\".to_string()),\n token: Some(\"verify_token\".to_string()),\n encoding_aes_key: Some(\"aes_key\".to_string()),\n account_id: Some(\"wxid_old@im.bot\".to_string()),\n bot_token: Some(\"tok_old\".to_string()),\n base_url: Some(\"https://ilinkai.weixin.qq.com\".to_string()),\n cdn_base_url: Some(\"https://novac2c.cdn.weixin.qq.com/c2c\".to_string()),\n dm_policy: \"allowlist\".to_string(),\n group_policy: \"open\".to_string(),\n allowed_users: vec![\"u1\".to_string()],\n group_allowed_users: vec![\"g1\".to_string()],\n enabled: false,\n });\n\n configure_weixin(&mock, &mut config).unwrap();\n\n let weixin = config.channels.weixin.unwrap();\n assert_eq!(weixin.account_id.as_deref(), Some(\"wxid_new@im.bot\"));\n assert_eq!(weixin.bot_token.as_deref(), Some(\"tok_new\"));\n assert_eq!(weixin.app_id.as_deref(), Some(\"app_existing\"));\n assert_eq!(weixin.app_secret.as_deref(), Some(\"secret_existing\"));\n assert_eq!(weixin.token.as_deref(), Some(\"verify_token\"));\n assert_eq!(weixin.encoding_aes_key.as_deref(), Some(\"aes_key\"));\n assert_eq!(\n weixin.base_url.as_deref(),\n Some(\"https://ilinkai.weixin.qq.com\")\n );\n assert_eq!(\n weixin.cdn_base_url.as_deref(),\n Some(\"https://novac2c.cdn.weixin.qq.com/c2c\")\n );\n assert_eq!(weixin.dm_policy, \"allowlist\");\n assert_eq!(weixin.group_policy, \"open\");\n assert_eq!(weixin.allowed_users, vec![\"u1\"]);\n assert_eq!(weixin.group_allowed_users, vec![\"g1\"]);\n assert!(weixin.enabled);\n }\n\n // ── Connectivity validation tests ──────────────────────────\n\n fn mock_connectivity_auth_fail(\n _provider_key: &str,\n _base_url: &str,\n _api_key: &str,\n ) -> ConnectivityResult {\n ConnectivityResult::AuthFailed(\"HTTP 401 Unauthorized\".to_string())\n }\n\n fn mock_connectivity_unreachable(\n _provider_key: &str,\n _base_url: &str,\n _api_key: &str,\n ) -> ConnectivityResult {\n ConnectivityResult::Unreachable(\"Connection timed out\".to_string())\n }\n\n #[test]\n fn connectivity_check_ok_shows_models() {\n let mock = MockWizard::new(vec![]);\n let result = run_connectivity_check(\n &mock,\n \"openai\",\n \"https://api.openai.com/v1\",\n \"sk-test-key\",\n mock_connectivity_ok,\n );\n assert!(result.is_ok());\n let output = mock.output();\n assert!(output.contains(\"Connected!\"));\n assert!(output.contains(\"gpt-4o\"));\n }\n\n #[test]\n fn connectivity_check_auth_fail_asks_continue() {\n let mock = MockWizard::new(vec![MockAction::Confirm {\n prompt_contains: \"Continue\",\n result: true,\n }]);\n let result = run_connectivity_check(\n &mock,\n \"openai\",\n \"https://api.openai.com/v1\",\n \"bad-key\",\n mock_connectivity_auth_fail,\n );\n assert!(result.is_ok());\n let output = mock.output();\n assert!(output.contains(\"validation failed\"));\n }\n\n #[test]\n fn connectivity_check_auth_fail_user_cancels() {\n let mock = MockWizard::new(vec![MockAction::Confirm {\n prompt_contains: \"Continue\",\n result: false,\n }]);\n let result = run_connectivity_check(\n &mock,\n \"openai\",\n \"https://api.openai.com/v1\",\n \"bad-key\",\n mock_connectivity_auth_fail,\n );\n assert!(result.is_err());\n }\n\n #[test]\n fn connectivity_check_unreachable_asks_continue() {\n let mock = MockWizard::new(vec![MockAction::Confirm {\n prompt_contains: \"Continue\",\n result: true,\n }]);\n let result = run_connectivity_check(\n &mock,\n \"openai\",\n \"https://api.openai.com/v1\",\n \"sk-test\",\n mock_connectivity_unreachable,\n );\n assert!(result.is_ok());\n let output = mock.output();\n assert!(output.contains(\"Could not verify\"));\n }\n\n #[test]\n fn connectivity_check_skips_ollama() {\n let mock = MockWizard::new(vec![]);\n let result = run_connectivity_check(\n &mock,\n \"ollama\",\n \"http://localhost:11434\",\n \"\",\n mock_connectivity_ok,\n );\n assert!(result.is_ok());\n // No output for Ollama (skipped)\n assert!(mock.output().is_empty());\n }\n\n #[test]\n fn parse_models_from_openai_response() {\n let body = r#\"{\"data\":[{\"id\":\"gpt-4o\"},{\"id\":\"gpt-4o-mini\"},{\"id\":\"gpt-3.5-turbo\"}]}\"#;\n let models = parse_openai_models(body);\n assert_eq!(models, vec![\"gpt-4o\", \"gpt-4o-mini\", \"gpt-3.5-turbo\"]);\n }\n\n #[test]\n fn parse_models_empty_response() {\n let models = parse_openai_models(\"not json\");\n assert!(models.is_empty());\n }\n\n #[test]\n fn extract_error_message_from_openai_format() {\n let body = r#\"{\"error\":{\"message\":\"Incorrect API key\",\"type\":\"invalid_request_error\"}}\"#;\n let msg = extract_error_message(body).unwrap();\n assert_eq!(msg, \"Incorrect API key\");\n }\n\n #[test]\n fn extract_error_message_no_message() {\n let body = r#\"{\"status\":\"ok\"}\"#;\n assert!(extract_error_message(body).is_none());\n }\n\n #[test]\n fn configure_provider_with_connectivity() {\n let mock = MockWizard::new(vec![\n MockAction::Select { result: 0 }, // openai\n MockAction::Input { result: \"gpt-4o\" },\n MockAction::Input {\n result: \"https://api.openai.com/v1\",\n },\n MockAction::Password {\n result: \"sk-testkey123456\",\n },\n ]);\n\n let mut config = Config::default();\n configure_provider_inner(&mock, &mut config, mock_connectivity_ok).unwrap();\n\n assert_eq!(config.agent.provider.as_deref(), Some(\"openai\"));\n assert_eq!(config.agent.model, \"gpt-4o\");\n let output = mock.output();\n assert!(output.contains(\"Connected!\"));\n }\n}\n"}, "77a44e8ca3e24d6705a10bba744c01bc05314926": {"t": 1785761069.5751128, "n": 57718, "h": "61dfd4c145cb2f16", "c": "//! Setup command — interactive wizard for configuring Kestrel.\n//!\n//! Supports back-navigation via a state-machine loop, Quick/Full setup modes,\n//! input validation, and enhanced configuration summaries.\n\nuse anyhow::{bail, Context, Result};\nuse console::Term;\nuse dialoguer::{Confirm, Input, Password as PasswordInput, Select};\nuse kestrel_config::{\n loader, paths,\n schema::{Config, FeishuConfig, ProviderEntry, TelegramConfig, WebSocketConfig, WeixinConfig},\n};\nuse owo_colors::OwoColorize;\nuse std::net::SocketAddr;\nuse std::path::Path;\n\n// ── Provider display info ──────────────────────────────────────\n\nstruct ProviderInfo {\n key: &'static str,\n display: &'static str,\n default_model: &'static str,\n default_url: &'static str,\n}\n\nconst PROVIDERS: &[ProviderInfo] = &[\n ProviderInfo {\n key: \"openai\",\n display: \"★ OpenAI\",\n default_model: \"gpt-4o\",\n default_url: \"https://api.openai.com/v1\",\n },\n ProviderInfo {\n key: \"anthropic\",\n display: \"★ Anthropic\",\n default_model: \"claude-sonnet-4-20250514\",\n default_url: \"https://api.anthropic.com\",\n },\n ProviderInfo {\n key: \"openrouter\",\n display: \"★ OpenRouter (multi-model)\",\n default_model: \"anthropic/claude-sonnet-4-20250514\",\n default_url: \"https://openrouter.ai/api/v1\",\n },\n ProviderInfo {\n key: \"ollama\",\n display: \" Ollama (local models)\",\n default_model: \"llama3\",\n default_url: \"http://localhost:11434\",\n },\n ProviderInfo {\n key: \"deepseek\",\n display: \" DeepSeek\",\n default_model: \"deepseek-chat\",\n default_url: \"https://api.deepseek.com\",\n },\n ProviderInfo {\n key: \"gemini\",\n display: \" Gemini\",\n default_model: \"gemini-2.5-pro\",\n default_url: \"https://generativelanguage.googleapis.com/v1beta\",\n },\n ProviderInfo {\n key: \"groq\",\n display: \" Groq\",\n default_model: \"llama-3.3-70b-versatile\",\n default_url: \"https://api.groq.com/openai/v1\",\n },\n ProviderInfo {\n key: \"moonshot\",\n display: \" Moonshot (月之暗面)\",\n default_model: \"moonshot-v1-8k\",\n default_url: \"https://api.moonshot.cn/v1\",\n },\n ProviderInfo {\n key: \"minimax\",\n display: \" MiniMax\",\n default_model: \"MiniMax-Text-01\",\n default_url: \"https://api.minimax.chat/v1\",\n },\n ProviderInfo {\n key: \"github_copilot\",\n display: \" GitHub Copilot\",\n default_model: \"gpt-4o\",\n default_url: \"https://api.githubcopilot.com\",\n },\n ProviderInfo {\n key: \"openai_codex\",\n display: \" OpenAI Codex\",\n default_model: \"codex-mini\",\n default_url: \"https://api.openai.com/v1\",\n },\n ProviderInfo {\n key: \"glm_coding_plan\",\n display: \" GLM Coding Plan (智谱)\",\n default_model: \"glm-5.1\",\n default_url: \"https://open.bigmodel.cn/api/coding/paas/v4\",\n },\n];\n\nconst TOTAL_CONFIG_STEPS: usize = 5;\n\nconst BACK_OPTION: &str = \"↩ Go back\";\n\n// ── Wizard step state machine ──────────────────────────────────\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\nenum WizardStep {\n Provider, // Step 1\n Telegram, // Step 2\n Feishu, // Step 3\n WebSocket, // Step 4\n WeChat, // Step 5\n Review, // Final (no step number)\n}\n\nimpl WizardStep {\n fn step_number(self) -> usize {\n match self {\n Self::Provider => 1,\n Self::Telegram => 2,\n Self::Feishu => 3,\n Self::WebSocket => 4,\n Self::WeChat => 5,\n Self::Review => 0,\n }\n }\n\n fn title(self) -> &'static str {\n match self {\n Self::Provider => \"Provider Configuration\",\n Self::Telegram => \"Telegram Channel\",\n Self::Feishu => \"Feishu / Lark Channel\",\n Self::WebSocket => \"WebSocket Port\",\n Self::WeChat => \"WeChat Channel\",\n Self::Review => \"Review & Save\",\n }\n }\n\n fn all() -> &'static [WizardStep] {\n &[\n WizardStep::Provider,\n WizardStep::Telegram,\n WizardStep::Feishu,\n WizardStep::WebSocket,\n WizardStep::WeChat,\n ]\n }\n}\n\n/// Whether a channel step was configured or skipped.\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\nenum ChannelStatus {\n Configured,\n Skipped,\n}\n\n// ── Trait for interactive I/O (enables testability) ────────────\n\ntrait WizardIo {\n fn confirm(&self, prompt: &str, default: bool) -> Result;\n fn select(&self, prompt: &str, items: &[&str], default: usize) -> Result;\n fn input_with_default(&self, prompt: &str, default: &str) -> Result;\n fn input_allow_empty(&self, prompt: &str) -> Result;\n fn password(&self, prompt: &str) -> Result;\n fn write_line(&self, line: &str) -> Result<()>;\n\n /// Show a confirmation with back option. Returns `None` if user chose back.\n fn confirm_or_back(&self, prompt: &str, default: bool) -> Result> {\n let items = if default {\n &[\"Yes\", \"Skip (keep current)\", BACK_OPTION] as &[&str]\n } else {\n &[\"Skip (keep current)\", \"Yes\", BACK_OPTION] as &[&str]\n };\n let default_idx = 0;\n let choice = self.select(prompt, items, default_idx)?;\n match choice {\n idx if items[idx] == BACK_OPTION => Ok(None),\n idx if items[idx] == \"Yes\" => Ok(Some(true)),\n _ => Ok(Some(false)),\n }\n }\n}\n\n// ── Production implementation using Term + dialoguer ───────────\n\nstruct TermWizard<'a> {\n term: &'a Term,\n}\n\nimpl<'a> TermWizard<'a> {\n fn new(term: &'a Term) -> Self {\n Self { term }\n }\n}\n\nimpl WizardIo for TermWizard<'_> {\n fn confirm(&self, prompt: &str, default: bool) -> Result {\n Ok(Confirm::new()\n .with_prompt(prompt)\n .default(default)\n .interact_on(self.term)?)\n }\n\n fn select(&self, prompt: &str, items: &[&str], default: usize) -> Result {\n Ok(Select::new()\n .with_prompt(prompt)\n .items(items)\n .default(default)\n .interact_on(self.term)?)\n }\n\n fn input_with_default(&self, prompt: &str, default: &str) -> Result {\n Ok(Input::new()\n .with_prompt(prompt)\n .default(default.to_string())\n .interact_text_on(self.term)?)\n }\n\n fn input_allow_empty(&self, prompt: &str) -> Result {\n Ok(Input::::new()\n .with_prompt(prompt)\n .allow_empty(true)\n .interact_text_on(self.term)?)\n }\n\n fn password(&self, prompt: &str) -> Result {\n Ok(PasswordInput::new()\n .with_prompt(prompt)\n .interact_on(self.term)?)\n }\n\n fn write_line(&self, line: &str) -> Result<()> {\n self.term.write_line(line)?;\n Ok(())\n }\n}\n\n// ── Entry point ────────────────────────────────────────────────\n\npub fn run(_config: Config) -> Result<()> {\n let term = Term::stdout();\n if !term.is_term() {\n bail!(\n \"Setup requires an interactive terminal. \\\n Run this command in a terminal, not in a pipe or CI environment.\"\n );\n }\n let io = TermWizard::new(&term);\n let config_path = paths::get_config_path()?;\n\n // Set up Ctrl+C handler for graceful exit\n ctrlc::set_handler(|| {\n println!();\n println!(\n \" {} Setup interrupted. Progress has not been saved.\",\n \"!\".yellow()\n );\n println!(\" Run `kestrel setup` again to start over.\");\n std::process::exit(1);\n })\n .ok(); // Ignore error if handler already set\n\n run_wizard(&io, &config_path)\n}\n\n// ── Wizard flow (state machine) ────────────────────────────────\n\nfn run_wizard(io: &dyn WizardIo, config_path: &Path) -> Result<()> {\n let is_first_run = !config_path.exists();\n print_banner(io, is_first_run)?;\n\n // ── Load existing config or start fresh ──────────────────────\n let mut config = if config_path.exists() {\n match load_existing_config(config_path) {\n Ok(existing) => {\n show_config_summary_simple(io, &existing)?;\n if io.confirm(\"Update existing config?\", true)? {\n existing\n } else {\n io.write_line(&format!(\n \" {} Keeping config at {}.\",\n \"✓\".green(),\n config_path.display()\n ))?;\n return Ok(());\n }\n }\n Err(e) => {\n io.write_line(&format!(\n \" {} Could not parse existing config: {}\",\n \"!\".yellow(),\n e\n ))?;\n if io.confirm(\"Start fresh with defaults?\", true)? {\n Config::default()\n } else {\n bail!(\"Setup cancelled.\");\n }\n }\n }\n } else {\n Config::default()\n };\n\n // ── State machine: Review-centric navigation ────────────────\n // After banner + config load, user lands on Review menu.\n // They can freely jump to any step, configure it, and return to Review.\n let mut channel_status = build_channel_status(&config);\n let mut current_step = WizardStep::Review;\n\n loop {\n match current_step {\n WizardStep::Provider => {\n print_step(io, current_step)?;\n match configure_provider(io, &mut config)? {\n StepAction::Back => {}\n StepAction::Continue => {\n channel_status[0] = ChannelStatus::Configured;\n }\n }\n current_step = WizardStep::Review;\n }\n WizardStep::Telegram => {\n print_step(io, current_step)?;\n let has_existing = config.channels.telegram.is_some();\n match configure_channel_step(\n io,\n &mut config,\n \"Telegram\",\n has_existing,\n configure_telegram,\n &mut channel_status[1],\n )? {\n StepAction::Back => {}\n StepAction::Continue => {}\n }\n current_step = WizardStep::Review;\n }\n WizardStep::Feishu => {\n print_step(io, current_step)?;\n let has_existing = config.channels.feishu.as_ref().is_some_and(|f| f.enabled);\n match configure_channel_step(\n io,\n &mut config,\n \"Feishu / Lark\",\n has_existing,\n configure_feishu,\n &mut channel_status[2],\n )? {\n StepAction::Back => {}\n StepAction::Continue => {}\n }\n current_step = WizardStep::Review;\n }\n WizardStep::WebSocket => {\n print_step(io, current_step)?;\n let has_existing = config\n .channels\n .websocket\n .as_ref()\n .is_some_and(|w| w.enabled);\n match configure_channel_step(\n io,\n &mut config,\n \"WebSocket\",\n has_existing,\n configure_websocket,\n &mut channel_status[3],\n )? {\n StepAction::Back => {}\n StepAction::Continue => {}\n }\n current_step = WizardStep::Review;\n }\n WizardStep::WeChat => {\n print_step(io, current_step)?;\n let has_existing = config.channels.weixin.is_some();\n match configure_channel_step(\n io,\n &mut config,\n \"WeChat\",\n has_existing,\n configure_weixin,\n &mut channel_status[4],\n )? {\n StepAction::Back => {}\n StepAction::Continue => {}\n }\n current_step = WizardStep::Review;\n }\n WizardStep::Review => {\n // Refresh status from actual config state\n channel_status = build_channel_status(&config);\n print_review(io, &config, &channel_status)?;\n\n let review_items = build_review_items(&channel_status);\n let review_refs: Vec<&str> = review_items.iter().map(|s| s.as_str()).collect();\n let default_save = review_items.len() - 1;\n let choice = io.select(\n \"Select a step to configure, or save\",\n &review_refs,\n default_save,\n )?;\n\n if choice == default_save {\n if save_config(io, &config, config_path)? {\n print_next_steps(io, &config)?;\n return Ok(());\n }\n // User cancelled save, stay in review\n } else if choice < WizardStep::all().len() {\n current_step = WizardStep::all()[choice];\n }\n }\n }\n }\n}\n\n/// Build channel status from actual config state (not just wizard tracking).\nfn build_channel_status(config: &Config) -> [ChannelStatus; 5] {\n [\n if config.agent.provider.is_some() {\n ChannelStatus::Configured\n } else {\n ChannelStatus::Skipped\n },\n if config.channels.telegram.is_some() {\n ChannelStatus::Configured\n } else {\n ChannelStatus::Skipped\n },\n if config.channels.feishu.as_ref().is_some_and(|f| f.enabled) {\n ChannelStatus::Configured\n } else {\n ChannelStatus::Skipped\n },\n if config\n .channels\n .websocket\n .as_ref()\n .is_some_and(|w| w.enabled)\n {\n ChannelStatus::Configured\n } else {\n ChannelStatus::Skipped\n },\n if config.channels.weixin.is_some() {\n ChannelStatus::Configured\n } else {\n ChannelStatus::Skipped\n },\n ]\n}\n\nfn build_review_items(channel_status: &[ChannelStatus; 5]) -> Vec {\n let mut items: Vec = WizardStep::all()\n .iter()\n .enumerate()\n .map(|(i, s)| {\n let num = s.step_number();\n let title = s.title();\n let status = match channel_status[i] {\n ChannelStatus::Configured => \"configured\".to_string(),\n ChannelStatus::Skipped => \"not configured\".to_string(),\n };\n format!(\"{}. {} ({})\", num, title, status)\n })\n .collect();\n items.push(\"Save configuration\".to_string());\n items\n}\n\n/// Result of a configure step: continue forward or go back.\nenum StepAction {\n Continue,\n Back,\n}\n\n/// Generic wrapper for channel configuration steps with back support.\nfn configure_channel_step(\n io: &dyn WizardIo,\n config: &mut Config,\n channel_name: &str,\n has_existing: bool,\n configure_fn: fn(&dyn WizardIo, &mut Config) -> Result,\n status: &mut ChannelStatus,\n) -> Result {\n let prompt = if has_existing {\n format!(\"Update {} configuration?\", channel_name)\n } else {\n format!(\"Set up {}?\", channel_name)\n };\n\n match io.confirm_or_back(&prompt, has_existing)? {\n None => return Ok(StepAction::Back),\n Some(false) => {\n io.write_line(\" Kept current configuration.\")?;\n *status = ChannelStatus::Skipped;\n return Ok(StepAction::Continue);\n }\n Some(true) => {}\n }\n\n match configure_fn(io, config) {\n Ok(true) => {\n *status = ChannelStatus::Configured;\n Ok(StepAction::Continue)\n }\n Ok(false) => {\n // Configure fn handled skip internally\n *status = ChannelStatus::Skipped;\n Ok(StepAction::Continue)\n }\n Err(e) => Err(e),\n }\n}\n\n// ── Banner & step printing ─────────────────────────────────────\n\nfn print_banner(io: &dyn WizardIo, is_first_run: bool) -> Result<()> {\n io.write_line(\"\")?;\n if is_first_run {\n io.write_line(&format!(\n \" {} {}\",\n \"▸\".cyan(),\n \"Kestrel 初始化配置向导\".bold().cyan()\n ))?;\n io.write_line(\"\")?;\n io.write_line(\" Welcome to Kestrel! Let's set up your configuration.\")?;\n io.write_line(\" You can re-run `kestrel setup` anytime to make changes.\")?;\n } else {\n io.write_line(&format!(\n \" {} {}\",\n \"▸\".cyan(),\n \"Kestrel 配置更新向导\".bold().cyan()\n ))?;\n io.write_line(\"\")?;\n io.write_line(\" Update your existing configuration.\")?;\n }\n io.write_line(\"\")?;\n Ok(())\n}\n\nfn print_step(io: &dyn WizardIo, step: WizardStep) -> Result<()> {\n io.write_line(\"\")?;\n let num = step.step_number();\n io.write_line(&format!(\n \" {} Step {}/{}: {}\",\n \"▸\".cyan(),\n num,\n TOTAL_CONFIG_STEPS,\n step.title().bold()\n ))?;\n io.write_line(&format!(\" {}\", \"─\".repeat(40).dimmed()))?;\n Ok(())\n}\n\nfn print_review(\n io: &dyn WizardIo,\n config: &Config,\n channel_status: &[ChannelStatus; 5],\n) -> Result<()> {\n io.write_line(\"\")?;\n io.write_line(&format!(\n \" {} {}\",\n \"▸\".cyan(),\n \"Review & Save\".bold().cyan()\n ))?;\n io.write_line(&format!(\" {}\", \"─\".repeat(50).dimmed()))?;\n io.write_line(\"\")?;\n show_config_summary(io, config, channel_status)?;\n io.write_line(\"\")?;\n Ok(())\n}\n\nfn load_existing_config(path: &Path) -> Result {\n let content = std::fs::read_to_string(path)\n .with_context(|| format!(\"Failed to read {}\", path.display()))?;\n let config: Config =\n toml::from_str(&content).with_context(|| format!(\"Failed to parse {}\", path.display()))?;\n Ok(config)\n}\n\n// ── Token masking & config summary ─────────────────────────────\n\nfn mask_token(token: &str) -> String {\n if token.is_empty() {\n \"(not set)\".to_string()\n } else if token.len() <= 8 {\n \"(masked)\".to_string()\n } else {\n format!(\"{}…{}\", &token[..3], &token[token.len() - 4..])\n }\n}\n\nfn show_config_summary(\n io: &dyn WizardIo,\n config: &Config,\n channel_status: &[ChannelStatus; 5],\n) -> Result<()> {\n let provider = config.agent.provider.as_deref().unwrap_or(\"default\");\n io.write_line(&format!(\" Provider: {}\", provider))?;\n io.write_line(&format!(\" Model: {}\", config.agent.model))?;\n\n // Show base URL\n if let Some(url) = config\n .agent\n .provider\n .as_deref()\n .and_then(|p| get_provider_url(config, p))\n {\n io.write_line(&format!(\" Base URL: {}\", url))?;\n }\n\n // Show API key status\n let key_status = config\n .agent\n .provider\n .as_deref()\n .and_then(|p| get_provider_api_key(config, p))\n .map(mask_token)\n .unwrap_or_else(|| \"(not set)\".to_string());\n io.write_line(&format!(\" API Key: {}\", key_status))?;\n\n io.write_line(&format!(\" Temperature: {}\", config.agent.temperature))?;\n io.write_line(&format!(\" Max tokens: {}\", config.agent.max_tokens))?;\n\n io.write_line(\"\")?;\n io.write_line(\" Channels:\")?;\n\n let channel_names = [\"Telegram\", \"Feishu\", \"WebSocket\", \"WeChat\"];\n for (i, name) in channel_names.iter().enumerate() {\n let status_str = match channel_status[i + 1] {\n ChannelStatus::Configured => \"✓ configured\".green().to_string(),\n ChannelStatus::Skipped => \"✗ skipped\".dimmed().to_string(),\n };\n io.write_line(&format!(\" {}: {}\", name, status_str))?;\n }\n\n // Show details for configured channels\n if let Some(ref tg) = config.channels.telegram {\n if !tg.token.is_empty() {\n io.write_line(&format!(\" Token: {}\", mask_token(&tg.token)))?;\n }\n }\n\n if let Some(ref ws) = config.channels.websocket {\n if ws.enabled {\n io.write_line(&format!(\" Address: {}\", ws.listen_addr))?;\n }\n }\n\n if let Some(ref wx) = config.channels.weixin {\n if wx.enabled {\n let acct = wx.account_id.as_deref().unwrap_or(\"(unknown)\");\n io.write_line(&format!(\" Account: {}\", mask_token(acct)))?;\n }\n }\n\n if let Some(ref fs) = config.channels.feishu {\n if fs.enabled {\n let app_id = fs.app_id.as_deref().unwrap_or(\"(unknown)\");\n io.write_line(&format!(\" App ID: {}\", mask_token(app_id)))?;\n }\n }\n\n Ok(())\n}\n\n/// Legacy summary without channel status (used for existing config review).\nfn show_config_summary_simple(io: &dyn WizardIo, config: &Config) -> Result<()> {\n let provider = config.agent.provider.as_deref().unwrap_or(\"default\");\n io.write_line(&format!(\" Provider: {}\", provider))?;\n io.write_line(&format!(\" Model: {}\", config.agent.model))?;\n io.write_line(&format!(\" Temperature: {}\", config.agent.temperature))?;\n io.write_line(&format!(\" Max tokens: {}\", config.agent.max_tokens))?;\n io.write_line(&format!(\" Streaming: {}\", config.agent.streaming))?;\n\n if let Some(ref tg) = config.channels.telegram {\n io.write_line(&format!(\" Telegram: {}\", mask_token(&tg.token)))?;\n }\n\n if let Some(ref fs) = config.channels.feishu {\n if fs.enabled {\n let app_id = fs.app_id.as_deref().unwrap_or(\"(not set)\");\n io.write_line(&format!(\" Feishu: {}\", mask_token(app_id)))?;\n }\n }\n\n if let Some(ref ws) = config.channels.websocket {\n if ws.enabled {\n io.write_line(&format!(\" WebSocket: {}\", ws.listen_addr))?;\n }\n }\n\n if let Some(ref wx) = config.channels.weixin {\n let status = if wx.enabled {\n mask_token(wx.bot_token.as_deref().unwrap_or(\"\"))\n } else {\n \"disabled\".to_string()\n };\n io.write_line(&format!(\" WeChat: {}\", status))?;\n }\n\n Ok(())\n}\n\nfn save_config(io: &dyn WizardIo, config: &Config, config_path: &Path) -> Result {\n io.write_line(&format!(\" Config path: {}\", config_path.display()))?;\n\n if !io.confirm(\"Write this configuration?\", true)? {\n io.write_line(&format!(\" {} Save cancelled.\", \"!\".yellow()))?;\n return Ok(false);\n }\n\n let home = config_path\n .parent()\n .context(\"Config path must have a parent directory\")?;\n\n std::fs::create_dir_all(home)\n .with_context(|| format!(\"Failed to create config home: {}\", home.display()))?;\n\n loader::save_config(config, config_path)?;\n\n for dir in [\"skills\", \"sessions\", \"learning\"] {\n let path = home.join(dir);\n std::fs::create_dir_all(&path)\n .with_context(|| format!(\"Failed to create directory: {}\", path.display()))?;\n }\n\n io.write_line(\"\")?;\n io.write_line(&format!(\n \" {} Configuration saved to {}\",\n \"✓\".green(),\n config_path.display()\n ))?;\n io.write_line(&format!(\n \" {} Created directories: skills, sessions, learning\",\n \"✓\".green()\n ))?;\n io.write_line(&format!(\" {} Setup complete!\", \"✓\".green()))?;\n\n Ok(true)\n}\n\nfn print_next_steps(io: &dyn WizardIo, config: &Config) -> Result<()> {\n io.write_line(\"\")?;\n io.write_line(&format!(\" {} Next steps:\", \"▸\".cyan()))?;\n io.write_line(\"\")?;\n io.write_line(\" 1. Try it out:\")?;\n io.write_line(\" kestrel agent\")?;\n io.write_line(\"\")?;\n\n let has_channel = config.channels.telegram.is_some()\n || config.channels.feishu.is_some()\n || config.channels.weixin.is_some()\n || config.channels.websocket.is_some();\n\n if has_channel {\n io.write_line(\" 2. Start the gateway (connect to chat platforms):\")?;\n io.write_line(\" kestrel gateway\")?;\n io.write_line(\"\")?;\n }\n\n if config.channels.websocket.is_some() {\n io.write_line(\" 3. Start the API server:\")?;\n io.write_line(\" kestrel serve\")?;\n io.write_line(\"\")?;\n }\n\n io.write_line(\" Check system health:\")?;\n io.write_line(\" kestrel doctor\")?;\n io.write_line(\"\")?;\n io.write_line(\" Re-run setup anytime:\")?;\n io.write_line(\" kestrel setup\")?;\n io.write_line(\"\")?;\n Ok(())\n}\n\n// ── Input validation ───────────────────────────────────────────\n\nfn validate_api_key(provider: &str, key: &str) -> Result {\n let key = key.trim().to_string();\n if key.is_empty() {\n bail!(\"API key cannot be empty\");\n }\n match provider {\n \"openai\" | \"openai_codex\" if !key.starts_with(\"sk-\") => {\n bail!(\"OpenAI API keys typically start with 'sk-'\");\n }\n \"anthropic\" if !key.starts_with(\"sk-ant-\") => {\n bail!(\"Anthropic API keys typically start with 'sk-ant-'\");\n }\n _ => {}\n }\n Ok(key)\n}\n\nfn validate_url(input: &str) -> Result {\n let url = input.trim().to_string();\n if url.is_empty() {\n return Ok(url);\n }\n if !url.starts_with(\"http://\") && !url.starts_with(\"https://\") {\n bail!(\"URL must start with http:// or https://\");\n }\n Ok(url)\n}\n\n// ── Provider configuration ─────────────────────────────────────\n\nfn configure_provider(io: &dyn WizardIo, config: &mut Config) -> Result {\n let display_names: Vec<&str> = PROVIDERS.iter().map(|p| p.display).collect();\n\n let default_idx = config\n .agent\n .provider\n .as_deref()\n .and_then(|p| PROVIDERS.iter().position(|info| info.key == p))\n .unwrap_or(0); // default to OpenAI (first)\n\n let provider_idx = io.select(\"Select LLM provider\", &display_names, default_idx)?;\n\n let provider_key = PROVIDERS[provider_idx].key;\n config.agent.provider = Some(provider_key.to_string());\n\n let default_model = PROVIDERS[provider_idx].default_model;\n let current_model = if config.agent.model.is_empty() {\n default_model\n } else {\n &config.agent.model\n };\n\n let model: String = io.input_with_default(\"Model name\", current_model)?;\n config.agent.model = model.trim().to_string();\n\n let default_url = PROVIDERS[provider_idx].default_url;\n let current_url = get_provider_url(config, provider_key).unwrap_or(default_url);\n\n if !current_url.is_empty() {\n let base_url: String = io.input_with_default(\"Base URL\", current_url)?;\n let base_url = validate_url(&base_url)?;\n set_provider_url(config, provider_key, &base_url);\n } else {\n let base_url: String = io.input_allow_empty(\"Base URL (leave empty for default)\")?;\n let base_url = validate_url(&base_url)?;\n if !base_url.is_empty() {\n set_provider_url(config, provider_key, &base_url);\n }\n }\n\n loop {\n let api_key: String = io.password(\"API key\")?;\n match validate_api_key(provider_key, &api_key) {\n Ok(key) => {\n set_provider_api_key(config, provider_key, &key);\n break;\n }\n Err(e) => {\n io.write_line(&format!(\" {} {}\", \"!\".yellow(), e))?;\n if !io.confirm(\"Try again?\", true)? {\n // Accept whatever was entered\n let key = api_key.trim().to_string();\n if !key.is_empty() {\n set_provider_api_key(config, provider_key, &key);\n }\n break;\n }\n }\n }\n }\n\n Ok(StepAction::Continue)\n}\n\n// ── Channel configurations ─────────────────────────────────────\n\n/// Configure Telegram. Returns true if configured, false if skipped.\nfn configure_telegram(io: &dyn WizardIo, config: &mut Config) -> Result {\n let current_token = config\n .channels\n .telegram\n .as_ref()\n .map(|tg| tg.token.as_str())\n .unwrap_or(\"\");\n\n let token: String = if current_token.is_empty() {\n io.input_allow_empty(\"Bot token (from @BotFather)\")?\n } else {\n io.input_with_default(\"Bot token (from @BotFather)\", current_token)?\n };\n\n let token = token.trim().to_string();\n if token.is_empty() {\n io.write_line(\" No token provided, skipping Telegram.\")?;\n return Ok(false);\n }\n\n let allowed: String = loop {\n let input: String =\n io.input_allow_empty(\"Allowed user IDs (comma-separated, leave empty for all)\")?;\n if input.trim().is_empty() {\n break String::new();\n }\n let ids: Vec<&str> = input\n .split(',')\n .map(|s| s.trim())\n .filter(|s| !s.is_empty())\n .collect();\n let mut valid = true;\n for id in &ids {\n if id.parse::().is_err() {\n io.write_line(&format!(\n \" {} '{}' is not a valid user ID (must be a positive integer).\",\n \"!\".yellow(),\n id\n ))?;\n valid = false;\n break;\n }\n }\n if valid {\n break input;\n }\n };\n\n let allowed_users: Vec = if allowed.trim().is_empty() {\n Vec::new()\n } else {\n allowed\n .split(',')\n .map(|s| s.trim().to_string())\n .filter(|s| !s.is_empty())\n .collect()\n };\n\n let (admin_users, enabled, streaming, proxy) = config\n .channels\n .telegram\n .as_ref()\n .map(|tg| {\n (\n tg.admin_users.clone(),\n tg.enabled,\n tg.streaming,\n tg.proxy.clone(),\n )\n })\n .unwrap_or((Vec::new(), true, true, None));\n\n config.channels.telegram = Some(TelegramConfig {\n token,\n allowed_users,\n admin_users,\n enabled,\n streaming,\n proxy,\n });\n\n io.write_line(&format!(\" {} Telegram configured.\", \"✓\".green()))?;\n Ok(true)\n}\n\n/// Configure Feishu. Returns true if configured, false if skipped.\nfn configure_feishu(io: &dyn WizardIo, config: &mut Config) -> Result {\n let domain_options = [\"Feishu (飞书)\", \"Lark (international)\"];\n let idx = io.select(\"Select platform\", &domain_options, 0)?;\n let domain = if idx == 1 { \"lark\" } else { \"feishu\" };\n\n let rt = tokio::runtime::Builder::new_current_thread()\n .enable_all()\n .build()\n .context(\"Failed to create tokio runtime\")?;\n\n let result = match rt.block_on(super::feishu_onboarding::run_onboarding(domain)) {\n Ok(r) => r,\n Err(e) => {\n io.write_line(&format!(\" {} Feishu setup skipped: {}\", \"!\".yellow(), e))?;\n return Ok(false);\n }\n };\n\n let existing_proxy = config\n .channels\n .feishu\n .as_ref()\n .and_then(|f| f.proxy.clone());\n\n config.channels.feishu = Some(FeishuConfig {\n app_id: Some(result.app_id),\n app_secret: Some(result.app_secret),\n enabled: true,\n proxy: existing_proxy,\n ..Default::default()\n });\n\n io.write_line(&format!(\" {} Feishu configured.\", \"✓\".green()))?;\n Ok(true)\n}\n\n/// Configure WebSocket. Returns true if configured, false if skipped.\nfn configure_websocket(io: &dyn WizardIo, config: &mut Config) -> Result {\n let default_addr = config\n .channels\n .websocket\n .as_ref()\n .map(|ws| ws.listen_addr.as_str())\n .unwrap_or(\"127.0.0.1:8090\");\n\n let listen_addr: String = loop {\n let input = io.input_with_default(\"Listen address\", default_addr)?;\n match input.parse::() {\n Ok(_) => break input,\n Err(e) => {\n io.write_line(&format!(\n \" {} Invalid address '{}': {}\",\n \"!\".yellow(),\n input,\n e\n ))?;\n }\n }\n };\n\n config.channels.websocket = Some(WebSocketConfig {\n enabled: true,\n listen_addr,\n auth: Default::default(),\n max_clients: 100,\n max_message_size: 1048576,\n });\n\n io.write_line(&format!(\" {} WebSocket configured.\", \"✓\".green()))?;\n Ok(true)\n}\n\n/// Configure WeChat. Returns true if configured, false if skipped.\nfn configure_weixin(io: &dyn WizardIo, config: &mut Config) -> Result {\n let choices = &[\n \"Scan QR code with WeChat (recommended)\",\n \"Enter credentials manually\",\n \"Skip\",\n ];\n let choice = io.select(\"How would you like to configure WeChat?\", choices, 0)?;\n\n match choice {\n 0 => {\n io.write_line(\n \" QR scan setup requires running `kestrel setup weixin` in a terminal.\",\n )?;\n io.write_line(\" Please run that command separately, then return to this wizard.\")?;\n if let Some(ref mut wx) = config.channels.weixin {\n if wx.account_id.is_some() && wx.bot_token.is_some() {\n wx.enabled = true;\n io.write_line(&format!(\n \" {} Existing WeChat credentials detected and enabled.\",\n \"✓\".green()\n ))?;\n return Ok(true);\n }\n }\n if config.channels.weixin.is_none() {\n io.write_line(\n \" No WeChat credentials found yet. Run `kestrel setup weixin` first.\",\n )?;\n }\n Ok(false)\n }\n 1 => {\n let current_account = config\n .channels\n .weixin\n .as_ref()\n .and_then(|w| w.account_id.as_deref())\n .unwrap_or(\"\");\n let account_id: String = if current_account.is_empty() {\n io.input_allow_empty(\"iLink account ID (e.g. wxid_xxx@im.bot)\")?\n } else {\n io.input_with_default(\"iLink account ID\", current_account)?\n };\n\n if account_id.trim().is_empty() {\n io.write_line(\" No account ID provided, skipping WeChat.\")?;\n return Ok(false);\n }\n\n let current_token = config\n .channels\n .weixin\n .as_ref()\n .and_then(|w| w.bot_token.as_deref())\n .unwrap_or(\"\");\n let bot_token: String = if current_token.is_empty() {\n io.input_allow_empty(\"iLink bot token\")?\n } else {\n io.input_with_default(\"iLink bot token\", current_token)?\n };\n\n if bot_token.trim().is_empty() {\n io.write_line(\" No bot token provided, skipping WeChat.\")?;\n return Ok(false);\n }\n\n let (\n app_id,\n app_secret,\n old_token,\n encoding_aes_key,\n base_url,\n cdn_base_url,\n dm_policy,\n group_policy,\n allowed_users,\n group_allowed_users,\n ) = config\n .channels\n .weixin\n .as_ref()\n .map(|w| {\n (\n w.app_id.clone(),\n w.app_secret.clone(),\n w.token.clone(),\n w.encoding_aes_key.clone(),\n w.base_url.clone(),\n w.cdn_base_url.clone(),\n w.dm_policy.clone(),\n w.group_policy.clone(),\n w.allowed_users.clone(),\n w.group_allowed_users.clone(),\n )\n })\n .unwrap_or_default();\n\n config.channels.weixin = Some(WeixinConfig {\n account_id: Some(account_id.trim().to_string()),\n bot_token: Some(bot_token.trim().to_string()),\n app_id,\n app_secret,\n token: old_token,\n encoding_aes_key,\n base_url,\n cdn_base_url,\n dm_policy,\n group_policy,\n allowed_users,\n group_allowed_users,\n enabled: true,\n });\n\n io.write_line(&format!(\" {} WeChat configured.\", \"✓\".green()))?;\n Ok(true)\n }\n _ => {\n io.write_line(\" Skipped.\")?;\n Ok(false)\n }\n }\n}\n\n// ── Provider field helpers ─────────────────────────────────────\n\nfn get_provider_entry_mut<'a>(\n config: &'a mut Config,\n provider: &str,\n) -> Option<&'a mut ProviderEntry> {\n match provider {\n \"anthropic\" => config.providers.anthropic.as_mut(),\n \"openai\" => config.providers.openai.as_mut(),\n \"openrouter\" => config.providers.openrouter.as_mut(),\n \"ollama\" => config.providers.ollama.as_mut(),\n \"deepseek\" => config.providers.deepseek.as_mut(),\n \"gemini\" => config.providers.gemini.as_mut(),\n \"groq\" => config.providers.groq.as_mut(),\n \"moonshot\" => config.providers.moonshot.as_mut(),\n \"minimax\" => config.providers.minimax.as_mut(),\n \"github_copilot\" => config.providers.github_copilot.as_mut(),\n \"openai_codex\" => config.providers.openai_codex.as_mut(),\n \"opencode_go\" => config.providers.opencode_go.as_mut(),\n \"glm_coding_plan\" => config.providers.glm_coding_plan.as_mut(),\n _ => None,\n }\n}\n\nfn ensure_provider_entry(config: &mut Config, provider: &str) {\n match provider {\n \"anthropic\" => {\n config\n .providers\n .anthropic\n .get_or_insert_with(ProviderEntry::default);\n }\n \"openai\" => {\n config\n .providers\n .openai\n .get_or_insert_with(ProviderEntry::default);\n }\n \"openrouter\" => {\n config\n .providers\n .openrouter\n .get_or_insert_with(ProviderEntry::default);\n }\n \"ollama\" => {\n config\n .providers\n .ollama\n .get_or_insert_with(ProviderEntry::default);\n }\n \"deepseek\" => {\n config\n .providers\n .deepseek\n .get_or_insert_with(ProviderEntry::default);\n }\n \"gemini\" => {\n config\n .providers\n .gemini\n .get_or_insert_with(ProviderEntry::default);\n }\n \"groq\" => {\n config\n .providers\n .groq\n .get_or_insert_with(ProviderEntry::default);\n }\n \"moonshot\" => {\n config\n .providers\n .moonshot\n .get_or_insert_with(ProviderEntry::default);\n }\n \"minimax\" => {\n config\n .providers\n .minimax\n .get_or_insert_with(ProviderEntry::default);\n }\n \"github_copilot\" => {\n config\n .providers\n .github_copilot\n .get_or_insert_with(ProviderEntry::default);\n }\n \"openai_codex\" => {\n config\n .providers\n .openai_codex\n .get_or_insert_with(ProviderEntry::default);\n }\n \"opencode_go\" => {\n config\n .providers\n .opencode_go\n .get_or_insert_with(ProviderEntry::default);\n }\n \"glm_coding_plan\" => {\n config\n .providers\n .glm_coding_plan\n .get_or_insert_with(ProviderEntry::default);\n }\n _ => {}\n }\n}\n\nfn get_provider_url<'a>(config: &'a Config, provider: &str) -> Option<&'a str> {\n let entry = match provider {\n \"anthropic\" => config.providers.anthropic.as_ref(),\n \"openai\" => config.providers.openai.as_ref(),\n \"openrouter\" => config.providers.openrouter.as_ref(),\n \"ollama\" => config.providers.ollama.as_ref(),\n \"deepseek\" => config.providers.deepseek.as_ref(),\n \"gemini\" => config.providers.gemini.as_ref(),\n \"groq\" => config.providers.groq.as_ref(),\n \"moonshot\" => config.providers.moonshot.as_ref(),\n \"minimax\" => config.providers.minimax.as_ref(),\n \"github_copilot\" => config.providers.github_copilot.as_ref(),\n \"openai_codex\" => config.providers.openai_codex.as_ref(),\n \"opencode_go\" => config.providers.opencode_go.as_ref(),\n \"glm_coding_plan\" => config.providers.glm_coding_plan.as_ref(),\n _ => None,\n };\n entry.and_then(|e| e.base_url.as_deref())\n}\n\nfn get_provider_api_key<'a>(config: &'a Config, provider: &str) -> Option<&'a str> {\n let entry = match provider {\n \"anthropic\" => config.providers.anthropic.as_ref(),\n \"openai\" => config.providers.openai.as_ref(),\n \"openrouter\" => config.providers.openrouter.as_ref(),\n \"ollama\" => config.providers.ollama.as_ref(),\n \"deepseek\" => config.providers.deepseek.as_ref(),\n \"gemini\" => config.providers.gemini.as_ref(),\n \"groq\" => config.providers.groq.as_ref(),\n \"moonshot\" => config.providers.moonshot.as_ref(),\n \"minimax\" => config.providers.minimax.as_ref(),\n \"github_copilot\" => config.providers.github_copilot.as_ref(),\n \"openai_codex\" => config.providers.openai_codex.as_ref(),\n \"opencode_go\" => config.providers.opencode_go.as_ref(),\n \"glm_coding_plan\" => config.providers.glm_coding_plan.as_ref(),\n _ => None,\n };\n entry.and_then(|e| e.api_key.as_deref())\n}\n\nfn set_provider_url(config: &mut Config, provider: &str, url: &str) {\n ensure_provider_entry(config, provider);\n if let Some(entry) = get_provider_entry_mut(config, provider) {\n entry.base_url = if url.is_empty() {\n None\n } else {\n Some(url.to_string())\n };\n }\n}\n\nfn set_provider_api_key(config: &mut Config, provider: &str, key: &str) {\n ensure_provider_entry(config, provider);\n if let Some(entry) = get_provider_entry_mut(config, provider) {\n entry.api_key = if key.is_empty() {\n None\n } else {\n Some(key.to_string())\n };\n }\n}\n\n// ── Tests ──────────────────────────────────────────────────────\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use std::cell::RefCell;\n use std::collections::VecDeque;\n\n fn template_toml() -> String {\n toml::to_string(&Config::default()).unwrap()\n }\n\n // ── Mock wizard IO for flow testing ─────────────────────────\n\n #[derive(Debug, Clone)]\n enum MockAction {\n Confirm {\n prompt_contains: &'static str,\n result: bool,\n },\n Select {\n result: usize,\n },\n Input {\n result: &'static str,\n },\n Password {\n result: &'static str,\n },\n }\n\n struct MockWizard {\n actions: RefCell>,\n output: RefCell,\n }\n\n impl MockWizard {\n fn new(actions: Vec) -> Self {\n Self {\n actions: RefCell::new(actions.into()),\n output: RefCell::new(String::new()),\n }\n }\n\n fn output(&self) -> String {\n self.output.borrow().clone()\n }\n }\n\n impl WizardIo for MockWizard {\n fn confirm(&self, prompt: &str, _default: bool) -> Result {\n let action = self.actions.borrow_mut().pop_front();\n match action {\n Some(MockAction::Confirm {\n prompt_contains,\n result,\n }) => {\n assert!(\n prompt.contains(prompt_contains),\n \"confirm prompt '{}' did not contain '{}'\",\n prompt,\n prompt_contains\n );\n Ok(result)\n }\n _ => panic!(\"unexpected confirm call, prompt: {}\", prompt),\n }\n }\n\n fn select(&self, _prompt: &str, _items: &[&str], _default: usize) -> Result {\n let action = self.actions.borrow_mut().pop_front();\n match action {\n Some(MockAction::Select { result }) => Ok(result),\n _ => panic!(\"unexpected select call\"),\n }\n }\n\n fn input_with_default(&self, _prompt: &str, _default: &str) -> Result {\n let action = self.actions.borrow_mut().pop_front();\n match action {\n Some(MockAction::Input { result }) => Ok(result.to_string()),\n _ => panic!(\"unexpected input_with_default call\"),\n }\n }\n\n fn input_allow_empty(&self, _prompt: &str) -> Result {\n let action = self.actions.borrow_mut().pop_front();\n match action {\n Some(MockAction::Input { result }) => Ok(result.to_string()),\n _ => panic!(\"unexpected input_allow_empty call\"),\n }\n }\n\n fn password(&self, _prompt: &str) -> Result {\n let action = self.actions.borrow_mut().pop_front();\n match action {\n Some(MockAction::Password { result }) => Ok(result.to_string()),\n _ => panic!(\"unexpected password call\"),\n }\n }\n\n fn write_line(&self, line: &str) -> Result<()> {\n self.output.borrow_mut().push_str(line);\n self.output.borrow_mut().push('\\n');\n Ok(())\n }\n }\n\n // ── Unit tests ──────────────────────────────────────────────\n\n #[test]\n fn setup_creates_template_config_when_config_is_missing() {\n let tmp = tempfile::tempdir().unwrap();\n let config_path = tmp.path().join(\"config.toml\");\n\n let config = Config::default();\n let home = config_path.parent().unwrap();\n std::fs::create_dir_all(home).unwrap();\n loader::save_config(&config, &config_path).unwrap();\n\n assert_eq!(\n std::fs::read_to_string(&config_path).unwrap(),\n template_toml()\n );\n assert!(config_path.exists());\n }\n\n #[test]\n fn provider_helpers_set_and_get_fields() {\n let mut config = Config::default();\n set_provider_url(&mut config, \"openai\", \"https://custom.api/v1\");\n set_provider_api_key(&mut config, \"openai\", \"sk-test-key\");\n\n let entry = config.providers.openai.as_ref().unwrap();\n assert_eq!(entry.base_url.as_deref(), Some(\"https://custom.api/v1\"));\n assert_eq!(entry.api_key.as_deref(), Some(\"sk-test-key\"));\n }\n\n #[test]\n fn provider_helpers_handle_unknown_provider() {\n let mut config = Config::default();\n set_provider_url(&mut config, \"nonexistent\", \"https://example.com\");\n assert!(get_provider_url(&config, \"nonexistent\").is_none());\n }\n\n #[test]\n fn wizard_keeps_existing_config() {\n let tmp = tempfile::tempdir().unwrap();\n let config_path = tmp.path().join(\"config.toml\");\n\n let existing = Config::default();\n std::fs::create_dir_all(config_path.parent().unwrap()).unwrap();\n loader::save_config(&existing, &config_path).unwrap();\n\n let mock = MockWizard::new(vec![MockAction::Confirm {\n prompt_contains: \"Update\",\n result: false,\n }]);\n\n run_wizard(&mock, &config_path).unwrap();\n\n assert_eq!(\n std::fs::read_to_string(&config_path).unwrap(),\n template_toml()\n );\n }\n\n #[test]\n fn wizard_fresh_quick_setup() {\n let tmp = tempfile::tempdir().unwrap();\n let config_path = tmp.path().join(\"config.toml\");\n\n let mock = MockWizard::new(vec![\n // Review: select Provider (index 0)\n MockAction::Select { result: 0 },\n // Step 1: Provider\n MockAction::Select { result: 0 }, // openai (first)\n MockAction::Input { result: \"gpt-4o\" },\n MockAction::Input {\n result: \"https://api.openai.com/v1\",\n },\n MockAction::Password {\n result: \"sk-test-key\",\n },\n // Review: Save (index 5 = last)\n MockAction::Select { result: 5 },\n MockAction::Confirm {\n prompt_contains: \"Write\",\n result: true,\n },\n ]);\n\n run_wizard(&mock, &config_path).unwrap();\n\n assert!(config_path.exists());\n let saved: Config =\n toml::from_str(&std::fs::read_to_string(&config_path).unwrap()).unwrap();\n assert_eq!(saved.agent.provider.as_deref(), Some(\"openai\"));\n assert_eq!(saved.agent.model, \"gpt-4o\");\n }\n\n #[test]\n fn wizard_setup_only_feishu() {\n // Test: user only configures Feishu, skips everything else\n let tmp = tempfile::tempdir().unwrap();\n let config_path = tmp.path().join(\"config.toml\");\n\n let mock = MockWizard::new(vec![\n // Review: save directly without configuring anything\n MockAction::Select { result: 5 }, // Save\n MockAction::Confirm {\n prompt_contains: \"Write\",\n result: true,\n },\n ]);\n\n run_wizard(&mock, &config_path).unwrap();\n\n assert!(config_path.exists());\n // Default config should be saved\n let saved: Config =\n toml::from_str(&std::fs::read_to_string(&config_path).unwrap()).unwrap();\n assert!(saved.agent.provider.is_none());\n }\n\n #[test]\n fn wizard_overwrite_existing_config() {\n let tmp = tempfile::tempdir().unwrap();\n let config_path = tmp.path().join(\"config.toml\");\n\n let existing = Config::default();\n std::fs::create_dir_all(config_path.parent().unwrap()).unwrap();\n loader::save_config(&existing, &config_path).unwrap();\n\n let mock = MockWizard::new(vec![\n // Update existing\n MockAction::Confirm {\n prompt_contains: \"Update\",\n result: true,\n },\n // Review: select Provider (index 0)\n MockAction::Select { result: 0 },\n // Step 1: Provider — select anthropic (index 1)\n MockAction::Select { result: 1 },\n MockAction::Input {\n result: \"claude-sonnet-4-20250514\",\n },\n MockAction::Input {\n result: \"https://api.anthropic.com\",\n },\n MockAction::Password {\n result: \"sk-ant-test\",\n },\n // Back to Review: Save (index 5)\n MockAction::Select { result: 5 },\n MockAction::Confirm {\n prompt_contains: \"Write\",\n result: true,\n },\n ]);\n\n run_wizard(&mock, &config_path).unwrap();\n\n let saved: Config =\n toml::from_str(&std::fs::read_to_string(&config_path).unwrap()).unwrap();\n assert_eq!(saved.agent.provider.as_deref(), Some(\"anthropic\"));\n assert_eq!(saved.agent.model, \"claude-sonnet-4-20250514\");\n }\n\n #[test]\n fn token_masking_short_tokens() {\n assert_eq!(mask_token(\"ab\"), \"(masked)\");\n assert_eq!(mask_token(\"\"), \"(not set)\");\n }\n\n #[test]\n fn token_masking_long_token() {\n let masked = mask_token(\"sk-proj-abcdefghijklmnop-b2cF\");\n assert!(masked.starts_with(\"sk-\"));\n assert!(masked.ends_with(\"b2cF\"));\n assert!(masked.contains(\"…\"));\n }\n\n #[test]\n fn validate_api_key_openai() {\n assert!(validate_api_key(\"openai\", \"sk-test123\").is_ok());\n assert!(validate_api_key(\"openai\", \"bad-key\").is_err());\n }\n\n #[test]\n fn validate_api_key_anthropic() {\n assert!(validate_api_key(\"anthropic\", \"sk-ant-test123\").is_ok());\n assert!(validate_api_key(\"anthropic\", \"bad-key\").is_err());\n }\n\n #[test]\n fn validate_api_key_other_providers() {\n assert!(validate_api_key(\"ollama\", \"any-key\").is_ok());\n }\n\n #[test]\n fn validate_api_key_empty() {\n assert!(validate_api_key(\"openai\", \"\").is_err());\n }\n\n #[test]\n fn validate_url_valid() {\n assert_eq!(\n validate_url(\"https://api.openai.com/v1\").unwrap(),\n \"https://api.openai.com/v1\"\n );\n assert_eq!(\n validate_url(\"http://localhost:11434\").unwrap(),\n \"http://localhost:11434\"\n );\n }\n\n #[test]\n fn validate_url_empty() {\n assert_eq!(validate_url(\"\").unwrap(), \"\");\n }\n\n #[test]\n fn validate_url_invalid() {\n assert!(validate_url(\"not-a-url\").is_err());\n assert!(validate_url(\"ftp://example.com\").is_err());\n }\n\n #[test]\n fn wizard_step_ordering() {\n assert_eq!(WizardStep::Provider.step_number(), 1);\n assert_eq!(WizardStep::Review.step_number(), 0);\n assert_eq!(WizardStep::all().len(), 5);\n assert_eq!(WizardStep::all()[0], WizardStep::Provider);\n assert_eq!(WizardStep::all()[4], WizardStep::WeChat);\n }\n\n fn tg(token: &str) -> TelegramConfig {\n TelegramConfig {\n token: token.to_string(),\n allowed_users: Vec::new(),\n admin_users: Vec::new(),\n enabled: true,\n streaming: false,\n proxy: None,\n }\n }\n\n #[test]\n fn token_masking_empty_token() {\n let mut config = Config::default();\n config.channels.telegram = Some(tg(\"\"));\n\n let mock = MockWizard::new(vec![]);\n show_config_summary_simple(&mock, &config).unwrap();\n\n let output = mock.output();\n assert!(output.contains(\"(not set)\"));\n }\n\n #[test]\n fn configure_weixin_qr_enables_existing_credentials() {\n let mock = MockWizard::new(vec![MockAction::Select { result: 0 }]);\n let mut config = Config::default();\n config.channels.weixin = Some(WeixinConfig {\n app_id: None,\n app_secret: None,\n token: None,\n encoding_aes_key: None,\n account_id: Some(\"wxid_existing@im.bot\".to_string()),\n bot_token: Some(\"tok_existing\".to_string()),\n base_url: None,\n cdn_base_url: None,\n dm_policy: \"open\".to_string(),\n group_policy: \"disabled\".to_string(),\n allowed_users: vec![],\n group_allowed_users: vec![],\n enabled: false,\n });\n\n configure_weixin(&mock, &mut config).unwrap();\n\n let weixin = config.channels.weixin.unwrap();\n assert!(weixin.enabled);\n assert_eq!(weixin.account_id.as_deref(), Some(\"wxid_existing@im.bot\"));\n assert_eq!(weixin.bot_token.as_deref(), Some(\"tok_existing\"));\n assert!(mock.output().contains(\"detected and enabled\"));\n }\n\n #[test]\n fn configure_weixin_manual_preserves_existing_fields() {\n let mock = MockWizard::new(vec![\n MockAction::Select { result: 1 },\n MockAction::Input {\n result: \"wxid_new@im.bot\",\n },\n MockAction::Input { result: \"tok_new\" },\n ]);\n let mut config = Config::default();\n config.channels.weixin = Some(WeixinConfig {\n app_id: Some(\"app_existing\".to_string()),\n app_secret: Some(\"secret_existing\".to_string()),\n token: Some(\"verify_token\".to_string()),\n encoding_aes_key: Some(\"aes_key\".to_string()),\n account_id: Some(\"wxid_old@im.bot\".to_string()),\n bot_token: Some(\"tok_old\".to_string()),\n base_url: Some(\"https://ilinkai.weixin.qq.com\".to_string()),\n cdn_base_url: Some(\"https://novac2c.cdn.weixin.qq.com/c2c\".to_string()),\n dm_policy: \"allowlist\".to_string(),\n group_policy: \"open\".to_string(),\n allowed_users: vec![\"u1\".to_string()],\n group_allowed_users: vec![\"g1\".to_string()],\n enabled: false,\n });\n\n configure_weixin(&mock, &mut config).unwrap();\n\n let weixin = config.channels.weixin.unwrap();\n assert_eq!(weixin.account_id.as_deref(), Some(\"wxid_new@im.bot\"));\n assert_eq!(weixin.bot_token.as_deref(), Some(\"tok_new\"));\n assert_eq!(weixin.app_id.as_deref(), Some(\"app_existing\"));\n assert_eq!(weixin.app_secret.as_deref(), Some(\"secret_existing\"));\n assert_eq!(weixin.token.as_deref(), Some(\"verify_token\"));\n assert_eq!(weixin.encoding_aes_key.as_deref(), Some(\"aes_key\"));\n assert_eq!(\n weixin.base_url.as_deref(),\n Some(\"https://ilinkai.weixin.qq.com\")\n );\n assert_eq!(\n weixin.cdn_base_url.as_deref(),\n Some(\"https://novac2c.cdn.weixin.qq.com/c2c\")\n );\n assert_eq!(weixin.dm_policy, \"allowlist\");\n assert_eq!(weixin.group_policy, \"open\");\n assert_eq!(weixin.allowed_users, vec![\"u1\"]);\n assert_eq!(weixin.group_allowed_users, vec![\"g1\"]);\n assert!(weixin.enabled);\n }\n}\n"}, "47b378ad357c4cce4ebdf22b99f97e556d1b1458": {"t": 1785761072.2081273, "n": 60663, "h": "7623af8840bf6ac2", "c": "//! Setup command — interactive wizard for configuring Kestrel.\n//!\n//! Supports back-navigation via a state-machine loop, Quick/Full setup modes,\n//! input validation, and enhanced configuration summaries.\n\nuse anyhow::{bail, Context, Result};\nuse console::Term;\nuse dialoguer::{Confirm, Input, Password as PasswordInput, Select};\nuse kestrel_config::{\n loader, paths,\n schema::{Config, FeishuConfig, ProviderEntry, TelegramConfig, WebSocketConfig, WeixinConfig},\n};\nuse owo_colors::OwoColorize;\nuse std::net::SocketAddr;\nuse std::path::Path;\n\n// ── Provider display info ──────────────────────────────────────\n\nstruct ProviderInfo {\n key: &'static str,\n display: &'static str,\n default_model: &'static str,\n default_url: &'static str,\n}\n\nconst PROVIDERS: &[ProviderInfo] = &[\n ProviderInfo {\n key: \"openai\",\n display: \"★ OpenAI\",\n default_model: \"gpt-4o\",\n default_url: \"https://api.openai.com/v1\",\n },\n ProviderInfo {\n key: \"anthropic\",\n display: \"★ Anthropic\",\n default_model: \"claude-sonnet-4-20250514\",\n default_url: \"https://api.anthropic.com\",\n },\n ProviderInfo {\n key: \"openrouter\",\n display: \"★ OpenRouter (multi-model)\",\n default_model: \"anthropic/claude-sonnet-4-20250514\",\n default_url: \"https://openrouter.ai/api/v1\",\n },\n ProviderInfo {\n key: \"ollama\",\n display: \" Ollama (local models)\",\n default_model: \"llama3\",\n default_url: \"http://localhost:11434\",\n },\n ProviderInfo {\n key: \"deepseek\",\n display: \" DeepSeek\",\n default_model: \"deepseek-chat\",\n default_url: \"https://api.deepseek.com\",\n },\n ProviderInfo {\n key: \"gemini\",\n display: \" Gemini\",\n default_model: \"gemini-2.5-pro\",\n default_url: \"https://generativelanguage.googleapis.com/v1beta\",\n },\n ProviderInfo {\n key: \"groq\",\n display: \" Groq\",\n default_model: \"llama-3.3-70b-versatile\",\n default_url: \"https://api.groq.com/openai/v1\",\n },\n ProviderInfo {\n key: \"moonshot\",\n display: \" Moonshot (月之暗面)\",\n default_model: \"moonshot-v1-8k\",\n default_url: \"https://api.moonshot.cn/v1\",\n },\n ProviderInfo {\n key: \"minimax\",\n display: \" MiniMax\",\n default_model: \"MiniMax-Text-01\",\n default_url: \"https://api.minimax.chat/v1\",\n },\n ProviderInfo {\n key: \"github_copilot\",\n display: \" GitHub Copilot\",\n default_model: \"gpt-4o\",\n default_url: \"https://api.githubcopilot.com\",\n },\n ProviderInfo {\n key: \"openai_codex\",\n display: \" OpenAI Codex\",\n default_model: \"codex-mini\",\n default_url: \"https://api.openai.com/v1\",\n },\n ProviderInfo {\n key: \"glm_coding_plan\",\n display: \" GLM Coding Plan (智谱)\",\n default_model: \"glm-5.1\",\n default_url: \"https://open.bigmodel.cn/api/coding/paas/v4\",\n },\n];\n\nconst TOTAL_CONFIG_STEPS: usize = 5;\n\nconst BACK_OPTION: &str = \"↩ Go back\";\n\n// ── Wizard step state machine ──────────────────────────────────\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\nenum WizardStep {\n Provider, // Step 1\n Telegram, // Step 2\n Feishu, // Step 3\n WebSocket, // Step 4\n WeChat, // Step 5\n Review, // Final (no step number)\n}\n\nimpl WizardStep {\n fn step_number(self) -> usize {\n match self {\n Self::Provider => 1,\n Self::Telegram => 2,\n Self::Feishu => 3,\n Self::WebSocket => 4,\n Self::WeChat => 5,\n Self::Review => 0,\n }\n }\n\n fn title(self) -> &'static str {\n match self {\n Self::Provider => \"Provider Configuration\",\n Self::Telegram => \"Telegram Channel\",\n Self::Feishu => \"Feishu / Lark Channel\",\n Self::WebSocket => \"WebSocket Port\",\n Self::WeChat => \"WeChat Channel\",\n Self::Review => \"Review & Save\",\n }\n }\n\n fn next(self) -> Option {\n match self {\n Self::Provider => Some(Self::Telegram),\n Self::Telegram => Some(Self::Feishu),\n Self::Feishu => Some(Self::WebSocket),\n Self::WebSocket => Some(Self::WeChat),\n Self::WeChat => Some(Self::Review),\n Self::Review => None,\n }\n }\n\n fn prev(self) -> Option {\n match self {\n Self::Provider => None,\n Self::Telegram => Some(Self::Provider),\n Self::Feishu => Some(Self::Telegram),\n Self::WebSocket => Some(Self::Feishu),\n Self::WeChat => Some(Self::WebSocket),\n Self::Review => Some(Self::WeChat),\n }\n }\n\n fn first() -> WizardStep {\n WizardStep::Provider\n }\n\n fn all() -> &'static [WizardStep] {\n &[\n WizardStep::Provider,\n WizardStep::Telegram,\n WizardStep::Feishu,\n WizardStep::WebSocket,\n WizardStep::WeChat,\n ]\n }\n}\n\n/// Whether a channel step was configured or skipped.\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\nenum ChannelStatus {\n Configured,\n Skipped,\n}\n\n// ── Trait for interactive I/O (enables testability) ────────────\n\ntrait WizardIo {\n fn confirm(&self, prompt: &str, default: bool) -> Result;\n fn select(&self, prompt: &str, items: &[&str], default: usize) -> Result;\n fn input_with_default(&self, prompt: &str, default: &str) -> Result;\n fn input_allow_empty(&self, prompt: &str) -> Result;\n fn password(&self, prompt: &str) -> Result;\n fn write_line(&self, line: &str) -> Result<()>;\n\n /// Show a confirmation with back option. Returns `None` if user chose back.\n fn confirm_or_back(&self, prompt: &str, default: bool) -> Result> {\n let items = if default {\n &[\"Yes\", \"No\", BACK_OPTION] as &[&str]\n } else {\n &[\"No\", \"Yes\", BACK_OPTION] as &[&str]\n };\n let default_idx = 0;\n let choice = self.select(prompt, items, default_idx)?;\n match choice {\n idx if items[idx] == BACK_OPTION => Ok(None),\n idx if items[idx] == \"Yes\" => Ok(Some(true)),\n _ => Ok(Some(false)),\n }\n }\n\n /// Show a select with back option appended. Returns `None` if user chose back.\n fn select_or_back(\n &self,\n prompt: &str,\n items: &[&str],\n default: usize,\n ) -> Result> {\n let mut all_items: Vec<&str> = items.to_vec();\n all_items.push(BACK_OPTION);\n let choice = self.select(prompt, &all_items, default)?;\n if all_items[choice] == BACK_OPTION {\n Ok(None)\n } else {\n Ok(Some(choice))\n }\n }\n}\n\n// ── Production implementation using Term + dialoguer ───────────\n\nstruct TermWizard<'a> {\n term: &'a Term,\n}\n\nimpl<'a> TermWizard<'a> {\n fn new(term: &'a Term) -> Self {\n Self { term }\n }\n}\n\nimpl WizardIo for TermWizard<'_> {\n fn confirm(&self, prompt: &str, default: bool) -> Result {\n Ok(Confirm::new()\n .with_prompt(prompt)\n .default(default)\n .interact_on(self.term)?)\n }\n\n fn select(&self, prompt: &str, items: &[&str], default: usize) -> Result {\n Ok(Select::new()\n .with_prompt(prompt)\n .items(items)\n .default(default)\n .interact_on(self.term)?)\n }\n\n fn input_with_default(&self, prompt: &str, default: &str) -> Result {\n Ok(Input::new()\n .with_prompt(prompt)\n .default(default.to_string())\n .interact_text_on(self.term)?)\n }\n\n fn input_allow_empty(&self, prompt: &str) -> Result {\n Ok(Input::::new()\n .with_prompt(prompt)\n .allow_empty(true)\n .interact_text_on(self.term)?)\n }\n\n fn password(&self, prompt: &str) -> Result {\n Ok(PasswordInput::new()\n .with_prompt(prompt)\n .interact_on(self.term)?)\n }\n\n fn write_line(&self, line: &str) -> Result<()> {\n self.term.write_line(line)?;\n Ok(())\n }\n}\n\n// ── Entry point ────────────────────────────────────────────────\n\npub fn run(_config: Config) -> Result<()> {\n let term = Term::stdout();\n if !term.is_term() {\n bail!(\n \"Setup requires an interactive terminal. \\\n Run this command in a terminal, not in a pipe or CI environment.\"\n );\n }\n let io = TermWizard::new(&term);\n let config_path = paths::get_config_path()?;\n\n // Set up Ctrl+C handler for graceful exit\n ctrlc::set_handler(|| {\n println!();\n println!(\n \" {} Setup interrupted. Progress has not been saved.\",\n \"!\".yellow()\n );\n println!(\" Run `kestrel setup` again to start over.\");\n std::process::exit(1);\n })\n .ok(); // Ignore error if handler already set\n\n run_wizard(&io, &config_path)\n}\n\n// ── Wizard flow (state machine) ────────────────────────────────\n\nfn run_wizard(io: &dyn WizardIo, config_path: &Path) -> Result<()> {\n let is_first_run = !config_path.exists();\n print_banner(io, is_first_run)?;\n\n // ── Load existing config or start fresh ──────────────────────\n let mut config = if config_path.exists() {\n match load_existing_config(config_path) {\n Ok(existing) => {\n show_config_summary_simple(io, &existing)?;\n if io.confirm(\"Update existing config?\", true)? {\n existing\n } else {\n io.write_line(&format!(\n \" {} Keeping config at {}.\",\n \"✓\".green(),\n config_path.display()\n ))?;\n return Ok(());\n }\n }\n Err(e) => {\n io.write_line(&format!(\n \" {} Could not parse existing config: {}\",\n \"!\".yellow(),\n e\n ))?;\n if io.confirm(\"Start fresh with defaults?\", true)? {\n Config::default()\n } else {\n bail!(\"Setup cancelled.\");\n }\n }\n }\n } else {\n Config::default()\n };\n\n // ── Quick vs Full setup mode (first run only) ────────────────\n let quick_mode = if is_first_run {\n let modes = &[\n \"Quick Setup — configure LLM provider only, start using in seconds\",\n \"Full Setup — configure all channels and advanced options\",\n ];\n let mode = io.select(\"Choose setup mode\", modes, 0)?;\n mode == 0\n } else {\n false\n };\n\n // ── State machine loop ───────────────────────────────────────\n let mut current_step = WizardStep::first();\n let mut channel_status: [ChannelStatus; 5] = [ChannelStatus::Skipped; 5];\n\n loop {\n // In quick mode, skip directly to Review after Provider\n if quick_mode && current_step != WizardStep::Provider && current_step != WizardStep::Review\n {\n current_step = WizardStep::Review;\n }\n\n match current_step {\n WizardStep::Provider => {\n print_step(io, current_step)?;\n match configure_provider(io, &mut config)? {\n StepAction::Back => {\n // Provider is first step, cannot go back\n }\n StepAction::Continue => {\n channel_status[0] = ChannelStatus::Configured;\n current_step = current_step.next().unwrap();\n }\n }\n }\n WizardStep::Telegram => {\n print_step(io, current_step)?;\n let has_existing = config.channels.telegram.is_some();\n match configure_channel_step(\n io,\n &mut config,\n \"Telegram\",\n has_existing,\n configure_telegram,\n &mut channel_status[1],\n )? {\n StepAction::Back => {\n current_step = current_step.prev().unwrap();\n }\n StepAction::Continue => {\n current_step = current_step.next().unwrap();\n }\n }\n }\n WizardStep::Feishu => {\n print_step(io, current_step)?;\n let has_existing = config.channels.feishu.as_ref().is_some_and(|f| f.enabled);\n match configure_channel_step(\n io,\n &mut config,\n \"Feishu / Lark\",\n has_existing,\n configure_feishu,\n &mut channel_status[2],\n )? {\n StepAction::Back => {\n current_step = current_step.prev().unwrap();\n }\n StepAction::Continue => {\n current_step = current_step.next().unwrap();\n }\n }\n }\n WizardStep::WebSocket => {\n print_step(io, current_step)?;\n let has_existing = config\n .channels\n .websocket\n .as_ref()\n .is_some_and(|w| w.enabled);\n match configure_channel_step(\n io,\n &mut config,\n \"WebSocket\",\n has_existing,\n configure_websocket,\n &mut channel_status[3],\n )? {\n StepAction::Back => {\n current_step = current_step.prev().unwrap();\n }\n StepAction::Continue => {\n current_step = current_step.next().unwrap();\n }\n }\n }\n WizardStep::WeChat => {\n print_step(io, current_step)?;\n let has_existing = config.channels.weixin.is_some();\n match configure_channel_step(\n io,\n &mut config,\n \"WeChat\",\n has_existing,\n configure_weixin,\n &mut channel_status[4],\n )? {\n StepAction::Back => {\n current_step = current_step.prev().unwrap();\n }\n StepAction::Continue => {\n current_step = current_step.next().unwrap();\n }\n }\n }\n WizardStep::Review => {\n print_review(io, &config, &channel_status)?;\n\n // Show options to re-configure or save\n let mut review_items: Vec = WizardStep::all()\n .iter()\n .enumerate()\n .map(|(i, s)| {\n let num = s.step_number();\n let title = s.title();\n let status = match i {\n 0 => \"✓\".to_string(),\n _ => match channel_status[i] {\n ChannelStatus::Configured => \"configured\".to_string(),\n ChannelStatus::Skipped => \"skipped\".to_string(),\n },\n };\n format!(\"{}. {} ({})\", num, title, status)\n })\n .collect();\n review_items.push(\"Save configuration\".to_string());\n\n // Build &str refs for select\n let review_refs: Vec<&str> = review_items.iter().map(|s| s.as_str()).collect();\n let default_save = review_items.len() - 1;\n let choice = io.select(\n \"Select a step to re-configure, or save\",\n &review_refs,\n default_save,\n )?;\n\n if choice == default_save {\n // Save\n if save_config(io, &config, config_path)? {\n print_next_steps(io, &config)?;\n return Ok(());\n }\n // User cancelled save, stay in review\n } else if choice < WizardStep::all().len() {\n current_step = WizardStep::all()[choice];\n }\n }\n }\n }\n}\n\n/// Result of a configure step: continue forward or go back.\nenum StepAction {\n Continue,\n Back,\n}\n\n/// Generic wrapper for channel configuration steps with back support.\nfn configure_channel_step(\n io: &dyn WizardIo,\n config: &mut Config,\n channel_name: &str,\n has_existing: bool,\n configure_fn: fn(&dyn WizardIo, &mut Config) -> Result,\n status: &mut ChannelStatus,\n) -> Result {\n let prompt = if has_existing {\n format!(\"Update {} configuration?\", channel_name)\n } else {\n format!(\"Set up {}?\", channel_name)\n };\n\n match io.confirm_or_back(&prompt, has_existing)? {\n None => return Ok(StepAction::Back),\n Some(false) => {\n io.write_line(&format!(\n \" Skipped. Run `kestrel setup` anytime to configure {}.\",\n channel_name\n ))?;\n *status = ChannelStatus::Skipped;\n return Ok(StepAction::Continue);\n }\n Some(true) => {}\n }\n\n match configure_fn(io, config) {\n Ok(true) => {\n *status = ChannelStatus::Configured;\n Ok(StepAction::Continue)\n }\n Ok(false) => {\n // Configure fn handled skip internally\n *status = ChannelStatus::Skipped;\n Ok(StepAction::Continue)\n }\n Err(e) => Err(e),\n }\n}\n\n// ── Banner & step printing ─────────────────────────────────────\n\nfn print_banner(io: &dyn WizardIo, is_first_run: bool) -> Result<()> {\n io.write_line(\"\")?;\n if is_first_run {\n io.write_line(&format!(\n \" {} {}\",\n \"▸\".cyan(),\n \"Kestrel 初始化配置向导\".bold().cyan()\n ))?;\n io.write_line(\"\")?;\n io.write_line(\" Welcome to Kestrel! Let's set up your configuration.\")?;\n io.write_line(\" You can re-run `kestrel setup` anytime to make changes.\")?;\n } else {\n io.write_line(&format!(\n \" {} {}\",\n \"▸\".cyan(),\n \"Kestrel 配置更新向导\".bold().cyan()\n ))?;\n io.write_line(\"\")?;\n io.write_line(\" Update your existing configuration.\")?;\n }\n io.write_line(\"\")?;\n Ok(())\n}\n\nfn print_step(io: &dyn WizardIo, step: WizardStep) -> Result<()> {\n io.write_line(\"\")?;\n let num = step.step_number();\n io.write_line(&format!(\n \" {} Step {}/{}: {}\",\n \"▸\".cyan(),\n num,\n TOTAL_CONFIG_STEPS,\n step.title().bold()\n ))?;\n io.write_line(&format!(\" {}\", \"─\".repeat(40).dimmed()))?;\n Ok(())\n}\n\nfn print_review(\n io: &dyn WizardIo,\n config: &Config,\n channel_status: &[ChannelStatus; 5],\n) -> Result<()> {\n io.write_line(\"\")?;\n io.write_line(&format!(\n \" {} {}\",\n \"▸\".cyan(),\n \"Review & Save\".bold().cyan()\n ))?;\n io.write_line(&format!(\" {}\", \"─\".repeat(50).dimmed()))?;\n io.write_line(\"\")?;\n show_config_summary(io, config, channel_status)?;\n io.write_line(\"\")?;\n Ok(())\n}\n\nfn load_existing_config(path: &Path) -> Result {\n let content = std::fs::read_to_string(path)\n .with_context(|| format!(\"Failed to read {}\", path.display()))?;\n let config: Config =\n toml::from_str(&content).with_context(|| format!(\"Failed to parse {}\", path.display()))?;\n Ok(config)\n}\n\n// ── Token masking & config summary ─────────────────────────────\n\nfn mask_token(token: &str) -> String {\n if token.is_empty() {\n \"(not set)\".to_string()\n } else if token.len() <= 8 {\n \"(masked)\".to_string()\n } else {\n format!(\"{}…{}\", &token[..3], &token[token.len() - 4..])\n }\n}\n\nfn show_config_summary(\n io: &dyn WizardIo,\n config: &Config,\n channel_status: &[ChannelStatus; 5],\n) -> Result<()> {\n let provider = config.agent.provider.as_deref().unwrap_or(\"default\");\n io.write_line(&format!(\" Provider: {}\", provider))?;\n io.write_line(&format!(\" Model: {}\", config.agent.model))?;\n\n // Show base URL\n if let Some(url) = config\n .agent\n .provider\n .as_deref()\n .and_then(|p| get_provider_url(config, p))\n {\n io.write_line(&format!(\" Base URL: {}\", url))?;\n }\n\n // Show API key status\n let key_status = config\n .agent\n .provider\n .as_deref()\n .and_then(|p| get_provider_api_key(config, p))\n .map(mask_token)\n .unwrap_or_else(|| \"(not set)\".to_string());\n io.write_line(&format!(\" API Key: {}\", key_status))?;\n\n io.write_line(&format!(\" Temperature: {}\", config.agent.temperature))?;\n io.write_line(&format!(\" Max tokens: {}\", config.agent.max_tokens))?;\n\n io.write_line(\"\")?;\n io.write_line(\" Channels:\")?;\n\n let channel_names = [\"Telegram\", \"Feishu\", \"WebSocket\", \"WeChat\"];\n for (i, name) in channel_names.iter().enumerate() {\n let status_str = match channel_status[i + 1] {\n ChannelStatus::Configured => \"✓ configured\".green().to_string(),\n ChannelStatus::Skipped => \"✗ skipped\".dimmed().to_string(),\n };\n io.write_line(&format!(\" {}: {}\", name, status_str))?;\n }\n\n // Show details for configured channels\n if let Some(ref tg) = config.channels.telegram {\n if !tg.token.is_empty() {\n io.write_line(&format!(\" Token: {}\", mask_token(&tg.token)))?;\n }\n }\n\n if let Some(ref ws) = config.channels.websocket {\n if ws.enabled {\n io.write_line(&format!(\" Address: {}\", ws.listen_addr))?;\n }\n }\n\n if let Some(ref wx) = config.channels.weixin {\n if wx.enabled {\n let acct = wx.account_id.as_deref().unwrap_or(\"(unknown)\");\n io.write_line(&format!(\" Account: {}\", mask_token(acct)))?;\n }\n }\n\n if let Some(ref fs) = config.channels.feishu {\n if fs.enabled {\n let app_id = fs.app_id.as_deref().unwrap_or(\"(unknown)\");\n io.write_line(&format!(\" App ID: {}\", mask_token(app_id)))?;\n }\n }\n\n Ok(())\n}\n\n/// Legacy summary without channel status (used for existing config review).\nfn show_config_summary_simple(io: &dyn WizardIo, config: &Config) -> Result<()> {\n let provider = config.agent.provider.as_deref().unwrap_or(\"default\");\n io.write_line(&format!(\" Provider: {}\", provider))?;\n io.write_line(&format!(\" Model: {}\", config.agent.model))?;\n io.write_line(&format!(\" Temperature: {}\", config.agent.temperature))?;\n io.write_line(&format!(\" Max tokens: {}\", config.agent.max_tokens))?;\n io.write_line(&format!(\" Streaming: {}\", config.agent.streaming))?;\n\n if let Some(ref tg) = config.channels.telegram {\n io.write_line(&format!(\" Telegram: {}\", mask_token(&tg.token)))?;\n }\n\n if let Some(ref fs) = config.channels.feishu {\n if fs.enabled {\n let app_id = fs.app_id.as_deref().unwrap_or(\"(not set)\");\n io.write_line(&format!(\" Feishu: {}\", mask_token(app_id)))?;\n }\n }\n\n if let Some(ref ws) = config.channels.websocket {\n if ws.enabled {\n io.write_line(&format!(\" WebSocket: {}\", ws.listen_addr))?;\n }\n }\n\n if let Some(ref wx) = config.channels.weixin {\n let status = if wx.enabled {\n mask_token(wx.bot_token.as_deref().unwrap_or(\"\"))\n } else {\n \"disabled\".to_string()\n };\n io.write_line(&format!(\" WeChat: {}\", status))?;\n }\n\n Ok(())\n}\n\nfn save_config(io: &dyn WizardIo, config: &Config, config_path: &Path) -> Result {\n io.write_line(&format!(\" Config path: {}\", config_path.display()))?;\n\n if !io.confirm(\"Write this configuration?\", true)? {\n io.write_line(&format!(\" {} Save cancelled.\", \"!\".yellow()))?;\n return Ok(false);\n }\n\n let home = config_path\n .parent()\n .context(\"Config path must have a parent directory\")?;\n\n std::fs::create_dir_all(home)\n .with_context(|| format!(\"Failed to create config home: {}\", home.display()))?;\n\n loader::save_config(config, config_path)?;\n\n for dir in [\"skills\", \"sessions\", \"learning\"] {\n let path = home.join(dir);\n std::fs::create_dir_all(&path)\n .with_context(|| format!(\"Failed to create directory: {}\", path.display()))?;\n }\n\n io.write_line(\"\")?;\n io.write_line(&format!(\n \" {} Configuration saved to {}\",\n \"✓\".green(),\n config_path.display()\n ))?;\n io.write_line(&format!(\n \" {} Created directories: skills, sessions, learning\",\n \"✓\".green()\n ))?;\n io.write_line(&format!(\" {} Setup complete!\", \"✓\".green()))?;\n\n Ok(true)\n}\n\nfn print_next_steps(io: &dyn WizardIo, config: &Config) -> Result<()> {\n io.write_line(\"\")?;\n io.write_line(&format!(\" {} Next steps:\", \"▸\".cyan()))?;\n io.write_line(\"\")?;\n io.write_line(\" 1. Try it out:\")?;\n io.write_line(\" kestrel agent\")?;\n io.write_line(\"\")?;\n\n let has_channel = config.channels.telegram.is_some()\n || config.channels.feishu.is_some()\n || config.channels.weixin.is_some()\n || config.channels.websocket.is_some();\n\n if has_channel {\n io.write_line(\" 2. Start the gateway (connect to chat platforms):\")?;\n io.write_line(\" kestrel gateway\")?;\n io.write_line(\"\")?;\n }\n\n if config.channels.websocket.is_some() {\n io.write_line(\" 3. Start the API server:\")?;\n io.write_line(\" kestrel serve\")?;\n io.write_line(\"\")?;\n }\n\n io.write_line(\" Check system health:\")?;\n io.write_line(\" kestrel doctor\")?;\n io.write_line(\"\")?;\n io.write_line(\" Re-run setup anytime:\")?;\n io.write_line(\" kestrel setup\")?;\n io.write_line(\"\")?;\n Ok(())\n}\n\n// ── Input validation ───────────────────────────────────────────\n\nfn validate_api_key(provider: &str, key: &str) -> Result {\n let key = key.trim().to_string();\n if key.is_empty() {\n bail!(\"API key cannot be empty\");\n }\n match provider {\n \"openai\" | \"openai_codex\" if !key.starts_with(\"sk-\") => {\n bail!(\"OpenAI API keys typically start with 'sk-'\");\n }\n \"anthropic\" if !key.starts_with(\"sk-ant-\") => {\n bail!(\"Anthropic API keys typically start with 'sk-ant-'\");\n }\n _ => {}\n }\n Ok(key)\n}\n\nfn validate_url(input: &str) -> Result {\n let url = input.trim().to_string();\n if url.is_empty() {\n return Ok(url);\n }\n if !url.starts_with(\"http://\") && !url.starts_with(\"https://\") {\n bail!(\"URL must start with http:// or https://\");\n }\n Ok(url)\n}\n\n// ── Provider configuration ─────────────────────────────────────\n\nfn configure_provider(io: &dyn WizardIo, config: &mut Config) -> Result {\n let display_names: Vec<&str> = PROVIDERS.iter().map(|p| p.display).collect();\n\n let default_idx = config\n .agent\n .provider\n .as_deref()\n .and_then(|p| PROVIDERS.iter().position(|info| info.key == p))\n .unwrap_or(0); // default to OpenAI (first)\n\n let provider_idx =\n match io.select_or_back(\"Select LLM provider\", &display_names, default_idx)? {\n None => return Ok(StepAction::Back),\n Some(idx) => idx,\n };\n\n let provider_key = PROVIDERS[provider_idx].key;\n config.agent.provider = Some(provider_key.to_string());\n\n let default_model = PROVIDERS[provider_idx].default_model;\n let current_model = if config.agent.model.is_empty() {\n default_model\n } else {\n &config.agent.model\n };\n\n let model: String = io.input_with_default(\"Model name\", current_model)?;\n config.agent.model = model.trim().to_string();\n\n let default_url = PROVIDERS[provider_idx].default_url;\n let current_url = get_provider_url(config, provider_key).unwrap_or(default_url);\n\n if !current_url.is_empty() {\n let base_url: String = io.input_with_default(\"Base URL\", current_url)?;\n let base_url = validate_url(&base_url)?;\n set_provider_url(config, provider_key, &base_url);\n } else {\n let base_url: String = io.input_allow_empty(\"Base URL (leave empty for default)\")?;\n let base_url = validate_url(&base_url)?;\n if !base_url.is_empty() {\n set_provider_url(config, provider_key, &base_url);\n }\n }\n\n loop {\n let api_key: String = io.password(\"API key\")?;\n match validate_api_key(provider_key, &api_key) {\n Ok(key) => {\n set_provider_api_key(config, provider_key, &key);\n break;\n }\n Err(e) => {\n io.write_line(&format!(\" {} {}\", \"!\".yellow(), e))?;\n if !io.confirm(\"Try again?\", true)? {\n // Accept whatever was entered\n let key = api_key.trim().to_string();\n if !key.is_empty() {\n set_provider_api_key(config, provider_key, &key);\n }\n break;\n }\n }\n }\n }\n\n Ok(StepAction::Continue)\n}\n\n// ── Channel configurations ─────────────────────────────────────\n\n/// Configure Telegram. Returns true if configured, false if skipped.\nfn configure_telegram(io: &dyn WizardIo, config: &mut Config) -> Result {\n let current_token = config\n .channels\n .telegram\n .as_ref()\n .map(|tg| tg.token.as_str())\n .unwrap_or(\"\");\n\n let token: String = if current_token.is_empty() {\n io.input_allow_empty(\"Bot token (from @BotFather)\")?\n } else {\n io.input_with_default(\"Bot token (from @BotFather)\", current_token)?\n };\n\n let token = token.trim().to_string();\n if token.is_empty() {\n io.write_line(\" No token provided, skipping Telegram.\")?;\n return Ok(false);\n }\n\n let allowed: String = loop {\n let input: String =\n io.input_allow_empty(\"Allowed user IDs (comma-separated, leave empty for all)\")?;\n if input.trim().is_empty() {\n break String::new();\n }\n let ids: Vec<&str> = input\n .split(',')\n .map(|s| s.trim())\n .filter(|s| !s.is_empty())\n .collect();\n let mut valid = true;\n for id in &ids {\n if id.parse::().is_err() {\n io.write_line(&format!(\n \" {} '{}' is not a valid user ID (must be a positive integer).\",\n \"!\".yellow(),\n id\n ))?;\n valid = false;\n break;\n }\n }\n if valid {\n break input;\n }\n };\n\n let allowed_users: Vec = if allowed.trim().is_empty() {\n Vec::new()\n } else {\n allowed\n .split(',')\n .map(|s| s.trim().to_string())\n .filter(|s| !s.is_empty())\n .collect()\n };\n\n let (admin_users, enabled, streaming, proxy) = config\n .channels\n .telegram\n .as_ref()\n .map(|tg| {\n (\n tg.admin_users.clone(),\n tg.enabled,\n tg.streaming,\n tg.proxy.clone(),\n )\n })\n .unwrap_or((Vec::new(), true, true, None));\n\n config.channels.telegram = Some(TelegramConfig {\n token,\n allowed_users,\n admin_users,\n enabled,\n streaming,\n proxy,\n });\n\n io.write_line(&format!(\" {} Telegram configured.\", \"✓\".green()))?;\n Ok(true)\n}\n\n/// Configure Feishu. Returns true if configured, false if skipped.\nfn configure_feishu(io: &dyn WizardIo, config: &mut Config) -> Result {\n let domain_options = [\"Feishu (飞书)\", \"Lark (international)\"];\n let idx = io.select(\"Select platform\", &domain_options, 0)?;\n let domain = if idx == 1 { \"lark\" } else { \"feishu\" };\n\n let rt = tokio::runtime::Builder::new_current_thread()\n .enable_all()\n .build()\n .context(\"Failed to create tokio runtime\")?;\n\n let result = match rt.block_on(super::feishu_onboarding::run_onboarding(domain)) {\n Ok(r) => r,\n Err(e) => {\n io.write_line(&format!(\" {} Feishu setup skipped: {}\", \"!\".yellow(), e))?;\n return Ok(false);\n }\n };\n\n let existing_proxy = config\n .channels\n .feishu\n .as_ref()\n .and_then(|f| f.proxy.clone());\n\n config.channels.feishu = Some(FeishuConfig {\n app_id: Some(result.app_id),\n app_secret: Some(result.app_secret),\n enabled: true,\n proxy: existing_proxy,\n ..Default::default()\n });\n\n io.write_line(&format!(\" {} Feishu configured.\", \"✓\".green()))?;\n Ok(true)\n}\n\n/// Configure WebSocket. Returns true if configured, false if skipped.\nfn configure_websocket(io: &dyn WizardIo, config: &mut Config) -> Result {\n let default_addr = config\n .channels\n .websocket\n .as_ref()\n .map(|ws| ws.listen_addr.as_str())\n .unwrap_or(\"127.0.0.1:8090\");\n\n let listen_addr: String = loop {\n let input = io.input_with_default(\"Listen address\", default_addr)?;\n match input.parse::() {\n Ok(_) => break input,\n Err(e) => {\n io.write_line(&format!(\n \" {} Invalid address '{}': {}\",\n \"!\".yellow(),\n input,\n e\n ))?;\n }\n }\n };\n\n config.channels.websocket = Some(WebSocketConfig {\n enabled: true,\n listen_addr,\n auth: Default::default(),\n max_clients: 100,\n max_message_size: 1048576,\n });\n\n io.write_line(&format!(\" {} WebSocket configured.\", \"✓\".green()))?;\n Ok(true)\n}\n\n/// Configure WeChat. Returns true if configured, false if skipped.\nfn configure_weixin(io: &dyn WizardIo, config: &mut Config) -> Result {\n let choices = &[\n \"Scan QR code with WeChat (recommended)\",\n \"Enter credentials manually\",\n \"Skip\",\n ];\n let choice = io.select(\"How would you like to configure WeChat?\", choices, 0)?;\n\n match choice {\n 0 => {\n io.write_line(\n \" QR scan setup requires running `kestrel setup weixin` in a terminal.\",\n )?;\n io.write_line(\" Please run that command separately, then return to this wizard.\")?;\n if let Some(ref mut wx) = config.channels.weixin {\n if wx.account_id.is_some() && wx.bot_token.is_some() {\n wx.enabled = true;\n io.write_line(&format!(\n \" {} Existing WeChat credentials detected and enabled.\",\n \"✓\".green()\n ))?;\n return Ok(true);\n }\n }\n if config.channels.weixin.is_none() {\n io.write_line(\n \" No WeChat credentials found yet. Run `kestrel setup weixin` first.\",\n )?;\n }\n Ok(false)\n }\n 1 => {\n let current_account = config\n .channels\n .weixin\n .as_ref()\n .and_then(|w| w.account_id.as_deref())\n .unwrap_or(\"\");\n let account_id: String = if current_account.is_empty() {\n io.input_allow_empty(\"iLink account ID (e.g. wxid_xxx@im.bot)\")?\n } else {\n io.input_with_default(\"iLink account ID\", current_account)?\n };\n\n if account_id.trim().is_empty() {\n io.write_line(\" No account ID provided, skipping WeChat.\")?;\n return Ok(false);\n }\n\n let current_token = config\n .channels\n .weixin\n .as_ref()\n .and_then(|w| w.bot_token.as_deref())\n .unwrap_or(\"\");\n let bot_token: String = if current_token.is_empty() {\n io.input_allow_empty(\"iLink bot token\")?\n } else {\n io.input_with_default(\"iLink bot token\", current_token)?\n };\n\n if bot_token.trim().is_empty() {\n io.write_line(\" No bot token provided, skipping WeChat.\")?;\n return Ok(false);\n }\n\n let (\n app_id,\n app_secret,\n old_token,\n encoding_aes_key,\n base_url,\n cdn_base_url,\n dm_policy,\n group_policy,\n allowed_users,\n group_allowed_users,\n ) = config\n .channels\n .weixin\n .as_ref()\n .map(|w| {\n (\n w.app_id.clone(),\n w.app_secret.clone(),\n w.token.clone(),\n w.encoding_aes_key.clone(),\n w.base_url.clone(),\n w.cdn_base_url.clone(),\n w.dm_policy.clone(),\n w.group_policy.clone(),\n w.allowed_users.clone(),\n w.group_allowed_users.clone(),\n )\n })\n .unwrap_or_default();\n\n config.channels.weixin = Some(WeixinConfig {\n account_id: Some(account_id.trim().to_string()),\n bot_token: Some(bot_token.trim().to_string()),\n app_id,\n app_secret,\n token: old_token,\n encoding_aes_key,\n base_url,\n cdn_base_url,\n dm_policy,\n group_policy,\n allowed_users,\n group_allowed_users,\n enabled: true,\n });\n\n io.write_line(&format!(\" {} WeChat configured.\", \"✓\".green()))?;\n Ok(true)\n }\n _ => {\n io.write_line(\" Skipped.\")?;\n Ok(false)\n }\n }\n}\n\n// ── Provider field helpers ─────────────────────────────────────\n\nfn get_provider_entry_mut<'a>(\n config: &'a mut Config,\n provider: &str,\n) -> Option<&'a mut ProviderEntry> {\n match provider {\n \"anthropic\" => config.providers.anthropic.as_mut(),\n \"openai\" => config.providers.openai.as_mut(),\n \"openrouter\" => config.providers.openrouter.as_mut(),\n \"ollama\" => config.providers.ollama.as_mut(),\n \"deepseek\" => config.providers.deepseek.as_mut(),\n \"gemini\" => config.providers.gemini.as_mut(),\n \"groq\" => config.providers.groq.as_mut(),\n \"moonshot\" => config.providers.moonshot.as_mut(),\n \"minimax\" => config.providers.minimax.as_mut(),\n \"github_copilot\" => config.providers.github_copilot.as_mut(),\n \"openai_codex\" => config.providers.openai_codex.as_mut(),\n \"opencode_go\" => config.providers.opencode_go.as_mut(),\n \"glm_coding_plan\" => config.providers.glm_coding_plan.as_mut(),\n _ => None,\n }\n}\n\nfn ensure_provider_entry(config: &mut Config, provider: &str) {\n match provider {\n \"anthropic\" => {\n config\n .providers\n .anthropic\n .get_or_insert_with(ProviderEntry::default);\n }\n \"openai\" => {\n config\n .providers\n .openai\n .get_or_insert_with(ProviderEntry::default);\n }\n \"openrouter\" => {\n config\n .providers\n .openrouter\n .get_or_insert_with(ProviderEntry::default);\n }\n \"ollama\" => {\n config\n .providers\n .ollama\n .get_or_insert_with(ProviderEntry::default);\n }\n \"deepseek\" => {\n config\n .providers\n .deepseek\n .get_or_insert_with(ProviderEntry::default);\n }\n \"gemini\" => {\n config\n .providers\n .gemini\n .get_or_insert_with(ProviderEntry::default);\n }\n \"groq\" => {\n config\n .providers\n .groq\n .get_or_insert_with(ProviderEntry::default);\n }\n \"moonshot\" => {\n config\n .providers\n .moonshot\n .get_or_insert_with(ProviderEntry::default);\n }\n \"minimax\" => {\n config\n .providers\n .minimax\n .get_or_insert_with(ProviderEntry::default);\n }\n \"github_copilot\" => {\n config\n .providers\n .github_copilot\n .get_or_insert_with(ProviderEntry::default);\n }\n \"openai_codex\" => {\n config\n .providers\n .openai_codex\n .get_or_insert_with(ProviderEntry::default);\n }\n \"opencode_go\" => {\n config\n .providers\n .opencode_go\n .get_or_insert_with(ProviderEntry::default);\n }\n \"glm_coding_plan\" => {\n config\n .providers\n .glm_coding_plan\n .get_or_insert_with(ProviderEntry::default);\n }\n _ => {}\n }\n}\n\nfn get_provider_url<'a>(config: &'a Config, provider: &str) -> Option<&'a str> {\n let entry = match provider {\n \"anthropic\" => config.providers.anthropic.as_ref(),\n \"openai\" => config.providers.openai.as_ref(),\n \"openrouter\" => config.providers.openrouter.as_ref(),\n \"ollama\" => config.providers.ollama.as_ref(),\n \"deepseek\" => config.providers.deepseek.as_ref(),\n \"gemini\" => config.providers.gemini.as_ref(),\n \"groq\" => config.providers.groq.as_ref(),\n \"moonshot\" => config.providers.moonshot.as_ref(),\n \"minimax\" => config.providers.minimax.as_ref(),\n \"github_copilot\" => config.providers.github_copilot.as_ref(),\n \"openai_codex\" => config.providers.openai_codex.as_ref(),\n \"opencode_go\" => config.providers.opencode_go.as_ref(),\n \"glm_coding_plan\" => config.providers.glm_coding_plan.as_ref(),\n _ => None,\n };\n entry.and_then(|e| e.base_url.as_deref())\n}\n\nfn get_provider_api_key<'a>(config: &'a Config, provider: &str) -> Option<&'a str> {\n let entry = match provider {\n \"anthropic\" => config.providers.anthropic.as_ref(),\n \"openai\" => config.providers.openai.as_ref(),\n \"openrouter\" => config.providers.openrouter.as_ref(),\n \"ollama\" => config.providers.ollama.as_ref(),\n \"deepseek\" => config.providers.deepseek.as_ref(),\n \"gemini\" => config.providers.gemini.as_ref(),\n \"groq\" => config.providers.groq.as_ref(),\n \"moonshot\" => config.providers.moonshot.as_ref(),\n \"minimax\" => config.providers.minimax.as_ref(),\n \"github_copilot\" => config.providers.github_copilot.as_ref(),\n \"openai_codex\" => config.providers.openai_codex.as_ref(),\n \"opencode_go\" => config.providers.opencode_go.as_ref(),\n \"glm_coding_plan\" => config.providers.glm_coding_plan.as_ref(),\n _ => None,\n };\n entry.and_then(|e| e.api_key.as_deref())\n}\n\nfn set_provider_url(config: &mut Config, provider: &str, url: &str) {\n ensure_provider_entry(config, provider);\n if let Some(entry) = get_provider_entry_mut(config, provider) {\n entry.base_url = if url.is_empty() {\n None\n } else {\n Some(url.to_string())\n };\n }\n}\n\nfn set_provider_api_key(config: &mut Config, provider: &str, key: &str) {\n ensure_provider_entry(config, provider);\n if let Some(entry) = get_provider_entry_mut(config, provider) {\n entry.api_key = if key.is_empty() {\n None\n } else {\n Some(key.to_string())\n };\n }\n}\n\n// ── Tests ──────────────────────────────────────────────────────\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use std::cell::RefCell;\n use std::collections::VecDeque;\n\n fn template_toml() -> String {\n toml::to_string(&Config::default()).unwrap()\n }\n\n // ── Mock wizard IO for flow testing ─────────────────────────\n\n #[derive(Debug, Clone)]\n enum MockAction {\n Confirm {\n prompt_contains: &'static str,\n result: bool,\n },\n Select {\n result: usize,\n },\n Input {\n result: &'static str,\n },\n Password {\n result: &'static str,\n },\n }\n\n struct MockWizard {\n actions: RefCell>,\n output: RefCell,\n }\n\n impl MockWizard {\n fn new(actions: Vec) -> Self {\n Self {\n actions: RefCell::new(actions.into()),\n output: RefCell::new(String::new()),\n }\n }\n\n fn output(&self) -> String {\n self.output.borrow().clone()\n }\n }\n\n impl WizardIo for MockWizard {\n fn confirm(&self, prompt: &str, _default: bool) -> Result {\n let action = self.actions.borrow_mut().pop_front();\n match action {\n Some(MockAction::Confirm {\n prompt_contains,\n result,\n }) => {\n assert!(\n prompt.contains(prompt_contains),\n \"confirm prompt '{}' did not contain '{}'\",\n prompt,\n prompt_contains\n );\n Ok(result)\n }\n _ => panic!(\"unexpected confirm call, prompt: {}\", prompt),\n }\n }\n\n fn select(&self, _prompt: &str, _items: &[&str], _default: usize) -> Result {\n let action = self.actions.borrow_mut().pop_front();\n match action {\n Some(MockAction::Select { result }) => Ok(result),\n _ => panic!(\"unexpected select call\"),\n }\n }\n\n fn input_with_default(&self, _prompt: &str, _default: &str) -> Result {\n let action = self.actions.borrow_mut().pop_front();\n match action {\n Some(MockAction::Input { result }) => Ok(result.to_string()),\n _ => panic!(\"unexpected input_with_default call\"),\n }\n }\n\n fn input_allow_empty(&self, _prompt: &str) -> Result {\n let action = self.actions.borrow_mut().pop_front();\n match action {\n Some(MockAction::Input { result }) => Ok(result.to_string()),\n _ => panic!(\"unexpected input_allow_empty call\"),\n }\n }\n\n fn password(&self, _prompt: &str) -> Result {\n let action = self.actions.borrow_mut().pop_front();\n match action {\n Some(MockAction::Password { result }) => Ok(result.to_string()),\n _ => panic!(\"unexpected password call\"),\n }\n }\n\n fn write_line(&self, line: &str) -> Result<()> {\n self.output.borrow_mut().push_str(line);\n self.output.borrow_mut().push('\\n');\n Ok(())\n }\n }\n\n // ── Unit tests ──────────────────────────────────────────────\n\n #[test]\n fn setup_creates_template_config_when_config_is_missing() {\n let tmp = tempfile::tempdir().unwrap();\n let config_path = tmp.path().join(\"config.toml\");\n\n let config = Config::default();\n let home = config_path.parent().unwrap();\n std::fs::create_dir_all(home).unwrap();\n loader::save_config(&config, &config_path).unwrap();\n\n assert_eq!(\n std::fs::read_to_string(&config_path).unwrap(),\n template_toml()\n );\n assert!(config_path.exists());\n }\n\n #[test]\n fn provider_helpers_set_and_get_fields() {\n let mut config = Config::default();\n set_provider_url(&mut config, \"openai\", \"https://custom.api/v1\");\n set_provider_api_key(&mut config, \"openai\", \"sk-test-key\");\n\n let entry = config.providers.openai.as_ref().unwrap();\n assert_eq!(entry.base_url.as_deref(), Some(\"https://custom.api/v1\"));\n assert_eq!(entry.api_key.as_deref(), Some(\"sk-test-key\"));\n }\n\n #[test]\n fn provider_helpers_handle_unknown_provider() {\n let mut config = Config::default();\n set_provider_url(&mut config, \"nonexistent\", \"https://example.com\");\n assert!(get_provider_url(&config, \"nonexistent\").is_none());\n }\n\n #[test]\n fn wizard_keeps_existing_config() {\n let tmp = tempfile::tempdir().unwrap();\n let config_path = tmp.path().join(\"config.toml\");\n\n let existing = Config::default();\n std::fs::create_dir_all(config_path.parent().unwrap()).unwrap();\n loader::save_config(&existing, &config_path).unwrap();\n\n let mock = MockWizard::new(vec![MockAction::Confirm {\n prompt_contains: \"Update\",\n result: false,\n }]);\n\n run_wizard(&mock, &config_path).unwrap();\n\n assert_eq!(\n std::fs::read_to_string(&config_path).unwrap(),\n template_toml()\n );\n }\n\n #[test]\n fn wizard_fresh_quick_setup() {\n let tmp = tempfile::tempdir().unwrap();\n let config_path = tmp.path().join(\"config.toml\");\n\n let mock = MockWizard::new(vec![\n // Quick mode selection\n MockAction::Select { result: 0 }, // Quick Setup\n // Step 1: Provider\n MockAction::Select { result: 0 }, // openai (first)\n MockAction::Input { result: \"gpt-4o\" },\n MockAction::Input {\n result: \"https://api.openai.com/v1\",\n },\n MockAction::Password {\n result: \"sk-test-key\",\n },\n // Review: Save\n MockAction::Select { result: 5 }, // \"Save configuration\" (index 5 = last)\n MockAction::Confirm {\n prompt_contains: \"Write\",\n result: true,\n },\n ]);\n\n run_wizard(&mock, &config_path).unwrap();\n\n assert!(config_path.exists());\n let saved: Config =\n toml::from_str(&std::fs::read_to_string(&config_path).unwrap()).unwrap();\n assert_eq!(saved.agent.provider.as_deref(), Some(\"openai\"));\n assert_eq!(saved.agent.model, \"gpt-4o\");\n }\n\n #[test]\n fn wizard_full_setup_all_skip() {\n let tmp = tempfile::tempdir().unwrap();\n let config_path = tmp.path().join(\"config.toml\");\n\n let mock = MockWizard::new(vec![\n // Full mode\n MockAction::Select { result: 1 }, // Full Setup\n // Step 1: Provider\n MockAction::Select { result: 0 }, // openai\n MockAction::Input { result: \"gpt-4o\" },\n MockAction::Input {\n result: \"https://api.openai.com/v1\",\n },\n MockAction::Password {\n result: \"sk-test-key\",\n },\n // Step 2: Telegram (skip) — confirm_or_back [No, Yes, ↩], index 0 = No\n MockAction::Select { result: 0 }, // \"No\"\n // Step 3: Feishu (skip)\n MockAction::Select { result: 0 }, // \"No\"\n // Step 4: WebSocket (skip)\n MockAction::Select { result: 0 }, // \"No\"\n // Step 5: WeChat (skip)\n MockAction::Select { result: 0 }, // \"No\"\n // Review: Save\n MockAction::Select { result: 5 }, // Save\n MockAction::Confirm {\n prompt_contains: \"Write\",\n result: true,\n },\n ]);\n\n run_wizard(&mock, &config_path).unwrap();\n\n assert!(config_path.exists());\n let saved: Config =\n toml::from_str(&std::fs::read_to_string(&config_path).unwrap()).unwrap();\n assert_eq!(saved.agent.provider.as_deref(), Some(\"openai\"));\n }\n\n #[test]\n fn wizard_overwrite_existing_config() {\n let tmp = tempfile::tempdir().unwrap();\n let config_path = tmp.path().join(\"config.toml\");\n\n let existing = Config::default();\n std::fs::create_dir_all(config_path.parent().unwrap()).unwrap();\n loader::save_config(&existing, &config_path).unwrap();\n\n let mock = MockWizard::new(vec![\n // Update existing\n MockAction::Confirm {\n prompt_contains: \"Update\",\n result: true,\n },\n // Full mode (not first run, so mode selection is skipped)\n // Step 1: Provider\n MockAction::Select { result: 1 }, // anthropic (index 1)\n MockAction::Input {\n result: \"claude-sonnet-4-20250514\",\n },\n MockAction::Input {\n result: \"https://api.anthropic.com\",\n },\n MockAction::Password {\n result: \"sk-ant-test\",\n },\n // Step 2-5: Skip all channels — confirm_or_back [No, Yes, ↩], index 0 = No\n MockAction::Select { result: 0 }, // Telegram No\n MockAction::Select { result: 0 }, // Feishu No\n MockAction::Select { result: 0 }, // WebSocket No\n MockAction::Select { result: 0 }, // WeChat No\n // Review: Save\n MockAction::Select { result: 5 }, // Save\n MockAction::Confirm {\n prompt_contains: \"Write\",\n result: true,\n },\n ]);\n\n run_wizard(&mock, &config_path).unwrap();\n\n let saved: Config =\n toml::from_str(&std::fs::read_to_string(&config_path).unwrap()).unwrap();\n assert_eq!(saved.agent.provider.as_deref(), Some(\"anthropic\"));\n assert_eq!(saved.agent.model, \"claude-sonnet-4-20250514\");\n }\n\n #[test]\n fn token_masking_short_tokens() {\n assert_eq!(mask_token(\"ab\"), \"(masked)\");\n assert_eq!(mask_token(\"\"), \"(not set)\");\n }\n\n #[test]\n fn token_masking_long_token() {\n let masked = mask_token(\"sk-proj-abcdefghijklmnop-b2cF\");\n assert!(masked.starts_with(\"sk-\"));\n assert!(masked.ends_with(\"b2cF\"));\n assert!(masked.contains(\"…\"));\n }\n\n #[test]\n fn validate_api_key_openai() {\n assert!(validate_api_key(\"openai\", \"sk-test123\").is_ok());\n assert!(validate_api_key(\"openai\", \"bad-key\").is_err());\n }\n\n #[test]\n fn validate_api_key_anthropic() {\n assert!(validate_api_key(\"anthropic\", \"sk-ant-test123\").is_ok());\n assert!(validate_api_key(\"anthropic\", \"bad-key\").is_err());\n }\n\n #[test]\n fn validate_api_key_other_providers() {\n assert!(validate_api_key(\"ollama\", \"any-key\").is_ok());\n }\n\n #[test]\n fn validate_api_key_empty() {\n assert!(validate_api_key(\"openai\", \"\").is_err());\n }\n\n #[test]\n fn validate_url_valid() {\n assert_eq!(\n validate_url(\"https://api.openai.com/v1\").unwrap(),\n \"https://api.openai.com/v1\"\n );\n assert_eq!(\n validate_url(\"http://localhost:11434\").unwrap(),\n \"http://localhost:11434\"\n );\n }\n\n #[test]\n fn validate_url_empty() {\n assert_eq!(validate_url(\"\").unwrap(), \"\");\n }\n\n #[test]\n fn validate_url_invalid() {\n assert!(validate_url(\"not-a-url\").is_err());\n assert!(validate_url(\"ftp://example.com\").is_err());\n }\n\n #[test]\n fn wizard_step_ordering() {\n assert_eq!(WizardStep::first(), WizardStep::Provider);\n assert_eq!(WizardStep::Provider.step_number(), 1);\n assert_eq!(WizardStep::Review.step_number(), 0);\n assert_eq!(WizardStep::Provider.next(), Some(WizardStep::Telegram));\n assert_eq!(WizardStep::Review.next(), None);\n assert_eq!(WizardStep::Provider.prev(), None);\n assert_eq!(WizardStep::Review.prev(), Some(WizardStep::WeChat));\n }\n\n fn tg(token: &str) -> TelegramConfig {\n TelegramConfig {\n token: token.to_string(),\n allowed_users: Vec::new(),\n admin_users: Vec::new(),\n enabled: true,\n streaming: false,\n proxy: None,\n }\n }\n\n #[test]\n fn token_masking_empty_token() {\n let mut config = Config::default();\n config.channels.telegram = Some(tg(\"\"));\n\n let mock = MockWizard::new(vec![]);\n show_config_summary_simple(&mock, &config).unwrap();\n\n let output = mock.output();\n assert!(output.contains(\"(not set)\"));\n }\n\n #[test]\n fn configure_weixin_qr_enables_existing_credentials() {\n let mock = MockWizard::new(vec![MockAction::Select { result: 0 }]);\n let mut config = Config::default();\n config.channels.weixin = Some(WeixinConfig {\n app_id: None,\n app_secret: None,\n token: None,\n encoding_aes_key: None,\n account_id: Some(\"wxid_existing@im.bot\".to_string()),\n bot_token: Some(\"tok_existing\".to_string()),\n base_url: None,\n cdn_base_url: None,\n dm_policy: \"open\".to_string(),\n group_policy: \"disabled\".to_string(),\n allowed_users: vec![],\n group_allowed_users: vec![],\n enabled: false,\n });\n\n configure_weixin(&mock, &mut config).unwrap();\n\n let weixin = config.channels.weixin.unwrap();\n assert!(weixin.enabled);\n assert_eq!(weixin.account_id.as_deref(), Some(\"wxid_existing@im.bot\"));\n assert_eq!(weixin.bot_token.as_deref(), Some(\"tok_existing\"));\n assert!(mock.output().contains(\"detected and enabled\"));\n }\n\n #[test]\n fn configure_weixin_manual_preserves_existing_fields() {\n let mock = MockWizard::new(vec![\n MockAction::Select { result: 1 },\n MockAction::Input {\n result: \"wxid_new@im.bot\",\n },\n MockAction::Input { result: \"tok_new\" },\n ]);\n let mut config = Config::default();\n config.channels.weixin = Some(WeixinConfig {\n app_id: Some(\"app_existing\".to_string()),\n app_secret: Some(\"secret_existing\".to_string()),\n token: Some(\"verify_token\".to_string()),\n encoding_aes_key: Some(\"aes_key\".to_string()),\n account_id: Some(\"wxid_old@im.bot\".to_string()),\n bot_token: Some(\"tok_old\".to_string()),\n base_url: Some(\"https://ilinkai.weixin.qq.com\".to_string()),\n cdn_base_url: Some(\"https://novac2c.cdn.weixin.qq.com/c2c\".to_string()),\n dm_policy: \"allowlist\".to_string(),\n group_policy: \"open\".to_string(),\n allowed_users: vec![\"u1\".to_string()],\n group_allowed_users: vec![\"g1\".to_string()],\n enabled: false,\n });\n\n configure_weixin(&mock, &mut config).unwrap();\n\n let weixin = config.channels.weixin.unwrap();\n assert_eq!(weixin.account_id.as_deref(), Some(\"wxid_new@im.bot\"));\n assert_eq!(weixin.bot_token.as_deref(), Some(\"tok_new\"));\n assert_eq!(weixin.app_id.as_deref(), Some(\"app_existing\"));\n assert_eq!(weixin.app_secret.as_deref(), Some(\"secret_existing\"));\n assert_eq!(weixin.token.as_deref(), Some(\"verify_token\"));\n assert_eq!(weixin.encoding_aes_key.as_deref(), Some(\"aes_key\"));\n assert_eq!(\n weixin.base_url.as_deref(),\n Some(\"https://ilinkai.weixin.qq.com\")\n );\n assert_eq!(\n weixin.cdn_base_url.as_deref(),\n Some(\"https://novac2c.cdn.weixin.qq.com/c2c\")\n );\n assert_eq!(weixin.dm_policy, \"allowlist\");\n assert_eq!(weixin.group_policy, \"open\");\n assert_eq!(weixin.allowed_users, vec![\"u1\"]);\n assert_eq!(weixin.group_allowed_users, vec![\"g1\"]);\n assert!(weixin.enabled);\n }\n}\n"}, "18f5ed5d08dbb840a860346b159e3d1ce38aa599": {"t": 1785761073.7856717, "n": 60617, "h": "7f0bd7881f3ebcec", "c": "//! Setup command — interactive wizard for configuring Kestrel.\n//!\n//! Supports back-navigation via a state-machine loop, Quick/Full setup modes,\n//! input validation, and enhanced configuration summaries.\n\nuse anyhow::{bail, Context, Result};\nuse console::Term;\nuse dialoguer::{Confirm, Input, Password as PasswordInput, Select};\nuse kestrel_config::{\n loader, paths,\n schema::{Config, FeishuConfig, ProviderEntry, TelegramConfig, WebSocketConfig, WeixinConfig},\n};\nuse owo_colors::OwoColorize;\nuse std::net::SocketAddr;\nuse std::path::Path;\n\n// ── Provider display info ──────────────────────────────────────\n\nstruct ProviderInfo {\n key: &'static str,\n display: &'static str,\n default_model: &'static str,\n default_url: &'static str,\n}\n\nconst PROVIDERS: &[ProviderInfo] = &[\n ProviderInfo {\n key: \"openai\",\n display: \"★ OpenAI\",\n default_model: \"gpt-4o\",\n default_url: \"https://api.openai.com/v1\",\n },\n ProviderInfo {\n key: \"anthropic\",\n display: \"★ Anthropic\",\n default_model: \"claude-sonnet-4-20250514\",\n default_url: \"https://api.anthropic.com\",\n },\n ProviderInfo {\n key: \"openrouter\",\n display: \"★ OpenRouter (multi-model)\",\n default_model: \"anthropic/claude-sonnet-4-20250514\",\n default_url: \"https://openrouter.ai/api/v1\",\n },\n ProviderInfo {\n key: \"ollama\",\n display: \" Ollama (local models)\",\n default_model: \"llama3\",\n default_url: \"http://localhost:11434\",\n },\n ProviderInfo {\n key: \"deepseek\",\n display: \" DeepSeek\",\n default_model: \"deepseek-chat\",\n default_url: \"https://api.deepseek.com\",\n },\n ProviderInfo {\n key: \"gemini\",\n display: \" Gemini\",\n default_model: \"gemini-2.5-pro\",\n default_url: \"https://generativelanguage.googleapis.com/v1beta\",\n },\n ProviderInfo {\n key: \"groq\",\n display: \" Groq\",\n default_model: \"llama-3.3-70b-versatile\",\n default_url: \"https://api.groq.com/openai/v1\",\n },\n ProviderInfo {\n key: \"moonshot\",\n display: \" Moonshot (月之暗面)\",\n default_model: \"moonshot-v1-8k\",\n default_url: \"https://api.moonshot.cn/v1\",\n },\n ProviderInfo {\n key: \"minimax\",\n display: \" MiniMax\",\n default_model: \"MiniMax-Text-01\",\n default_url: \"https://api.minimax.chat/v1\",\n },\n ProviderInfo {\n key: \"github_copilot\",\n display: \" GitHub Copilot\",\n default_model: \"gpt-4o\",\n default_url: \"https://api.githubcopilot.com\",\n },\n ProviderInfo {\n key: \"openai_codex\",\n display: \" OpenAI Codex\",\n default_model: \"codex-mini\",\n default_url: \"https://api.openai.com/v1\",\n },\n ProviderInfo {\n key: \"glm_coding_plan\",\n display: \" GLM Coding Plan (智谱)\",\n default_model: \"glm-5.1\",\n default_url: \"https://open.bigmodel.cn/api/coding/paas/v4\",\n },\n];\n\nconst TOTAL_CONFIG_STEPS: usize = 5;\n\nconst BACK_OPTION: &str = \"↩ Go back\";\n\n// ── Wizard step state machine ──────────────────────────────────\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\nenum WizardStep {\n Provider, // Step 1\n Telegram, // Step 2\n Feishu, // Step 3\n WebSocket, // Step 4\n WeChat, // Step 5\n Review, // Final (no step number)\n}\n\nimpl WizardStep {\n fn step_number(self) -> usize {\n match self {\n Self::Provider => 1,\n Self::Telegram => 2,\n Self::Feishu => 3,\n Self::WebSocket => 4,\n Self::WeChat => 5,\n Self::Review => 0,\n }\n }\n\n fn title(self) -> &'static str {\n match self {\n Self::Provider => \"Provider Configuration\",\n Self::Telegram => \"Telegram Channel\",\n Self::Feishu => \"Feishu / Lark Channel\",\n Self::WebSocket => \"WebSocket Port\",\n Self::WeChat => \"WeChat Channel\",\n Self::Review => \"Review & Save\",\n }\n }\n\n fn next(self) -> Option {\n match self {\n Self::Provider => Some(Self::Telegram),\n Self::Telegram => Some(Self::Feishu),\n Self::Feishu => Some(Self::WebSocket),\n Self::WebSocket => Some(Self::WeChat),\n Self::WeChat => Some(Self::Review),\n Self::Review => None,\n }\n }\n\n fn prev(self) -> Option {\n match self {\n Self::Provider => None,\n Self::Telegram => Some(Self::Provider),\n Self::Feishu => Some(Self::Telegram),\n Self::WebSocket => Some(Self::Feishu),\n Self::WeChat => Some(Self::WebSocket),\n Self::Review => Some(Self::WeChat),\n }\n }\n\n fn first() -> WizardStep {\n WizardStep::Provider\n }\n\n fn all() -> &'static [WizardStep] {\n &[\n WizardStep::Provider,\n WizardStep::Telegram,\n WizardStep::Feishu,\n WizardStep::WebSocket,\n WizardStep::WeChat,\n ]\n }\n}\n\n/// Whether a channel step was configured or skipped.\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\nenum ChannelStatus {\n Configured,\n Skipped,\n}\n\n// ── Trait for interactive I/O (enables testability) ────────────\n\ntrait WizardIo {\n fn confirm(&self, prompt: &str, default: bool) -> Result;\n fn select(&self, prompt: &str, items: &[&str], default: usize) -> Result;\n fn input_with_default(&self, prompt: &str, default: &str) -> Result;\n fn input_allow_empty(&self, prompt: &str) -> Result;\n fn password(&self, prompt: &str) -> Result;\n fn write_line(&self, line: &str) -> Result<()>;\n\n /// Show a confirmation with back option. Returns `None` if user chose back.\n fn confirm_or_back(&self, prompt: &str, default: bool) -> Result> {\n let items = if default {\n &[\"Yes\", \"No\", BACK_OPTION] as &[&str]\n } else {\n &[\"No\", \"Yes\", BACK_OPTION] as &[&str]\n };\n let default_idx = 0;\n let choice = self.select(prompt, items, default_idx)?;\n match choice {\n idx if items[idx] == BACK_OPTION => Ok(None),\n idx if items[idx] == \"Yes\" => Ok(Some(true)),\n _ => Ok(Some(false)),\n }\n }\n\n /// Show a select with back option appended. Returns `None` if user chose back.\n fn select_or_back(\n &self,\n prompt: &str,\n items: &[&str],\n default: usize,\n ) -> Result> {\n let mut all_items: Vec<&str> = items.to_vec();\n all_items.push(BACK_OPTION);\n let choice = self.select(prompt, &all_items, default)?;\n if all_items[choice] == BACK_OPTION {\n Ok(None)\n } else {\n Ok(Some(choice))\n }\n }\n}\n\n// ── Production implementation using Term + dialoguer ───────────\n\nstruct TermWizard<'a> {\n term: &'a Term,\n}\n\nimpl<'a> TermWizard<'a> {\n fn new(term: &'a Term) -> Self {\n Self { term }\n }\n}\n\nimpl WizardIo for TermWizard<'_> {\n fn confirm(&self, prompt: &str, default: bool) -> Result {\n Ok(Confirm::new()\n .with_prompt(prompt)\n .default(default)\n .interact_on(self.term)?)\n }\n\n fn select(&self, prompt: &str, items: &[&str], default: usize) -> Result {\n Ok(Select::new()\n .with_prompt(prompt)\n .items(items)\n .default(default)\n .interact_on(self.term)?)\n }\n\n fn input_with_default(&self, prompt: &str, default: &str) -> Result {\n Ok(Input::new()\n .with_prompt(prompt)\n .default(default.to_string())\n .interact_text_on(self.term)?)\n }\n\n fn input_allow_empty(&self, prompt: &str) -> Result {\n Ok(Input::::new()\n .with_prompt(prompt)\n .allow_empty(true)\n .interact_text_on(self.term)?)\n }\n\n fn password(&self, prompt: &str) -> Result {\n Ok(PasswordInput::new()\n .with_prompt(prompt)\n .interact_on(self.term)?)\n }\n\n fn write_line(&self, line: &str) -> Result<()> {\n self.term.write_line(line)?;\n Ok(())\n }\n}\n\n// ── Entry point ────────────────────────────────────────────────\n\npub fn run(_config: Config) -> Result<()> {\n let term = Term::stdout();\n if !term.is_term() {\n bail!(\n \"Setup requires an interactive terminal. \\\n Run this command in a terminal, not in a pipe or CI environment.\"\n );\n }\n let io = TermWizard::new(&term);\n let config_path = paths::get_config_path()?;\n\n // Set up Ctrl+C handler for graceful exit\n ctrlc::set_handler(|| {\n println!();\n println!(\n \" {} Setup interrupted. Progress has not been saved.\",\n \"!\".yellow()\n );\n println!(\" Run `kestrel setup` again to start over.\");\n std::process::exit(1);\n })\n .ok(); // Ignore error if handler already set\n\n run_wizard(&io, &config_path)\n}\n\n// ── Wizard flow (state machine) ────────────────────────────────\n\nfn run_wizard(io: &dyn WizardIo, config_path: &Path) -> Result<()> {\n let is_first_run = !config_path.exists();\n print_banner(io, is_first_run)?;\n\n // ── Load existing config or start fresh ──────────────────────\n let mut config = if config_path.exists() {\n match load_existing_config(config_path) {\n Ok(existing) => {\n show_config_summary_simple(io, &existing)?;\n if io.confirm(\"Update existing config?\", true)? {\n existing\n } else {\n io.write_line(&format!(\n \" {} Keeping config at {}.\",\n \"✓\".green(),\n config_path.display()\n ))?;\n return Ok(());\n }\n }\n Err(e) => {\n io.write_line(&format!(\n \" {} Could not parse existing config: {}\",\n \"!\".yellow(),\n e\n ))?;\n if io.confirm(\"Start fresh with defaults?\", true)? {\n Config::default()\n } else {\n bail!(\"Setup cancelled.\");\n }\n }\n }\n } else {\n Config::default()\n };\n\n // ── Quick vs Full setup mode (first run only) ────────────────\n let quick_mode = if is_first_run {\n let modes = &[\n \"Quick Setup — configure LLM provider only, start using in seconds\",\n \"Full Setup — configure all channels and advanced options\",\n ];\n let mode = io.select(\"Choose setup mode\", modes, 0)?;\n mode == 0\n } else {\n false\n };\n\n // ── State machine loop ───────────────────────────────────────\n let mut current_step = WizardStep::first();\n let mut channel_status: [ChannelStatus; 5] = [ChannelStatus::Skipped; 5];\n\n loop {\n // In quick mode, skip directly to Review after Provider\n if quick_mode && current_step != WizardStep::Provider && current_step != WizardStep::Review\n {\n current_step = WizardStep::Review;\n }\n\n match current_step {\n WizardStep::Provider => {\n print_step(io, current_step)?;\n match configure_provider(io, &mut config)? {\n StepAction::Back => {\n // Provider is first step, cannot go back\n }\n StepAction::Continue => {\n channel_status[0] = ChannelStatus::Configured;\n current_step = current_step.next().unwrap();\n }\n }\n }\n WizardStep::Telegram => {\n print_step(io, current_step)?;\n let has_existing = config.channels.telegram.is_some();\n match configure_channel_step(\n io,\n &mut config,\n \"Telegram\",\n has_existing,\n configure_telegram,\n &mut channel_status[1],\n )? {\n StepAction::Back => {\n current_step = current_step.prev().unwrap();\n }\n StepAction::Continue => {\n current_step = current_step.next().unwrap();\n }\n }\n }\n WizardStep::Feishu => {\n print_step(io, current_step)?;\n let has_existing = config.channels.feishu.as_ref().is_some_and(|f| f.enabled);\n match configure_channel_step(\n io,\n &mut config,\n \"Feishu / Lark\",\n has_existing,\n configure_feishu,\n &mut channel_status[2],\n )? {\n StepAction::Back => {\n current_step = current_step.prev().unwrap();\n }\n StepAction::Continue => {\n current_step = current_step.next().unwrap();\n }\n }\n }\n WizardStep::WebSocket => {\n print_step(io, current_step)?;\n let has_existing = config\n .channels\n .websocket\n .as_ref()\n .is_some_and(|w| w.enabled);\n match configure_channel_step(\n io,\n &mut config,\n \"WebSocket\",\n has_existing,\n configure_websocket,\n &mut channel_status[3],\n )? {\n StepAction::Back => {\n current_step = current_step.prev().unwrap();\n }\n StepAction::Continue => {\n current_step = current_step.next().unwrap();\n }\n }\n }\n WizardStep::WeChat => {\n print_step(io, current_step)?;\n let has_existing = config.channels.weixin.is_some();\n match configure_channel_step(\n io,\n &mut config,\n \"WeChat\",\n has_existing,\n configure_weixin,\n &mut channel_status[4],\n )? {\n StepAction::Back => {\n current_step = current_step.prev().unwrap();\n }\n StepAction::Continue => {\n current_step = current_step.next().unwrap();\n }\n }\n }\n WizardStep::Review => {\n print_review(io, &config, &channel_status)?;\n\n // Show options to re-configure or save\n let mut review_items: Vec = WizardStep::all()\n .iter()\n .enumerate()\n .map(|(i, s)| {\n let num = s.step_number();\n let title = s.title();\n let status = match i {\n 0 => \"✓\".to_string(),\n _ => match channel_status[i] {\n ChannelStatus::Configured => \"configured\".to_string(),\n ChannelStatus::Skipped => \"skipped\".to_string(),\n },\n };\n format!(\"{}. {} ({})\", num, title, status)\n })\n .collect();\n review_items.push(\"Save configuration\".to_string());\n\n // Build &str refs for select\n let review_refs: Vec<&str> = review_items.iter().map(|s| s.as_str()).collect();\n let default_save = review_items.len() - 1;\n let choice = io.select(\n \"Select a step to re-configure, or save\",\n &review_refs,\n default_save,\n )?;\n\n if choice == default_save {\n // Save\n if save_config(io, &config, config_path)? {\n print_next_steps(io, &config)?;\n return Ok(());\n }\n // User cancelled save, stay in review\n } else if choice < WizardStep::all().len() {\n current_step = WizardStep::all()[choice];\n }\n }\n }\n }\n}\n\n/// Result of a configure step: continue forward or go back.\nenum StepAction {\n Continue,\n Back,\n}\n\n/// Generic wrapper for channel configuration steps with back support.\nfn configure_channel_step(\n io: &dyn WizardIo,\n config: &mut Config,\n channel_name: &str,\n has_existing: bool,\n configure_fn: fn(&dyn WizardIo, &mut Config) -> Result,\n status: &mut ChannelStatus,\n) -> Result {\n let prompt = if has_existing {\n format!(\"Update {} configuration?\", channel_name)\n } else {\n format!(\"Set up {}?\", channel_name)\n };\n\n match io.confirm_or_back(&prompt, has_existing)? {\n None => return Ok(StepAction::Back),\n Some(false) => {\n io.write_line(&format!(\n \" Skipped. Run `kestrel setup` anytime to configure {}.\",\n channel_name\n ))?;\n *status = ChannelStatus::Skipped;\n return Ok(StepAction::Continue);\n }\n Some(true) => {}\n }\n\n match configure_fn(io, config) {\n Ok(true) => {\n *status = ChannelStatus::Configured;\n Ok(StepAction::Continue)\n }\n Ok(false) => {\n // Configure fn handled skip internally\n *status = ChannelStatus::Skipped;\n Ok(StepAction::Continue)\n }\n Err(e) => Err(e),\n }\n}\n\n// ── Banner & step printing ─────────────────────────────────────\n\nfn print_banner(io: &dyn WizardIo, is_first_run: bool) -> Result<()> {\n io.write_line(\"\")?;\n if is_first_run {\n io.write_line(&format!(\n \" {} {}\",\n \"▸\".cyan(),\n \"Kestrel 初始化配置向导\".bold().cyan()\n ))?;\n io.write_line(\"\")?;\n io.write_line(\" Welcome to Kestrel! Let's set up your configuration.\")?;\n io.write_line(\" You can re-run `kestrel setup` anytime to make changes.\")?;\n } else {\n io.write_line(&format!(\n \" {} {}\",\n \"▸\".cyan(),\n \"Kestrel 配置更新向导\".bold().cyan()\n ))?;\n io.write_line(\"\")?;\n io.write_line(\" Update your existing configuration.\")?;\n }\n io.write_line(\"\")?;\n Ok(())\n}\n\nfn print_step(io: &dyn WizardIo, step: WizardStep) -> Result<()> {\n io.write_line(\"\")?;\n let num = step.step_number();\n io.write_line(&format!(\n \" {} Step {}/{}: {}\",\n \"▸\".cyan(),\n num,\n TOTAL_CONFIG_STEPS,\n step.title().bold()\n ))?;\n io.write_line(&format!(\" {}\", \"─\".repeat(40).dimmed()))?;\n Ok(())\n}\n\nfn print_review(\n io: &dyn WizardIo,\n config: &Config,\n channel_status: &[ChannelStatus; 5],\n) -> Result<()> {\n io.write_line(\"\")?;\n io.write_line(&format!(\n \" {} {}\",\n \"▸\".cyan(),\n \"Review & Save\".bold().cyan()\n ))?;\n io.write_line(&format!(\" {}\", \"─\".repeat(50).dimmed()))?;\n io.write_line(\"\")?;\n show_config_summary(io, config, channel_status)?;\n io.write_line(\"\")?;\n Ok(())\n}\n\nfn load_existing_config(path: &Path) -> Result {\n let content = std::fs::read_to_string(path)\n .with_context(|| format!(\"Failed to read {}\", path.display()))?;\n let config: Config =\n toml::from_str(&content).with_context(|| format!(\"Failed to parse {}\", path.display()))?;\n Ok(config)\n}\n\n// ── Token masking & config summary ─────────────────────────────\n\nfn mask_token(token: &str) -> String {\n if token.is_empty() {\n \"(not set)\".to_string()\n } else if token.len() <= 8 {\n \"(masked)\".to_string()\n } else {\n format!(\"{}…{}\", &token[..3], &token[token.len() - 4..])\n }\n}\n\nfn show_config_summary(\n io: &dyn WizardIo,\n config: &Config,\n channel_status: &[ChannelStatus; 5],\n) -> Result<()> {\n let provider = config.agent.provider.as_deref().unwrap_or(\"default\");\n io.write_line(&format!(\" Provider: {}\", provider))?;\n io.write_line(&format!(\" Model: {}\", config.agent.model))?;\n\n // Show base URL\n if let Some(url) = config\n .agent\n .provider\n .as_deref()\n .and_then(|p| get_provider_url(config, p))\n {\n io.write_line(&format!(\" Base URL: {}\", url))?;\n }\n\n // Show API key status\n let key_status = config\n .agent\n .provider\n .as_deref()\n .and_then(|p| get_provider_api_key(config, p))\n .map(mask_token)\n .unwrap_or_else(|| \"(not set)\".to_string());\n io.write_line(&format!(\" API Key: {}\", key_status))?;\n\n io.write_line(&format!(\" Temperature: {}\", config.agent.temperature))?;\n io.write_line(&format!(\" Max tokens: {}\", config.agent.max_tokens))?;\n\n io.write_line(\"\")?;\n io.write_line(\" Channels:\")?;\n\n let channel_names = [\"Telegram\", \"Feishu\", \"WebSocket\", \"WeChat\"];\n for (i, name) in channel_names.iter().enumerate() {\n let status_str = match channel_status[i + 1] {\n ChannelStatus::Configured => \"✓ configured\".green().to_string(),\n ChannelStatus::Skipped => \"✗ skipped\".dimmed().to_string(),\n };\n io.write_line(&format!(\" {}: {}\", name, status_str))?;\n }\n\n // Show details for configured channels\n if let Some(ref tg) = config.channels.telegram {\n if !tg.token.is_empty() {\n io.write_line(&format!(\" Token: {}\", mask_token(&tg.token)))?;\n }\n }\n\n if let Some(ref ws) = config.channels.websocket {\n if ws.enabled {\n io.write_line(&format!(\" Address: {}\", ws.listen_addr))?;\n }\n }\n\n if let Some(ref wx) = config.channels.weixin {\n if wx.enabled {\n let acct = wx.account_id.as_deref().unwrap_or(\"(unknown)\");\n io.write_line(&format!(\" Account: {}\", mask_token(acct)))?;\n }\n }\n\n if let Some(ref fs) = config.channels.feishu {\n if fs.enabled {\n let app_id = fs.app_id.as_deref().unwrap_or(\"(unknown)\");\n io.write_line(&format!(\" App ID: {}\", mask_token(app_id)))?;\n }\n }\n\n Ok(())\n}\n\n/// Legacy summary without channel status (used for existing config review).\nfn show_config_summary_simple(io: &dyn WizardIo, config: &Config) -> Result<()> {\n let provider = config.agent.provider.as_deref().unwrap_or(\"default\");\n io.write_line(&format!(\" Provider: {}\", provider))?;\n io.write_line(&format!(\" Model: {}\", config.agent.model))?;\n io.write_line(&format!(\" Temperature: {}\", config.agent.temperature))?;\n io.write_line(&format!(\" Max tokens: {}\", config.agent.max_tokens))?;\n io.write_line(&format!(\" Streaming: {}\", config.agent.streaming))?;\n\n if let Some(ref tg) = config.channels.telegram {\n io.write_line(&format!(\" Telegram: {}\", mask_token(&tg.token)))?;\n }\n\n if let Some(ref fs) = config.channels.feishu {\n if fs.enabled {\n let app_id = fs.app_id.as_deref().unwrap_or(\"(not set)\");\n io.write_line(&format!(\" Feishu: {}\", mask_token(app_id)))?;\n }\n }\n\n if let Some(ref ws) = config.channels.websocket {\n if ws.enabled {\n io.write_line(&format!(\" WebSocket: {}\", ws.listen_addr))?;\n }\n }\n\n if let Some(ref wx) = config.channels.weixin {\n let status = if wx.enabled {\n mask_token(wx.bot_token.as_deref().unwrap_or(\"\"))\n } else {\n \"disabled\".to_string()\n };\n io.write_line(&format!(\" WeChat: {}\", status))?;\n }\n\n Ok(())\n}\n\nfn save_config(io: &dyn WizardIo, config: &Config, config_path: &Path) -> Result {\n io.write_line(&format!(\" Config path: {}\", config_path.display()))?;\n\n if !io.confirm(\"Write this configuration?\", true)? {\n io.write_line(&format!(\" {} Save cancelled.\", \"!\".yellow()))?;\n return Ok(false);\n }\n\n let home = config_path\n .parent()\n .context(\"Config path must have a parent directory\")?;\n\n std::fs::create_dir_all(home)\n .with_context(|| format!(\"Failed to create config home: {}\", home.display()))?;\n\n loader::save_config(config, config_path)?;\n\n for dir in [\"skills\", \"sessions\", \"learning\"] {\n let path = home.join(dir);\n std::fs::create_dir_all(&path)\n .with_context(|| format!(\"Failed to create directory: {}\", path.display()))?;\n }\n\n io.write_line(\"\")?;\n io.write_line(&format!(\n \" {} Configuration saved to {}\",\n \"✓\".green(),\n config_path.display()\n ))?;\n io.write_line(&format!(\n \" {} Created directories: skills, sessions, learning\",\n \"✓\".green()\n ))?;\n io.write_line(&format!(\" {} Setup complete!\", \"✓\".green()))?;\n\n Ok(true)\n}\n\nfn print_next_steps(io: &dyn WizardIo, config: &Config) -> Result<()> {\n io.write_line(\"\")?;\n io.write_line(&format!(\" {} Next steps:\", \"▸\".cyan()))?;\n io.write_line(\"\")?;\n io.write_line(\" 1. Try it out:\")?;\n io.write_line(\" kestrel agent\")?;\n io.write_line(\"\")?;\n\n let has_channel = config.channels.telegram.is_some()\n || config.channels.feishu.is_some()\n || config.channels.weixin.is_some()\n || config.channels.websocket.is_some();\n\n if has_channel {\n io.write_line(\" 2. Start the gateway (connect to chat platforms):\")?;\n io.write_line(\" kestrel gateway\")?;\n io.write_line(\"\")?;\n }\n\n if config.channels.websocket.is_some() {\n io.write_line(\" 3. Start the API server:\")?;\n io.write_line(\" kestrel serve\")?;\n io.write_line(\"\")?;\n }\n\n io.write_line(\" Check system health:\")?;\n io.write_line(\" kestrel doctor\")?;\n io.write_line(\"\")?;\n io.write_line(\" Re-run setup anytime:\")?;\n io.write_line(\" kestrel setup\")?;\n io.write_line(\"\")?;\n Ok(())\n}\n\n// ── Input validation ───────────────────────────────────────────\n\nfn validate_api_key(provider: &str, key: &str) -> Result {\n let key = key.trim().to_string();\n if key.is_empty() {\n bail!(\"API key cannot be empty\");\n }\n match provider {\n \"openai\" | \"openai_codex\" if !key.starts_with(\"sk-\") => {\n bail!(\"OpenAI API keys typically start with 'sk-'\");\n }\n \"anthropic\" if !key.starts_with(\"sk-ant-\") => {\n bail!(\"Anthropic API keys typically start with 'sk-ant-'\");\n }\n _ => {}\n }\n Ok(key)\n}\n\nfn validate_url(input: &str) -> Result {\n let url = input.trim().to_string();\n if url.is_empty() {\n return Ok(url);\n }\n if !url.starts_with(\"http://\") && !url.starts_with(\"https://\") {\n bail!(\"URL must start with http:// or https://\");\n }\n Ok(url)\n}\n\n// ── Provider configuration ─────────────────────────────────────\n\nfn configure_provider(io: &dyn WizardIo, config: &mut Config) -> Result {\n let display_names: Vec<&str> = PROVIDERS.iter().map(|p| p.display).collect();\n\n let default_idx = config\n .agent\n .provider\n .as_deref()\n .and_then(|p| PROVIDERS.iter().position(|info| info.key == p))\n .unwrap_or(0); // default to OpenAI (first)\n\n let provider_idx =\n match io.select_or_back(\"Select LLM provider\", &display_names, default_idx)? {\n None => return Ok(StepAction::Back),\n Some(idx) => idx,\n };\n\n let provider_key = PROVIDERS[provider_idx].key;\n config.agent.provider = Some(provider_key.to_string());\n\n let default_model = PROVIDERS[provider_idx].default_model;\n let current_model = if config.agent.model.is_empty() {\n default_model\n } else {\n &config.agent.model\n };\n\n let model: String = io.input_with_default(\"Model name\", current_model)?;\n config.agent.model = model.trim().to_string();\n\n let default_url = PROVIDERS[provider_idx].default_url;\n let current_url = get_provider_url(config, provider_key).unwrap_or(default_url);\n\n if !current_url.is_empty() {\n let base_url: String = io.input_with_default(\"Base URL\", current_url)?;\n let base_url = validate_url(&base_url)?;\n set_provider_url(config, provider_key, &base_url);\n } else {\n let base_url: String = io.input_allow_empty(\"Base URL (leave empty for default)\")?;\n let base_url = validate_url(&base_url)?;\n if !base_url.is_empty() {\n set_provider_url(config, provider_key, &base_url);\n }\n }\n\n loop {\n let api_key: String = io.password(\"API key\")?;\n match validate_api_key(provider_key, &api_key) {\n Ok(key) => {\n set_provider_api_key(config, provider_key, &key);\n break;\n }\n Err(e) => {\n io.write_line(&format!(\" {} {}\", \"!\".yellow(), e))?;\n if !io.confirm(\"Try again?\", true)? {\n // Accept whatever was entered\n let key = api_key.trim().to_string();\n if !key.is_empty() {\n set_provider_api_key(config, provider_key, &key);\n }\n break;\n }\n }\n }\n }\n\n Ok(StepAction::Continue)\n}\n\n// ── Channel configurations ─────────────────────────────────────\n\n/// Configure Telegram. Returns true if configured, false if skipped.\nfn configure_telegram(io: &dyn WizardIo, config: &mut Config) -> Result {\n let current_token = config\n .channels\n .telegram\n .as_ref()\n .map(|tg| tg.token.as_str())\n .unwrap_or(\"\");\n\n let token: String = if current_token.is_empty() {\n io.input_allow_empty(\"Bot token (from @BotFather)\")?\n } else {\n io.input_with_default(\"Bot token (from @BotFather)\", current_token)?\n };\n\n let token = token.trim().to_string();\n if token.is_empty() {\n io.write_line(\" No token provided, skipping Telegram.\")?;\n return Ok(false);\n }\n\n let allowed: String = loop {\n let input: String =\n io.input_allow_empty(\"Allowed user IDs (comma-separated, leave empty for all)\")?;\n if input.trim().is_empty() {\n break String::new();\n }\n let ids: Vec<&str> = input\n .split(',')\n .map(|s| s.trim())\n .filter(|s| !s.is_empty())\n .collect();\n let mut valid = true;\n for id in &ids {\n if id.parse::().is_err() {\n io.write_line(&format!(\n \" {} '{}' is not a valid user ID (must be a positive integer).\",\n \"!\".yellow(),\n id\n ))?;\n valid = false;\n break;\n }\n }\n if valid {\n break input;\n }\n };\n\n let allowed_users: Vec = if allowed.trim().is_empty() {\n Vec::new()\n } else {\n allowed\n .split(',')\n .map(|s| s.trim().to_string())\n .filter(|s| !s.is_empty())\n .collect()\n };\n\n let (admin_users, enabled, streaming, proxy) = config\n .channels\n .telegram\n .as_ref()\n .map(|tg| {\n (\n tg.admin_users.clone(),\n tg.enabled,\n tg.streaming,\n tg.proxy.clone(),\n )\n })\n .unwrap_or((Vec::new(), true, true, None));\n\n config.channels.telegram = Some(TelegramConfig {\n token,\n allowed_users,\n admin_users,\n enabled,\n streaming,\n proxy,\n });\n\n io.write_line(&format!(\" {} Telegram configured.\", \"✓\".green()))?;\n Ok(true)\n}\n\n/// Configure Feishu. Returns true if configured, false if skipped.\nfn configure_feishu(io: &dyn WizardIo, config: &mut Config) -> Result {\n let domain_options = [\"Feishu (飞书)\", \"Lark (international)\"];\n let idx = io.select(\"Select platform\", &domain_options, 0)?;\n let domain = if idx == 1 { \"lark\" } else { \"feishu\" };\n\n let rt = tokio::runtime::Builder::new_current_thread()\n .enable_all()\n .build()\n .context(\"Failed to create tokio runtime\")?;\n\n let result = match rt.block_on(super::feishu_onboarding::run_onboarding(domain)) {\n Ok(r) => r,\n Err(e) => {\n io.write_line(&format!(\" {} Feishu setup skipped: {}\", \"!\".yellow(), e))?;\n return Ok(false);\n }\n };\n\n let existing_proxy = config\n .channels\n .feishu\n .as_ref()\n .and_then(|f| f.proxy.clone());\n\n config.channels.feishu = Some(FeishuConfig {\n app_id: Some(result.app_id),\n app_secret: Some(result.app_secret),\n enabled: true,\n proxy: existing_proxy,\n ..Default::default()\n });\n\n io.write_line(&format!(\" {} Feishu configured.\", \"✓\".green()))?;\n Ok(true)\n}\n\n/// Configure WebSocket. Returns true if configured, false if skipped.\nfn configure_websocket(io: &dyn WizardIo, config: &mut Config) -> Result {\n let default_addr = config\n .channels\n .websocket\n .as_ref()\n .map(|ws| ws.listen_addr.as_str())\n .unwrap_or(\"127.0.0.1:8090\");\n\n let listen_addr: String = loop {\n let input = io.input_with_default(\"Listen address\", default_addr)?;\n match input.parse::() {\n Ok(_) => break input,\n Err(e) => {\n io.write_line(&format!(\n \" {} Invalid address '{}': {}\",\n \"!\".yellow(),\n input,\n e\n ))?;\n }\n }\n };\n\n config.channels.websocket = Some(WebSocketConfig {\n enabled: true,\n listen_addr,\n auth: Default::default(),\n max_clients: 100,\n max_message_size: 1048576,\n });\n\n io.write_line(&format!(\" {} WebSocket configured.\", \"✓\".green()))?;\n Ok(true)\n}\n\n/// Configure WeChat. Returns true if configured, false if skipped.\nfn configure_weixin(io: &dyn WizardIo, config: &mut Config) -> Result {\n let choices = &[\n \"Scan QR code with WeChat (recommended)\",\n \"Enter credentials manually\",\n \"Skip\",\n ];\n let choice = io.select(\"How would you like to configure WeChat?\", choices, 0)?;\n\n match choice {\n 0 => {\n io.write_line(\n \" QR scan setup requires running `kestrel setup weixin` in a terminal.\",\n )?;\n io.write_line(\" Please run that command separately, then return to this wizard.\")?;\n if let Some(ref mut wx) = config.channels.weixin {\n if wx.account_id.is_some() && wx.bot_token.is_some() {\n wx.enabled = true;\n io.write_line(&format!(\n \" {} Existing WeChat credentials detected and enabled.\",\n \"✓\".green()\n ))?;\n return Ok(true);\n }\n }\n if config.channels.weixin.is_none() {\n io.write_line(\n \" No WeChat credentials found yet. Run `kestrel setup weixin` first.\",\n )?;\n }\n Ok(false)\n }\n 1 => {\n let current_account = config\n .channels\n .weixin\n .as_ref()\n .and_then(|w| w.account_id.as_deref())\n .unwrap_or(\"\");\n let account_id: String = if current_account.is_empty() {\n io.input_allow_empty(\"iLink account ID (e.g. wxid_xxx@im.bot)\")?\n } else {\n io.input_with_default(\"iLink account ID\", current_account)?\n };\n\n if account_id.trim().is_empty() {\n io.write_line(\" No account ID provided, skipping WeChat.\")?;\n return Ok(false);\n }\n\n let current_token = config\n .channels\n .weixin\n .as_ref()\n .and_then(|w| w.bot_token.as_deref())\n .unwrap_or(\"\");\n let bot_token: String = if current_token.is_empty() {\n io.input_allow_empty(\"iLink bot token\")?\n } else {\n io.input_with_default(\"iLink bot token\", current_token)?\n };\n\n if bot_token.trim().is_empty() {\n io.write_line(\" No bot token provided, skipping WeChat.\")?;\n return Ok(false);\n }\n\n let (\n app_id,\n app_secret,\n old_token,\n encoding_aes_key,\n base_url,\n cdn_base_url,\n dm_policy,\n group_policy,\n allowed_users,\n group_allowed_users,\n ) = config\n .channels\n .weixin\n .as_ref()\n .map(|w| {\n (\n w.app_id.clone(),\n w.app_secret.clone(),\n w.token.clone(),\n w.encoding_aes_key.clone(),\n w.base_url.clone(),\n w.cdn_base_url.clone(),\n w.dm_policy.clone(),\n w.group_policy.clone(),\n w.allowed_users.clone(),\n w.group_allowed_users.clone(),\n )\n })\n .unwrap_or_default();\n\n config.channels.weixin = Some(WeixinConfig {\n account_id: Some(account_id.trim().to_string()),\n bot_token: Some(bot_token.trim().to_string()),\n app_id,\n app_secret,\n token: old_token,\n encoding_aes_key,\n base_url,\n cdn_base_url,\n dm_policy,\n group_policy,\n allowed_users,\n group_allowed_users,\n enabled: true,\n });\n\n io.write_line(&format!(\" {} WeChat configured.\", \"✓\".green()))?;\n Ok(true)\n }\n _ => {\n io.write_line(\" Skipped.\")?;\n Ok(false)\n }\n }\n}\n\n// ── Provider field helpers ─────────────────────────────────────\n\nfn get_provider_entry_mut<'a>(\n config: &'a mut Config,\n provider: &str,\n) -> Option<&'a mut ProviderEntry> {\n match provider {\n \"anthropic\" => config.providers.anthropic.as_mut(),\n \"openai\" => config.providers.openai.as_mut(),\n \"openrouter\" => config.providers.openrouter.as_mut(),\n \"ollama\" => config.providers.ollama.as_mut(),\n \"deepseek\" => config.providers.deepseek.as_mut(),\n \"gemini\" => config.providers.gemini.as_mut(),\n \"groq\" => config.providers.groq.as_mut(),\n \"moonshot\" => config.providers.moonshot.as_mut(),\n \"minimax\" => config.providers.minimax.as_mut(),\n \"github_copilot\" => config.providers.github_copilot.as_mut(),\n \"openai_codex\" => config.providers.openai_codex.as_mut(),\n \"opencode_go\" => config.providers.opencode_go.as_mut(),\n \"glm_coding_plan\" => config.providers.glm_coding_plan.as_mut(),\n _ => None,\n }\n}\n\nfn ensure_provider_entry(config: &mut Config, provider: &str) {\n match provider {\n \"anthropic\" => {\n config\n .providers\n .anthropic\n .get_or_insert_with(ProviderEntry::default);\n }\n \"openai\" => {\n config\n .providers\n .openai\n .get_or_insert_with(ProviderEntry::default);\n }\n \"openrouter\" => {\n config\n .providers\n .openrouter\n .get_or_insert_with(ProviderEntry::default);\n }\n \"ollama\" => {\n config\n .providers\n .ollama\n .get_or_insert_with(ProviderEntry::default);\n }\n \"deepseek\" => {\n config\n .providers\n .deepseek\n .get_or_insert_with(ProviderEntry::default);\n }\n \"gemini\" => {\n config\n .providers\n .gemini\n .get_or_insert_with(ProviderEntry::default);\n }\n \"groq\" => {\n config\n .providers\n .groq\n .get_or_insert_with(ProviderEntry::default);\n }\n \"moonshot\" => {\n config\n .providers\n .moonshot\n .get_or_insert_with(ProviderEntry::default);\n }\n \"minimax\" => {\n config\n .providers\n .minimax\n .get_or_insert_with(ProviderEntry::default);\n }\n \"github_copilot\" => {\n config\n .providers\n .github_copilot\n .get_or_insert_with(ProviderEntry::default);\n }\n \"openai_codex\" => {\n config\n .providers\n .openai_codex\n .get_or_insert_with(ProviderEntry::default);\n }\n \"opencode_go\" => {\n config\n .providers\n .opencode_go\n .get_or_insert_with(ProviderEntry::default);\n }\n \"glm_coding_plan\" => {\n config\n .providers\n .glm_coding_plan\n .get_or_insert_with(ProviderEntry::default);\n }\n _ => {}\n }\n}\n\nfn get_provider_url<'a>(config: &'a Config, provider: &str) -> Option<&'a str> {\n let entry = match provider {\n \"anthropic\" => config.providers.anthropic.as_ref(),\n \"openai\" => config.providers.openai.as_ref(),\n \"openrouter\" => config.providers.openrouter.as_ref(),\n \"ollama\" => config.providers.ollama.as_ref(),\n \"deepseek\" => config.providers.deepseek.as_ref(),\n \"gemini\" => config.providers.gemini.as_ref(),\n \"groq\" => config.providers.groq.as_ref(),\n \"moonshot\" => config.providers.moonshot.as_ref(),\n \"minimax\" => config.providers.minimax.as_ref(),\n \"github_copilot\" => config.providers.github_copilot.as_ref(),\n \"openai_codex\" => config.providers.openai_codex.as_ref(),\n \"opencode_go\" => config.providers.opencode_go.as_ref(),\n \"glm_coding_plan\" => config.providers.glm_coding_plan.as_ref(),\n _ => None,\n };\n entry.and_then(|e| e.base_url.as_deref())\n}\n\nfn get_provider_api_key<'a>(config: &'a Config, provider: &str) -> Option<&'a str> {\n let entry = match provider {\n \"anthropic\" => config.providers.anthropic.as_ref(),\n \"openai\" => config.providers.openai.as_ref(),\n \"openrouter\" => config.providers.openrouter.as_ref(),\n \"ollama\" => config.providers.ollama.as_ref(),\n \"deepseek\" => config.providers.deepseek.as_ref(),\n \"gemini\" => config.providers.gemini.as_ref(),\n \"groq\" => config.providers.groq.as_ref(),\n \"moonshot\" => config.providers.moonshot.as_ref(),\n \"minimax\" => config.providers.minimax.as_ref(),\n \"github_copilot\" => config.providers.github_copilot.as_ref(),\n \"openai_codex\" => config.providers.openai_codex.as_ref(),\n \"opencode_go\" => config.providers.opencode_go.as_ref(),\n \"glm_coding_plan\" => config.providers.glm_coding_plan.as_ref(),\n _ => None,\n };\n entry.and_then(|e| e.api_key.as_deref())\n}\n\nfn set_provider_url(config: &mut Config, provider: &str, url: &str) {\n ensure_provider_entry(config, provider);\n if let Some(entry) = get_provider_entry_mut(config, provider) {\n entry.base_url = if url.is_empty() {\n None\n } else {\n Some(url.to_string())\n };\n }\n}\n\nfn set_provider_api_key(config: &mut Config, provider: &str, key: &str) {\n ensure_provider_entry(config, provider);\n if let Some(entry) = get_provider_entry_mut(config, provider) {\n entry.api_key = if key.is_empty() {\n None\n } else {\n Some(key.to_string())\n };\n }\n}\n\n// ── Tests ──────────────────────────────────────────────────────\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use std::cell::RefCell;\n use std::collections::VecDeque;\n\n fn template_toml() -> String {\n toml::to_string(&Config::default()).unwrap()\n }\n\n // ── Mock wizard IO for flow testing ─────────────────────────\n\n #[derive(Debug, Clone)]\n enum MockAction {\n Confirm {\n prompt_contains: &'static str,\n result: bool,\n },\n Select {\n result: usize,\n },\n Input {\n result: &'static str,\n },\n Password {\n result: &'static str,\n },\n }\n\n struct MockWizard {\n actions: RefCell>,\n output: RefCell,\n }\n\n impl MockWizard {\n fn new(actions: Vec) -> Self {\n Self {\n actions: RefCell::new(actions.into()),\n output: RefCell::new(String::new()),\n }\n }\n\n fn output(&self) -> String {\n self.output.borrow().clone()\n }\n }\n\n impl WizardIo for MockWizard {\n fn confirm(&self, prompt: &str, _default: bool) -> Result {\n let action = self.actions.borrow_mut().pop_front();\n match action {\n Some(MockAction::Confirm {\n prompt_contains,\n result,\n }) => {\n assert!(\n prompt.contains(prompt_contains),\n \"confirm prompt '{}' did not contain '{}'\",\n prompt,\n prompt_contains\n );\n Ok(result)\n }\n _ => panic!(\"unexpected confirm call, prompt: {}\", prompt),\n }\n }\n\n fn select(&self, _prompt: &str, _items: &[&str], _default: usize) -> Result {\n let action = self.actions.borrow_mut().pop_front();\n match action {\n Some(MockAction::Select { result }) => Ok(result),\n _ => panic!(\"unexpected select call\"),\n }\n }\n\n fn input_with_default(&self, _prompt: &str, _default: &str) -> Result {\n let action = self.actions.borrow_mut().pop_front();\n match action {\n Some(MockAction::Input { result }) => Ok(result.to_string()),\n _ => panic!(\"unexpected input_with_default call\"),\n }\n }\n\n fn input_allow_empty(&self, _prompt: &str) -> Result {\n let action = self.actions.borrow_mut().pop_front();\n match action {\n Some(MockAction::Input { result }) => Ok(result.to_string()),\n _ => panic!(\"unexpected input_allow_empty call\"),\n }\n }\n\n fn password(&self, _prompt: &str) -> Result {\n let action = self.actions.borrow_mut().pop_front();\n match action {\n Some(MockAction::Password { result }) => Ok(result.to_string()),\n _ => panic!(\"unexpected password call\"),\n }\n }\n\n fn write_line(&self, line: &str) -> Result<()> {\n self.output.borrow_mut().push_str(line);\n self.output.borrow_mut().push('\\n');\n Ok(())\n }\n }\n\n // ── Unit tests ──────────────────────────────────────────────\n\n #[test]\n fn setup_creates_template_config_when_config_is_missing() {\n let tmp = tempfile::tempdir().unwrap();\n let config_path = tmp.path().join(\"config.toml\");\n\n let config = Config::default();\n let home = config_path.parent().unwrap();\n std::fs::create_dir_all(home).unwrap();\n loader::save_config(&config, &config_path).unwrap();\n\n assert_eq!(\n std::fs::read_to_string(&config_path).unwrap(),\n template_toml()\n );\n assert!(config_path.exists());\n }\n\n #[test]\n fn provider_helpers_set_and_get_fields() {\n let mut config = Config::default();\n set_provider_url(&mut config, \"openai\", \"https://custom.api/v1\");\n set_provider_api_key(&mut config, \"openai\", \"sk-test-key\");\n\n let entry = config.providers.openai.as_ref().unwrap();\n assert_eq!(entry.base_url.as_deref(), Some(\"https://custom.api/v1\"));\n assert_eq!(entry.api_key.as_deref(), Some(\"sk-test-key\"));\n }\n\n #[test]\n fn provider_helpers_handle_unknown_provider() {\n let mut config = Config::default();\n set_provider_url(&mut config, \"nonexistent\", \"https://example.com\");\n assert!(get_provider_url(&config, \"nonexistent\").is_none());\n }\n\n #[test]\n fn wizard_keeps_existing_config() {\n let tmp = tempfile::tempdir().unwrap();\n let config_path = tmp.path().join(\"config.toml\");\n\n let existing = Config::default();\n std::fs::create_dir_all(config_path.parent().unwrap()).unwrap();\n loader::save_config(&existing, &config_path).unwrap();\n\n let mock = MockWizard::new(vec![MockAction::Confirm {\n prompt_contains: \"Update\",\n result: false,\n }]);\n\n run_wizard(&mock, &config_path).unwrap();\n\n assert_eq!(\n std::fs::read_to_string(&config_path).unwrap(),\n template_toml()\n );\n }\n\n #[test]\n fn wizard_fresh_quick_setup() {\n let tmp = tempfile::tempdir().unwrap();\n let config_path = tmp.path().join(\"config.toml\");\n\n let mock = MockWizard::new(vec![\n // Quick mode selection\n MockAction::Select { result: 0 }, // Quick Setup\n // Step 1: Provider\n MockAction::Select { result: 0 }, // openai (first)\n MockAction::Input { result: \"gpt-4o\" },\n MockAction::Input {\n result: \"https://api.openai.com/v1\",\n },\n MockAction::Password {\n result: \"sk-test-key\",\n },\n // Review: Save\n MockAction::Select { result: 5 }, // \"Save configuration\" (index 5 = last)\n MockAction::Confirm {\n prompt_contains: \"Write\",\n result: true,\n },\n ]);\n\n run_wizard(&mock, &config_path).unwrap();\n\n assert!(config_path.exists());\n let saved: Config =\n toml::from_str(&std::fs::read_to_string(&config_path).unwrap()).unwrap();\n assert_eq!(saved.agent.provider.as_deref(), Some(\"openai\"));\n assert_eq!(saved.agent.model, \"gpt-4o\");\n }\n\n #[test]\n fn wizard_full_setup_all_skip() {\n let tmp = tempfile::tempdir().unwrap();\n let config_path = tmp.path().join(\"config.toml\");\n\n let mock = MockWizard::new(vec![\n // Full mode\n MockAction::Select { result: 1 }, // Full Setup\n // Step 1: Provider\n MockAction::Select { result: 0 }, // openai\n MockAction::Input { result: \"gpt-4o\" },\n MockAction::Input {\n result: \"https://api.openai.com/v1\",\n },\n MockAction::Password {\n result: \"sk-test-key\",\n },\n // Step 2: Telegram (skip via \"No\" in confirm_or_back)\n MockAction::Select { result: 1 }, // \"No\" in [No, Yes, ↩]\n // Step 3: Feishu (skip)\n MockAction::Select { result: 1 }, // \"No\"\n // Step 4: WebSocket (skip)\n MockAction::Select { result: 1 }, // \"No\"\n // Step 5: WeChat (skip)\n MockAction::Select { result: 1 }, // \"No\"\n // Review: Save\n MockAction::Select { result: 5 }, // Save\n MockAction::Confirm {\n prompt_contains: \"Write\",\n result: true,\n },\n ]);\n\n run_wizard(&mock, &config_path).unwrap();\n\n assert!(config_path.exists());\n let saved: Config =\n toml::from_str(&std::fs::read_to_string(&config_path).unwrap()).unwrap();\n assert_eq!(saved.agent.provider.as_deref(), Some(\"openai\"));\n }\n\n #[test]\n fn wizard_overwrite_existing_config() {\n let tmp = tempfile::tempdir().unwrap();\n let config_path = tmp.path().join(\"config.toml\");\n\n let existing = Config::default();\n std::fs::create_dir_all(config_path.parent().unwrap()).unwrap();\n loader::save_config(&existing, &config_path).unwrap();\n\n let mock = MockWizard::new(vec![\n // Update existing\n MockAction::Confirm {\n prompt_contains: \"Update\",\n result: true,\n },\n // Full mode (not first run, so mode selection is skipped)\n // Step 1: Provider\n MockAction::Select { result: 1 }, // anthropic (index 1)\n MockAction::Input {\n result: \"claude-sonnet-4-20250514\",\n },\n MockAction::Input {\n result: \"https://api.anthropic.com\",\n },\n MockAction::Password {\n result: \"sk-ant-test\",\n },\n // Step 2-5: Skip all channels\n MockAction::Select { result: 1 }, // Telegram No\n MockAction::Select { result: 1 }, // Feishu No\n MockAction::Select { result: 1 }, // WebSocket No\n MockAction::Select { result: 1 }, // WeChat No\n // Review: Save\n MockAction::Select { result: 5 }, // Save\n MockAction::Confirm {\n prompt_contains: \"Write\",\n result: true,\n },\n ]);\n\n run_wizard(&mock, &config_path).unwrap();\n\n let saved: Config =\n toml::from_str(&std::fs::read_to_string(&config_path).unwrap()).unwrap();\n assert_eq!(saved.agent.provider.as_deref(), Some(\"anthropic\"));\n assert_eq!(saved.agent.model, \"claude-sonnet-4-20250514\");\n }\n\n #[test]\n fn token_masking_short_tokens() {\n assert_eq!(mask_token(\"ab\"), \"(masked)\");\n assert_eq!(mask_token(\"\"), \"(not set)\");\n }\n\n #[test]\n fn token_masking_long_token() {\n let masked = mask_token(\"sk-proj-abcdefghijklmnop-b2cF\");\n assert!(masked.starts_with(\"sk-\"));\n assert!(masked.ends_with(\"b2cF\"));\n assert!(masked.contains(\"…\"));\n }\n\n #[test]\n fn validate_api_key_openai() {\n assert!(validate_api_key(\"openai\", \"sk-test123\").is_ok());\n assert!(validate_api_key(\"openai\", \"bad-key\").is_err());\n }\n\n #[test]\n fn validate_api_key_anthropic() {\n assert!(validate_api_key(\"anthropic\", \"sk-ant-test123\").is_ok());\n assert!(validate_api_key(\"anthropic\", \"bad-key\").is_err());\n }\n\n #[test]\n fn validate_api_key_other_providers() {\n assert!(validate_api_key(\"ollama\", \"any-key\").is_ok());\n }\n\n #[test]\n fn validate_api_key_empty() {\n assert!(validate_api_key(\"openai\", \"\").is_err());\n }\n\n #[test]\n fn validate_url_valid() {\n assert_eq!(\n validate_url(\"https://api.openai.com/v1\").unwrap(),\n \"https://api.openai.com/v1\"\n );\n assert_eq!(\n validate_url(\"http://localhost:11434\").unwrap(),\n \"http://localhost:11434\"\n );\n }\n\n #[test]\n fn validate_url_empty() {\n assert_eq!(validate_url(\"\").unwrap(), \"\");\n }\n\n #[test]\n fn validate_url_invalid() {\n assert!(validate_url(\"not-a-url\").is_err());\n assert!(validate_url(\"ftp://example.com\").is_err());\n }\n\n #[test]\n fn wizard_step_ordering() {\n assert_eq!(WizardStep::first(), WizardStep::Provider);\n assert_eq!(WizardStep::Provider.step_number(), 1);\n assert_eq!(WizardStep::Review.step_number(), 0);\n assert_eq!(WizardStep::Provider.next(), Some(WizardStep::Telegram));\n assert_eq!(WizardStep::Review.next(), None);\n assert_eq!(WizardStep::Provider.prev(), None);\n assert_eq!(WizardStep::Review.prev(), Some(WizardStep::WeChat));\n }\n\n fn tg(token: &str) -> TelegramConfig {\n TelegramConfig {\n token: token.to_string(),\n allowed_users: Vec::new(),\n admin_users: Vec::new(),\n enabled: true,\n streaming: false,\n proxy: None,\n }\n }\n\n #[test]\n fn token_masking_empty_token() {\n let mut config = Config::default();\n config.channels.telegram = Some(tg(\"\"));\n\n let mock = MockWizard::new(vec![]);\n show_config_summary_simple(&mock, &config).unwrap();\n\n let output = mock.output();\n assert!(output.contains(\"(not set)\"));\n }\n\n #[test]\n fn configure_weixin_qr_enables_existing_credentials() {\n let mock = MockWizard::new(vec![MockAction::Select { result: 0 }]);\n let mut config = Config::default();\n config.channels.weixin = Some(WeixinConfig {\n app_id: None,\n app_secret: None,\n token: None,\n encoding_aes_key: None,\n account_id: Some(\"wxid_existing@im.bot\".to_string()),\n bot_token: Some(\"tok_existing\".to_string()),\n base_url: None,\n cdn_base_url: None,\n dm_policy: \"open\".to_string(),\n group_policy: \"disabled\".to_string(),\n allowed_users: vec![],\n group_allowed_users: vec![],\n enabled: false,\n });\n\n configure_weixin(&mock, &mut config).unwrap();\n\n let weixin = config.channels.weixin.unwrap();\n assert!(weixin.enabled);\n assert_eq!(weixin.account_id.as_deref(), Some(\"wxid_existing@im.bot\"));\n assert_eq!(weixin.bot_token.as_deref(), Some(\"tok_existing\"));\n assert!(mock.output().contains(\"detected and enabled\"));\n }\n\n #[test]\n fn configure_weixin_manual_preserves_existing_fields() {\n let mock = MockWizard::new(vec![\n MockAction::Select { result: 1 },\n MockAction::Input {\n result: \"wxid_new@im.bot\",\n },\n MockAction::Input { result: \"tok_new\" },\n ]);\n let mut config = Config::default();\n config.channels.weixin = Some(WeixinConfig {\n app_id: Some(\"app_existing\".to_string()),\n app_secret: Some(\"secret_existing\".to_string()),\n token: Some(\"verify_token\".to_string()),\n encoding_aes_key: Some(\"aes_key\".to_string()),\n account_id: Some(\"wxid_old@im.bot\".to_string()),\n bot_token: Some(\"tok_old\".to_string()),\n base_url: Some(\"https://ilinkai.weixin.qq.com\".to_string()),\n cdn_base_url: Some(\"https://novac2c.cdn.weixin.qq.com/c2c\".to_string()),\n dm_policy: \"allowlist\".to_string(),\n group_policy: \"open\".to_string(),\n allowed_users: vec![\"u1\".to_string()],\n group_allowed_users: vec![\"g1\".to_string()],\n enabled: false,\n });\n\n configure_weixin(&mock, &mut config).unwrap();\n\n let weixin = config.channels.weixin.unwrap();\n assert_eq!(weixin.account_id.as_deref(), Some(\"wxid_new@im.bot\"));\n assert_eq!(weixin.bot_token.as_deref(), Some(\"tok_new\"));\n assert_eq!(weixin.app_id.as_deref(), Some(\"app_existing\"));\n assert_eq!(weixin.app_secret.as_deref(), Some(\"secret_existing\"));\n assert_eq!(weixin.token.as_deref(), Some(\"verify_token\"));\n assert_eq!(weixin.encoding_aes_key.as_deref(), Some(\"aes_key\"));\n assert_eq!(\n weixin.base_url.as_deref(),\n Some(\"https://ilinkai.weixin.qq.com\")\n );\n assert_eq!(\n weixin.cdn_base_url.as_deref(),\n Some(\"https://novac2c.cdn.weixin.qq.com/c2c\")\n );\n assert_eq!(weixin.dm_policy, \"allowlist\");\n assert_eq!(weixin.group_policy, \"open\");\n assert_eq!(weixin.allowed_users, vec![\"u1\"]);\n assert_eq!(weixin.group_allowed_users, vec![\"g1\"]);\n assert!(weixin.enabled);\n }\n}\n"}, "7eaf9f107179553a4944887630448a975524f1ac": {"t": 1785761075.2049246, "n": 41760, "h": "3bd6aa8a0ad022d6", "c": "//! Setup command — interactive wizard for configuring Kestrel.\n\nuse anyhow::{bail, Context, Result};\nuse console::Term;\nuse dialoguer::{Confirm, Input, Password as PasswordInput, Select};\nuse kestrel_config::{\n loader, paths,\n schema::{Config, FeishuConfig, ProviderEntry, TelegramConfig, WebSocketConfig, WeixinConfig},\n};\nuse owo_colors::OwoColorize;\nuse std::net::SocketAddr;\nuse std::path::Path;\n\nconst PROVIDER_NAMES: &[&str] = &[\n \"anthropic\",\n \"openai\",\n \"openrouter\",\n \"ollama\",\n \"deepseek\",\n \"gemini\",\n \"groq\",\n \"moonshot\",\n \"minimax\",\n \"github_copilot\",\n \"openai_codex\",\n \"glm_coding_plan\",\n];\n\nconst TOTAL_STEPS: usize = 6;\n\n// ── Trait for interactive I/O (enables testability) ────────────\n\ntrait WizardIo {\n fn confirm(&self, prompt: &str, default: bool) -> Result;\n fn select(&self, prompt: &str, items: &[&str], default: usize) -> Result;\n fn input_with_default(&self, prompt: &str, default: &str) -> Result;\n fn input_allow_empty(&self, prompt: &str) -> Result;\n fn password(&self, prompt: &str) -> Result;\n fn write_line(&self, line: &str) -> Result<()>;\n}\n\n// ── Production implementation using Term + dialoguer ───────────\n\nstruct TermWizard<'a> {\n term: &'a Term,\n}\n\nimpl<'a> TermWizard<'a> {\n fn new(term: &'a Term) -> Self {\n Self { term }\n }\n}\n\nimpl WizardIo for TermWizard<'_> {\n fn confirm(&self, prompt: &str, default: bool) -> Result {\n Ok(Confirm::new()\n .with_prompt(prompt)\n .default(default)\n .interact_on(self.term)?)\n }\n\n fn select(&self, prompt: &str, items: &[&str], default: usize) -> Result {\n Ok(Select::new()\n .with_prompt(prompt)\n .items(items)\n .default(default)\n .interact_on(self.term)?)\n }\n\n fn input_with_default(&self, prompt: &str, default: &str) -> Result {\n Ok(Input::new()\n .with_prompt(prompt)\n .default(default.to_string())\n .interact_text_on(self.term)?)\n }\n\n fn input_allow_empty(&self, prompt: &str) -> Result {\n Ok(Input::::new()\n .with_prompt(prompt)\n .allow_empty(true)\n .interact_text_on(self.term)?)\n }\n\n fn password(&self, prompt: &str) -> Result {\n Ok(PasswordInput::new()\n .with_prompt(prompt)\n .interact_on(self.term)?)\n }\n\n fn write_line(&self, line: &str) -> Result<()> {\n self.term.write_line(line)?;\n Ok(())\n }\n}\n\n// ── Provider dispatch macro (single source of truth) ───────────\n\n#[allow(unused_macros)]\nmacro_rules! provider_field {\n ($config:expr, $provider:expr, mut) => {\n match $provider {\n \"anthropic\" => Some(&mut $config.providers.anthropic),\n \"openai\" => Some(&mut $config.providers.openai),\n \"openrouter\" => Some(&mut $config.providers.openrouter),\n \"ollama\" => Some(&mut $config.providers.ollama),\n \"deepseek\" => Some(&mut $config.providers.deepseek),\n \"gemini\" => Some(&mut $config.providers.gemini),\n \"groq\" => Some(&mut $config.providers.groq),\n \"moonshot\" => Some(&mut $config.providers.moonshot),\n \"minimax\" => Some(&mut $config.providers.minimax),\n \"github_copilot\" => Some(&mut $config.providers.github_copilot),\n \"openai_codex\" => Some(&mut $config.providers.openai_codex),\n _ => None,\n }\n };\n ($config:expr, $provider:expr) => {\n match $provider {\n \"anthropic\" => Some(&$config.providers.anthropic),\n \"openai\" => Some(&$config.providers.openai),\n \"openrouter\" => Some(&$config.providers.openrouter),\n \"ollama\" => Some(&$config.providers.ollama),\n \"deepseek\" => Some(&$config.providers.deepseek),\n \"gemini\" => Some(&$config.providers.gemini),\n \"groq\" => Some(&$config.providers.groq),\n \"moonshot\" => Some(&$config.providers.moonshot),\n \"minimax\" => Some(&$config.providers.minimax),\n \"github_copilot\" => Some(&$config.providers.github_copilot),\n \"openai_codex\" => Some(&$config.providers.openai_codex),\n _ => None,\n }\n };\n}\n\n// ── Entry point ────────────────────────────────────────────────\n\npub fn run(_config: Config) -> Result<()> {\n let term = Term::stdout();\n if !term.is_term() {\n bail!(\n \"Setup requires an interactive terminal. \\\n Run this command in a terminal, not in a pipe or CI environment.\"\n );\n }\n let io = TermWizard::new(&term);\n let config_path = paths::get_config_path()?;\n run_wizard(&io, &config_path)\n}\n\n// ── Wizard flow ────────────────────────────────────────────────\n\nfn run_wizard(io: &dyn WizardIo, config_path: &Path) -> Result<()> {\n print_banner(io)?;\n\n // ── Step 1: Check existing config ────────────────────────────\n print_step(io, 1, \"Existing Configuration\")?;\n\n let mut config = if config_path.exists() {\n match load_existing_config(config_path) {\n Ok(existing) => {\n show_config_summary(io, &existing)?;\n if io.confirm(\"Update existing config?\", true)? {\n existing\n } else {\n io.write_line(&format!(\n \" {} Keeping config at {}.\",\n \"✓\".green(),\n config_path.display()\n ))?;\n return Ok(());\n }\n }\n Err(e) => {\n io.write_line(&format!(\n \" {} Could not parse existing config: {}\",\n \"!\".yellow(),\n e\n ))?;\n if io.confirm(\"Start fresh with defaults?\", true)? {\n Config::default()\n } else {\n bail!(\"Setup cancelled.\");\n }\n }\n }\n } else {\n io.write_line(\" No config file found. Starting fresh.\")?;\n Config::default()\n };\n\n // ── Step 2: Provider configuration ───────────────────────────\n print_step(io, 2, \"Provider Configuration\")?;\n configure_provider(io, &mut config)?;\n\n // ── Step 3: Telegram channel ─────────────────────────────────\n print_step(io, 3, \"Telegram Channel\")?;\n configure_telegram(io, &mut config)?;\n\n // ── Step 4: Feishu / Lark channel ────────────────────────────\n print_step(io, 4, \"Feishu / Lark Channel\")?;\n configure_feishu(io, &mut config)?;\n\n // ── Step 5: WebSocket port ───────────────────────────────────\n print_step(io, 5, \"WebSocket Port\")?;\n configure_websocket(io, &mut config)?;\n\n // ── Step 5: Weixin channel ───────────────────────────────────\n print_step(io, 5, \"WeChat Channel\")?;\n configure_weixin(io, &mut config)?;\n\n // ── Step 6: Validate & write ─────────────────────────────────\n print_step(io, 6, \"Save Configuration\")?;\n\n io.write_line(&format!(\" Config path: {}\", config_path.display()))?;\n io.write_line(\"\")?;\n show_config_summary(io, &config)?;\n io.write_line(\"\")?;\n\n if !io.confirm(\"Write this configuration?\", true)? {\n io.write_line(&format!(\" {} Setup cancelled.\", \"!\".yellow()))?;\n return Ok(());\n }\n\n let home = config_path\n .parent()\n .context(\"Config path must have a parent directory\")?;\n\n std::fs::create_dir_all(home)\n .with_context(|| format!(\"Failed to create config home: {}\", home.display()))?;\n\n loader::save_config(&config, config_path)?;\n\n for dir in [\"skills\", \"sessions\", \"learning\"] {\n let path = home.join(dir);\n std::fs::create_dir_all(&path)\n .with_context(|| format!(\"Failed to create directory: {}\", path.display()))?;\n }\n\n io.write_line(\"\")?;\n io.write_line(&format!(\n \" {} Configuration saved to {}\",\n \"✓\".green(),\n config_path.display()\n ))?;\n io.write_line(&format!(\n \" {} Created directories: skills, sessions, learning\",\n \"✓\".green()\n ))?;\n io.write_line(&format!(\" {} Setup complete!\", \"✓\".green()))?;\n\n Ok(())\n}\n\nfn print_banner(io: &dyn WizardIo) -> Result<()> {\n io.write_line(\"\")?;\n io.write_line(&format!(\n \" {} {}\",\n \"▸\".cyan(),\n \"Kestrel Setup Wizard\".bold().cyan()\n ))?;\n io.write_line(\"\")?;\n Ok(())\n}\n\nfn print_step(io: &dyn WizardIo, step: usize, title: &str) -> Result<()> {\n io.write_line(\"\")?;\n io.write_line(&format!(\n \" {} Step {}/{}: {}\",\n \"▸\".cyan(),\n step,\n TOTAL_STEPS,\n title.bold()\n ))?;\n io.write_line(&format!(\" {}\", \"─\".repeat(40).dimmed()))?;\n Ok(())\n}\n\nfn load_existing_config(path: &Path) -> Result {\n let content = std::fs::read_to_string(path)\n .with_context(|| format!(\"Failed to read {}\", path.display()))?;\n let config: Config =\n toml::from_str(&content).with_context(|| format!(\"Failed to parse {}\", path.display()))?;\n Ok(config)\n}\n\nfn mask_token(token: &str) -> String {\n if token.is_empty() {\n \"(not set)\".to_string()\n } else if token.len() <= 3 {\n \"(masked)\".to_string()\n } else {\n format!(\"{}…(masked)\", &token[..3])\n }\n}\n\nfn show_config_summary(io: &dyn WizardIo, config: &Config) -> Result<()> {\n let provider = config.agent.provider.as_deref().unwrap_or(\"default\");\n io.write_line(&format!(\" Model: {}\", config.agent.model))?;\n io.write_line(&format!(\" Provider: {}\", provider))?;\n io.write_line(&format!(\" Temperature: {}\", config.agent.temperature))?;\n io.write_line(&format!(\" Max tokens: {}\", config.agent.max_tokens))?;\n io.write_line(&format!(\" Streaming: {}\", config.agent.streaming))?;\n\n if let Some(ref tg) = config.channels.telegram {\n io.write_line(&format!(\" Telegram: {}\", mask_token(&tg.token)))?;\n }\n\n if let Some(ref fs) = config.channels.feishu {\n if fs.enabled {\n let app_id = fs.app_id.as_deref().unwrap_or(\"(not set)\");\n io.write_line(&format!(\" Feishu: {}\", mask_token(app_id)))?;\n }\n }\n\n if let Some(ref ws) = config.channels.websocket {\n if ws.enabled {\n io.write_line(&format!(\" WebSocket: {}\", ws.listen_addr))?;\n }\n }\n\n if let Some(ref wx) = config.channels.weixin {\n let status = if wx.enabled {\n mask_token(wx.bot_token.as_deref().unwrap_or(\"\"))\n } else {\n \"disabled\".to_string()\n };\n io.write_line(&format!(\" WeChat: {}\", status))?;\n }\n\n Ok(())\n}\n\nfn configure_provider(io: &dyn WizardIo, config: &mut Config) -> Result<()> {\n let default_idx = config\n .agent\n .provider\n .as_deref()\n .and_then(|p| PROVIDER_NAMES.iter().position(|&n| n == p))\n .unwrap_or(1); // default to \"openai\"\n\n let provider_name = io.select(\"Select LLM provider\", PROVIDER_NAMES, default_idx)?;\n\n let provider_key = PROVIDER_NAMES[provider_name];\n config.agent.provider = Some(provider_key.to_string());\n\n let default_model = match provider_key {\n \"anthropic\" => \"claude-sonnet-4-20250514\",\n \"openai\" => \"gpt-4o\",\n \"openrouter\" => \"anthropic/claude-sonnet-4-20250514\",\n \"ollama\" => \"llama3\",\n \"deepseek\" => \"deepseek-chat\",\n \"gemini\" => \"gemini-2.5-pro\",\n \"groq\" => \"llama-3.3-70b-versatile\",\n \"moonshot\" => \"moonshot-v1-8k\",\n \"minimax\" => \"MiniMax-Text-01\",\n \"github_copilot\" => \"gpt-4o\",\n \"openai_codex\" => \"codex-mini\",\n \"glm_coding_plan\" => \"glm-5.1\",\n _ => \"gpt-4o\",\n };\n\n let current_model = if config.agent.model.is_empty() {\n default_model\n } else {\n &config.agent.model\n };\n\n let model: String = io.input_with_default(\"Model name\", current_model)?;\n config.agent.model = model;\n\n let default_url = match provider_key {\n \"anthropic\" => \"https://api.anthropic.com\",\n \"openai\" => \"https://api.openai.com/v1\",\n \"openrouter\" => \"https://openrouter.ai/api/v1\",\n \"ollama\" => \"http://localhost:11434\",\n \"deepseek\" => \"https://api.deepseek.com\",\n \"gemini\" => \"https://generativelanguage.googleapis.com/v1beta\",\n \"groq\" => \"https://api.groq.com/openai/v1\",\n \"moonshot\" => \"https://api.moonshot.cn/v1\",\n \"minimax\" => \"https://api.minimax.chat/v1\",\n \"github_copilot\" => \"https://api.githubcopilot.com\",\n \"openai_codex\" => \"https://api.openai.com/v1\",\n \"glm_coding_plan\" => \"https://open.bigmodel.cn/api/coding/paas/v4\",\n _ => \"\",\n };\n\n let current_url = get_provider_url(config, provider_key).unwrap_or(default_url);\n\n if !current_url.is_empty() {\n let base_url: String = io.input_with_default(\"Base URL\", current_url)?;\n set_provider_url(config, provider_key, &base_url);\n } else {\n let base_url: String = io.input_allow_empty(\"Base URL (leave empty for default)\")?;\n if !base_url.is_empty() {\n set_provider_url(config, provider_key, &base_url);\n }\n }\n\n let api_key: String = io.password(\"API key\")?;\n if !api_key.is_empty() {\n set_provider_api_key(config, provider_key, &api_key);\n }\n\n Ok(())\n}\n\nfn configure_telegram(io: &dyn WizardIo, config: &mut Config) -> Result<()> {\n let setup_tg = if config.channels.telegram.is_some() {\n io.confirm(\"Configure Telegram bot?\", true)?\n } else {\n io.confirm(\"Set up a Telegram bot?\", false)?\n };\n\n if !setup_tg {\n io.write_line(\" Skipped.\")?;\n return Ok(());\n }\n\n let current_token = config\n .channels\n .telegram\n .as_ref()\n .map(|tg| tg.token.as_str())\n .unwrap_or(\"\");\n\n let token: String = if current_token.is_empty() {\n io.input_allow_empty(\"Bot token (from @BotFather)\")?\n } else {\n io.input_with_default(\"Bot token (from @BotFather)\", current_token)?\n };\n\n if token.is_empty() {\n io.write_line(\" No token provided, skipping Telegram.\")?;\n return Ok(());\n }\n\n let allowed: String = loop {\n let input: String =\n io.input_allow_empty(\"Allowed user IDs (comma-separated, leave empty for all)\")?;\n if input.trim().is_empty() {\n break String::new();\n }\n let ids: Vec<&str> = input\n .split(',')\n .map(|s| s.trim())\n .filter(|s| !s.is_empty())\n .collect();\n let mut valid = true;\n for id in &ids {\n if id.parse::().is_err() {\n io.write_line(&format!(\n \" {} '{}' is not a valid user ID (must be a positive integer).\",\n \"!\".yellow(),\n id\n ))?;\n valid = false;\n break;\n }\n }\n if valid {\n break input;\n }\n };\n\n let allowed_users: Vec = if allowed.trim().is_empty() {\n Vec::new()\n } else {\n allowed\n .split(',')\n .map(|s| s.trim().to_string())\n .filter(|s| !s.is_empty())\n .collect()\n };\n\n // Preserve existing values for fields not explicitly configured in the wizard.\n let (admin_users, enabled, streaming, proxy) = config\n .channels\n .telegram\n .as_ref()\n .map(|tg| {\n (\n tg.admin_users.clone(),\n tg.enabled,\n tg.streaming,\n tg.proxy.clone(),\n )\n })\n .unwrap_or((Vec::new(), true, true, None));\n\n config.channels.telegram = Some(TelegramConfig {\n token,\n allowed_users,\n admin_users,\n enabled,\n streaming,\n proxy,\n });\n\n Ok(())\n}\n\nfn configure_feishu(io: &dyn WizardIo, config: &mut Config) -> Result<()> {\n let has_existing = config.channels.feishu.as_ref().is_some_and(|f| f.enabled);\n\n let setup = if has_existing {\n io.confirm(\"Reconfigure Feishu / Lark?\", true)?\n } else {\n io.confirm(\"Set up Feishu / Lark?\", false)?\n };\n\n if !setup {\n io.write_line(\" Skipped.\")?;\n return Ok(());\n }\n\n let domain_options = [\"Feishu (飞书)\", \"Lark (international)\"];\n let idx = io.select(\"Select platform\", &domain_options, 0)?;\n let domain = if idx == 1 { \"lark\" } else { \"feishu\" };\n\n let rt = tokio::runtime::Builder::new_current_thread()\n .enable_all()\n .build()\n .context(\"Failed to create tokio runtime\")?;\n\n // TODO: Creating a nested tokio runtime here means this cannot be called\n // from within an existing async context (e.g. tests). A future refactor\n // could make `run_wizard` async or accept a runtime handle.\n let result = match rt.block_on(super::feishu_onboarding::run_onboarding(domain)) {\n Ok(r) => r,\n Err(e) => {\n io.write_line(&format!(\" {} Feishu setup skipped: {}\", \"!\".yellow(), e))?;\n return Ok(());\n }\n };\n\n let existing_proxy = config\n .channels\n .feishu\n .as_ref()\n .and_then(|f| f.proxy.clone());\n\n config.channels.feishu = Some(FeishuConfig {\n app_id: Some(result.app_id),\n app_secret: Some(result.app_secret),\n enabled: true,\n proxy: existing_proxy,\n ..Default::default()\n });\n\n Ok(())\n}\n\nfn configure_websocket(io: &dyn WizardIo, config: &mut Config) -> Result<()> {\n let default_addr = config\n .channels\n .websocket\n .as_ref()\n .map(|ws| ws.listen_addr.as_str())\n .unwrap_or(\"127.0.0.1:8090\");\n\n let enable = io.confirm(\"Enable WebSocket channel?\", false)?;\n\n if !enable {\n config.channels.websocket = None;\n io.write_line(\" Skipped.\")?;\n return Ok(());\n }\n\n let listen_addr: String = loop {\n let input = io.input_with_default(\"Listen address\", default_addr)?;\n match input.parse::() {\n Ok(_) => break input,\n Err(e) => {\n io.write_line(&format!(\n \" {} Invalid address '{}': {}\",\n \"!\".yellow(),\n input,\n e\n ))?;\n }\n }\n };\n\n config.channels.websocket = Some(WebSocketConfig {\n enabled: true,\n listen_addr,\n auth: Default::default(),\n max_clients: 100,\n max_message_size: 1048576,\n });\n\n Ok(())\n}\n\nfn configure_weixin(io: &dyn WizardIo, config: &mut Config) -> Result<()> {\n let setup_wx = if config.channels.weixin.is_some() {\n io.confirm(\"Configure WeChat channel?\", true)?\n } else {\n io.confirm(\"Set up a WeChat channel?\", false)?\n };\n\n if !setup_wx {\n io.write_line(\" Skipped.\")?;\n return Ok(());\n }\n\n let choices = &[\n \"Scan QR code with WeChat (recommended)\",\n \"Enter credentials manually\",\n \"Skip\",\n ];\n let choice = io.select(\"How would you like to configure WeChat?\", choices, 0)?;\n\n match choice {\n 0 => {\n io.write_line(\n \" QR scan setup requires running `kestrel setup weixin` in a terminal.\",\n )?;\n io.write_line(\" Please run that command separately, then return to this wizard.\")?;\n // Re-enable the channel when QR login credentials already exist.\n if let Some(ref mut wx) = config.channels.weixin {\n if wx.account_id.is_some() && wx.bot_token.is_some() {\n wx.enabled = true;\n io.write_line(&format!(\n \" {} Existing WeChat credentials detected and enabled.\",\n \"✓\".green()\n ))?;\n }\n }\n // If no credentials yet, just leave channel unconfigured\n if config.channels.weixin.is_none() {\n io.write_line(\n \" No WeChat credentials found yet. Run `kestrel setup weixin` first.\",\n )?;\n }\n }\n 1 => {\n let current_account = config\n .channels\n .weixin\n .as_ref()\n .and_then(|w| w.account_id.as_deref())\n .unwrap_or(\"\");\n let account_id: String = if current_account.is_empty() {\n io.input_allow_empty(\"iLink account ID (e.g. wxid_xxx@im.bot)\")?\n } else {\n io.input_with_default(\"iLink account ID\", current_account)?\n };\n\n if account_id.trim().is_empty() {\n io.write_line(\" No account ID provided, skipping WeChat.\")?;\n return Ok(());\n }\n\n let current_token = config\n .channels\n .weixin\n .as_ref()\n .and_then(|w| w.bot_token.as_deref())\n .unwrap_or(\"\");\n let bot_token: String = if current_token.is_empty() {\n io.input_allow_empty(\"iLink bot token\")?\n } else {\n io.input_with_default(\"iLink bot token\", current_token)?\n };\n\n if bot_token.trim().is_empty() {\n io.write_line(\" No bot token provided, skipping WeChat.\")?;\n return Ok(());\n }\n\n // Preserve existing fields\n let (\n app_id,\n app_secret,\n old_token,\n encoding_aes_key,\n base_url,\n cdn_base_url,\n dm_policy,\n group_policy,\n allowed_users,\n group_allowed_users,\n ) = config\n .channels\n .weixin\n .as_ref()\n .map(|w| {\n (\n w.app_id.clone(),\n w.app_secret.clone(),\n w.token.clone(),\n w.encoding_aes_key.clone(),\n w.base_url.clone(),\n w.cdn_base_url.clone(),\n w.dm_policy.clone(),\n w.group_policy.clone(),\n w.allowed_users.clone(),\n w.group_allowed_users.clone(),\n )\n })\n .unwrap_or_default();\n\n config.channels.weixin = Some(WeixinConfig {\n account_id: Some(account_id.trim().to_string()),\n bot_token: Some(bot_token.trim().to_string()),\n app_id,\n app_secret,\n token: old_token,\n encoding_aes_key,\n base_url,\n cdn_base_url,\n dm_policy,\n group_policy,\n allowed_users,\n group_allowed_users,\n enabled: true,\n });\n\n io.write_line(&format!(\" {} WeChat credentials saved.\", \"✓\".green()))?;\n }\n _ => {\n io.write_line(\" Skipped.\")?;\n }\n }\n\n Ok(())\n}\n\n// ── Provider field helpers (using macro) ───────────────────────\n\nfn get_provider_entry_mut<'a>(\n config: &'a mut Config,\n provider: &str,\n) -> Option<&'a mut ProviderEntry> {\n match provider {\n \"anthropic\" => config.providers.anthropic.as_mut(),\n \"openai\" => config.providers.openai.as_mut(),\n \"openrouter\" => config.providers.openrouter.as_mut(),\n \"ollama\" => config.providers.ollama.as_mut(),\n \"deepseek\" => config.providers.deepseek.as_mut(),\n \"gemini\" => config.providers.gemini.as_mut(),\n \"groq\" => config.providers.groq.as_mut(),\n \"moonshot\" => config.providers.moonshot.as_mut(),\n \"minimax\" => config.providers.minimax.as_mut(),\n \"github_copilot\" => config.providers.github_copilot.as_mut(),\n \"openai_codex\" => config.providers.openai_codex.as_mut(),\n \"opencode_go\" => config.providers.opencode_go.as_mut(),\n \"glm_coding_plan\" => config.providers.glm_coding_plan.as_mut(),\n _ => None,\n }\n}\n\nfn ensure_provider_entry(config: &mut Config, provider: &str) {\n match provider {\n \"anthropic\" => {\n config\n .providers\n .anthropic\n .get_or_insert_with(ProviderEntry::default);\n }\n \"openai\" => {\n config\n .providers\n .openai\n .get_or_insert_with(ProviderEntry::default);\n }\n \"openrouter\" => {\n config\n .providers\n .openrouter\n .get_or_insert_with(ProviderEntry::default);\n }\n \"ollama\" => {\n config\n .providers\n .ollama\n .get_or_insert_with(ProviderEntry::default);\n }\n \"deepseek\" => {\n config\n .providers\n .deepseek\n .get_or_insert_with(ProviderEntry::default);\n }\n \"gemini\" => {\n config\n .providers\n .gemini\n .get_or_insert_with(ProviderEntry::default);\n }\n \"groq\" => {\n config\n .providers\n .groq\n .get_or_insert_with(ProviderEntry::default);\n }\n \"moonshot\" => {\n config\n .providers\n .moonshot\n .get_or_insert_with(ProviderEntry::default);\n }\n \"minimax\" => {\n config\n .providers\n .minimax\n .get_or_insert_with(ProviderEntry::default);\n }\n \"github_copilot\" => {\n config\n .providers\n .github_copilot\n .get_or_insert_with(ProviderEntry::default);\n }\n \"openai_codex\" => {\n config\n .providers\n .openai_codex\n .get_or_insert_with(ProviderEntry::default);\n }\n \"opencode_go\" => {\n config\n .providers\n .opencode_go\n .get_or_insert_with(ProviderEntry::default);\n }\n \"glm_coding_plan\" => {\n config\n .providers\n .glm_coding_plan\n .get_or_insert_with(ProviderEntry::default);\n }\n _ => {}\n }\n}\n\nfn get_provider_url<'a>(config: &'a Config, provider: &str) -> Option<&'a str> {\n let entry = match provider {\n \"anthropic\" => config.providers.anthropic.as_ref(),\n \"openai\" => config.providers.openai.as_ref(),\n \"openrouter\" => config.providers.openrouter.as_ref(),\n \"ollama\" => config.providers.ollama.as_ref(),\n \"deepseek\" => config.providers.deepseek.as_ref(),\n \"gemini\" => config.providers.gemini.as_ref(),\n \"groq\" => config.providers.groq.as_ref(),\n \"moonshot\" => config.providers.moonshot.as_ref(),\n \"minimax\" => config.providers.minimax.as_ref(),\n \"github_copilot\" => config.providers.github_copilot.as_ref(),\n \"openai_codex\" => config.providers.openai_codex.as_ref(),\n \"opencode_go\" => config.providers.opencode_go.as_ref(),\n \"glm_coding_plan\" => config.providers.glm_coding_plan.as_ref(),\n _ => None,\n };\n entry.and_then(|e| e.base_url.as_deref())\n}\n\nfn set_provider_url(config: &mut Config, provider: &str, url: &str) {\n ensure_provider_entry(config, provider);\n if let Some(entry) = get_provider_entry_mut(config, provider) {\n entry.base_url = if url.is_empty() {\n None\n } else {\n Some(url.to_string())\n };\n }\n}\n\nfn set_provider_api_key(config: &mut Config, provider: &str, key: &str) {\n ensure_provider_entry(config, provider);\n if let Some(entry) = get_provider_entry_mut(config, provider) {\n entry.api_key = if key.is_empty() {\n None\n } else {\n Some(key.to_string())\n };\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use std::cell::RefCell;\n use std::collections::VecDeque;\n\n fn template_toml() -> String {\n toml::to_string(&Config::default()).unwrap()\n }\n\n // ── Mock wizard IO for flow testing ─────────────────────────\n\n #[derive(Debug, Clone)]\n enum MockAction {\n Confirm {\n prompt_contains: &'static str,\n result: bool,\n },\n Select {\n result: usize,\n },\n Input {\n result: &'static str,\n },\n Password {\n result: &'static str,\n },\n }\n\n struct MockWizard {\n actions: RefCell>,\n output: RefCell,\n }\n\n impl MockWizard {\n fn new(actions: Vec) -> Self {\n Self {\n actions: RefCell::new(actions.into()),\n output: RefCell::new(String::new()),\n }\n }\n\n fn output(&self) -> String {\n self.output.borrow().clone()\n }\n }\n\n impl WizardIo for MockWizard {\n fn confirm(&self, prompt: &str, _default: bool) -> Result {\n let action = self.actions.borrow_mut().pop_front();\n match action {\n Some(MockAction::Confirm {\n prompt_contains,\n result,\n }) => {\n assert!(\n prompt.contains(prompt_contains),\n \"confirm prompt '{}' did not contain '{}'\",\n prompt,\n prompt_contains\n );\n Ok(result)\n }\n _ => panic!(\"unexpected confirm call, prompt: {}\", prompt),\n }\n }\n\n fn select(&self, _prompt: &str, _items: &[&str], _default: usize) -> Result {\n let action = self.actions.borrow_mut().pop_front();\n match action {\n Some(MockAction::Select { result }) => Ok(result),\n _ => panic!(\"unexpected select call\"),\n }\n }\n\n fn input_with_default(&self, _prompt: &str, _default: &str) -> Result {\n let action = self.actions.borrow_mut().pop_front();\n match action {\n Some(MockAction::Input { result }) => Ok(result.to_string()),\n _ => panic!(\"unexpected input_with_default call\"),\n }\n }\n\n fn input_allow_empty(&self, _prompt: &str) -> Result {\n let action = self.actions.borrow_mut().pop_front();\n match action {\n Some(MockAction::Input { result }) => Ok(result.to_string()),\n _ => panic!(\"unexpected input_allow_empty call\"),\n }\n }\n\n fn password(&self, _prompt: &str) -> Result {\n let action = self.actions.borrow_mut().pop_front();\n match action {\n Some(MockAction::Password { result }) => Ok(result.to_string()),\n _ => panic!(\"unexpected password call\"),\n }\n }\n\n fn write_line(&self, line: &str) -> Result<()> {\n self.output.borrow_mut().push_str(line);\n self.output.borrow_mut().push('\\n');\n Ok(())\n }\n }\n\n // ── Unit tests ──────────────────────────────────────────────\n\n #[test]\n fn setup_creates_template_config_when_config_is_missing() {\n let tmp = tempfile::tempdir().unwrap();\n let config_path = tmp.path().join(\"config.toml\");\n\n let config = Config::default();\n let home = config_path.parent().unwrap();\n std::fs::create_dir_all(home).unwrap();\n loader::save_config(&config, &config_path).unwrap();\n\n assert_eq!(\n std::fs::read_to_string(&config_path).unwrap(),\n template_toml()\n );\n assert!(config_path.exists());\n }\n\n #[test]\n fn provider_helpers_set_and_get_fields() {\n let mut config = Config::default();\n set_provider_url(&mut config, \"openai\", \"https://custom.api/v1\");\n set_provider_api_key(&mut config, \"openai\", \"sk-test-key\");\n\n let entry = config.providers.openai.as_ref().unwrap();\n assert_eq!(entry.base_url.as_deref(), Some(\"https://custom.api/v1\"));\n assert_eq!(entry.api_key.as_deref(), Some(\"sk-test-key\"));\n }\n\n #[test]\n fn provider_helpers_handle_unknown_provider() {\n let mut config = Config::default();\n set_provider_url(&mut config, \"nonexistent\", \"https://example.com\");\n assert!(get_provider_url(&config, \"nonexistent\").is_none());\n }\n\n #[test]\n fn wizard_keeps_existing_config() {\n let tmp = tempfile::tempdir().unwrap();\n let config_path = tmp.path().join(\"config.toml\");\n\n let existing = Config::default();\n std::fs::create_dir_all(config_path.parent().unwrap()).unwrap();\n loader::save_config(&existing, &config_path).unwrap();\n\n let mock = MockWizard::new(vec![MockAction::Confirm {\n prompt_contains: \"Update\",\n result: false,\n }]);\n\n run_wizard(&mock, &config_path).unwrap();\n\n // Config should be unchanged\n assert_eq!(\n std::fs::read_to_string(&config_path).unwrap(),\n template_toml()\n );\n }\n\n #[test]\n fn wizard_fresh_setup() {\n let tmp = tempfile::tempdir().unwrap();\n let config_path = tmp.path().join(\"config.toml\");\n\n let mock = MockWizard::new(vec![\n // Step 2: Provider\n MockAction::Select { result: 1 }, // openai\n MockAction::Input { result: \"gpt-4o\" },\n MockAction::Input {\n result: \"https://api.openai.com/v1\",\n },\n MockAction::Password {\n result: \"sk-test-key\",\n },\n // Step 3: Telegram (skip)\n MockAction::Confirm {\n prompt_contains: \"Telegram\",\n result: false,\n },\n // Step 4: Feishu (skip)\n MockAction::Confirm {\n prompt_contains: \"Feishu\",\n result: false,\n },\n // Step 5: WebSocket (skip)\n MockAction::Confirm {\n prompt_contains: \"WebSocket\",\n result: false,\n },\n // Step 5: WeChat (skip)\n MockAction::Confirm {\n prompt_contains: \"WeChat\",\n result: false,\n },\n // Step 6: Save\n MockAction::Confirm {\n prompt_contains: \"Write\",\n result: true,\n },\n ]);\n\n run_wizard(&mock, &config_path).unwrap();\n\n assert!(config_path.exists());\n let saved: Config =\n toml::from_str(&std::fs::read_to_string(&config_path).unwrap()).unwrap();\n assert_eq!(saved.agent.provider.as_deref(), Some(\"openai\"));\n assert_eq!(saved.agent.model, \"gpt-4o\");\n }\n\n #[test]\n fn wizard_overwrite_existing_config() {\n let tmp = tempfile::tempdir().unwrap();\n let config_path = tmp.path().join(\"config.toml\");\n\n let existing = Config::default();\n std::fs::create_dir_all(config_path.parent().unwrap()).unwrap();\n loader::save_config(&existing, &config_path).unwrap();\n\n let mock = MockWizard::new(vec![\n // Step 1: Overwrite\n MockAction::Confirm {\n prompt_contains: \"Update\",\n result: true,\n },\n // Step 2: Provider\n MockAction::Select { result: 0 }, // anthropic\n MockAction::Input {\n result: \"claude-sonnet-4-20250514\",\n },\n MockAction::Input {\n result: \"https://api.anthropic.com\",\n },\n MockAction::Password {\n result: \"sk-ant-test\",\n },\n // Step 3: Telegram (skip)\n MockAction::Confirm {\n prompt_contains: \"Telegram\",\n result: false,\n },\n // Step 4: Feishu (skip)\n MockAction::Confirm {\n prompt_contains: \"Feishu\",\n result: false,\n },\n // Step 5: WebSocket (skip)\n MockAction::Confirm {\n prompt_contains: \"WebSocket\",\n result: false,\n },\n // Step 5: WeChat (skip)\n MockAction::Confirm {\n prompt_contains: \"WeChat\",\n result: false,\n },\n // Step 6: Save\n MockAction::Confirm {\n prompt_contains: \"Write\",\n result: true,\n },\n ]);\n\n run_wizard(&mock, &config_path).unwrap();\n\n let saved: Config =\n toml::from_str(&std::fs::read_to_string(&config_path).unwrap()).unwrap();\n assert_eq!(saved.agent.provider.as_deref(), Some(\"anthropic\"));\n assert_eq!(saved.agent.model, \"claude-sonnet-4-20250514\");\n }\n\n fn tg(token: &str) -> TelegramConfig {\n TelegramConfig {\n token: token.to_string(),\n allowed_users: Vec::new(),\n admin_users: Vec::new(),\n enabled: true,\n streaming: false,\n proxy: None,\n }\n }\n\n #[test]\n fn token_masking_short_tokens() {\n let mut config = Config::default();\n config.channels.telegram = Some(tg(\"ab\"));\n\n let mock = MockWizard::new(vec![]);\n show_config_summary(&mock, &config).unwrap();\n\n let output = mock.output();\n assert!(output.contains(\"(masked)\"));\n assert!(!output.contains(\"ab…\"));\n }\n\n #[test]\n fn token_masking_empty_token() {\n let mut config = Config::default();\n config.channels.telegram = Some(tg(\"\"));\n\n let mock = MockWizard::new(vec![]);\n show_config_summary(&mock, &config).unwrap();\n\n let output = mock.output();\n assert!(output.contains(\"(not set)\"));\n }\n\n #[test]\n fn configure_weixin_qr_enables_existing_credentials() {\n let mock = MockWizard::new(vec![\n MockAction::Confirm {\n prompt_contains: \"WeChat\",\n result: true,\n },\n MockAction::Select { result: 0 },\n ]);\n let mut config = Config::default();\n config.channels.weixin = Some(WeixinConfig {\n app_id: None,\n app_secret: None,\n token: None,\n encoding_aes_key: None,\n account_id: Some(\"wxid_existing@im.bot\".to_string()),\n bot_token: Some(\"tok_existing\".to_string()),\n base_url: None,\n cdn_base_url: None,\n dm_policy: \"open\".to_string(),\n group_policy: \"disabled\".to_string(),\n allowed_users: vec![],\n group_allowed_users: vec![],\n enabled: false,\n });\n\n configure_weixin(&mock, &mut config).unwrap();\n\n let weixin = config.channels.weixin.unwrap();\n assert!(weixin.enabled);\n assert_eq!(weixin.account_id.as_deref(), Some(\"wxid_existing@im.bot\"));\n assert_eq!(weixin.bot_token.as_deref(), Some(\"tok_existing\"));\n assert!(mock.output().contains(\"detected and enabled\"));\n }\n\n #[test]\n fn configure_weixin_manual_preserves_existing_fields() {\n let mock = MockWizard::new(vec![\n MockAction::Confirm {\n prompt_contains: \"WeChat\",\n result: true,\n },\n MockAction::Select { result: 1 },\n MockAction::Input {\n result: \"wxid_new@im.bot\",\n },\n MockAction::Input { result: \"tok_new\" },\n ]);\n let mut config = Config::default();\n config.channels.weixin = Some(WeixinConfig {\n app_id: Some(\"app_existing\".to_string()),\n app_secret: Some(\"secret_existing\".to_string()),\n token: Some(\"verify_token\".to_string()),\n encoding_aes_key: Some(\"aes_key\".to_string()),\n account_id: Some(\"wxid_old@im.bot\".to_string()),\n bot_token: Some(\"tok_old\".to_string()),\n base_url: Some(\"https://ilinkai.weixin.qq.com\".to_string()),\n cdn_base_url: Some(\"https://novac2c.cdn.weixin.qq.com/c2c\".to_string()),\n dm_policy: \"allowlist\".to_string(),\n group_policy: \"open\".to_string(),\n allowed_users: vec![\"u1\".to_string()],\n group_allowed_users: vec![\"g1\".to_string()],\n enabled: false,\n });\n\n configure_weixin(&mock, &mut config).unwrap();\n\n let weixin = config.channels.weixin.unwrap();\n assert_eq!(weixin.account_id.as_deref(), Some(\"wxid_new@im.bot\"));\n assert_eq!(weixin.bot_token.as_deref(), Some(\"tok_new\"));\n assert_eq!(weixin.app_id.as_deref(), Some(\"app_existing\"));\n assert_eq!(weixin.app_secret.as_deref(), Some(\"secret_existing\"));\n assert_eq!(weixin.token.as_deref(), Some(\"verify_token\"));\n assert_eq!(weixin.encoding_aes_key.as_deref(), Some(\"aes_key\"));\n assert_eq!(\n weixin.base_url.as_deref(),\n Some(\"https://ilinkai.weixin.qq.com\")\n );\n assert_eq!(\n weixin.cdn_base_url.as_deref(),\n Some(\"https://novac2c.cdn.weixin.qq.com/c2c\")\n );\n assert_eq!(weixin.dm_policy, \"allowlist\");\n assert_eq!(weixin.group_policy, \"open\");\n assert_eq!(weixin.allowed_users, vec![\"u1\"]);\n assert_eq!(weixin.group_allowed_users, vec![\"g1\"]);\n assert!(weixin.enabled);\n }\n}\n"}, "0e16f6ddbdcf0b143ea931cc52e50606aca93c1d": {"t": 1785761076.727712, "n": 41731, "h": "c627267589c72622", "c": "//! Setup command — interactive wizard for configuring Kestrel.\n\nuse anyhow::{bail, Context, Result};\nuse console::Term;\nuse dialoguer::{Confirm, Input, Password as PasswordInput, Select};\nuse kestrel_config::{\n loader, paths,\n schema::{Config, FeishuConfig, ProviderEntry, TelegramConfig, WebSocketConfig, WeixinConfig},\n};\nuse owo_colors::OwoColorize;\nuse std::net::SocketAddr;\nuse std::path::Path;\n\nconst PROVIDER_NAMES: &[&str] = &[\n \"anthropic\",\n \"openai\",\n \"openrouter\",\n \"ollama\",\n \"deepseek\",\n \"gemini\",\n \"groq\",\n \"moonshot\",\n \"minimax\",\n \"github_copilot\",\n \"openai_codex\",\n \"glm_coding_plan\",\n];\n\nconst TOTAL_STEPS: usize = 6;\n\n// ── Trait for interactive I/O (enables testability) ────────────\n\ntrait WizardIo {\n fn confirm(&self, prompt: &str, default: bool) -> Result;\n fn select(&self, prompt: &str, items: &[&str], default: usize) -> Result;\n fn input_with_default(&self, prompt: &str, default: &str) -> Result;\n fn input_allow_empty(&self, prompt: &str) -> Result;\n fn password(&self, prompt: &str) -> Result;\n fn write_line(&self, line: &str) -> Result<()>;\n}\n\n// ── Production implementation using Term + dialoguer ───────────\n\nstruct TermWizard<'a> {\n term: &'a Term,\n}\n\nimpl<'a> TermWizard<'a> {\n fn new(term: &'a Term) -> Self {\n Self { term }\n }\n}\n\nimpl WizardIo for TermWizard<'_> {\n fn confirm(&self, prompt: &str, default: bool) -> Result {\n Ok(Confirm::new()\n .with_prompt(prompt)\n .default(default)\n .interact_on(self.term)?)\n }\n\n fn select(&self, prompt: &str, items: &[&str], default: usize) -> Result {\n Ok(Select::new()\n .with_prompt(prompt)\n .items(items)\n .default(default)\n .interact_on(self.term)?)\n }\n\n fn input_with_default(&self, prompt: &str, default: &str) -> Result {\n Ok(Input::new()\n .with_prompt(prompt)\n .default(default.to_string())\n .interact_text_on(self.term)?)\n }\n\n fn input_allow_empty(&self, prompt: &str) -> Result {\n Ok(Input::::new()\n .with_prompt(prompt)\n .allow_empty(true)\n .interact_text_on(self.term)?)\n }\n\n fn password(&self, prompt: &str) -> Result {\n Ok(PasswordInput::new()\n .with_prompt(prompt)\n .interact_on(self.term)?)\n }\n\n fn write_line(&self, line: &str) -> Result<()> {\n self.term.write_line(line)?;\n Ok(())\n }\n}\n\n// ── Provider dispatch macro (single source of truth) ───────────\n\n#[allow(unused_macros)]\nmacro_rules! provider_field {\n ($config:expr, $provider:expr, mut) => {\n match $provider {\n \"anthropic\" => Some(&mut $config.providers.anthropic),\n \"openai\" => Some(&mut $config.providers.openai),\n \"openrouter\" => Some(&mut $config.providers.openrouter),\n \"ollama\" => Some(&mut $config.providers.ollama),\n \"deepseek\" => Some(&mut $config.providers.deepseek),\n \"gemini\" => Some(&mut $config.providers.gemini),\n \"groq\" => Some(&mut $config.providers.groq),\n \"moonshot\" => Some(&mut $config.providers.moonshot),\n \"minimax\" => Some(&mut $config.providers.minimax),\n \"github_copilot\" => Some(&mut $config.providers.github_copilot),\n \"openai_codex\" => Some(&mut $config.providers.openai_codex),\n _ => None,\n }\n };\n ($config:expr, $provider:expr) => {\n match $provider {\n \"anthropic\" => Some(&$config.providers.anthropic),\n \"openai\" => Some(&$config.providers.openai),\n \"openrouter\" => Some(&$config.providers.openrouter),\n \"ollama\" => Some(&$config.providers.ollama),\n \"deepseek\" => Some(&$config.providers.deepseek),\n \"gemini\" => Some(&$config.providers.gemini),\n \"groq\" => Some(&$config.providers.groq),\n \"moonshot\" => Some(&$config.providers.moonshot),\n \"minimax\" => Some(&$config.providers.minimax),\n \"github_copilot\" => Some(&$config.providers.github_copilot),\n \"openai_codex\" => Some(&$config.providers.openai_codex),\n _ => None,\n }\n };\n}\n\n// ── Entry point ────────────────────────────────────────────────\n\npub fn run(_config: Config) -> Result<()> {\n let term = Term::stdout();\n if !term.is_term() {\n bail!(\n \"Setup requires an interactive terminal. \\\n Run this command in a terminal, not in a pipe or CI environment.\"\n );\n }\n let io = TermWizard::new(&term);\n let config_path = paths::get_config_path()?;\n run_wizard(&io, &config_path)\n}\n\n// ── Wizard flow ────────────────────────────────────────────────\n\nfn run_wizard(io: &dyn WizardIo, config_path: &Path) -> Result<()> {\n print_banner(io)?;\n\n // ── Step 1: Check existing config ────────────────────────────\n print_step(io, 1, \"Existing Configuration\")?;\n\n let mut config = if config_path.exists() {\n match load_existing_config(config_path) {\n Ok(existing) => {\n show_config_summary(io, &existing)?;\n if io.confirm(\"Update existing config?\", true)? {\n existing\n } else {\n io.write_line(&format!(\n \" {} Keeping config at {}.\",\n \"✓\".green(),\n config_path.display()\n ))?;\n return Ok(());\n }\n }\n Err(e) => {\n io.write_line(&format!(\n \" {} Could not parse existing config: {}\",\n \"!\".yellow(),\n e\n ))?;\n if io.confirm(\"Start fresh with defaults?\", true)? {\n Config::default()\n } else {\n bail!(\"Setup cancelled.\");\n }\n }\n }\n } else {\n io.write_line(\" No config file found. Starting fresh.\")?;\n Config::default()\n };\n\n // ── Step 2: Provider configuration ───────────────────────────\n print_step(io, 2, \"Provider Configuration\")?;\n configure_provider(io, &mut config)?;\n\n // ── Step 3: Telegram channel ─────────────────────────────────\n print_step(io, 3, \"Telegram Channel\")?;\n configure_telegram(io, &mut config)?;\n\n // ── Step 4: Feishu / Lark channel ────────────────────────────\n print_step(io, 4, \"Feishu / Lark Channel\")?;\n configure_feishu(io, &mut config)?;\n\n // ── Step 5: WebSocket port ───────────────────────────────────\n print_step(io, 5, \"WebSocket Port\")?;\n configure_websocket(io, &mut config)?;\n\n // ── Step 5: Weixin channel ───────────────────────────────────\n print_step(io, 5, \"WeChat Channel\")?;\n configure_weixin(io, &mut config)?;\n\n // ── Step 6: Validate & write ─────────────────────────────────\n print_step(io, 6, \"Save Configuration\")?;\n\n io.write_line(&format!(\" Config path: {}\", config_path.display()))?;\n io.write_line(\"\")?;\n show_config_summary(io, &config)?;\n io.write_line(\"\")?;\n\n if !io.confirm(\"Write this configuration?\", true)? {\n io.write_line(&format!(\" {} Setup cancelled.\", \"!\".yellow()))?;\n return Ok(());\n }\n\n let home = config_path\n .parent()\n .context(\"Config path must have a parent directory\")?;\n\n std::fs::create_dir_all(home)\n .with_context(|| format!(\"Failed to create config home: {}\", home.display()))?;\n\n loader::save_config(&config, config_path)?;\n\n for dir in [\"skills\", \"sessions\", \"learning\"] {\n let path = home.join(dir);\n std::fs::create_dir_all(&path)\n .with_context(|| format!(\"Failed to create directory: {}\", path.display()))?;\n }\n\n io.write_line(\"\")?;\n io.write_line(&format!(\n \" {} Configuration saved to {}\",\n \"✓\".green(),\n config_path.display()\n ))?;\n io.write_line(&format!(\n \" {} Created directories: skills, sessions, learning\",\n \"✓\".green()\n ))?;\n io.write_line(&format!(\" {} Setup complete!\", \"✓\".green()))?;\n\n Ok(())\n}\n\nfn print_banner(io: &dyn WizardIo) -> Result<()> {\n io.write_line(\"\")?;\n io.write_line(&format!(\n \" {} {}\",\n \"▸\".cyan(),\n \"Kestrel Setup Wizard\".bold().cyan()\n ))?;\n io.write_line(\"\")?;\n Ok(())\n}\n\nfn print_step(io: &dyn WizardIo, step: usize, title: &str) -> Result<()> {\n io.write_line(\"\")?;\n io.write_line(&format!(\n \" {} Step {}/{}: {}\",\n \"▸\".cyan(),\n step,\n TOTAL_STEPS,\n title.bold()\n ))?;\n io.write_line(&format!(\" {}\", \"─\".repeat(40).dimmed()))?;\n Ok(())\n}\n\nfn load_existing_config(path: &Path) -> Result {\n let content = std::fs::read_to_string(path)\n .with_context(|| format!(\"Failed to read {}\", path.display()))?;\n let config: Config =\n toml::from_str(&content).with_context(|| format!(\"Failed to parse {}\", path.display()))?;\n Ok(config)\n}\n\nfn mask_token(token: &str) -> String {\n if token.is_empty() {\n \"(not set)\".to_string()\n } else if token.len() <= 3 {\n \"(masked)\".to_string()\n } else {\n format!(\"{}…(masked)\", &token[..3])\n }\n}\n\nfn show_config_summary(io: &dyn WizardIo, config: &Config) -> Result<()> {\n let provider = config.agent.provider.as_deref().unwrap_or(\"default\");\n io.write_line(&format!(\" Model: {}\", config.agent.model))?;\n io.write_line(&format!(\" Provider: {}\", provider))?;\n io.write_line(&format!(\" Temperature: {}\", config.agent.temperature))?;\n io.write_line(&format!(\" Max tokens: {}\", config.agent.max_tokens))?;\n io.write_line(&format!(\" Streaming: {}\", config.agent.streaming))?;\n\n if let Some(ref tg) = config.channels.telegram {\n io.write_line(&format!(\" Telegram: {}\", mask_token(&tg.token)))?;\n }\n\n if let Some(ref fs) = config.channels.feishu {\n if fs.enabled {\n let app_id = fs.app_id.as_deref().unwrap_or(\"(not set)\");\n io.write_line(&format!(\" Feishu: {}\", mask_token(app_id)))?;\n }\n }\n\n if let Some(ref ws) = config.channels.websocket {\n if ws.enabled {\n io.write_line(&format!(\" WebSocket: {}\", ws.listen_addr))?;\n }\n }\n\n if let Some(ref wx) = config.channels.weixin {\n let status = if wx.enabled {\n mask_token(wx.bot_token.as_deref().unwrap_or(\"\"))\n } else {\n \"disabled\".to_string()\n };\n io.write_line(&format!(\" WeChat: {}\", status))?;\n }\n\n Ok(())\n}\n\nfn configure_provider(io: &dyn WizardIo, config: &mut Config) -> Result<()> {\n let default_idx = config\n .agent\n .provider\n .as_deref()\n .and_then(|p| PROVIDER_NAMES.iter().position(|&n| n == p))\n .unwrap_or(1); // default to \"openai\"\n\n let provider_name = io.select(\"Select LLM provider\", PROVIDER_NAMES, default_idx)?;\n\n let provider_key = PROVIDER_NAMES[provider_name];\n config.agent.provider = Some(provider_key.to_string());\n\n let default_model = match provider_key {\n \"anthropic\" => \"claude-sonnet-4-20250514\",\n \"openai\" => \"gpt-4o\",\n \"openrouter\" => \"anthropic/claude-sonnet-4-20250514\",\n \"ollama\" => \"llama3\",\n \"deepseek\" => \"deepseek-chat\",\n \"gemini\" => \"gemini-2.5-pro\",\n \"groq\" => \"llama-3.3-70b-versatile\",\n \"moonshot\" => \"moonshot-v1-8k\",\n \"minimax\" => \"MiniMax-Text-01\",\n \"github_copilot\" => \"gpt-4o\",\n \"openai_codex\" => \"codex-mini\",\n \"glm_coding_plan\" => \"glm-5.1\",\n _ => \"gpt-4o\",\n };\n\n let current_model = if config.agent.model.is_empty() {\n default_model\n } else {\n &config.agent.model\n };\n\n let model: String = io.input_with_default(\"Model name\", current_model)?;\n config.agent.model = model;\n\n let default_url = match provider_key {\n \"anthropic\" => \"https://api.anthropic.com\",\n \"openai\" => \"https://api.openai.com/v1\",\n \"openrouter\" => \"https://openrouter.ai/api/v1\",\n \"ollama\" => \"http://localhost:11434\",\n \"deepseek\" => \"https://api.deepseek.com\",\n \"gemini\" => \"https://generativelanguage.googleapis.com/v1beta\",\n \"groq\" => \"https://api.groq.com/openai/v1\",\n \"moonshot\" => \"https://api.moonshot.cn/v1\",\n \"minimax\" => \"https://api.minimax.chat/v1\",\n \"github_copilot\" => \"https://api.githubcopilot.com\",\n \"openai_codex\" => \"https://api.openai.com/v1\",\n \"glm_coding_plan\" => \"https://open.bigmodel.cn/api/coding/paas/v4\",\n _ => \"\",\n };\n\n let current_url = get_provider_url(config, provider_key).unwrap_or(default_url);\n\n if !current_url.is_empty() {\n let base_url: String = io.input_with_default(\"Base URL\", current_url)?;\n set_provider_url(config, provider_key, &base_url);\n } else {\n let base_url: String = io.input_allow_empty(\"Base URL (leave empty for default)\")?;\n if !base_url.is_empty() {\n set_provider_url(config, provider_key, &base_url);\n }\n }\n\n let api_key: String = io.password(\"API key\")?;\n if !api_key.is_empty() {\n set_provider_api_key(config, provider_key, &api_key);\n }\n\n Ok(())\n}\n\nfn configure_telegram(io: &dyn WizardIo, config: &mut Config) -> Result<()> {\n let setup_tg = if config.channels.telegram.is_some() {\n io.confirm(\"Configure Telegram bot?\", true)?\n } else {\n io.confirm(\"Set up a Telegram bot?\", false)?\n };\n\n if !setup_tg {\n io.write_line(\" Skipped.\")?;\n return Ok(());\n }\n\n let current_token = config\n .channels\n .telegram\n .as_ref()\n .map(|tg| tg.token.as_str())\n .unwrap_or(\"\");\n\n let token: String = if current_token.is_empty() {\n io.input_allow_empty(\"Bot token (from @BotFather)\")?\n } else {\n io.input_with_default(\"Bot token (from @BotFather)\", current_token)?\n };\n\n if token.is_empty() {\n io.write_line(\" No token provided, skipping Telegram.\")?;\n return Ok(());\n }\n\n let allowed: String = loop {\n let input: String =\n io.input_allow_empty(\"Allowed user IDs (comma-separated, leave empty for all)\")?;\n if input.trim().is_empty() {\n break String::new();\n }\n let ids: Vec<&str> = input\n .split(',')\n .map(|s| s.trim())\n .filter(|s| !s.is_empty())\n .collect();\n let mut valid = true;\n for id in &ids {\n if id.parse::().is_err() {\n io.write_line(&format!(\n \" {} '{}' is not a valid user ID (must be a positive integer).\",\n \"!\".yellow(),\n id\n ))?;\n valid = false;\n break;\n }\n }\n if valid {\n break input;\n }\n };\n\n let allowed_users: Vec = if allowed.trim().is_empty() {\n Vec::new()\n } else {\n allowed\n .split(',')\n .map(|s| s.trim().to_string())\n .filter(|s| !s.is_empty())\n .collect()\n };\n\n // Preserve existing values for fields not explicitly configured in the wizard.\n let (admin_users, enabled, streaming, proxy) = config\n .channels\n .telegram\n .as_ref()\n .map(|tg| {\n (\n tg.admin_users.clone(),\n tg.enabled,\n tg.streaming,\n tg.proxy.clone(),\n )\n })\n .unwrap_or((Vec::new(), true, true, None));\n\n config.channels.telegram = Some(TelegramConfig {\n token,\n allowed_users,\n admin_users,\n enabled,\n streaming,\n proxy,\n });\n\n Ok(())\n}\n\nfn configure_feishu(io: &dyn WizardIo, config: &mut Config) -> Result<()> {\n let has_existing = config.channels.feishu.as_ref().is_some_and(|f| f.enabled);\n\n let setup = if has_existing {\n io.confirm(\"Reconfigure Feishu / Lark?\", true)?\n } else {\n io.confirm(\"Set up Feishu / Lark?\", false)?\n };\n\n if !setup {\n io.write_line(\" Skipped.\")?;\n return Ok(());\n }\n\n let domain_options = [\"Feishu (飞书)\", \"Lark (international)\"];\n let idx = io.select(\"Select platform\", &domain_options, 0)?;\n let domain = if idx == 1 { \"lark\" } else { \"feishu\" };\n\n let rt = tokio::runtime::Builder::new_current_thread()\n .enable_all()\n .build()\n .context(\"Failed to create tokio runtime\")?;\n\n // TODO: Creating a nested tokio runtime here means this cannot be called\n // from within an existing async context (e.g. tests). A future refactor\n // could make `run_wizard` async or accept a runtime handle.\n let result = match rt.block_on(super::feishu_onboarding::run_onboarding(domain)) {\n Ok(r) => r,\n Err(e) => {\n io.write_line(&format!(\" {} Feishu setup skipped: {}\", \"!\".yellow(), e))?;\n return Ok(());\n }\n };\n\n let existing_proxy = config\n .channels\n .feishu\n .as_ref()\n .and_then(|f| f.proxy.clone());\n\n config.channels.feishu = Some(FeishuConfig {\n app_id: Some(result.app_id),\n app_secret: Some(result.app_secret),\n enabled: true,\n proxy: existing_proxy,\n });\n\n Ok(())\n}\n\nfn configure_websocket(io: &dyn WizardIo, config: &mut Config) -> Result<()> {\n let default_addr = config\n .channels\n .websocket\n .as_ref()\n .map(|ws| ws.listen_addr.as_str())\n .unwrap_or(\"127.0.0.1:8090\");\n\n let enable = io.confirm(\"Enable WebSocket channel?\", false)?;\n\n if !enable {\n config.channels.websocket = None;\n io.write_line(\" Skipped.\")?;\n return Ok(());\n }\n\n let listen_addr: String = loop {\n let input = io.input_with_default(\"Listen address\", default_addr)?;\n match input.parse::() {\n Ok(_) => break input,\n Err(e) => {\n io.write_line(&format!(\n \" {} Invalid address '{}': {}\",\n \"!\".yellow(),\n input,\n e\n ))?;\n }\n }\n };\n\n config.channels.websocket = Some(WebSocketConfig {\n enabled: true,\n listen_addr,\n auth: Default::default(),\n max_clients: 100,\n max_message_size: 1048576,\n });\n\n Ok(())\n}\n\nfn configure_weixin(io: &dyn WizardIo, config: &mut Config) -> Result<()> {\n let setup_wx = if config.channels.weixin.is_some() {\n io.confirm(\"Configure WeChat channel?\", true)?\n } else {\n io.confirm(\"Set up a WeChat channel?\", false)?\n };\n\n if !setup_wx {\n io.write_line(\" Skipped.\")?;\n return Ok(());\n }\n\n let choices = &[\n \"Scan QR code with WeChat (recommended)\",\n \"Enter credentials manually\",\n \"Skip\",\n ];\n let choice = io.select(\"How would you like to configure WeChat?\", choices, 0)?;\n\n match choice {\n 0 => {\n io.write_line(\n \" QR scan setup requires running `kestrel setup weixin` in a terminal.\",\n )?;\n io.write_line(\" Please run that command separately, then return to this wizard.\")?;\n // Re-enable the channel when QR login credentials already exist.\n if let Some(ref mut wx) = config.channels.weixin {\n if wx.account_id.is_some() && wx.bot_token.is_some() {\n wx.enabled = true;\n io.write_line(&format!(\n \" {} Existing WeChat credentials detected and enabled.\",\n \"✓\".green()\n ))?;\n }\n }\n // If no credentials yet, just leave channel unconfigured\n if config.channels.weixin.is_none() {\n io.write_line(\n \" No WeChat credentials found yet. Run `kestrel setup weixin` first.\",\n )?;\n }\n }\n 1 => {\n let current_account = config\n .channels\n .weixin\n .as_ref()\n .and_then(|w| w.account_id.as_deref())\n .unwrap_or(\"\");\n let account_id: String = if current_account.is_empty() {\n io.input_allow_empty(\"iLink account ID (e.g. wxid_xxx@im.bot)\")?\n } else {\n io.input_with_default(\"iLink account ID\", current_account)?\n };\n\n if account_id.trim().is_empty() {\n io.write_line(\" No account ID provided, skipping WeChat.\")?;\n return Ok(());\n }\n\n let current_token = config\n .channels\n .weixin\n .as_ref()\n .and_then(|w| w.bot_token.as_deref())\n .unwrap_or(\"\");\n let bot_token: String = if current_token.is_empty() {\n io.input_allow_empty(\"iLink bot token\")?\n } else {\n io.input_with_default(\"iLink bot token\", current_token)?\n };\n\n if bot_token.trim().is_empty() {\n io.write_line(\" No bot token provided, skipping WeChat.\")?;\n return Ok(());\n }\n\n // Preserve existing fields\n let (\n app_id,\n app_secret,\n old_token,\n encoding_aes_key,\n base_url,\n cdn_base_url,\n dm_policy,\n group_policy,\n allowed_users,\n group_allowed_users,\n ) = config\n .channels\n .weixin\n .as_ref()\n .map(|w| {\n (\n w.app_id.clone(),\n w.app_secret.clone(),\n w.token.clone(),\n w.encoding_aes_key.clone(),\n w.base_url.clone(),\n w.cdn_base_url.clone(),\n w.dm_policy.clone(),\n w.group_policy.clone(),\n w.allowed_users.clone(),\n w.group_allowed_users.clone(),\n )\n })\n .unwrap_or_default();\n\n config.channels.weixin = Some(WeixinConfig {\n account_id: Some(account_id.trim().to_string()),\n bot_token: Some(bot_token.trim().to_string()),\n app_id,\n app_secret,\n token: old_token,\n encoding_aes_key,\n base_url,\n cdn_base_url,\n dm_policy,\n group_policy,\n allowed_users,\n group_allowed_users,\n enabled: true,\n });\n\n io.write_line(&format!(\" {} WeChat credentials saved.\", \"✓\".green()))?;\n }\n _ => {\n io.write_line(\" Skipped.\")?;\n }\n }\n\n Ok(())\n}\n\n// ── Provider field helpers (using macro) ───────────────────────\n\nfn get_provider_entry_mut<'a>(\n config: &'a mut Config,\n provider: &str,\n) -> Option<&'a mut ProviderEntry> {\n match provider {\n \"anthropic\" => config.providers.anthropic.as_mut(),\n \"openai\" => config.providers.openai.as_mut(),\n \"openrouter\" => config.providers.openrouter.as_mut(),\n \"ollama\" => config.providers.ollama.as_mut(),\n \"deepseek\" => config.providers.deepseek.as_mut(),\n \"gemini\" => config.providers.gemini.as_mut(),\n \"groq\" => config.providers.groq.as_mut(),\n \"moonshot\" => config.providers.moonshot.as_mut(),\n \"minimax\" => config.providers.minimax.as_mut(),\n \"github_copilot\" => config.providers.github_copilot.as_mut(),\n \"openai_codex\" => config.providers.openai_codex.as_mut(),\n \"opencode_go\" => config.providers.opencode_go.as_mut(),\n \"glm_coding_plan\" => config.providers.glm_coding_plan.as_mut(),\n _ => None,\n }\n}\n\nfn ensure_provider_entry(config: &mut Config, provider: &str) {\n match provider {\n \"anthropic\" => {\n config\n .providers\n .anthropic\n .get_or_insert_with(ProviderEntry::default);\n }\n \"openai\" => {\n config\n .providers\n .openai\n .get_or_insert_with(ProviderEntry::default);\n }\n \"openrouter\" => {\n config\n .providers\n .openrouter\n .get_or_insert_with(ProviderEntry::default);\n }\n \"ollama\" => {\n config\n .providers\n .ollama\n .get_or_insert_with(ProviderEntry::default);\n }\n \"deepseek\" => {\n config\n .providers\n .deepseek\n .get_or_insert_with(ProviderEntry::default);\n }\n \"gemini\" => {\n config\n .providers\n .gemini\n .get_or_insert_with(ProviderEntry::default);\n }\n \"groq\" => {\n config\n .providers\n .groq\n .get_or_insert_with(ProviderEntry::default);\n }\n \"moonshot\" => {\n config\n .providers\n .moonshot\n .get_or_insert_with(ProviderEntry::default);\n }\n \"minimax\" => {\n config\n .providers\n .minimax\n .get_or_insert_with(ProviderEntry::default);\n }\n \"github_copilot\" => {\n config\n .providers\n .github_copilot\n .get_or_insert_with(ProviderEntry::default);\n }\n \"openai_codex\" => {\n config\n .providers\n .openai_codex\n .get_or_insert_with(ProviderEntry::default);\n }\n \"opencode_go\" => {\n config\n .providers\n .opencode_go\n .get_or_insert_with(ProviderEntry::default);\n }\n \"glm_coding_plan\" => {\n config\n .providers\n .glm_coding_plan\n .get_or_insert_with(ProviderEntry::default);\n }\n _ => {}\n }\n}\n\nfn get_provider_url<'a>(config: &'a Config, provider: &str) -> Option<&'a str> {\n let entry = match provider {\n \"anthropic\" => config.providers.anthropic.as_ref(),\n \"openai\" => config.providers.openai.as_ref(),\n \"openrouter\" => config.providers.openrouter.as_ref(),\n \"ollama\" => config.providers.ollama.as_ref(),\n \"deepseek\" => config.providers.deepseek.as_ref(),\n \"gemini\" => config.providers.gemini.as_ref(),\n \"groq\" => config.providers.groq.as_ref(),\n \"moonshot\" => config.providers.moonshot.as_ref(),\n \"minimax\" => config.providers.minimax.as_ref(),\n \"github_copilot\" => config.providers.github_copilot.as_ref(),\n \"openai_codex\" => config.providers.openai_codex.as_ref(),\n \"opencode_go\" => config.providers.opencode_go.as_ref(),\n \"glm_coding_plan\" => config.providers.glm_coding_plan.as_ref(),\n _ => None,\n };\n entry.and_then(|e| e.base_url.as_deref())\n}\n\nfn set_provider_url(config: &mut Config, provider: &str, url: &str) {\n ensure_provider_entry(config, provider);\n if let Some(entry) = get_provider_entry_mut(config, provider) {\n entry.base_url = if url.is_empty() {\n None\n } else {\n Some(url.to_string())\n };\n }\n}\n\nfn set_provider_api_key(config: &mut Config, provider: &str, key: &str) {\n ensure_provider_entry(config, provider);\n if let Some(entry) = get_provider_entry_mut(config, provider) {\n entry.api_key = if key.is_empty() {\n None\n } else {\n Some(key.to_string())\n };\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use std::cell::RefCell;\n use std::collections::VecDeque;\n\n fn template_toml() -> String {\n toml::to_string(&Config::default()).unwrap()\n }\n\n // ── Mock wizard IO for flow testing ─────────────────────────\n\n #[derive(Debug, Clone)]\n enum MockAction {\n Confirm {\n prompt_contains: &'static str,\n result: bool,\n },\n Select {\n result: usize,\n },\n Input {\n result: &'static str,\n },\n Password {\n result: &'static str,\n },\n }\n\n struct MockWizard {\n actions: RefCell>,\n output: RefCell,\n }\n\n impl MockWizard {\n fn new(actions: Vec) -> Self {\n Self {\n actions: RefCell::new(actions.into()),\n output: RefCell::new(String::new()),\n }\n }\n\n fn output(&self) -> String {\n self.output.borrow().clone()\n }\n }\n\n impl WizardIo for MockWizard {\n fn confirm(&self, prompt: &str, _default: bool) -> Result {\n let action = self.actions.borrow_mut().pop_front();\n match action {\n Some(MockAction::Confirm {\n prompt_contains,\n result,\n }) => {\n assert!(\n prompt.contains(prompt_contains),\n \"confirm prompt '{}' did not contain '{}'\",\n prompt,\n prompt_contains\n );\n Ok(result)\n }\n _ => panic!(\"unexpected confirm call, prompt: {}\", prompt),\n }\n }\n\n fn select(&self, _prompt: &str, _items: &[&str], _default: usize) -> Result {\n let action = self.actions.borrow_mut().pop_front();\n match action {\n Some(MockAction::Select { result }) => Ok(result),\n _ => panic!(\"unexpected select call\"),\n }\n }\n\n fn input_with_default(&self, _prompt: &str, _default: &str) -> Result {\n let action = self.actions.borrow_mut().pop_front();\n match action {\n Some(MockAction::Input { result }) => Ok(result.to_string()),\n _ => panic!(\"unexpected input_with_default call\"),\n }\n }\n\n fn input_allow_empty(&self, _prompt: &str) -> Result {\n let action = self.actions.borrow_mut().pop_front();\n match action {\n Some(MockAction::Input { result }) => Ok(result.to_string()),\n _ => panic!(\"unexpected input_allow_empty call\"),\n }\n }\n\n fn password(&self, _prompt: &str) -> Result {\n let action = self.actions.borrow_mut().pop_front();\n match action {\n Some(MockAction::Password { result }) => Ok(result.to_string()),\n _ => panic!(\"unexpected password call\"),\n }\n }\n\n fn write_line(&self, line: &str) -> Result<()> {\n self.output.borrow_mut().push_str(line);\n self.output.borrow_mut().push('\\n');\n Ok(())\n }\n }\n\n // ── Unit tests ──────────────────────────────────────────────\n\n #[test]\n fn setup_creates_template_config_when_config_is_missing() {\n let tmp = tempfile::tempdir().unwrap();\n let config_path = tmp.path().join(\"config.toml\");\n\n let config = Config::default();\n let home = config_path.parent().unwrap();\n std::fs::create_dir_all(home).unwrap();\n loader::save_config(&config, &config_path).unwrap();\n\n assert_eq!(\n std::fs::read_to_string(&config_path).unwrap(),\n template_toml()\n );\n assert!(config_path.exists());\n }\n\n #[test]\n fn provider_helpers_set_and_get_fields() {\n let mut config = Config::default();\n set_provider_url(&mut config, \"openai\", \"https://custom.api/v1\");\n set_provider_api_key(&mut config, \"openai\", \"sk-test-key\");\n\n let entry = config.providers.openai.as_ref().unwrap();\n assert_eq!(entry.base_url.as_deref(), Some(\"https://custom.api/v1\"));\n assert_eq!(entry.api_key.as_deref(), Some(\"sk-test-key\"));\n }\n\n #[test]\n fn provider_helpers_handle_unknown_provider() {\n let mut config = Config::default();\n set_provider_url(&mut config, \"nonexistent\", \"https://example.com\");\n assert!(get_provider_url(&config, \"nonexistent\").is_none());\n }\n\n #[test]\n fn wizard_keeps_existing_config() {\n let tmp = tempfile::tempdir().unwrap();\n let config_path = tmp.path().join(\"config.toml\");\n\n let existing = Config::default();\n std::fs::create_dir_all(config_path.parent().unwrap()).unwrap();\n loader::save_config(&existing, &config_path).unwrap();\n\n let mock = MockWizard::new(vec![MockAction::Confirm {\n prompt_contains: \"Update\",\n result: false,\n }]);\n\n run_wizard(&mock, &config_path).unwrap();\n\n // Config should be unchanged\n assert_eq!(\n std::fs::read_to_string(&config_path).unwrap(),\n template_toml()\n );\n }\n\n #[test]\n fn wizard_fresh_setup() {\n let tmp = tempfile::tempdir().unwrap();\n let config_path = tmp.path().join(\"config.toml\");\n\n let mock = MockWizard::new(vec![\n // Step 2: Provider\n MockAction::Select { result: 1 }, // openai\n MockAction::Input { result: \"gpt-4o\" },\n MockAction::Input {\n result: \"https://api.openai.com/v1\",\n },\n MockAction::Password {\n result: \"sk-test-key\",\n },\n // Step 3: Telegram (skip)\n MockAction::Confirm {\n prompt_contains: \"Telegram\",\n result: false,\n },\n // Step 4: Feishu (skip)\n MockAction::Confirm {\n prompt_contains: \"Feishu\",\n result: false,\n },\n // Step 5: WebSocket (skip)\n MockAction::Confirm {\n prompt_contains: \"WebSocket\",\n result: false,\n },\n // Step 5: WeChat (skip)\n MockAction::Confirm {\n prompt_contains: \"WeChat\",\n result: false,\n },\n // Step 6: Save\n MockAction::Confirm {\n prompt_contains: \"Write\",\n result: true,\n },\n ]);\n\n run_wizard(&mock, &config_path).unwrap();\n\n assert!(config_path.exists());\n let saved: Config =\n toml::from_str(&std::fs::read_to_string(&config_path).unwrap()).unwrap();\n assert_eq!(saved.agent.provider.as_deref(), Some(\"openai\"));\n assert_eq!(saved.agent.model, \"gpt-4o\");\n }\n\n #[test]\n fn wizard_overwrite_existing_config() {\n let tmp = tempfile::tempdir().unwrap();\n let config_path = tmp.path().join(\"config.toml\");\n\n let existing = Config::default();\n std::fs::create_dir_all(config_path.parent().unwrap()).unwrap();\n loader::save_config(&existing, &config_path).unwrap();\n\n let mock = MockWizard::new(vec![\n // Step 1: Overwrite\n MockAction::Confirm {\n prompt_contains: \"Update\",\n result: true,\n },\n // Step 2: Provider\n MockAction::Select { result: 0 }, // anthropic\n MockAction::Input {\n result: \"claude-sonnet-4-20250514\",\n },\n MockAction::Input {\n result: \"https://api.anthropic.com\",\n },\n MockAction::Password {\n result: \"sk-ant-test\",\n },\n // Step 3: Telegram (skip)\n MockAction::Confirm {\n prompt_contains: \"Telegram\",\n result: false,\n },\n // Step 4: Feishu (skip)\n MockAction::Confirm {\n prompt_contains: \"Feishu\",\n result: false,\n },\n // Step 5: WebSocket (skip)\n MockAction::Confirm {\n prompt_contains: \"WebSocket\",\n result: false,\n },\n // Step 5: WeChat (skip)\n MockAction::Confirm {\n prompt_contains: \"WeChat\",\n result: false,\n },\n // Step 6: Save\n MockAction::Confirm {\n prompt_contains: \"Write\",\n result: true,\n },\n ]);\n\n run_wizard(&mock, &config_path).unwrap();\n\n let saved: Config =\n toml::from_str(&std::fs::read_to_string(&config_path).unwrap()).unwrap();\n assert_eq!(saved.agent.provider.as_deref(), Some(\"anthropic\"));\n assert_eq!(saved.agent.model, \"claude-sonnet-4-20250514\");\n }\n\n fn tg(token: &str) -> TelegramConfig {\n TelegramConfig {\n token: token.to_string(),\n allowed_users: Vec::new(),\n admin_users: Vec::new(),\n enabled: true,\n streaming: false,\n proxy: None,\n }\n }\n\n #[test]\n fn token_masking_short_tokens() {\n let mut config = Config::default();\n config.channels.telegram = Some(tg(\"ab\"));\n\n let mock = MockWizard::new(vec![]);\n show_config_summary(&mock, &config).unwrap();\n\n let output = mock.output();\n assert!(output.contains(\"(masked)\"));\n assert!(!output.contains(\"ab…\"));\n }\n\n #[test]\n fn token_masking_empty_token() {\n let mut config = Config::default();\n config.channels.telegram = Some(tg(\"\"));\n\n let mock = MockWizard::new(vec![]);\n show_config_summary(&mock, &config).unwrap();\n\n let output = mock.output();\n assert!(output.contains(\"(not set)\"));\n }\n\n #[test]\n fn configure_weixin_qr_enables_existing_credentials() {\n let mock = MockWizard::new(vec![\n MockAction::Confirm {\n prompt_contains: \"WeChat\",\n result: true,\n },\n MockAction::Select { result: 0 },\n ]);\n let mut config = Config::default();\n config.channels.weixin = Some(WeixinConfig {\n app_id: None,\n app_secret: None,\n token: None,\n encoding_aes_key: None,\n account_id: Some(\"wxid_existing@im.bot\".to_string()),\n bot_token: Some(\"tok_existing\".to_string()),\n base_url: None,\n cdn_base_url: None,\n dm_policy: \"open\".to_string(),\n group_policy: \"disabled\".to_string(),\n allowed_users: vec![],\n group_allowed_users: vec![],\n enabled: false,\n });\n\n configure_weixin(&mock, &mut config).unwrap();\n\n let weixin = config.channels.weixin.unwrap();\n assert!(weixin.enabled);\n assert_eq!(weixin.account_id.as_deref(), Some(\"wxid_existing@im.bot\"));\n assert_eq!(weixin.bot_token.as_deref(), Some(\"tok_existing\"));\n assert!(mock.output().contains(\"detected and enabled\"));\n }\n\n #[test]\n fn configure_weixin_manual_preserves_existing_fields() {\n let mock = MockWizard::new(vec![\n MockAction::Confirm {\n prompt_contains: \"WeChat\",\n result: true,\n },\n MockAction::Select { result: 1 },\n MockAction::Input {\n result: \"wxid_new@im.bot\",\n },\n MockAction::Input { result: \"tok_new\" },\n ]);\n let mut config = Config::default();\n config.channels.weixin = Some(WeixinConfig {\n app_id: Some(\"app_existing\".to_string()),\n app_secret: Some(\"secret_existing\".to_string()),\n token: Some(\"verify_token\".to_string()),\n encoding_aes_key: Some(\"aes_key\".to_string()),\n account_id: Some(\"wxid_old@im.bot\".to_string()),\n bot_token: Some(\"tok_old\".to_string()),\n base_url: Some(\"https://ilinkai.weixin.qq.com\".to_string()),\n cdn_base_url: Some(\"https://novac2c.cdn.weixin.qq.com/c2c\".to_string()),\n dm_policy: \"allowlist\".to_string(),\n group_policy: \"open\".to_string(),\n allowed_users: vec![\"u1\".to_string()],\n group_allowed_users: vec![\"g1\".to_string()],\n enabled: false,\n });\n\n configure_weixin(&mock, &mut config).unwrap();\n\n let weixin = config.channels.weixin.unwrap();\n assert_eq!(weixin.account_id.as_deref(), Some(\"wxid_new@im.bot\"));\n assert_eq!(weixin.bot_token.as_deref(), Some(\"tok_new\"));\n assert_eq!(weixin.app_id.as_deref(), Some(\"app_existing\"));\n assert_eq!(weixin.app_secret.as_deref(), Some(\"secret_existing\"));\n assert_eq!(weixin.token.as_deref(), Some(\"verify_token\"));\n assert_eq!(weixin.encoding_aes_key.as_deref(), Some(\"aes_key\"));\n assert_eq!(\n weixin.base_url.as_deref(),\n Some(\"https://ilinkai.weixin.qq.com\")\n );\n assert_eq!(\n weixin.cdn_base_url.as_deref(),\n Some(\"https://novac2c.cdn.weixin.qq.com/c2c\")\n );\n assert_eq!(weixin.dm_policy, \"allowlist\");\n assert_eq!(weixin.group_policy, \"open\");\n assert_eq!(weixin.allowed_users, vec![\"u1\"]);\n assert_eq!(weixin.group_allowed_users, vec![\"g1\"]);\n assert!(weixin.enabled);\n }\n}\n"}, "bc2d52430c2b393c3ba57c7d46b06a21b8760f02": {"t": 1785761078.5487416, "n": 38075, "h": "0e5affa1decf78d5", "c": "//! Setup command — interactive wizard for configuring Kestrel.\n\nuse anyhow::{bail, Context, Result};\nuse console::Term;\nuse dialoguer::{Confirm, Input, Password as PasswordInput, Select};\nuse kestrel_config::{\n loader, paths,\n schema::{Config, FeishuConfig, ProviderEntry, TelegramConfig, WebSocketConfig, WeixinConfig},\n};\nuse owo_colors::OwoColorize;\nuse std::net::SocketAddr;\nuse std::path::Path;\n\nconst PROVIDER_NAMES: &[&str] = &[\n \"anthropic\",\n \"openai\",\n \"openrouter\",\n \"ollama\",\n \"deepseek\",\n \"gemini\",\n \"groq\",\n \"moonshot\",\n \"minimax\",\n \"github_copilot\",\n \"openai_codex\",\n \"glm_coding_plan\",\n];\n\nconst TOTAL_STEPS: usize = 6;\n\n// ── Trait for interactive I/O (enables testability) ────────────\n\ntrait WizardIo {\n fn confirm(&self, prompt: &str, default: bool) -> Result;\n fn select(&self, prompt: &str, items: &[&str], default: usize) -> Result;\n fn input_with_default(&self, prompt: &str, default: &str) -> Result;\n fn input_allow_empty(&self, prompt: &str) -> Result;\n fn password(&self, prompt: &str) -> Result;\n fn write_line(&self, line: &str) -> Result<()>;\n}\n\n// ── Production implementation using Term + dialoguer ───────────\n\nstruct TermWizard<'a> {\n term: &'a Term,\n}\n\nimpl<'a> TermWizard<'a> {\n fn new(term: &'a Term) -> Self {\n Self { term }\n }\n}\n\nimpl WizardIo for TermWizard<'_> {\n fn confirm(&self, prompt: &str, default: bool) -> Result {\n Ok(Confirm::new()\n .with_prompt(prompt)\n .default(default)\n .interact_on(self.term)?)\n }\n\n fn select(&self, prompt: &str, items: &[&str], default: usize) -> Result {\n Ok(Select::new()\n .with_prompt(prompt)\n .items(items)\n .default(default)\n .interact_on(self.term)?)\n }\n\n fn input_with_default(&self, prompt: &str, default: &str) -> Result {\n Ok(Input::new()\n .with_prompt(prompt)\n .default(default.to_string())\n .interact_text_on(self.term)?)\n }\n\n fn input_allow_empty(&self, prompt: &str) -> Result {\n Ok(Input::::new()\n .with_prompt(prompt)\n .allow_empty(true)\n .interact_text_on(self.term)?)\n }\n\n fn password(&self, prompt: &str) -> Result {\n Ok(PasswordInput::new()\n .with_prompt(prompt)\n .interact_on(self.term)?)\n }\n\n fn write_line(&self, line: &str) -> Result<()> {\n self.term.write_line(line)?;\n Ok(())\n }\n}\n\n// ── Provider dispatch macro (single source of truth) ───────────\n\n#[allow(unused_macros)]\nmacro_rules! provider_field {\n ($config:expr, $provider:expr, mut) => {\n match $provider {\n \"anthropic\" => Some(&mut $config.providers.anthropic),\n \"openai\" => Some(&mut $config.providers.openai),\n \"openrouter\" => Some(&mut $config.providers.openrouter),\n \"ollama\" => Some(&mut $config.providers.ollama),\n \"deepseek\" => Some(&mut $config.providers.deepseek),\n \"gemini\" => Some(&mut $config.providers.gemini),\n \"groq\" => Some(&mut $config.providers.groq),\n \"moonshot\" => Some(&mut $config.providers.moonshot),\n \"minimax\" => Some(&mut $config.providers.minimax),\n \"github_copilot\" => Some(&mut $config.providers.github_copilot),\n \"openai_codex\" => Some(&mut $config.providers.openai_codex),\n _ => None,\n }\n };\n ($config:expr, $provider:expr) => {\n match $provider {\n \"anthropic\" => Some(&$config.providers.anthropic),\n \"openai\" => Some(&$config.providers.openai),\n \"openrouter\" => Some(&$config.providers.openrouter),\n \"ollama\" => Some(&$config.providers.ollama),\n \"deepseek\" => Some(&$config.providers.deepseek),\n \"gemini\" => Some(&$config.providers.gemini),\n \"groq\" => Some(&$config.providers.groq),\n \"moonshot\" => Some(&$config.providers.moonshot),\n \"minimax\" => Some(&$config.providers.minimax),\n \"github_copilot\" => Some(&$config.providers.github_copilot),\n \"openai_codex\" => Some(&$config.providers.openai_codex),\n _ => None,\n }\n };\n}\n\n// ── Entry point ────────────────────────────────────────────────\n\npub fn run(_config: Config) -> Result<()> {\n let term = Term::stdout();\n if !term.is_term() {\n bail!(\n \"Setup requires an interactive terminal. \\\n Run this command in a terminal, not in a pipe or CI environment.\"\n );\n }\n let io = TermWizard::new(&term);\n let config_path = paths::get_config_path()?;\n run_wizard(&io, &config_path)\n}\n\n// ── Wizard flow ────────────────────────────────────────────────\n\nfn run_wizard(io: &dyn WizardIo, config_path: &Path) -> Result<()> {\n print_banner(io)?;\n\n // ── Step 1: Check existing config ────────────────────────────\n print_step(io, 1, \"Existing Configuration\")?;\n\n let mut config = if config_path.exists() {\n match load_existing_config(config_path) {\n Ok(existing) => {\n show_config_summary(io, &existing)?;\n if io.confirm(\"Update existing config?\", true)? {\n existing\n } else {\n io.write_line(&format!(\n \" {} Keeping config at {}.\",\n \"✓\".green(),\n config_path.display()\n ))?;\n return Ok(());\n }\n }\n Err(e) => {\n io.write_line(&format!(\n \" {} Could not parse existing config: {}\",\n \"!\".yellow(),\n e\n ))?;\n if io.confirm(\"Start fresh with defaults?\", true)? {\n Config::default()\n } else {\n bail!(\"Setup cancelled.\");\n }\n }\n }\n } else {\n io.write_line(\" No config file found. Starting fresh.\")?;\n Config::default()\n };\n\n // ── Step 2: Provider configuration ───────────────────────────\n print_step(io, 2, \"Provider Configuration\")?;\n configure_provider(io, &mut config)?;\n\n // ── Step 3: Telegram channel ─────────────────────────────────\n print_step(io, 3, \"Telegram Channel\")?;\n configure_telegram(io, &mut config)?;\n\n // ── Step 4: Feishu / Lark channel ────────────────────────────\n print_step(io, 4, \"Feishu / Lark Channel\")?;\n configure_feishu(io, &mut config)?;\n\n // ── Step 5: WebSocket port ───────────────────────────────────\n print_step(io, 5, \"WebSocket Port\")?;\n configure_websocket(io, &mut config)?;\n\n // ── Step 5: Weixin channel ───────────────────────────────────\n print_step(io, 5, \"WeChat Channel\")?;\n configure_weixin(io, &mut config)?;\n\n // ── Step 6: Validate & write ─────────────────────────────────\n print_step(io, 6, \"Save Configuration\")?;\n\n io.write_line(&format!(\" Config path: {}\", config_path.display()))?;\n io.write_line(\"\")?;\n show_config_summary(io, &config)?;\n io.write_line(\"\")?;\n\n if !io.confirm(\"Write this configuration?\", true)? {\n io.write_line(&format!(\" {} Setup cancelled.\", \"!\".yellow()))?;\n return Ok(());\n }\n\n let home = config_path\n .parent()\n .context(\"Config path must have a parent directory\")?;\n\n std::fs::create_dir_all(home)\n .with_context(|| format!(\"Failed to create config home: {}\", home.display()))?;\n\n loader::save_config(&config, config_path)?;\n\n for dir in [\"skills\", \"sessions\", \"learning\"] {\n let path = home.join(dir);\n std::fs::create_dir_all(&path)\n .with_context(|| format!(\"Failed to create directory: {}\", path.display()))?;\n }\n\n io.write_line(\"\")?;\n io.write_line(&format!(\n \" {} Configuration saved to {}\",\n \"✓\".green(),\n config_path.display()\n ))?;\n io.write_line(&format!(\n \" {} Created directories: skills, sessions, learning\",\n \"✓\".green()\n ))?;\n io.write_line(&format!(\" {} Setup complete!\", \"✓\".green()))?;\n\n Ok(())\n}\n\nfn print_banner(io: &dyn WizardIo) -> Result<()> {\n io.write_line(\"\")?;\n io.write_line(&format!(\n \" {} {}\",\n \"▸\".cyan(),\n \"Kestrel Setup Wizard\".bold().cyan()\n ))?;\n io.write_line(\"\")?;\n Ok(())\n}\n\nfn print_step(io: &dyn WizardIo, step: usize, title: &str) -> Result<()> {\n io.write_line(\"\")?;\n io.write_line(&format!(\n \" {} Step {}/{}: {}\",\n \"▸\".cyan(),\n step,\n TOTAL_STEPS,\n title.bold()\n ))?;\n io.write_line(&format!(\" {}\", \"─\".repeat(40).dimmed()))?;\n Ok(())\n}\n\nfn load_existing_config(path: &Path) -> Result {\n let content = std::fs::read_to_string(path)\n .with_context(|| format!(\"Failed to read {}\", path.display()))?;\n let config: Config =\n toml::from_str(&content).with_context(|| format!(\"Failed to parse {}\", path.display()))?;\n Ok(config)\n}\n\nfn mask_token(token: &str) -> String {\n if token.is_empty() {\n \"(not set)\".to_string()\n } else if token.len() <= 3 {\n \"(masked)\".to_string()\n } else {\n format!(\"{}…(masked)\", &token[..3])\n }\n}\n\nfn show_config_summary(io: &dyn WizardIo, config: &Config) -> Result<()> {\n let provider = config.agent.provider.as_deref().unwrap_or(\"default\");\n io.write_line(&format!(\" Model: {}\", config.agent.model))?;\n io.write_line(&format!(\" Provider: {}\", provider))?;\n io.write_line(&format!(\" Temperature: {}\", config.agent.temperature))?;\n io.write_line(&format!(\" Max tokens: {}\", config.agent.max_tokens))?;\n io.write_line(&format!(\" Streaming: {}\", config.agent.streaming))?;\n\n if let Some(ref tg) = config.channels.telegram {\n io.write_line(&format!(\" Telegram: {}\", mask_token(&tg.token)))?;\n }\n\n if let Some(ref fs) = config.channels.feishu {\n if fs.enabled {\n let app_id = fs.app_id.as_deref().unwrap_or(\"(not set)\");\n io.write_line(&format!(\" Feishu: {}\", mask_token(app_id)))?;\n }\n }\n\n if let Some(ref ws) = config.channels.websocket {\n if ws.enabled {\n io.write_line(&format!(\" WebSocket: {}\", ws.listen_addr))?;\n }\n }\n\n if let Some(ref wx) = config.channels.weixin {\n let status = if wx.enabled {\n mask_token(wx.bot_token.as_deref().unwrap_or(\"\"))\n } else {\n \"disabled\".to_string()\n };\n io.write_line(&format!(\" WeChat: {}\", status))?;\n }\n\n Ok(())\n}\n\nfn configure_provider(io: &dyn WizardIo, config: &mut Config) -> Result<()> {\n let default_idx = config\n .agent\n .provider\n .as_deref()\n .and_then(|p| PROVIDER_NAMES.iter().position(|&n| n == p))\n .unwrap_or(1); // default to \"openai\"\n\n let provider_name = io.select(\"Select LLM provider\", PROVIDER_NAMES, default_idx)?;\n\n let provider_key = PROVIDER_NAMES[provider_name];\n config.agent.provider = Some(provider_key.to_string());\n\n let default_model = match provider_key {\n \"anthropic\" => \"claude-sonnet-4-20250514\",\n \"openai\" => \"gpt-4o\",\n \"openrouter\" => \"anthropic/claude-sonnet-4-20250514\",\n \"ollama\" => \"llama3\",\n \"deepseek\" => \"deepseek-chat\",\n \"gemini\" => \"gemini-2.5-pro\",\n \"groq\" => \"llama-3.3-70b-versatile\",\n \"moonshot\" => \"moonshot-v1-8k\",\n \"minimax\" => \"MiniMax-Text-01\",\n \"github_copilot\" => \"gpt-4o\",\n \"openai_codex\" => \"codex-mini\",\n \"glm_coding_plan\" => \"glm-5.1\",\n _ => \"gpt-4o\",\n };\n\n let current_model = if config.agent.model.is_empty() {\n default_model\n } else {\n &config.agent.model\n };\n\n let model: String = io.input_with_default(\"Model name\", current_model)?;\n config.agent.model = model;\n\n let default_url = match provider_key {\n \"anthropic\" => \"https://api.anthropic.com\",\n \"openai\" => \"https://api.openai.com/v1\",\n \"openrouter\" => \"https://openrouter.ai/api/v1\",\n \"ollama\" => \"http://localhost:11434\",\n \"deepseek\" => \"https://api.deepseek.com\",\n \"gemini\" => \"https://generativelanguage.googleapis.com/v1beta\",\n \"groq\" => \"https://api.groq.com/openai/v1\",\n \"moonshot\" => \"https://api.moonshot.cn/v1\",\n \"minimax\" => \"https://api.minimax.chat/v1\",\n \"github_copilot\" => \"https://api.githubcopilot.com\",\n \"openai_codex\" => \"https://api.openai.com/v1\",\n \"glm_coding_plan\" => \"https://open.bigmodel.cn/api/coding/paas/v4\",\n _ => \"\",\n };\n\n let current_url = get_provider_url(config, provider_key).unwrap_or(default_url);\n\n if !current_url.is_empty() {\n let base_url: String = io.input_with_default(\"Base URL\", current_url)?;\n set_provider_url(config, provider_key, &base_url);\n } else {\n let base_url: String = io.input_allow_empty(\"Base URL (leave empty for default)\")?;\n if !base_url.is_empty() {\n set_provider_url(config, provider_key, &base_url);\n }\n }\n\n let api_key: String = io.password(\"API key\")?;\n if !api_key.is_empty() {\n set_provider_api_key(config, provider_key, &api_key);\n }\n\n Ok(())\n}\n\nfn configure_telegram(io: &dyn WizardIo, config: &mut Config) -> Result<()> {\n let setup_tg = if config.channels.telegram.is_some() {\n io.confirm(\"Configure Telegram bot?\", true)?\n } else {\n io.confirm(\"Set up a Telegram bot?\", false)?\n };\n\n if !setup_tg {\n io.write_line(\" Skipped.\")?;\n return Ok(());\n }\n\n let current_token = config\n .channels\n .telegram\n .as_ref()\n .map(|tg| tg.token.as_str())\n .unwrap_or(\"\");\n\n let token: String = if current_token.is_empty() {\n io.input_allow_empty(\"Bot token (from @BotFather)\")?\n } else {\n io.input_with_default(\"Bot token (from @BotFather)\", current_token)?\n };\n\n if token.is_empty() {\n io.write_line(\" No token provided, skipping Telegram.\")?;\n return Ok(());\n }\n\n let allowed: String = loop {\n let input: String =\n io.input_allow_empty(\"Allowed user IDs (comma-separated, leave empty for all)\")?;\n if input.trim().is_empty() {\n break String::new();\n }\n let ids: Vec<&str> = input\n .split(',')\n .map(|s| s.trim())\n .filter(|s| !s.is_empty())\n .collect();\n let mut valid = true;\n for id in &ids {\n if id.parse::().is_err() {\n io.write_line(&format!(\n \" {} '{}' is not a valid user ID (must be a positive integer).\",\n \"!\".yellow(),\n id\n ))?;\n valid = false;\n break;\n }\n }\n if valid {\n break input;\n }\n };\n\n let allowed_users: Vec = if allowed.trim().is_empty() {\n Vec::new()\n } else {\n allowed\n .split(',')\n .map(|s| s.trim().to_string())\n .filter(|s| !s.is_empty())\n .collect()\n };\n\n // Preserve existing values for fields not explicitly configured in the wizard.\n let (admin_users, enabled, streaming, proxy) = config\n .channels\n .telegram\n .as_ref()\n .map(|tg| {\n (\n tg.admin_users.clone(),\n tg.enabled,\n tg.streaming,\n tg.proxy.clone(),\n )\n })\n .unwrap_or((Vec::new(), true, true, None));\n\n config.channels.telegram = Some(TelegramConfig {\n token,\n allowed_users,\n admin_users,\n enabled,\n streaming,\n proxy,\n });\n\n Ok(())\n}\n\nfn configure_feishu(io: &dyn WizardIo, config: &mut Config) -> Result<()> {\n let has_existing = config.channels.feishu.as_ref().is_some_and(|f| f.enabled);\n\n let setup = if has_existing {\n io.confirm(\"Reconfigure Feishu / Lark?\", true)?\n } else {\n io.confirm(\"Set up Feishu / Lark?\", false)?\n };\n\n if !setup {\n io.write_line(\" Skipped.\")?;\n return Ok(());\n }\n\n let domain_options = [\"Feishu (飞书)\", \"Lark (international)\"];\n let idx = io.select(\"Select platform\", &domain_options, 0)?;\n let domain = if idx == 1 { \"lark\" } else { \"feishu\" };\n\n let rt = tokio::runtime::Builder::new_current_thread()\n .enable_all()\n .build()\n .context(\"Failed to create tokio runtime\")?;\n\n // TODO: Creating a nested tokio runtime here means this cannot be called\n // from within an existing async context (e.g. tests). A future refactor\n // could make `run_wizard` async or accept a runtime handle.\n let result = match rt.block_on(super::feishu_onboarding::run_onboarding(domain)) {\n Ok(r) => r,\n Err(e) => {\n io.write_line(&format!(\" {} Feishu setup skipped: {}\", \"!\".yellow(), e))?;\n return Ok(());\n }\n };\n\n let existing_proxy = config\n .channels\n .feishu\n .as_ref()\n .and_then(|f| f.proxy.clone());\n\n config.channels.feishu = Some(FeishuConfig {\n app_id: Some(result.app_id),\n app_secret: Some(result.app_secret),\n enabled: true,\n proxy: existing_proxy,\n });\n\n Ok(())\n}\n\nfn configure_websocket(io: &dyn WizardIo, config: &mut Config) -> Result<()> {\n let default_addr = config\n .channels\n .websocket\n .as_ref()\n .map(|ws| ws.listen_addr.as_str())\n .unwrap_or(\"127.0.0.1:8090\");\n\n let enable = io.confirm(\"Enable WebSocket channel?\", false)?;\n\n if !enable {\n config.channels.websocket = None;\n io.write_line(\" Skipped.\")?;\n return Ok(());\n }\n\n let listen_addr: String = loop {\n let input = io.input_with_default(\"Listen address\", default_addr)?;\n match input.parse::() {\n Ok(_) => break input,\n Err(e) => {\n io.write_line(&format!(\n \" {} Invalid address '{}': {}\",\n \"!\".yellow(),\n input,\n e\n ))?;\n }\n }\n };\n\n config.channels.websocket = Some(WebSocketConfig {\n enabled: true,\n listen_addr,\n auth: Default::default(),\n max_clients: 100,\n max_message_size: 1048576,\n });\n\n Ok(())\n}\n\nfn configure_weixin(io: &dyn WizardIo, config: &mut Config) -> Result<()> {\n let setup_wx = if config.channels.weixin.is_some() {\n io.confirm(\"Configure WeChat channel?\", true)?\n } else {\n io.confirm(\"Set up a WeChat channel?\", false)?\n };\n\n if !setup_wx {\n io.write_line(\" Skipped.\")?;\n return Ok(());\n }\n\n let choices = &[\n \"Scan QR code with WeChat (recommended)\",\n \"Enter credentials manually\",\n \"Skip\",\n ];\n let choice = io.select(\"How would you like to configure WeChat?\", choices, 0)?;\n\n match choice {\n 0 => {\n io.write_line(\n \" QR scan setup requires running `kestrel setup weixin` in a terminal.\",\n )?;\n io.write_line(\" Please run that command separately, then return to this wizard.\")?;\n // Mark as enabled if credentials already exist\n if let Some(ref wx) = config.channels.weixin {\n if wx.account_id.is_some() && wx.bot_token.is_some() {\n io.write_line(&format!(\n \" {} Existing WeChat credentials detected.\",\n \"✓\".green()\n ))?;\n }\n }\n // If no credentials yet, just leave channel unconfigured\n if config.channels.weixin.is_none() {\n io.write_line(\n \" No WeChat credentials found yet. Run `kestrel setup weixin` first.\",\n )?;\n }\n }\n 1 => {\n let current_account = config\n .channels\n .weixin\n .as_ref()\n .and_then(|w| w.account_id.as_deref())\n .unwrap_or(\"\");\n let account_id: String = if current_account.is_empty() {\n io.input_allow_empty(\"iLink account ID (e.g. wxid_xxx@im.bot)\")?\n } else {\n io.input_with_default(\"iLink account ID\", current_account)?\n };\n\n if account_id.trim().is_empty() {\n io.write_line(\" No account ID provided, skipping WeChat.\")?;\n return Ok(());\n }\n\n let current_token = config\n .channels\n .weixin\n .as_ref()\n .and_then(|w| w.bot_token.as_deref())\n .unwrap_or(\"\");\n let bot_token: String = if current_token.is_empty() {\n io.input_allow_empty(\"iLink bot token\")?\n } else {\n io.input_with_default(\"iLink bot token\", current_token)?\n };\n\n if bot_token.trim().is_empty() {\n io.write_line(\" No bot token provided, skipping WeChat.\")?;\n return Ok(());\n }\n\n // Preserve existing fields\n let (\n app_id,\n app_secret,\n old_token,\n encoding_aes_key,\n base_url,\n cdn_base_url,\n dm_policy,\n group_policy,\n allowed_users,\n group_allowed_users,\n ) = config\n .channels\n .weixin\n .as_ref()\n .map(|w| {\n (\n w.app_id.clone(),\n w.app_secret.clone(),\n w.token.clone(),\n w.encoding_aes_key.clone(),\n w.base_url.clone(),\n w.cdn_base_url.clone(),\n w.dm_policy.clone(),\n w.group_policy.clone(),\n w.allowed_users.clone(),\n w.group_allowed_users.clone(),\n )\n })\n .unwrap_or_default();\n\n config.channels.weixin = Some(WeixinConfig {\n account_id: Some(account_id.trim().to_string()),\n bot_token: Some(bot_token.trim().to_string()),\n app_id,\n app_secret,\n token: old_token,\n encoding_aes_key,\n base_url,\n cdn_base_url,\n dm_policy,\n group_policy,\n allowed_users,\n group_allowed_users,\n enabled: true,\n });\n\n io.write_line(&format!(\" {} WeChat credentials saved.\", \"✓\".green()))?;\n }\n _ => {\n io.write_line(\" Skipped.\")?;\n }\n }\n\n Ok(())\n}\n\n// ── Provider field helpers (using macro) ───────────────────────\n\nfn get_provider_entry_mut<'a>(\n config: &'a mut Config,\n provider: &str,\n) -> Option<&'a mut ProviderEntry> {\n match provider {\n \"anthropic\" => config.providers.anthropic.as_mut(),\n \"openai\" => config.providers.openai.as_mut(),\n \"openrouter\" => config.providers.openrouter.as_mut(),\n \"ollama\" => config.providers.ollama.as_mut(),\n \"deepseek\" => config.providers.deepseek.as_mut(),\n \"gemini\" => config.providers.gemini.as_mut(),\n \"groq\" => config.providers.groq.as_mut(),\n \"moonshot\" => config.providers.moonshot.as_mut(),\n \"minimax\" => config.providers.minimax.as_mut(),\n \"github_copilot\" => config.providers.github_copilot.as_mut(),\n \"openai_codex\" => config.providers.openai_codex.as_mut(),\n \"opencode_go\" => config.providers.opencode_go.as_mut(),\n \"glm_coding_plan\" => config.providers.glm_coding_plan.as_mut(),\n _ => None,\n }\n}\n\nfn ensure_provider_entry(config: &mut Config, provider: &str) {\n match provider {\n \"anthropic\" => {\n config\n .providers\n .anthropic\n .get_or_insert_with(ProviderEntry::default);\n }\n \"openai\" => {\n config\n .providers\n .openai\n .get_or_insert_with(ProviderEntry::default);\n }\n \"openrouter\" => {\n config\n .providers\n .openrouter\n .get_or_insert_with(ProviderEntry::default);\n }\n \"ollama\" => {\n config\n .providers\n .ollama\n .get_or_insert_with(ProviderEntry::default);\n }\n \"deepseek\" => {\n config\n .providers\n .deepseek\n .get_or_insert_with(ProviderEntry::default);\n }\n \"gemini\" => {\n config\n .providers\n .gemini\n .get_or_insert_with(ProviderEntry::default);\n }\n \"groq\" => {\n config\n .providers\n .groq\n .get_or_insert_with(ProviderEntry::default);\n }\n \"moonshot\" => {\n config\n .providers\n .moonshot\n .get_or_insert_with(ProviderEntry::default);\n }\n \"minimax\" => {\n config\n .providers\n .minimax\n .get_or_insert_with(ProviderEntry::default);\n }\n \"github_copilot\" => {\n config\n .providers\n .github_copilot\n .get_or_insert_with(ProviderEntry::default);\n }\n \"openai_codex\" => {\n config\n .providers\n .openai_codex\n .get_or_insert_with(ProviderEntry::default);\n }\n \"opencode_go\" => {\n config\n .providers\n .opencode_go\n .get_or_insert_with(ProviderEntry::default);\n }\n \"glm_coding_plan\" => {\n config\n .providers\n .glm_coding_plan\n .get_or_insert_with(ProviderEntry::default);\n }\n _ => {}\n }\n}\n\nfn get_provider_url<'a>(config: &'a Config, provider: &str) -> Option<&'a str> {\n let entry = match provider {\n \"anthropic\" => config.providers.anthropic.as_ref(),\n \"openai\" => config.providers.openai.as_ref(),\n \"openrouter\" => config.providers.openrouter.as_ref(),\n \"ollama\" => config.providers.ollama.as_ref(),\n \"deepseek\" => config.providers.deepseek.as_ref(),\n \"gemini\" => config.providers.gemini.as_ref(),\n \"groq\" => config.providers.groq.as_ref(),\n \"moonshot\" => config.providers.moonshot.as_ref(),\n \"minimax\" => config.providers.minimax.as_ref(),\n \"github_copilot\" => config.providers.github_copilot.as_ref(),\n \"openai_codex\" => config.providers.openai_codex.as_ref(),\n \"opencode_go\" => config.providers.opencode_go.as_ref(),\n \"glm_coding_plan\" => config.providers.glm_coding_plan.as_ref(),\n _ => None,\n };\n entry.and_then(|e| e.base_url.as_deref())\n}\n\nfn set_provider_url(config: &mut Config, provider: &str, url: &str) {\n ensure_provider_entry(config, provider);\n if let Some(entry) = get_provider_entry_mut(config, provider) {\n entry.base_url = if url.is_empty() {\n None\n } else {\n Some(url.to_string())\n };\n }\n}\n\nfn set_provider_api_key(config: &mut Config, provider: &str, key: &str) {\n ensure_provider_entry(config, provider);\n if let Some(entry) = get_provider_entry_mut(config, provider) {\n entry.api_key = if key.is_empty() {\n None\n } else {\n Some(key.to_string())\n };\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use std::cell::RefCell;\n use std::collections::VecDeque;\n\n fn template_toml() -> String {\n toml::to_string(&Config::default()).unwrap()\n }\n\n // ── Mock wizard IO for flow testing ─────────────────────────\n\n #[derive(Debug, Clone)]\n enum MockAction {\n Confirm {\n prompt_contains: &'static str,\n result: bool,\n },\n Select {\n result: usize,\n },\n Input {\n result: &'static str,\n },\n Password {\n result: &'static str,\n },\n }\n\n struct MockWizard {\n actions: RefCell>,\n output: RefCell,\n }\n\n impl MockWizard {\n fn new(actions: Vec) -> Self {\n Self {\n actions: RefCell::new(actions.into()),\n output: RefCell::new(String::new()),\n }\n }\n\n fn output(&self) -> String {\n self.output.borrow().clone()\n }\n }\n\n impl WizardIo for MockWizard {\n fn confirm(&self, prompt: &str, _default: bool) -> Result {\n let action = self.actions.borrow_mut().pop_front();\n match action {\n Some(MockAction::Confirm {\n prompt_contains,\n result,\n }) => {\n assert!(\n prompt.contains(prompt_contains),\n \"confirm prompt '{}' did not contain '{}'\",\n prompt,\n prompt_contains\n );\n Ok(result)\n }\n _ => panic!(\"unexpected confirm call, prompt: {}\", prompt),\n }\n }\n\n fn select(&self, _prompt: &str, _items: &[&str], _default: usize) -> Result {\n let action = self.actions.borrow_mut().pop_front();\n match action {\n Some(MockAction::Select { result }) => Ok(result),\n _ => panic!(\"unexpected select call\"),\n }\n }\n\n fn input_with_default(&self, _prompt: &str, _default: &str) -> Result {\n let action = self.actions.borrow_mut().pop_front();\n match action {\n Some(MockAction::Input { result }) => Ok(result.to_string()),\n _ => panic!(\"unexpected input_with_default call\"),\n }\n }\n\n fn input_allow_empty(&self, _prompt: &str) -> Result {\n let action = self.actions.borrow_mut().pop_front();\n match action {\n Some(MockAction::Input { result }) => Ok(result.to_string()),\n _ => panic!(\"unexpected input_allow_empty call\"),\n }\n }\n\n fn password(&self, _prompt: &str) -> Result {\n let action = self.actions.borrow_mut().pop_front();\n match action {\n Some(MockAction::Password { result }) => Ok(result.to_string()),\n _ => panic!(\"unexpected password call\"),\n }\n }\n\n fn write_line(&self, line: &str) -> Result<()> {\n self.output.borrow_mut().push_str(line);\n self.output.borrow_mut().push('\\n');\n Ok(())\n }\n }\n\n // ── Unit tests ──────────────────────────────────────────────\n\n #[test]\n fn setup_creates_template_config_when_config_is_missing() {\n let tmp = tempfile::tempdir().unwrap();\n let config_path = tmp.path().join(\"config.toml\");\n\n let config = Config::default();\n let home = config_path.parent().unwrap();\n std::fs::create_dir_all(home).unwrap();\n loader::save_config(&config, &config_path).unwrap();\n\n assert_eq!(\n std::fs::read_to_string(&config_path).unwrap(),\n template_toml()\n );\n assert!(config_path.exists());\n }\n\n #[test]\n fn provider_helpers_set_and_get_fields() {\n let mut config = Config::default();\n set_provider_url(&mut config, \"openai\", \"https://custom.api/v1\");\n set_provider_api_key(&mut config, \"openai\", \"sk-test-key\");\n\n let entry = config.providers.openai.as_ref().unwrap();\n assert_eq!(entry.base_url.as_deref(), Some(\"https://custom.api/v1\"));\n assert_eq!(entry.api_key.as_deref(), Some(\"sk-test-key\"));\n }\n\n #[test]\n fn provider_helpers_handle_unknown_provider() {\n let mut config = Config::default();\n set_provider_url(&mut config, \"nonexistent\", \"https://example.com\");\n assert!(get_provider_url(&config, \"nonexistent\").is_none());\n }\n\n #[test]\n fn wizard_keeps_existing_config() {\n let tmp = tempfile::tempdir().unwrap();\n let config_path = tmp.path().join(\"config.toml\");\n\n let existing = Config::default();\n std::fs::create_dir_all(config_path.parent().unwrap()).unwrap();\n loader::save_config(&existing, &config_path).unwrap();\n\n let mock = MockWizard::new(vec![MockAction::Confirm {\n prompt_contains: \"Update\",\n result: false,\n }]);\n\n run_wizard(&mock, &config_path).unwrap();\n\n // Config should be unchanged\n assert_eq!(\n std::fs::read_to_string(&config_path).unwrap(),\n template_toml()\n );\n }\n\n #[test]\n fn wizard_fresh_setup() {\n let tmp = tempfile::tempdir().unwrap();\n let config_path = tmp.path().join(\"config.toml\");\n\n let mock = MockWizard::new(vec![\n // Step 2: Provider\n MockAction::Select { result: 1 }, // openai\n MockAction::Input { result: \"gpt-4o\" },\n MockAction::Input {\n result: \"https://api.openai.com/v1\",\n },\n MockAction::Password {\n result: \"sk-test-key\",\n },\n // Step 3: Telegram (skip)\n MockAction::Confirm {\n prompt_contains: \"Telegram\",\n result: false,\n },\n // Step 4: Feishu (skip)\n MockAction::Confirm {\n prompt_contains: \"Feishu\",\n result: false,\n },\n // Step 5: WebSocket (skip)\n MockAction::Confirm {\n prompt_contains: \"WebSocket\",\n result: false,\n },\n // Step 5: WeChat (skip)\n MockAction::Confirm {\n prompt_contains: \"WeChat\",\n result: false,\n },\n // Step 6: Save\n MockAction::Confirm {\n prompt_contains: \"Write\",\n result: true,\n },\n ]);\n\n run_wizard(&mock, &config_path).unwrap();\n\n assert!(config_path.exists());\n let saved: Config =\n toml::from_str(&std::fs::read_to_string(&config_path).unwrap()).unwrap();\n assert_eq!(saved.agent.provider.as_deref(), Some(\"openai\"));\n assert_eq!(saved.agent.model, \"gpt-4o\");\n }\n\n #[test]\n fn wizard_overwrite_existing_config() {\n let tmp = tempfile::tempdir().unwrap();\n let config_path = tmp.path().join(\"config.toml\");\n\n let existing = Config::default();\n std::fs::create_dir_all(config_path.parent().unwrap()).unwrap();\n loader::save_config(&existing, &config_path).unwrap();\n\n let mock = MockWizard::new(vec![\n // Step 1: Overwrite\n MockAction::Confirm {\n prompt_contains: \"Update\",\n result: true,\n },\n // Step 2: Provider\n MockAction::Select { result: 0 }, // anthropic\n MockAction::Input {\n result: \"claude-sonnet-4-20250514\",\n },\n MockAction::Input {\n result: \"https://api.anthropic.com\",\n },\n MockAction::Password {\n result: \"sk-ant-test\",\n },\n // Step 3: Telegram (skip)\n MockAction::Confirm {\n prompt_contains: \"Telegram\",\n result: false,\n },\n // Step 4: Feishu (skip)\n MockAction::Confirm {\n prompt_contains: \"Feishu\",\n result: false,\n },\n // Step 5: WebSocket (skip)\n MockAction::Confirm {\n prompt_contains: \"WebSocket\",\n result: false,\n },\n // Step 5: WeChat (skip)\n MockAction::Confirm {\n prompt_contains: \"WeChat\",\n result: false,\n },\n // Step 6: Save\n MockAction::Confirm {\n prompt_contains: \"Write\",\n result: true,\n },\n ]);\n\n run_wizard(&mock, &config_path).unwrap();\n\n let saved: Config =\n toml::from_str(&std::fs::read_to_string(&config_path).unwrap()).unwrap();\n assert_eq!(saved.agent.provider.as_deref(), Some(\"anthropic\"));\n assert_eq!(saved.agent.model, \"claude-sonnet-4-20250514\");\n }\n\n fn tg(token: &str) -> TelegramConfig {\n TelegramConfig {\n token: token.to_string(),\n allowed_users: Vec::new(),\n admin_users: Vec::new(),\n enabled: true,\n streaming: false,\n proxy: None,\n }\n }\n\n #[test]\n fn token_masking_short_tokens() {\n let mut config = Config::default();\n config.channels.telegram = Some(tg(\"ab\"));\n\n let mock = MockWizard::new(vec![]);\n show_config_summary(&mock, &config).unwrap();\n\n let output = mock.output();\n assert!(output.contains(\"(masked)\"));\n assert!(!output.contains(\"ab…\"));\n }\n\n #[test]\n fn token_masking_empty_token() {\n let mut config = Config::default();\n config.channels.telegram = Some(tg(\"\"));\n\n let mock = MockWizard::new(vec![]);\n show_config_summary(&mock, &config).unwrap();\n\n let output = mock.output();\n assert!(output.contains(\"(not set)\"));\n }\n}\n"}, "ce75d0bbd7ad9beed2a8928c45262854bebeef90": {"t": 1785761079.937963, "n": 35777, "h": "8053cc613885dda7", "c": "//! Setup command — interactive wizard for configuring Kestrel.\n\nuse anyhow::{bail, Context, Result};\nuse console::Term;\nuse dialoguer::{Confirm, Input, Password as PasswordInput, Select};\nuse kestrel_config::{\n loader, paths,\n schema::{Config, ProviderEntry, TelegramConfig, WebSocketConfig, WeixinConfig},\n};\nuse owo_colors::OwoColorize;\nuse std::net::SocketAddr;\nuse std::path::Path;\n\nconst PROVIDER_NAMES: &[&str] = &[\n \"anthropic\",\n \"openai\",\n \"openrouter\",\n \"ollama\",\n \"deepseek\",\n \"gemini\",\n \"groq\",\n \"moonshot\",\n \"minimax\",\n \"github_copilot\",\n \"openai_codex\",\n \"glm_coding_plan\",\n];\n\nconst TOTAL_STEPS: usize = 6;\n\n// ── Trait for interactive I/O (enables testability) ────────────\n\ntrait WizardIo {\n fn confirm(&self, prompt: &str, default: bool) -> Result;\n fn select(&self, prompt: &str, items: &[&str], default: usize) -> Result;\n fn input_with_default(&self, prompt: &str, default: &str) -> Result;\n fn input_allow_empty(&self, prompt: &str) -> Result;\n fn password(&self, prompt: &str) -> Result;\n fn write_line(&self, line: &str) -> Result<()>;\n}\n\n// ── Production implementation using Term + dialoguer ───────────\n\nstruct TermWizard<'a> {\n term: &'a Term,\n}\n\nimpl<'a> TermWizard<'a> {\n fn new(term: &'a Term) -> Self {\n Self { term }\n }\n}\n\nimpl WizardIo for TermWizard<'_> {\n fn confirm(&self, prompt: &str, default: bool) -> Result {\n Ok(Confirm::new()\n .with_prompt(prompt)\n .default(default)\n .interact_on(self.term)?)\n }\n\n fn select(&self, prompt: &str, items: &[&str], default: usize) -> Result {\n Ok(Select::new()\n .with_prompt(prompt)\n .items(items)\n .default(default)\n .interact_on(self.term)?)\n }\n\n fn input_with_default(&self, prompt: &str, default: &str) -> Result {\n Ok(Input::new()\n .with_prompt(prompt)\n .default(default.to_string())\n .interact_text_on(self.term)?)\n }\n\n fn input_allow_empty(&self, prompt: &str) -> Result {\n Ok(Input::::new()\n .with_prompt(prompt)\n .allow_empty(true)\n .interact_text_on(self.term)?)\n }\n\n fn password(&self, prompt: &str) -> Result {\n Ok(PasswordInput::new()\n .with_prompt(prompt)\n .interact_on(self.term)?)\n }\n\n fn write_line(&self, line: &str) -> Result<()> {\n self.term.write_line(line)?;\n Ok(())\n }\n}\n\n// ── Provider dispatch macro (single source of truth) ───────────\n\n#[allow(unused_macros)]\nmacro_rules! provider_field {\n ($config:expr, $provider:expr, mut) => {\n match $provider {\n \"anthropic\" => Some(&mut $config.providers.anthropic),\n \"openai\" => Some(&mut $config.providers.openai),\n \"openrouter\" => Some(&mut $config.providers.openrouter),\n \"ollama\" => Some(&mut $config.providers.ollama),\n \"deepseek\" => Some(&mut $config.providers.deepseek),\n \"gemini\" => Some(&mut $config.providers.gemini),\n \"groq\" => Some(&mut $config.providers.groq),\n \"moonshot\" => Some(&mut $config.providers.moonshot),\n \"minimax\" => Some(&mut $config.providers.minimax),\n \"github_copilot\" => Some(&mut $config.providers.github_copilot),\n \"openai_codex\" => Some(&mut $config.providers.openai_codex),\n _ => None,\n }\n };\n ($config:expr, $provider:expr) => {\n match $provider {\n \"anthropic\" => Some(&$config.providers.anthropic),\n \"openai\" => Some(&$config.providers.openai),\n \"openrouter\" => Some(&$config.providers.openrouter),\n \"ollama\" => Some(&$config.providers.ollama),\n \"deepseek\" => Some(&$config.providers.deepseek),\n \"gemini\" => Some(&$config.providers.gemini),\n \"groq\" => Some(&$config.providers.groq),\n \"moonshot\" => Some(&$config.providers.moonshot),\n \"minimax\" => Some(&$config.providers.minimax),\n \"github_copilot\" => Some(&$config.providers.github_copilot),\n \"openai_codex\" => Some(&$config.providers.openai_codex),\n _ => None,\n }\n };\n}\n\n// ── Entry point ────────────────────────────────────────────────\n\npub fn run(_config: Config) -> Result<()> {\n let term = Term::stdout();\n if !term.is_term() {\n bail!(\n \"Setup requires an interactive terminal. \\\n Run this command in a terminal, not in a pipe or CI environment.\"\n );\n }\n let io = TermWizard::new(&term);\n let config_path = paths::get_config_path()?;\n run_wizard(&io, &config_path)\n}\n\n// ── Wizard flow ────────────────────────────────────────────────\n\nfn run_wizard(io: &dyn WizardIo, config_path: &Path) -> Result<()> {\n print_banner(io)?;\n\n // ── Step 1: Check existing config ────────────────────────────\n print_step(io, 1, \"Existing Configuration\")?;\n\n let mut config = if config_path.exists() {\n match load_existing_config(config_path) {\n Ok(existing) => {\n show_config_summary(io, &existing)?;\n if io.confirm(\"Update existing config?\", true)? {\n existing\n } else {\n io.write_line(&format!(\n \" {} Keeping config at {}.\",\n \"✓\".green(),\n config_path.display()\n ))?;\n return Ok(());\n }\n }\n Err(e) => {\n io.write_line(&format!(\n \" {} Could not parse existing config: {}\",\n \"!\".yellow(),\n e\n ))?;\n if io.confirm(\"Start fresh with defaults?\", true)? {\n Config::default()\n } else {\n bail!(\"Setup cancelled.\");\n }\n }\n }\n } else {\n io.write_line(\" No config file found. Starting fresh.\")?;\n Config::default()\n };\n\n // ── Step 2: Provider configuration ───────────────────────────\n print_step(io, 2, \"Provider Configuration\")?;\n configure_provider(io, &mut config)?;\n\n // ── Step 3: Telegram channel ─────────────────────────────────\n print_step(io, 3, \"Telegram Channel\")?;\n configure_telegram(io, &mut config)?;\n\n // ── Step 4: WebSocket port ───────────────────────────────────\n print_step(io, 4, \"WebSocket Port\")?;\n configure_websocket(io, &mut config)?;\n\n // ── Step 5: Weixin channel ───────────────────────────────────\n print_step(io, 5, \"WeChat Channel\")?;\n configure_weixin(io, &mut config)?;\n\n // ── Step 6: Validate & write ─────────────────────────────────\n print_step(io, 6, \"Save Configuration\")?;\n\n io.write_line(&format!(\" Config path: {}\", config_path.display()))?;\n io.write_line(\"\")?;\n show_config_summary(io, &config)?;\n io.write_line(\"\")?;\n\n if !io.confirm(\"Write this configuration?\", true)? {\n io.write_line(&format!(\" {} Setup cancelled.\", \"!\".yellow()))?;\n return Ok(());\n }\n\n let home = config_path\n .parent()\n .context(\"Config path must have a parent directory\")?;\n\n std::fs::create_dir_all(home)\n .with_context(|| format!(\"Failed to create config home: {}\", home.display()))?;\n\n loader::save_config(&config, config_path)?;\n\n for dir in [\"skills\", \"sessions\", \"learning\"] {\n let path = home.join(dir);\n std::fs::create_dir_all(&path)\n .with_context(|| format!(\"Failed to create directory: {}\", path.display()))?;\n }\n\n io.write_line(\"\")?;\n io.write_line(&format!(\n \" {} Configuration saved to {}\",\n \"✓\".green(),\n config_path.display()\n ))?;\n io.write_line(&format!(\n \" {} Created directories: skills, sessions, learning\",\n \"✓\".green()\n ))?;\n io.write_line(&format!(\" {} Setup complete!\", \"✓\".green()))?;\n\n Ok(())\n}\n\nfn print_banner(io: &dyn WizardIo) -> Result<()> {\n io.write_line(\"\")?;\n io.write_line(&format!(\n \" {} {}\",\n \"▸\".cyan(),\n \"Kestrel Setup Wizard\".bold().cyan()\n ))?;\n io.write_line(\"\")?;\n Ok(())\n}\n\nfn print_step(io: &dyn WizardIo, step: usize, title: &str) -> Result<()> {\n io.write_line(\"\")?;\n io.write_line(&format!(\n \" {} Step {}/{}: {}\",\n \"▸\".cyan(),\n step,\n TOTAL_STEPS,\n title.bold()\n ))?;\n io.write_line(&format!(\" {}\", \"─\".repeat(40).dimmed()))?;\n Ok(())\n}\n\nfn load_existing_config(path: &Path) -> Result {\n let content = std::fs::read_to_string(path)\n .with_context(|| format!(\"Failed to read {}\", path.display()))?;\n let config: Config =\n toml::from_str(&content).with_context(|| format!(\"Failed to parse {}\", path.display()))?;\n Ok(config)\n}\n\nfn mask_token(token: &str) -> String {\n if token.is_empty() {\n \"(not set)\".to_string()\n } else if token.len() <= 3 {\n \"(masked)\".to_string()\n } else {\n format!(\"{}…(masked)\", &token[..3])\n }\n}\n\nfn show_config_summary(io: &dyn WizardIo, config: &Config) -> Result<()> {\n let provider = config.agent.provider.as_deref().unwrap_or(\"default\");\n io.write_line(&format!(\" Model: {}\", config.agent.model))?;\n io.write_line(&format!(\" Provider: {}\", provider))?;\n io.write_line(&format!(\" Temperature: {}\", config.agent.temperature))?;\n io.write_line(&format!(\" Max tokens: {}\", config.agent.max_tokens))?;\n io.write_line(&format!(\" Streaming: {}\", config.agent.streaming))?;\n\n if let Some(ref tg) = config.channels.telegram {\n io.write_line(&format!(\" Telegram: {}\", mask_token(&tg.token)))?;\n }\n\n if let Some(ref ws) = config.channels.websocket {\n if ws.enabled {\n io.write_line(&format!(\" WebSocket: {}\", ws.listen_addr))?;\n }\n }\n\n if let Some(ref wx) = config.channels.weixin {\n let status = if wx.enabled {\n mask_token(wx.bot_token.as_deref().unwrap_or(\"\"))\n } else {\n \"disabled\".to_string()\n };\n io.write_line(&format!(\" WeChat: {}\", status))?;\n }\n\n Ok(())\n}\n\nfn configure_provider(io: &dyn WizardIo, config: &mut Config) -> Result<()> {\n let default_idx = config\n .agent\n .provider\n .as_deref()\n .and_then(|p| PROVIDER_NAMES.iter().position(|&n| n == p))\n .unwrap_or(1); // default to \"openai\"\n\n let provider_name = io.select(\"Select LLM provider\", PROVIDER_NAMES, default_idx)?;\n\n let provider_key = PROVIDER_NAMES[provider_name];\n config.agent.provider = Some(provider_key.to_string());\n\n let default_model = match provider_key {\n \"anthropic\" => \"claude-sonnet-4-20250514\",\n \"openai\" => \"gpt-4o\",\n \"openrouter\" => \"anthropic/claude-sonnet-4-20250514\",\n \"ollama\" => \"llama3\",\n \"deepseek\" => \"deepseek-chat\",\n \"gemini\" => \"gemini-2.5-pro\",\n \"groq\" => \"llama-3.3-70b-versatile\",\n \"moonshot\" => \"moonshot-v1-8k\",\n \"minimax\" => \"MiniMax-Text-01\",\n \"github_copilot\" => \"gpt-4o\",\n \"openai_codex\" => \"codex-mini\",\n \"glm_coding_plan\" => \"glm-5.1\",\n _ => \"gpt-4o\",\n };\n\n let current_model = if config.agent.model.is_empty() {\n default_model\n } else {\n &config.agent.model\n };\n\n let model: String = io.input_with_default(\"Model name\", current_model)?;\n config.agent.model = model;\n\n let default_url = match provider_key {\n \"anthropic\" => \"https://api.anthropic.com\",\n \"openai\" => \"https://api.openai.com/v1\",\n \"openrouter\" => \"https://openrouter.ai/api/v1\",\n \"ollama\" => \"http://localhost:11434\",\n \"deepseek\" => \"https://api.deepseek.com\",\n \"gemini\" => \"https://generativelanguage.googleapis.com/v1beta\",\n \"groq\" => \"https://api.groq.com/openai/v1\",\n \"moonshot\" => \"https://api.moonshot.cn/v1\",\n \"minimax\" => \"https://api.minimax.chat/v1\",\n \"github_copilot\" => \"https://api.githubcopilot.com\",\n \"openai_codex\" => \"https://api.openai.com/v1\",\n \"glm_coding_plan\" => \"https://open.bigmodel.cn/api/coding/paas/v4\",\n _ => \"\",\n };\n\n let current_url = get_provider_url(config, provider_key).unwrap_or(default_url);\n\n if !current_url.is_empty() {\n let base_url: String = io.input_with_default(\"Base URL\", current_url)?;\n set_provider_url(config, provider_key, &base_url);\n } else {\n let base_url: String = io.input_allow_empty(\"Base URL (leave empty for default)\")?;\n if !base_url.is_empty() {\n set_provider_url(config, provider_key, &base_url);\n }\n }\n\n let api_key: String = io.password(\"API key\")?;\n if !api_key.is_empty() {\n set_provider_api_key(config, provider_key, &api_key);\n }\n\n Ok(())\n}\n\nfn configure_telegram(io: &dyn WizardIo, config: &mut Config) -> Result<()> {\n let setup_tg = if config.channels.telegram.is_some() {\n io.confirm(\"Configure Telegram bot?\", true)?\n } else {\n io.confirm(\"Set up a Telegram bot?\", false)?\n };\n\n if !setup_tg {\n io.write_line(\" Skipped.\")?;\n return Ok(());\n }\n\n let current_token = config\n .channels\n .telegram\n .as_ref()\n .map(|tg| tg.token.as_str())\n .unwrap_or(\"\");\n\n let token: String = if current_token.is_empty() {\n io.input_allow_empty(\"Bot token (from @BotFather)\")?\n } else {\n io.input_with_default(\"Bot token (from @BotFather)\", current_token)?\n };\n\n if token.is_empty() {\n io.write_line(\" No token provided, skipping Telegram.\")?;\n return Ok(());\n }\n\n let allowed: String = loop {\n let input: String =\n io.input_allow_empty(\"Allowed user IDs (comma-separated, leave empty for all)\")?;\n if input.trim().is_empty() {\n break String::new();\n }\n let ids: Vec<&str> = input\n .split(',')\n .map(|s| s.trim())\n .filter(|s| !s.is_empty())\n .collect();\n let mut valid = true;\n for id in &ids {\n if id.parse::().is_err() {\n io.write_line(&format!(\n \" {} '{}' is not a valid user ID (must be a positive integer).\",\n \"!\".yellow(),\n id\n ))?;\n valid = false;\n break;\n }\n }\n if valid {\n break input;\n }\n };\n\n let allowed_users: Vec = if allowed.trim().is_empty() {\n Vec::new()\n } else {\n allowed\n .split(',')\n .map(|s| s.trim().to_string())\n .filter(|s| !s.is_empty())\n .collect()\n };\n\n // Preserve existing values for fields not explicitly configured in the wizard.\n let (admin_users, enabled, streaming, proxy) = config\n .channels\n .telegram\n .as_ref()\n .map(|tg| {\n (\n tg.admin_users.clone(),\n tg.enabled,\n tg.streaming,\n tg.proxy.clone(),\n )\n })\n .unwrap_or((Vec::new(), true, true, None));\n\n config.channels.telegram = Some(TelegramConfig {\n token,\n allowed_users,\n admin_users,\n enabled,\n streaming,\n proxy,\n });\n\n Ok(())\n}\n\nfn configure_websocket(io: &dyn WizardIo, config: &mut Config) -> Result<()> {\n let default_addr = config\n .channels\n .websocket\n .as_ref()\n .map(|ws| ws.listen_addr.as_str())\n .unwrap_or(\"127.0.0.1:8090\");\n\n let enable = io.confirm(\"Enable WebSocket channel?\", false)?;\n\n if !enable {\n config.channels.websocket = None;\n io.write_line(\" Skipped.\")?;\n return Ok(());\n }\n\n let listen_addr: String = loop {\n let input = io.input_with_default(\"Listen address\", default_addr)?;\n match input.parse::() {\n Ok(_) => break input,\n Err(e) => {\n io.write_line(&format!(\n \" {} Invalid address '{}': {}\",\n \"!\".yellow(),\n input,\n e\n ))?;\n }\n }\n };\n\n config.channels.websocket = Some(WebSocketConfig {\n enabled: true,\n listen_addr,\n auth: Default::default(),\n max_clients: 100,\n max_message_size: 1048576,\n });\n\n Ok(())\n}\n\nfn configure_weixin(io: &dyn WizardIo, config: &mut Config) -> Result<()> {\n let setup_wx = if config.channels.weixin.is_some() {\n io.confirm(\"Configure WeChat channel?\", true)?\n } else {\n io.confirm(\"Set up a WeChat channel?\", false)?\n };\n\n if !setup_wx {\n io.write_line(\" Skipped.\")?;\n return Ok(());\n }\n\n let choices = &[\n \"Scan QR code with WeChat (recommended)\",\n \"Enter credentials manually\",\n \"Skip\",\n ];\n let choice = io.select(\"How would you like to configure WeChat?\", choices, 0)?;\n\n match choice {\n 0 => {\n io.write_line(\n \" QR scan setup requires running `kestrel setup weixin` in a terminal.\",\n )?;\n io.write_line(\" Please run that command separately, then return to this wizard.\")?;\n // Mark as enabled if credentials already exist\n if let Some(ref wx) = config.channels.weixin {\n if wx.account_id.is_some() && wx.bot_token.is_some() {\n io.write_line(&format!(\n \" {} Existing WeChat credentials detected.\",\n \"✓\".green()\n ))?;\n }\n }\n // If no credentials yet, just leave channel unconfigured\n if config.channels.weixin.is_none() {\n io.write_line(\n \" No WeChat credentials found yet. Run `kestrel setup weixin` first.\",\n )?;\n }\n }\n 1 => {\n let current_account = config\n .channels\n .weixin\n .as_ref()\n .and_then(|w| w.account_id.as_deref())\n .unwrap_or(\"\");\n let account_id: String = if current_account.is_empty() {\n io.input_allow_empty(\"iLink account ID (e.g. wxid_xxx@im.bot)\")?\n } else {\n io.input_with_default(\"iLink account ID\", current_account)?\n };\n\n if account_id.trim().is_empty() {\n io.write_line(\" No account ID provided, skipping WeChat.\")?;\n return Ok(());\n }\n\n let current_token = config\n .channels\n .weixin\n .as_ref()\n .and_then(|w| w.bot_token.as_deref())\n .unwrap_or(\"\");\n let bot_token: String = if current_token.is_empty() {\n io.input_allow_empty(\"iLink bot token\")?\n } else {\n io.input_with_default(\"iLink bot token\", current_token)?\n };\n\n if bot_token.trim().is_empty() {\n io.write_line(\" No bot token provided, skipping WeChat.\")?;\n return Ok(());\n }\n\n // Preserve existing fields\n let (\n app_id,\n app_secret,\n old_token,\n encoding_aes_key,\n base_url,\n cdn_base_url,\n dm_policy,\n group_policy,\n allowed_users,\n group_allowed_users,\n ) = config\n .channels\n .weixin\n .as_ref()\n .map(|w| {\n (\n w.app_id.clone(),\n w.app_secret.clone(),\n w.token.clone(),\n w.encoding_aes_key.clone(),\n w.base_url.clone(),\n w.cdn_base_url.clone(),\n w.dm_policy.clone(),\n w.group_policy.clone(),\n w.allowed_users.clone(),\n w.group_allowed_users.clone(),\n )\n })\n .unwrap_or_default();\n\n config.channels.weixin = Some(WeixinConfig {\n account_id: Some(account_id.trim().to_string()),\n bot_token: Some(bot_token.trim().to_string()),\n app_id,\n app_secret,\n token: old_token,\n encoding_aes_key,\n base_url,\n cdn_base_url,\n dm_policy,\n group_policy,\n allowed_users,\n group_allowed_users,\n enabled: true,\n });\n\n io.write_line(&format!(\" {} WeChat credentials saved.\", \"✓\".green()))?;\n }\n _ => {\n io.write_line(\" Skipped.\")?;\n }\n }\n\n Ok(())\n}\n\n// ── Provider field helpers (using macro) ───────────────────────\n\nfn get_provider_entry_mut<'a>(\n config: &'a mut Config,\n provider: &str,\n) -> Option<&'a mut ProviderEntry> {\n match provider {\n \"anthropic\" => config.providers.anthropic.as_mut(),\n \"openai\" => config.providers.openai.as_mut(),\n \"openrouter\" => config.providers.openrouter.as_mut(),\n \"ollama\" => config.providers.ollama.as_mut(),\n \"deepseek\" => config.providers.deepseek.as_mut(),\n \"gemini\" => config.providers.gemini.as_mut(),\n \"groq\" => config.providers.groq.as_mut(),\n \"moonshot\" => config.providers.moonshot.as_mut(),\n \"minimax\" => config.providers.minimax.as_mut(),\n \"github_copilot\" => config.providers.github_copilot.as_mut(),\n \"openai_codex\" => config.providers.openai_codex.as_mut(),\n \"opencode_go\" => config.providers.opencode_go.as_mut(),\n \"glm_coding_plan\" => config.providers.glm_coding_plan.as_mut(),\n _ => None,\n }\n}\n\nfn ensure_provider_entry(config: &mut Config, provider: &str) {\n match provider {\n \"anthropic\" => {\n config\n .providers\n .anthropic\n .get_or_insert_with(ProviderEntry::default);\n }\n \"openai\" => {\n config\n .providers\n .openai\n .get_or_insert_with(ProviderEntry::default);\n }\n \"openrouter\" => {\n config\n .providers\n .openrouter\n .get_or_insert_with(ProviderEntry::default);\n }\n \"ollama\" => {\n config\n .providers\n .ollama\n .get_or_insert_with(ProviderEntry::default);\n }\n \"deepseek\" => {\n config\n .providers\n .deepseek\n .get_or_insert_with(ProviderEntry::default);\n }\n \"gemini\" => {\n config\n .providers\n .gemini\n .get_or_insert_with(ProviderEntry::default);\n }\n \"groq\" => {\n config\n .providers\n .groq\n .get_or_insert_with(ProviderEntry::default);\n }\n \"moonshot\" => {\n config\n .providers\n .moonshot\n .get_or_insert_with(ProviderEntry::default);\n }\n \"minimax\" => {\n config\n .providers\n .minimax\n .get_or_insert_with(ProviderEntry::default);\n }\n \"github_copilot\" => {\n config\n .providers\n .github_copilot\n .get_or_insert_with(ProviderEntry::default);\n }\n \"openai_codex\" => {\n config\n .providers\n .openai_codex\n .get_or_insert_with(ProviderEntry::default);\n }\n \"opencode_go\" => {\n config\n .providers\n .opencode_go\n .get_or_insert_with(ProviderEntry::default);\n }\n \"glm_coding_plan\" => {\n config\n .providers\n .glm_coding_plan\n .get_or_insert_with(ProviderEntry::default);\n }\n _ => {}\n }\n}\n\nfn get_provider_url<'a>(config: &'a Config, provider: &str) -> Option<&'a str> {\n let entry = match provider {\n \"anthropic\" => config.providers.anthropic.as_ref(),\n \"openai\" => config.providers.openai.as_ref(),\n \"openrouter\" => config.providers.openrouter.as_ref(),\n \"ollama\" => config.providers.ollama.as_ref(),\n \"deepseek\" => config.providers.deepseek.as_ref(),\n \"gemini\" => config.providers.gemini.as_ref(),\n \"groq\" => config.providers.groq.as_ref(),\n \"moonshot\" => config.providers.moonshot.as_ref(),\n \"minimax\" => config.providers.minimax.as_ref(),\n \"github_copilot\" => config.providers.github_copilot.as_ref(),\n \"openai_codex\" => config.providers.openai_codex.as_ref(),\n \"opencode_go\" => config.providers.opencode_go.as_ref(),\n \"glm_coding_plan\" => config.providers.glm_coding_plan.as_ref(),\n _ => None,\n };\n entry.and_then(|e| e.base_url.as_deref())\n}\n\nfn set_provider_url(config: &mut Config, provider: &str, url: &str) {\n ensure_provider_entry(config, provider);\n if let Some(entry) = get_provider_entry_mut(config, provider) {\n entry.base_url = if url.is_empty() {\n None\n } else {\n Some(url.to_string())\n };\n }\n}\n\nfn set_provider_api_key(config: &mut Config, provider: &str, key: &str) {\n ensure_provider_entry(config, provider);\n if let Some(entry) = get_provider_entry_mut(config, provider) {\n entry.api_key = if key.is_empty() {\n None\n } else {\n Some(key.to_string())\n };\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use std::cell::RefCell;\n use std::collections::VecDeque;\n\n fn template_toml() -> String {\n toml::to_string(&Config::default()).unwrap()\n }\n\n // ── Mock wizard IO for flow testing ─────────────────────────\n\n #[derive(Debug, Clone)]\n enum MockAction {\n Confirm {\n prompt_contains: &'static str,\n result: bool,\n },\n Select {\n result: usize,\n },\n Input {\n result: &'static str,\n },\n Password {\n result: &'static str,\n },\n }\n\n struct MockWizard {\n actions: RefCell>,\n output: RefCell,\n }\n\n impl MockWizard {\n fn new(actions: Vec) -> Self {\n Self {\n actions: RefCell::new(actions.into()),\n output: RefCell::new(String::new()),\n }\n }\n\n fn output(&self) -> String {\n self.output.borrow().clone()\n }\n }\n\n impl WizardIo for MockWizard {\n fn confirm(&self, prompt: &str, _default: bool) -> Result {\n let action = self.actions.borrow_mut().pop_front();\n match action {\n Some(MockAction::Confirm {\n prompt_contains,\n result,\n }) => {\n assert!(\n prompt.contains(prompt_contains),\n \"confirm prompt '{}' did not contain '{}'\",\n prompt,\n prompt_contains\n );\n Ok(result)\n }\n _ => panic!(\"unexpected confirm call, prompt: {}\", prompt),\n }\n }\n\n fn select(&self, _prompt: &str, _items: &[&str], _default: usize) -> Result {\n let action = self.actions.borrow_mut().pop_front();\n match action {\n Some(MockAction::Select { result }) => Ok(result),\n _ => panic!(\"unexpected select call\"),\n }\n }\n\n fn input_with_default(&self, _prompt: &str, _default: &str) -> Result {\n let action = self.actions.borrow_mut().pop_front();\n match action {\n Some(MockAction::Input { result }) => Ok(result.to_string()),\n _ => panic!(\"unexpected input_with_default call\"),\n }\n }\n\n fn input_allow_empty(&self, _prompt: &str) -> Result {\n let action = self.actions.borrow_mut().pop_front();\n match action {\n Some(MockAction::Input { result }) => Ok(result.to_string()),\n _ => panic!(\"unexpected input_allow_empty call\"),\n }\n }\n\n fn password(&self, _prompt: &str) -> Result {\n let action = self.actions.borrow_mut().pop_front();\n match action {\n Some(MockAction::Password { result }) => Ok(result.to_string()),\n _ => panic!(\"unexpected password call\"),\n }\n }\n\n fn write_line(&self, line: &str) -> Result<()> {\n self.output.borrow_mut().push_str(line);\n self.output.borrow_mut().push('\\n');\n Ok(())\n }\n }\n\n // ── Unit tests ──────────────────────────────────────────────\n\n #[test]\n fn setup_creates_template_config_when_config_is_missing() {\n let tmp = tempfile::tempdir().unwrap();\n let config_path = tmp.path().join(\"config.toml\");\n\n let config = Config::default();\n let home = config_path.parent().unwrap();\n std::fs::create_dir_all(home).unwrap();\n loader::save_config(&config, &config_path).unwrap();\n\n assert_eq!(\n std::fs::read_to_string(&config_path).unwrap(),\n template_toml()\n );\n assert!(config_path.exists());\n }\n\n #[test]\n fn provider_helpers_set_and_get_fields() {\n let mut config = Config::default();\n set_provider_url(&mut config, \"openai\", \"https://custom.api/v1\");\n set_provider_api_key(&mut config, \"openai\", \"sk-test-key\");\n\n let entry = config.providers.openai.as_ref().unwrap();\n assert_eq!(entry.base_url.as_deref(), Some(\"https://custom.api/v1\"));\n assert_eq!(entry.api_key.as_deref(), Some(\"sk-test-key\"));\n }\n\n #[test]\n fn provider_helpers_handle_unknown_provider() {\n let mut config = Config::default();\n set_provider_url(&mut config, \"nonexistent\", \"https://example.com\");\n assert!(get_provider_url(&config, \"nonexistent\").is_none());\n }\n\n #[test]\n fn wizard_keeps_existing_config() {\n let tmp = tempfile::tempdir().unwrap();\n let config_path = tmp.path().join(\"config.toml\");\n\n let existing = Config::default();\n std::fs::create_dir_all(config_path.parent().unwrap()).unwrap();\n loader::save_config(&existing, &config_path).unwrap();\n\n let mock = MockWizard::new(vec![MockAction::Confirm {\n prompt_contains: \"Update\",\n result: false,\n }]);\n\n run_wizard(&mock, &config_path).unwrap();\n\n // Config should be unchanged\n assert_eq!(\n std::fs::read_to_string(&config_path).unwrap(),\n template_toml()\n );\n }\n\n #[test]\n fn wizard_fresh_setup() {\n let tmp = tempfile::tempdir().unwrap();\n let config_path = tmp.path().join(\"config.toml\");\n\n let mock = MockWizard::new(vec![\n // Step 2: Provider\n MockAction::Select { result: 1 }, // openai\n MockAction::Input { result: \"gpt-4o\" },\n MockAction::Input {\n result: \"https://api.openai.com/v1\",\n },\n MockAction::Password {\n result: \"sk-test-key\",\n },\n // Step 3: Telegram (skip)\n MockAction::Confirm {\n prompt_contains: \"Telegram\",\n result: false,\n },\n // Step 4: WebSocket (skip)\n MockAction::Confirm {\n prompt_contains: \"WebSocket\",\n result: false,\n },\n // Step 5: WeChat (skip)\n MockAction::Confirm {\n prompt_contains: \"WeChat\",\n result: false,\n },\n // Step 6: Save\n MockAction::Confirm {\n prompt_contains: \"Write\",\n result: true,\n },\n ]);\n\n run_wizard(&mock, &config_path).unwrap();\n\n assert!(config_path.exists());\n let saved: Config =\n toml::from_str(&std::fs::read_to_string(&config_path).unwrap()).unwrap();\n assert_eq!(saved.agent.provider.as_deref(), Some(\"openai\"));\n assert_eq!(saved.agent.model, \"gpt-4o\");\n }\n\n #[test]\n fn wizard_overwrite_existing_config() {\n let tmp = tempfile::tempdir().unwrap();\n let config_path = tmp.path().join(\"config.toml\");\n\n let existing = Config::default();\n std::fs::create_dir_all(config_path.parent().unwrap()).unwrap();\n loader::save_config(&existing, &config_path).unwrap();\n\n let mock = MockWizard::new(vec![\n // Step 1: Overwrite\n MockAction::Confirm {\n prompt_contains: \"Update\",\n result: true,\n },\n // Step 2: Provider\n MockAction::Select { result: 0 }, // anthropic\n MockAction::Input {\n result: \"claude-sonnet-4-20250514\",\n },\n MockAction::Input {\n result: \"https://api.anthropic.com\",\n },\n MockAction::Password {\n result: \"sk-ant-test\",\n },\n // Step 3: Telegram (skip)\n MockAction::Confirm {\n prompt_contains: \"Telegram\",\n result: false,\n },\n // Step 4: WebSocket (skip)\n MockAction::Confirm {\n prompt_contains: \"WebSocket\",\n result: false,\n },\n // Step 5: WeChat (skip)\n MockAction::Confirm {\n prompt_contains: \"WeChat\",\n result: false,\n },\n // Step 6: Save\n MockAction::Confirm {\n prompt_contains: \"Write\",\n result: true,\n },\n ]);\n\n run_wizard(&mock, &config_path).unwrap();\n\n let saved: Config =\n toml::from_str(&std::fs::read_to_string(&config_path).unwrap()).unwrap();\n assert_eq!(saved.agent.provider.as_deref(), Some(\"anthropic\"));\n assert_eq!(saved.agent.model, \"claude-sonnet-4-20250514\");\n }\n\n fn tg(token: &str) -> TelegramConfig {\n TelegramConfig {\n token: token.to_string(),\n allowed_users: Vec::new(),\n admin_users: Vec::new(),\n enabled: true,\n streaming: false,\n proxy: None,\n }\n }\n\n #[test]\n fn token_masking_short_tokens() {\n let mut config = Config::default();\n config.channels.telegram = Some(tg(\"ab\"));\n\n let mock = MockWizard::new(vec![]);\n show_config_summary(&mock, &config).unwrap();\n\n let output = mock.output();\n assert!(output.contains(\"(masked)\"));\n assert!(!output.contains(\"ab…\"));\n }\n\n #[test]\n fn token_masking_empty_token() {\n let mut config = Config::default();\n config.channels.telegram = Some(tg(\"\"));\n\n let mock = MockWizard::new(vec![]);\n show_config_summary(&mock, &config).unwrap();\n\n let output = mock.output();\n assert!(output.contains(\"(not set)\"));\n }\n}\n"}, "eb37fcde9c82f9dbac9d45f39cf2d189ee355382": {"t": 1785761081.4265623, "n": 30470, "h": "487fe7e6659432c7", "c": "//! Setup command — interactive wizard for configuring Kestrel.\n\nuse anyhow::{bail, Context, Result};\nuse console::Term;\nuse dialoguer::{Confirm, Input, Password as PasswordInput, Select};\nuse kestrel_config::{\n loader, paths,\n schema::{Config, ProviderEntry, TelegramConfig, WebSocketConfig},\n};\nuse owo_colors::OwoColorize;\nuse std::net::SocketAddr;\nuse std::path::Path;\n\nconst PROVIDER_NAMES: &[&str] = &[\n \"anthropic\",\n \"openai\",\n \"openrouter\",\n \"ollama\",\n \"deepseek\",\n \"gemini\",\n \"groq\",\n \"moonshot\",\n \"minimax\",\n \"github_copilot\",\n \"openai_codex\",\n \"glm_coding_plan\",\n];\n\nconst TOTAL_STEPS: usize = 5;\n\n// ── Trait for interactive I/O (enables testability) ────────────\n\ntrait WizardIo {\n fn confirm(&self, prompt: &str, default: bool) -> Result;\n fn select(&self, prompt: &str, items: &[&str], default: usize) -> Result;\n fn input_with_default(&self, prompt: &str, default: &str) -> Result;\n fn input_allow_empty(&self, prompt: &str) -> Result;\n fn password(&self, prompt: &str) -> Result;\n fn write_line(&self, line: &str) -> Result<()>;\n}\n\n// ── Production implementation using Term + dialoguer ───────────\n\nstruct TermWizard<'a> {\n term: &'a Term,\n}\n\nimpl<'a> TermWizard<'a> {\n fn new(term: &'a Term) -> Self {\n Self { term }\n }\n}\n\nimpl WizardIo for TermWizard<'_> {\n fn confirm(&self, prompt: &str, default: bool) -> Result {\n Ok(Confirm::new()\n .with_prompt(prompt)\n .default(default)\n .interact_on(self.term)?)\n }\n\n fn select(&self, prompt: &str, items: &[&str], default: usize) -> Result {\n Ok(Select::new()\n .with_prompt(prompt)\n .items(items)\n .default(default)\n .interact_on(self.term)?)\n }\n\n fn input_with_default(&self, prompt: &str, default: &str) -> Result {\n Ok(Input::new()\n .with_prompt(prompt)\n .default(default.to_string())\n .interact_text_on(self.term)?)\n }\n\n fn input_allow_empty(&self, prompt: &str) -> Result {\n Ok(Input::::new()\n .with_prompt(prompt)\n .allow_empty(true)\n .interact_text_on(self.term)?)\n }\n\n fn password(&self, prompt: &str) -> Result {\n Ok(PasswordInput::new()\n .with_prompt(prompt)\n .interact_on(self.term)?)\n }\n\n fn write_line(&self, line: &str) -> Result<()> {\n self.term.write_line(line)?;\n Ok(())\n }\n}\n\n// ── Provider dispatch macro (single source of truth) ───────────\n\n#[allow(unused_macros)]\nmacro_rules! provider_field {\n ($config:expr, $provider:expr, mut) => {\n match $provider {\n \"anthropic\" => Some(&mut $config.providers.anthropic),\n \"openai\" => Some(&mut $config.providers.openai),\n \"openrouter\" => Some(&mut $config.providers.openrouter),\n \"ollama\" => Some(&mut $config.providers.ollama),\n \"deepseek\" => Some(&mut $config.providers.deepseek),\n \"gemini\" => Some(&mut $config.providers.gemini),\n \"groq\" => Some(&mut $config.providers.groq),\n \"moonshot\" => Some(&mut $config.providers.moonshot),\n \"minimax\" => Some(&mut $config.providers.minimax),\n \"github_copilot\" => Some(&mut $config.providers.github_copilot),\n \"openai_codex\" => Some(&mut $config.providers.openai_codex),\n _ => None,\n }\n };\n ($config:expr, $provider:expr) => {\n match $provider {\n \"anthropic\" => Some(&$config.providers.anthropic),\n \"openai\" => Some(&$config.providers.openai),\n \"openrouter\" => Some(&$config.providers.openrouter),\n \"ollama\" => Some(&$config.providers.ollama),\n \"deepseek\" => Some(&$config.providers.deepseek),\n \"gemini\" => Some(&$config.providers.gemini),\n \"groq\" => Some(&$config.providers.groq),\n \"moonshot\" => Some(&$config.providers.moonshot),\n \"minimax\" => Some(&$config.providers.minimax),\n \"github_copilot\" => Some(&$config.providers.github_copilot),\n \"openai_codex\" => Some(&$config.providers.openai_codex),\n _ => None,\n }\n };\n}\n\n// ── Entry point ────────────────────────────────────────────────\n\npub fn run(_config: Config) -> Result<()> {\n let term = Term::stdout();\n if !term.is_term() {\n bail!(\n \"Setup requires an interactive terminal. \\\n Run this command in a terminal, not in a pipe or CI environment.\"\n );\n }\n let io = TermWizard::new(&term);\n let config_path = paths::get_config_path()?;\n run_wizard(&io, &config_path)\n}\n\n// ── Wizard flow ────────────────────────────────────────────────\n\nfn run_wizard(io: &dyn WizardIo, config_path: &Path) -> Result<()> {\n print_banner(io)?;\n\n // ── Step 1: Check existing config ────────────────────────────\n print_step(io, 1, \"Existing Configuration\")?;\n\n let mut config = if config_path.exists() {\n match load_existing_config(config_path) {\n Ok(existing) => {\n show_config_summary(io, &existing)?;\n if io.confirm(\"Update existing config?\", true)? {\n existing\n } else {\n io.write_line(&format!(\n \" {} Keeping config at {}.\",\n \"✓\".green(),\n config_path.display()\n ))?;\n return Ok(());\n }\n }\n Err(e) => {\n io.write_line(&format!(\n \" {} Could not parse existing config: {}\",\n \"!\".yellow(),\n e\n ))?;\n if io.confirm(\"Start fresh with defaults?\", true)? {\n Config::default()\n } else {\n bail!(\"Setup cancelled.\");\n }\n }\n }\n } else {\n io.write_line(\" No config file found. Starting fresh.\")?;\n Config::default()\n };\n\n // ── Step 2: Provider configuration ───────────────────────────\n print_step(io, 2, \"Provider Configuration\")?;\n configure_provider(io, &mut config)?;\n\n // ── Step 3: Telegram channel ─────────────────────────────────\n print_step(io, 3, \"Telegram Channel\")?;\n configure_telegram(io, &mut config)?;\n\n // ── Step 4: WebSocket port ───────────────────────────────────\n print_step(io, 4, \"WebSocket Port\")?;\n configure_websocket(io, &mut config)?;\n\n // ── Step 5: Validate & write ─────────────────────────────────\n print_step(io, 5, \"Save Configuration\")?;\n\n io.write_line(&format!(\" Config path: {}\", config_path.display()))?;\n io.write_line(\"\")?;\n show_config_summary(io, &config)?;\n io.write_line(\"\")?;\n\n if !io.confirm(\"Write this configuration?\", true)? {\n io.write_line(&format!(\" {} Setup cancelled.\", \"!\".yellow()))?;\n return Ok(());\n }\n\n let home = config_path\n .parent()\n .context(\"Config path must have a parent directory\")?;\n\n std::fs::create_dir_all(home)\n .with_context(|| format!(\"Failed to create config home: {}\", home.display()))?;\n\n loader::save_config(&config, config_path)?;\n\n for dir in [\"skills\", \"sessions\", \"learning\"] {\n let path = home.join(dir);\n std::fs::create_dir_all(&path)\n .with_context(|| format!(\"Failed to create directory: {}\", path.display()))?;\n }\n\n io.write_line(\"\")?;\n io.write_line(&format!(\n \" {} Configuration saved to {}\",\n \"✓\".green(),\n config_path.display()\n ))?;\n io.write_line(&format!(\n \" {} Created directories: skills, sessions, learning\",\n \"✓\".green()\n ))?;\n io.write_line(&format!(\" {} Setup complete!\", \"✓\".green()))?;\n\n Ok(())\n}\n\nfn print_banner(io: &dyn WizardIo) -> Result<()> {\n io.write_line(\"\")?;\n io.write_line(&format!(\n \" {} {}\",\n \"▸\".cyan(),\n \"Kestrel Setup Wizard\".bold().cyan()\n ))?;\n io.write_line(\"\")?;\n Ok(())\n}\n\nfn print_step(io: &dyn WizardIo, step: usize, title: &str) -> Result<()> {\n io.write_line(\"\")?;\n io.write_line(&format!(\n \" {} Step {}/{}: {}\",\n \"▸\".cyan(),\n step,\n TOTAL_STEPS,\n title.bold()\n ))?;\n io.write_line(&format!(\" {}\", \"─\".repeat(40).dimmed()))?;\n Ok(())\n}\n\nfn load_existing_config(path: &Path) -> Result {\n let content = std::fs::read_to_string(path)\n .with_context(|| format!(\"Failed to read {}\", path.display()))?;\n let config: Config =\n toml::from_str(&content).with_context(|| format!(\"Failed to parse {}\", path.display()))?;\n Ok(config)\n}\n\nfn mask_token(token: &str) -> String {\n if token.is_empty() {\n \"(not set)\".to_string()\n } else if token.len() <= 3 {\n \"(masked)\".to_string()\n } else {\n format!(\"{}…(masked)\", &token[..3])\n }\n}\n\nfn show_config_summary(io: &dyn WizardIo, config: &Config) -> Result<()> {\n let provider = config.agent.provider.as_deref().unwrap_or(\"default\");\n io.write_line(&format!(\" Model: {}\", config.agent.model))?;\n io.write_line(&format!(\" Provider: {}\", provider))?;\n io.write_line(&format!(\" Temperature: {}\", config.agent.temperature))?;\n io.write_line(&format!(\" Max tokens: {}\", config.agent.max_tokens))?;\n io.write_line(&format!(\" Streaming: {}\", config.agent.streaming))?;\n\n if let Some(ref tg) = config.channels.telegram {\n io.write_line(&format!(\" Telegram: {}\", mask_token(&tg.token)))?;\n }\n\n if let Some(ref ws) = config.channels.websocket {\n if ws.enabled {\n io.write_line(&format!(\" WebSocket: {}\", ws.listen_addr))?;\n }\n }\n\n Ok(())\n}\n\nfn configure_provider(io: &dyn WizardIo, config: &mut Config) -> Result<()> {\n let default_idx = config\n .agent\n .provider\n .as_deref()\n .and_then(|p| PROVIDER_NAMES.iter().position(|&n| n == p))\n .unwrap_or(1); // default to \"openai\"\n\n let provider_name = io.select(\"Select LLM provider\", PROVIDER_NAMES, default_idx)?;\n\n let provider_key = PROVIDER_NAMES[provider_name];\n config.agent.provider = Some(provider_key.to_string());\n\n let default_model = match provider_key {\n \"anthropic\" => \"claude-sonnet-4-20250514\",\n \"openai\" => \"gpt-4o\",\n \"openrouter\" => \"anthropic/claude-sonnet-4-20250514\",\n \"ollama\" => \"llama3\",\n \"deepseek\" => \"deepseek-chat\",\n \"gemini\" => \"gemini-2.5-pro\",\n \"groq\" => \"llama-3.3-70b-versatile\",\n \"moonshot\" => \"moonshot-v1-8k\",\n \"minimax\" => \"MiniMax-Text-01\",\n \"github_copilot\" => \"gpt-4o\",\n \"openai_codex\" => \"codex-mini\",\n \"glm_coding_plan\" => \"glm-5.1\",\n _ => \"gpt-4o\",\n };\n\n let current_model = if config.agent.model.is_empty() {\n default_model\n } else {\n &config.agent.model\n };\n\n let model: String = io.input_with_default(\"Model name\", current_model)?;\n config.agent.model = model;\n\n let default_url = match provider_key {\n \"anthropic\" => \"https://api.anthropic.com\",\n \"openai\" => \"https://api.openai.com/v1\",\n \"openrouter\" => \"https://openrouter.ai/api/v1\",\n \"ollama\" => \"http://localhost:11434\",\n \"deepseek\" => \"https://api.deepseek.com\",\n \"gemini\" => \"https://generativelanguage.googleapis.com/v1beta\",\n \"groq\" => \"https://api.groq.com/openai/v1\",\n \"moonshot\" => \"https://api.moonshot.cn/v1\",\n \"minimax\" => \"https://api.minimax.chat/v1\",\n \"github_copilot\" => \"https://api.githubcopilot.com\",\n \"openai_codex\" => \"https://api.openai.com/v1\",\n \"glm_coding_plan\" => \"https://open.bigmodel.cn/api/coding/paas/v4\",\n _ => \"\",\n };\n\n let current_url = get_provider_url(config, provider_key).unwrap_or(default_url);\n\n if !current_url.is_empty() {\n let base_url: String = io.input_with_default(\"Base URL\", current_url)?;\n set_provider_url(config, provider_key, &base_url);\n } else {\n let base_url: String = io.input_allow_empty(\"Base URL (leave empty for default)\")?;\n if !base_url.is_empty() {\n set_provider_url(config, provider_key, &base_url);\n }\n }\n\n let api_key: String = io.password(\"API key\")?;\n if !api_key.is_empty() {\n set_provider_api_key(config, provider_key, &api_key);\n }\n\n Ok(())\n}\n\nfn configure_telegram(io: &dyn WizardIo, config: &mut Config) -> Result<()> {\n let setup_tg = if config.channels.telegram.is_some() {\n io.confirm(\"Configure Telegram bot?\", true)?\n } else {\n io.confirm(\"Set up a Telegram bot?\", false)?\n };\n\n if !setup_tg {\n io.write_line(\" Skipped.\")?;\n return Ok(());\n }\n\n let current_token = config\n .channels\n .telegram\n .as_ref()\n .map(|tg| tg.token.as_str())\n .unwrap_or(\"\");\n\n let token: String = if current_token.is_empty() {\n io.input_allow_empty(\"Bot token (from @BotFather)\")?\n } else {\n io.input_with_default(\"Bot token (from @BotFather)\", current_token)?\n };\n\n if token.is_empty() {\n io.write_line(\" No token provided, skipping Telegram.\")?;\n return Ok(());\n }\n\n let allowed: String = loop {\n let input: String =\n io.input_allow_empty(\"Allowed user IDs (comma-separated, leave empty for all)\")?;\n if input.trim().is_empty() {\n break String::new();\n }\n let ids: Vec<&str> = input\n .split(',')\n .map(|s| s.trim())\n .filter(|s| !s.is_empty())\n .collect();\n let mut valid = true;\n for id in &ids {\n if id.parse::().is_err() {\n io.write_line(&format!(\n \" {} '{}' is not a valid user ID (must be a positive integer).\",\n \"!\".yellow(),\n id\n ))?;\n valid = false;\n break;\n }\n }\n if valid {\n break input;\n }\n };\n\n let allowed_users: Vec = if allowed.trim().is_empty() {\n Vec::new()\n } else {\n allowed\n .split(',')\n .map(|s| s.trim().to_string())\n .filter(|s| !s.is_empty())\n .collect()\n };\n\n // Preserve existing values for fields not explicitly configured in the wizard.\n let (admin_users, enabled, streaming, proxy) = config\n .channels\n .telegram\n .as_ref()\n .map(|tg| {\n (\n tg.admin_users.clone(),\n tg.enabled,\n tg.streaming,\n tg.proxy.clone(),\n )\n })\n .unwrap_or((Vec::new(), true, true, None));\n\n config.channels.telegram = Some(TelegramConfig {\n token,\n allowed_users,\n admin_users,\n enabled,\n streaming,\n proxy,\n });\n\n Ok(())\n}\n\nfn configure_websocket(io: &dyn WizardIo, config: &mut Config) -> Result<()> {\n let default_addr = config\n .channels\n .websocket\n .as_ref()\n .map(|ws| ws.listen_addr.as_str())\n .unwrap_or(\"127.0.0.1:8090\");\n\n let enable = io.confirm(\"Enable WebSocket channel?\", false)?;\n\n if !enable {\n config.channels.websocket = None;\n io.write_line(\" Skipped.\")?;\n return Ok(());\n }\n\n let listen_addr: String = loop {\n let input = io.input_with_default(\"Listen address\", default_addr)?;\n match input.parse::() {\n Ok(_) => break input,\n Err(e) => {\n io.write_line(&format!(\n \" {} Invalid address '{}': {}\",\n \"!\".yellow(),\n input,\n e\n ))?;\n }\n }\n };\n\n config.channels.websocket = Some(WebSocketConfig {\n enabled: true,\n listen_addr,\n auth: Default::default(),\n max_clients: 100,\n max_message_size: 1048576,\n });\n\n Ok(())\n}\n\n// ── Provider field helpers (using macro) ───────────────────────\n\nfn get_provider_entry_mut<'a>(\n config: &'a mut Config,\n provider: &str,\n) -> Option<&'a mut ProviderEntry> {\n match provider {\n \"anthropic\" => config.providers.anthropic.as_mut(),\n \"openai\" => config.providers.openai.as_mut(),\n \"openrouter\" => config.providers.openrouter.as_mut(),\n \"ollama\" => config.providers.ollama.as_mut(),\n \"deepseek\" => config.providers.deepseek.as_mut(),\n \"gemini\" => config.providers.gemini.as_mut(),\n \"groq\" => config.providers.groq.as_mut(),\n \"moonshot\" => config.providers.moonshot.as_mut(),\n \"minimax\" => config.providers.minimax.as_mut(),\n \"github_copilot\" => config.providers.github_copilot.as_mut(),\n \"openai_codex\" => config.providers.openai_codex.as_mut(),\n \"opencode_go\" => config.providers.opencode_go.as_mut(),\n \"glm_coding_plan\" => config.providers.glm_coding_plan.as_mut(),\n _ => None,\n }\n}\n\nfn ensure_provider_entry(config: &mut Config, provider: &str) {\n match provider {\n \"anthropic\" => {\n config\n .providers\n .anthropic\n .get_or_insert_with(ProviderEntry::default);\n }\n \"openai\" => {\n config\n .providers\n .openai\n .get_or_insert_with(ProviderEntry::default);\n }\n \"openrouter\" => {\n config\n .providers\n .openrouter\n .get_or_insert_with(ProviderEntry::default);\n }\n \"ollama\" => {\n config\n .providers\n .ollama\n .get_or_insert_with(ProviderEntry::default);\n }\n \"deepseek\" => {\n config\n .providers\n .deepseek\n .get_or_insert_with(ProviderEntry::default);\n }\n \"gemini\" => {\n config\n .providers\n .gemini\n .get_or_insert_with(ProviderEntry::default);\n }\n \"groq\" => {\n config\n .providers\n .groq\n .get_or_insert_with(ProviderEntry::default);\n }\n \"moonshot\" => {\n config\n .providers\n .moonshot\n .get_or_insert_with(ProviderEntry::default);\n }\n \"minimax\" => {\n config\n .providers\n .minimax\n .get_or_insert_with(ProviderEntry::default);\n }\n \"github_copilot\" => {\n config\n .providers\n .github_copilot\n .get_or_insert_with(ProviderEntry::default);\n }\n \"openai_codex\" => {\n config\n .providers\n .openai_codex\n .get_or_insert_with(ProviderEntry::default);\n }\n \"opencode_go\" => {\n config\n .providers\n .opencode_go\n .get_or_insert_with(ProviderEntry::default);\n }\n \"glm_coding_plan\" => {\n config\n .providers\n .glm_coding_plan\n .get_or_insert_with(ProviderEntry::default);\n }\n _ => {}\n }\n}\n\nfn get_provider_url<'a>(config: &'a Config, provider: &str) -> Option<&'a str> {\n let entry = match provider {\n \"anthropic\" => config.providers.anthropic.as_ref(),\n \"openai\" => config.providers.openai.as_ref(),\n \"openrouter\" => config.providers.openrouter.as_ref(),\n \"ollama\" => config.providers.ollama.as_ref(),\n \"deepseek\" => config.providers.deepseek.as_ref(),\n \"gemini\" => config.providers.gemini.as_ref(),\n \"groq\" => config.providers.groq.as_ref(),\n \"moonshot\" => config.providers.moonshot.as_ref(),\n \"minimax\" => config.providers.minimax.as_ref(),\n \"github_copilot\" => config.providers.github_copilot.as_ref(),\n \"openai_codex\" => config.providers.openai_codex.as_ref(),\n \"opencode_go\" => config.providers.opencode_go.as_ref(),\n \"glm_coding_plan\" => config.providers.glm_coding_plan.as_ref(),\n _ => None,\n };\n entry.and_then(|e| e.base_url.as_deref())\n}\n\nfn set_provider_url(config: &mut Config, provider: &str, url: &str) {\n ensure_provider_entry(config, provider);\n if let Some(entry) = get_provider_entry_mut(config, provider) {\n entry.base_url = if url.is_empty() {\n None\n } else {\n Some(url.to_string())\n };\n }\n}\n\nfn set_provider_api_key(config: &mut Config, provider: &str, key: &str) {\n ensure_provider_entry(config, provider);\n if let Some(entry) = get_provider_entry_mut(config, provider) {\n entry.api_key = if key.is_empty() {\n None\n } else {\n Some(key.to_string())\n };\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use std::cell::RefCell;\n use std::collections::VecDeque;\n\n fn template_toml() -> String {\n toml::to_string(&Config::default()).unwrap()\n }\n\n // ── Mock wizard IO for flow testing ─────────────────────────\n\n #[derive(Debug, Clone)]\n enum MockAction {\n Confirm {\n prompt_contains: &'static str,\n result: bool,\n },\n Select {\n result: usize,\n },\n Input {\n result: &'static str,\n },\n Password {\n result: &'static str,\n },\n }\n\n struct MockWizard {\n actions: RefCell>,\n output: RefCell,\n }\n\n impl MockWizard {\n fn new(actions: Vec) -> Self {\n Self {\n actions: RefCell::new(actions.into()),\n output: RefCell::new(String::new()),\n }\n }\n\n fn output(&self) -> String {\n self.output.borrow().clone()\n }\n }\n\n impl WizardIo for MockWizard {\n fn confirm(&self, prompt: &str, _default: bool) -> Result {\n let action = self.actions.borrow_mut().pop_front();\n match action {\n Some(MockAction::Confirm {\n prompt_contains,\n result,\n }) => {\n assert!(\n prompt.contains(prompt_contains),\n \"confirm prompt '{}' did not contain '{}'\",\n prompt,\n prompt_contains\n );\n Ok(result)\n }\n _ => panic!(\"unexpected confirm call, prompt: {}\", prompt),\n }\n }\n\n fn select(&self, _prompt: &str, _items: &[&str], _default: usize) -> Result {\n let action = self.actions.borrow_mut().pop_front();\n match action {\n Some(MockAction::Select { result }) => Ok(result),\n _ => panic!(\"unexpected select call\"),\n }\n }\n\n fn input_with_default(&self, _prompt: &str, _default: &str) -> Result {\n let action = self.actions.borrow_mut().pop_front();\n match action {\n Some(MockAction::Input { result }) => Ok(result.to_string()),\n _ => panic!(\"unexpected input_with_default call\"),\n }\n }\n\n fn input_allow_empty(&self, _prompt: &str) -> Result {\n let action = self.actions.borrow_mut().pop_front();\n match action {\n Some(MockAction::Input { result }) => Ok(result.to_string()),\n _ => panic!(\"unexpected input_allow_empty call\"),\n }\n }\n\n fn password(&self, _prompt: &str) -> Result {\n let action = self.actions.borrow_mut().pop_front();\n match action {\n Some(MockAction::Password { result }) => Ok(result.to_string()),\n _ => panic!(\"unexpected password call\"),\n }\n }\n\n fn write_line(&self, line: &str) -> Result<()> {\n self.output.borrow_mut().push_str(line);\n self.output.borrow_mut().push('\\n');\n Ok(())\n }\n }\n\n // ── Unit tests ──────────────────────────────────────────────\n\n #[test]\n fn setup_creates_template_config_when_config_is_missing() {\n let tmp = tempfile::tempdir().unwrap();\n let config_path = tmp.path().join(\"config.toml\");\n\n let config = Config::default();\n let home = config_path.parent().unwrap();\n std::fs::create_dir_all(home).unwrap();\n loader::save_config(&config, &config_path).unwrap();\n\n assert_eq!(\n std::fs::read_to_string(&config_path).unwrap(),\n template_toml()\n );\n assert!(config_path.exists());\n }\n\n #[test]\n fn provider_helpers_set_and_get_fields() {\n let mut config = Config::default();\n set_provider_url(&mut config, \"openai\", \"https://custom.api/v1\");\n set_provider_api_key(&mut config, \"openai\", \"sk-test-key\");\n\n let entry = config.providers.openai.as_ref().unwrap();\n assert_eq!(entry.base_url.as_deref(), Some(\"https://custom.api/v1\"));\n assert_eq!(entry.api_key.as_deref(), Some(\"sk-test-key\"));\n }\n\n #[test]\n fn provider_helpers_handle_unknown_provider() {\n let mut config = Config::default();\n set_provider_url(&mut config, \"nonexistent\", \"https://example.com\");\n assert!(get_provider_url(&config, \"nonexistent\").is_none());\n }\n\n #[test]\n fn wizard_keeps_existing_config() {\n let tmp = tempfile::tempdir().unwrap();\n let config_path = tmp.path().join(\"config.toml\");\n\n let existing = Config::default();\n std::fs::create_dir_all(config_path.parent().unwrap()).unwrap();\n loader::save_config(&existing, &config_path).unwrap();\n\n let mock = MockWizard::new(vec![MockAction::Confirm {\n prompt_contains: \"Update\",\n result: false,\n }]);\n\n run_wizard(&mock, &config_path).unwrap();\n\n // Config should be unchanged\n assert_eq!(\n std::fs::read_to_string(&config_path).unwrap(),\n template_toml()\n );\n }\n\n #[test]\n fn wizard_fresh_setup() {\n let tmp = tempfile::tempdir().unwrap();\n let config_path = tmp.path().join(\"config.toml\");\n\n let mock = MockWizard::new(vec![\n // Step 2: Provider\n MockAction::Select { result: 1 }, // openai\n MockAction::Input { result: \"gpt-4o\" },\n MockAction::Input {\n result: \"https://api.openai.com/v1\",\n },\n MockAction::Password {\n result: \"sk-test-key\",\n },\n // Step 3: Telegram (skip)\n MockAction::Confirm {\n prompt_contains: \"Telegram\",\n result: false,\n },\n // Step 4: WebSocket (skip)\n MockAction::Confirm {\n prompt_contains: \"WebSocket\",\n result: false,\n },\n // Step 5: Save\n MockAction::Confirm {\n prompt_contains: \"Write\",\n result: true,\n },\n ]);\n\n run_wizard(&mock, &config_path).unwrap();\n\n assert!(config_path.exists());\n let saved: Config =\n toml::from_str(&std::fs::read_to_string(&config_path).unwrap()).unwrap();\n assert_eq!(saved.agent.provider.as_deref(), Some(\"openai\"));\n assert_eq!(saved.agent.model, \"gpt-4o\");\n }\n\n #[test]\n fn wizard_overwrite_existing_config() {\n let tmp = tempfile::tempdir().unwrap();\n let config_path = tmp.path().join(\"config.toml\");\n\n let existing = Config::default();\n std::fs::create_dir_all(config_path.parent().unwrap()).unwrap();\n loader::save_config(&existing, &config_path).unwrap();\n\n let mock = MockWizard::new(vec![\n // Step 1: Overwrite\n MockAction::Confirm {\n prompt_contains: \"Update\",\n result: true,\n },\n // Step 2: Provider\n MockAction::Select { result: 0 }, // anthropic\n MockAction::Input {\n result: \"claude-sonnet-4-20250514\",\n },\n MockAction::Input {\n result: \"https://api.anthropic.com\",\n },\n MockAction::Password {\n result: \"sk-ant-test\",\n },\n // Step 3: Telegram (skip)\n MockAction::Confirm {\n prompt_contains: \"Telegram\",\n result: false,\n },\n // Step 4: WebSocket (skip)\n MockAction::Confirm {\n prompt_contains: \"WebSocket\",\n result: false,\n },\n // Step 5: Save\n MockAction::Confirm {\n prompt_contains: \"Write\",\n result: true,\n },\n ]);\n\n run_wizard(&mock, &config_path).unwrap();\n\n let saved: Config =\n toml::from_str(&std::fs::read_to_string(&config_path).unwrap()).unwrap();\n assert_eq!(saved.agent.provider.as_deref(), Some(\"anthropic\"));\n assert_eq!(saved.agent.model, \"claude-sonnet-4-20250514\");\n }\n\n fn tg(token: &str) -> TelegramConfig {\n TelegramConfig {\n token: token.to_string(),\n allowed_users: Vec::new(),\n admin_users: Vec::new(),\n enabled: true,\n streaming: false,\n proxy: None,\n }\n }\n\n #[test]\n fn token_masking_short_tokens() {\n let mut config = Config::default();\n config.channels.telegram = Some(tg(\"ab\"));\n\n let mock = MockWizard::new(vec![]);\n show_config_summary(&mock, &config).unwrap();\n\n let output = mock.output();\n assert!(output.contains(\"(masked)\"));\n assert!(!output.contains(\"ab…\"));\n }\n\n #[test]\n fn token_masking_empty_token() {\n let mut config = Config::default();\n config.channels.telegram = Some(tg(\"\"));\n\n let mock = MockWizard::new(vec![]);\n show_config_summary(&mock, &config).unwrap();\n\n let output = mock.output();\n assert!(output.contains(\"(not set)\"));\n }\n}\n"}, "5265d056ac60948d16828c8fe9f629430f4112a0": {"t": 1785761082.8176587, "n": 30446, "h": "16a078ae75547a1e", "c": "//! Setup command — interactive wizard for configuring Kestrel.\n\nuse anyhow::{bail, Context, Result};\nuse console::Term;\nuse dialoguer::{Confirm, Input, Password as PasswordInput, Select};\nuse kestrel_config::{\n loader, paths,\n schema::{Config, ProviderEntry, TelegramConfig, WebSocketConfig},\n};\nuse owo_colors::OwoColorize;\nuse std::net::SocketAddr;\nuse std::path::Path;\n\nconst PROVIDER_NAMES: &[&str] = &[\n \"anthropic\",\n \"openai\",\n \"openrouter\",\n \"ollama\",\n \"deepseek\",\n \"gemini\",\n \"groq\",\n \"moonshot\",\n \"minimax\",\n \"github_copilot\",\n \"openai_codex\",\n \"glm_coding_plan\",\n];\n\nconst TOTAL_STEPS: usize = 5;\n\n// ── Trait for interactive I/O (enables testability) ────────────\n\ntrait WizardIo {\n fn confirm(&self, prompt: &str, default: bool) -> Result;\n fn select(&self, prompt: &str, items: &[&str], default: usize) -> Result;\n fn input_with_default(&self, prompt: &str, default: &str) -> Result;\n fn input_allow_empty(&self, prompt: &str) -> Result;\n fn password(&self, prompt: &str) -> Result;\n fn write_line(&self, line: &str) -> Result<()>;\n}\n\n// ── Production implementation using Term + dialoguer ───────────\n\nstruct TermWizard<'a> {\n term: &'a Term,\n}\n\nimpl<'a> TermWizard<'a> {\n fn new(term: &'a Term) -> Self {\n Self { term }\n }\n}\n\nimpl WizardIo for TermWizard<'_> {\n fn confirm(&self, prompt: &str, default: bool) -> Result {\n Ok(Confirm::new()\n .with_prompt(prompt)\n .default(default)\n .interact_on(self.term)?)\n }\n\n fn select(&self, prompt: &str, items: &[&str], default: usize) -> Result {\n Ok(Select::new()\n .with_prompt(prompt)\n .items(items)\n .default(default)\n .interact_on(self.term)?)\n }\n\n fn input_with_default(&self, prompt: &str, default: &str) -> Result {\n Ok(Input::new()\n .with_prompt(prompt)\n .default(default.to_string())\n .interact_text_on(self.term)?)\n }\n\n fn input_allow_empty(&self, prompt: &str) -> Result {\n Ok(Input::::new()\n .with_prompt(prompt)\n .allow_empty(true)\n .interact_text_on(self.term)?)\n }\n\n fn password(&self, prompt: &str) -> Result {\n Ok(PasswordInput::new()\n .with_prompt(prompt)\n .interact_on(self.term)?)\n }\n\n fn write_line(&self, line: &str) -> Result<()> {\n self.term.write_line(line)?;\n Ok(())\n }\n}\n\n// ── Provider dispatch macro (single source of truth) ───────────\n\nmacro_rules! provider_field {\n ($config:expr, $provider:expr, mut) => {\n match $provider {\n \"anthropic\" => Some(&mut $config.providers.anthropic),\n \"openai\" => Some(&mut $config.providers.openai),\n \"openrouter\" => Some(&mut $config.providers.openrouter),\n \"ollama\" => Some(&mut $config.providers.ollama),\n \"deepseek\" => Some(&mut $config.providers.deepseek),\n \"gemini\" => Some(&mut $config.providers.gemini),\n \"groq\" => Some(&mut $config.providers.groq),\n \"moonshot\" => Some(&mut $config.providers.moonshot),\n \"minimax\" => Some(&mut $config.providers.minimax),\n \"github_copilot\" => Some(&mut $config.providers.github_copilot),\n \"openai_codex\" => Some(&mut $config.providers.openai_codex),\n _ => None,\n }\n };\n ($config:expr, $provider:expr) => {\n match $provider {\n \"anthropic\" => Some(&$config.providers.anthropic),\n \"openai\" => Some(&$config.providers.openai),\n \"openrouter\" => Some(&$config.providers.openrouter),\n \"ollama\" => Some(&$config.providers.ollama),\n \"deepseek\" => Some(&$config.providers.deepseek),\n \"gemini\" => Some(&$config.providers.gemini),\n \"groq\" => Some(&$config.providers.groq),\n \"moonshot\" => Some(&$config.providers.moonshot),\n \"minimax\" => Some(&$config.providers.minimax),\n \"github_copilot\" => Some(&$config.providers.github_copilot),\n \"openai_codex\" => Some(&$config.providers.openai_codex),\n _ => None,\n }\n };\n}\n\n// ── Entry point ────────────────────────────────────────────────\n\npub fn run(_config: Config) -> Result<()> {\n let term = Term::stdout();\n if !term.is_term() {\n bail!(\n \"Setup requires an interactive terminal. \\\n Run this command in a terminal, not in a pipe or CI environment.\"\n );\n }\n let io = TermWizard::new(&term);\n let config_path = paths::get_config_path()?;\n run_wizard(&io, &config_path)\n}\n\n// ── Wizard flow ────────────────────────────────────────────────\n\nfn run_wizard(io: &dyn WizardIo, config_path: &Path) -> Result<()> {\n print_banner(io)?;\n\n // ── Step 1: Check existing config ────────────────────────────\n print_step(io, 1, \"Existing Configuration\")?;\n\n let mut config = if config_path.exists() {\n match load_existing_config(config_path) {\n Ok(existing) => {\n show_config_summary(io, &existing)?;\n if io.confirm(\"Update existing config?\", true)? {\n existing\n } else {\n io.write_line(&format!(\n \" {} Keeping config at {}.\",\n \"✓\".green(),\n config_path.display()\n ))?;\n return Ok(());\n }\n }\n Err(e) => {\n io.write_line(&format!(\n \" {} Could not parse existing config: {}\",\n \"!\".yellow(),\n e\n ))?;\n if io.confirm(\"Start fresh with defaults?\", true)? {\n Config::default()\n } else {\n bail!(\"Setup cancelled.\");\n }\n }\n }\n } else {\n io.write_line(\" No config file found. Starting fresh.\")?;\n Config::default()\n };\n\n // ── Step 2: Provider configuration ───────────────────────────\n print_step(io, 2, \"Provider Configuration\")?;\n configure_provider(io, &mut config)?;\n\n // ── Step 3: Telegram channel ─────────────────────────────────\n print_step(io, 3, \"Telegram Channel\")?;\n configure_telegram(io, &mut config)?;\n\n // ── Step 4: WebSocket port ───────────────────────────────────\n print_step(io, 4, \"WebSocket Port\")?;\n configure_websocket(io, &mut config)?;\n\n // ── Step 5: Validate & write ─────────────────────────────────\n print_step(io, 5, \"Save Configuration\")?;\n\n io.write_line(&format!(\" Config path: {}\", config_path.display()))?;\n io.write_line(\"\")?;\n show_config_summary(io, &config)?;\n io.write_line(\"\")?;\n\n if !io.confirm(\"Write this configuration?\", true)? {\n io.write_line(&format!(\" {} Setup cancelled.\", \"!\".yellow()))?;\n return Ok(());\n }\n\n let home = config_path\n .parent()\n .context(\"Config path must have a parent directory\")?;\n\n std::fs::create_dir_all(home)\n .with_context(|| format!(\"Failed to create config home: {}\", home.display()))?;\n\n loader::save_config(&config, config_path)?;\n\n for dir in [\"skills\", \"sessions\", \"learning\"] {\n let path = home.join(dir);\n std::fs::create_dir_all(&path)\n .with_context(|| format!(\"Failed to create directory: {}\", path.display()))?;\n }\n\n io.write_line(\"\")?;\n io.write_line(&format!(\n \" {} Configuration saved to {}\",\n \"✓\".green(),\n config_path.display()\n ))?;\n io.write_line(&format!(\n \" {} Created directories: skills, sessions, learning\",\n \"✓\".green()\n ))?;\n io.write_line(&format!(\" {} Setup complete!\", \"✓\".green()))?;\n\n Ok(())\n}\n\nfn print_banner(io: &dyn WizardIo) -> Result<()> {\n io.write_line(\"\")?;\n io.write_line(&format!(\n \" {} {}\",\n \"▸\".cyan(),\n \"Kestrel Setup Wizard\".bold().cyan()\n ))?;\n io.write_line(\"\")?;\n Ok(())\n}\n\nfn print_step(io: &dyn WizardIo, step: usize, title: &str) -> Result<()> {\n io.write_line(\"\")?;\n io.write_line(&format!(\n \" {} Step {}/{}: {}\",\n \"▸\".cyan(),\n step,\n TOTAL_STEPS,\n title.bold()\n ))?;\n io.write_line(&format!(\" {}\", \"─\".repeat(40).dimmed()))?;\n Ok(())\n}\n\nfn load_existing_config(path: &Path) -> Result {\n let content = std::fs::read_to_string(path)\n .with_context(|| format!(\"Failed to read {}\", path.display()))?;\n let config: Config =\n toml::from_str(&content).with_context(|| format!(\"Failed to parse {}\", path.display()))?;\n Ok(config)\n}\n\nfn mask_token(token: &str) -> String {\n if token.is_empty() {\n \"(not set)\".to_string()\n } else if token.len() <= 3 {\n \"(masked)\".to_string()\n } else {\n format!(\"{}…(masked)\", &token[..3])\n }\n}\n\nfn show_config_summary(io: &dyn WizardIo, config: &Config) -> Result<()> {\n let provider = config.agent.provider.as_deref().unwrap_or(\"default\");\n io.write_line(&format!(\" Model: {}\", config.agent.model))?;\n io.write_line(&format!(\" Provider: {}\", provider))?;\n io.write_line(&format!(\" Temperature: {}\", config.agent.temperature))?;\n io.write_line(&format!(\" Max tokens: {}\", config.agent.max_tokens))?;\n io.write_line(&format!(\" Streaming: {}\", config.agent.streaming))?;\n\n if let Some(ref tg) = config.channels.telegram {\n io.write_line(&format!(\" Telegram: {}\", mask_token(&tg.token)))?;\n }\n\n if let Some(ref ws) = config.channels.websocket {\n if ws.enabled {\n io.write_line(&format!(\" WebSocket: {}\", ws.listen_addr))?;\n }\n }\n\n Ok(())\n}\n\nfn configure_provider(io: &dyn WizardIo, config: &mut Config) -> Result<()> {\n let default_idx = config\n .agent\n .provider\n .as_deref()\n .and_then(|p| PROVIDER_NAMES.iter().position(|&n| n == p))\n .unwrap_or(1); // default to \"openai\"\n\n let provider_name = io.select(\"Select LLM provider\", PROVIDER_NAMES, default_idx)?;\n\n let provider_key = PROVIDER_NAMES[provider_name];\n config.agent.provider = Some(provider_key.to_string());\n\n let default_model = match provider_key {\n \"anthropic\" => \"claude-sonnet-4-20250514\",\n \"openai\" => \"gpt-4o\",\n \"openrouter\" => \"anthropic/claude-sonnet-4-20250514\",\n \"ollama\" => \"llama3\",\n \"deepseek\" => \"deepseek-chat\",\n \"gemini\" => \"gemini-2.5-pro\",\n \"groq\" => \"llama-3.3-70b-versatile\",\n \"moonshot\" => \"moonshot-v1-8k\",\n \"minimax\" => \"MiniMax-Text-01\",\n \"github_copilot\" => \"gpt-4o\",\n \"openai_codex\" => \"codex-mini\",\n \"glm_coding_plan\" => \"glm-5.1\",\n _ => \"gpt-4o\",\n };\n\n let current_model = if config.agent.model.is_empty() {\n default_model\n } else {\n &config.agent.model\n };\n\n let model: String = io.input_with_default(\"Model name\", current_model)?;\n config.agent.model = model;\n\n let default_url = match provider_key {\n \"anthropic\" => \"https://api.anthropic.com\",\n \"openai\" => \"https://api.openai.com/v1\",\n \"openrouter\" => \"https://openrouter.ai/api/v1\",\n \"ollama\" => \"http://localhost:11434\",\n \"deepseek\" => \"https://api.deepseek.com\",\n \"gemini\" => \"https://generativelanguage.googleapis.com/v1beta\",\n \"groq\" => \"https://api.groq.com/openai/v1\",\n \"moonshot\" => \"https://api.moonshot.cn/v1\",\n \"minimax\" => \"https://api.minimax.chat/v1\",\n \"github_copilot\" => \"https://api.githubcopilot.com\",\n \"openai_codex\" => \"https://api.openai.com/v1\",\n \"glm_coding_plan\" => \"https://open.bigmodel.cn/api/coding/paas/v4\",\n _ => \"\",\n };\n\n let current_url = get_provider_url(config, provider_key).unwrap_or(default_url);\n\n if !current_url.is_empty() {\n let base_url: String = io.input_with_default(\"Base URL\", current_url)?;\n set_provider_url(config, provider_key, &base_url);\n } else {\n let base_url: String = io.input_allow_empty(\"Base URL (leave empty for default)\")?;\n if !base_url.is_empty() {\n set_provider_url(config, provider_key, &base_url);\n }\n }\n\n let api_key: String = io.password(\"API key\")?;\n if !api_key.is_empty() {\n set_provider_api_key(config, provider_key, &api_key);\n }\n\n Ok(())\n}\n\nfn configure_telegram(io: &dyn WizardIo, config: &mut Config) -> Result<()> {\n let setup_tg = if config.channels.telegram.is_some() {\n io.confirm(\"Configure Telegram bot?\", true)?\n } else {\n io.confirm(\"Set up a Telegram bot?\", false)?\n };\n\n if !setup_tg {\n io.write_line(\" Skipped.\")?;\n return Ok(());\n }\n\n let current_token = config\n .channels\n .telegram\n .as_ref()\n .map(|tg| tg.token.as_str())\n .unwrap_or(\"\");\n\n let token: String = if current_token.is_empty() {\n io.input_allow_empty(\"Bot token (from @BotFather)\")?\n } else {\n io.input_with_default(\"Bot token (from @BotFather)\", current_token)?\n };\n\n if token.is_empty() {\n io.write_line(\" No token provided, skipping Telegram.\")?;\n return Ok(());\n }\n\n let allowed: String = loop {\n let input: String =\n io.input_allow_empty(\"Allowed user IDs (comma-separated, leave empty for all)\")?;\n if input.trim().is_empty() {\n break String::new();\n }\n let ids: Vec<&str> = input\n .split(',')\n .map(|s| s.trim())\n .filter(|s| !s.is_empty())\n .collect();\n let mut valid = true;\n for id in &ids {\n if id.parse::().is_err() {\n io.write_line(&format!(\n \" {} '{}' is not a valid user ID (must be a positive integer).\",\n \"!\".yellow(),\n id\n ))?;\n valid = false;\n break;\n }\n }\n if valid {\n break input;\n }\n };\n\n let allowed_users: Vec = if allowed.trim().is_empty() {\n Vec::new()\n } else {\n allowed\n .split(',')\n .map(|s| s.trim().to_string())\n .filter(|s| !s.is_empty())\n .collect()\n };\n\n // Preserve existing values for fields not explicitly configured in the wizard.\n let (admin_users, enabled, streaming, proxy) = config\n .channels\n .telegram\n .as_ref()\n .map(|tg| {\n (\n tg.admin_users.clone(),\n tg.enabled,\n tg.streaming,\n tg.proxy.clone(),\n )\n })\n .unwrap_or((Vec::new(), true, true, None));\n\n config.channels.telegram = Some(TelegramConfig {\n token,\n allowed_users,\n admin_users,\n enabled,\n streaming,\n proxy,\n });\n\n Ok(())\n}\n\nfn configure_websocket(io: &dyn WizardIo, config: &mut Config) -> Result<()> {\n let default_addr = config\n .channels\n .websocket\n .as_ref()\n .map(|ws| ws.listen_addr.as_str())\n .unwrap_or(\"127.0.0.1:8090\");\n\n let enable = io.confirm(\"Enable WebSocket channel?\", false)?;\n\n if !enable {\n config.channels.websocket = None;\n io.write_line(\" Skipped.\")?;\n return Ok(());\n }\n\n let listen_addr: String = loop {\n let input = io.input_with_default(\"Listen address\", default_addr)?;\n match input.parse::() {\n Ok(_) => break input,\n Err(e) => {\n io.write_line(&format!(\n \" {} Invalid address '{}': {}\",\n \"!\".yellow(),\n input,\n e\n ))?;\n }\n }\n };\n\n config.channels.websocket = Some(WebSocketConfig {\n enabled: true,\n listen_addr,\n auth: Default::default(),\n max_clients: 100,\n max_message_size: 1048576,\n });\n\n Ok(())\n}\n\n// ── Provider field helpers (using macro) ───────────────────────\n\nfn get_provider_entry_mut<'a>(\n config: &'a mut Config,\n provider: &str,\n) -> Option<&'a mut ProviderEntry> {\n match provider {\n \"anthropic\" => config.providers.anthropic.as_mut(),\n \"openai\" => config.providers.openai.as_mut(),\n \"openrouter\" => config.providers.openrouter.as_mut(),\n \"ollama\" => config.providers.ollama.as_mut(),\n \"deepseek\" => config.providers.deepseek.as_mut(),\n \"gemini\" => config.providers.gemini.as_mut(),\n \"groq\" => config.providers.groq.as_mut(),\n \"moonshot\" => config.providers.moonshot.as_mut(),\n \"minimax\" => config.providers.minimax.as_mut(),\n \"github_copilot\" => config.providers.github_copilot.as_mut(),\n \"openai_codex\" => config.providers.openai_codex.as_mut(),\n \"opencode_go\" => config.providers.opencode_go.as_mut(),\n \"glm_coding_plan\" => config.providers.glm_coding_plan.as_mut(),\n _ => None,\n }\n}\n\nfn ensure_provider_entry(config: &mut Config, provider: &str) {\n match provider {\n \"anthropic\" => {\n config\n .providers\n .anthropic\n .get_or_insert_with(ProviderEntry::default);\n }\n \"openai\" => {\n config\n .providers\n .openai\n .get_or_insert_with(ProviderEntry::default);\n }\n \"openrouter\" => {\n config\n .providers\n .openrouter\n .get_or_insert_with(ProviderEntry::default);\n }\n \"ollama\" => {\n config\n .providers\n .ollama\n .get_or_insert_with(ProviderEntry::default);\n }\n \"deepseek\" => {\n config\n .providers\n .deepseek\n .get_or_insert_with(ProviderEntry::default);\n }\n \"gemini\" => {\n config\n .providers\n .gemini\n .get_or_insert_with(ProviderEntry::default);\n }\n \"groq\" => {\n config\n .providers\n .groq\n .get_or_insert_with(ProviderEntry::default);\n }\n \"moonshot\" => {\n config\n .providers\n .moonshot\n .get_or_insert_with(ProviderEntry::default);\n }\n \"minimax\" => {\n config\n .providers\n .minimax\n .get_or_insert_with(ProviderEntry::default);\n }\n \"github_copilot\" => {\n config\n .providers\n .github_copilot\n .get_or_insert_with(ProviderEntry::default);\n }\n \"openai_codex\" => {\n config\n .providers\n .openai_codex\n .get_or_insert_with(ProviderEntry::default);\n }\n \"opencode_go\" => {\n config\n .providers\n .opencode_go\n .get_or_insert_with(ProviderEntry::default);\n }\n \"glm_coding_plan\" => {\n config\n .providers\n .glm_coding_plan\n .get_or_insert_with(ProviderEntry::default);\n }\n _ => {}\n }\n}\n\nfn get_provider_url<'a>(config: &'a Config, provider: &str) -> Option<&'a str> {\n let entry = match provider {\n \"anthropic\" => config.providers.anthropic.as_ref(),\n \"openai\" => config.providers.openai.as_ref(),\n \"openrouter\" => config.providers.openrouter.as_ref(),\n \"ollama\" => config.providers.ollama.as_ref(),\n \"deepseek\" => config.providers.deepseek.as_ref(),\n \"gemini\" => config.providers.gemini.as_ref(),\n \"groq\" => config.providers.groq.as_ref(),\n \"moonshot\" => config.providers.moonshot.as_ref(),\n \"minimax\" => config.providers.minimax.as_ref(),\n \"github_copilot\" => config.providers.github_copilot.as_ref(),\n \"openai_codex\" => config.providers.openai_codex.as_ref(),\n \"opencode_go\" => config.providers.opencode_go.as_ref(),\n \"glm_coding_plan\" => config.providers.glm_coding_plan.as_ref(),\n _ => None,\n };\n entry.and_then(|e| e.base_url.as_deref())\n}\n\nfn set_provider_url(config: &mut Config, provider: &str, url: &str) {\n ensure_provider_entry(config, provider);\n if let Some(entry) = get_provider_entry_mut(config, provider) {\n entry.base_url = if url.is_empty() {\n None\n } else {\n Some(url.to_string())\n };\n }\n}\n\nfn set_provider_api_key(config: &mut Config, provider: &str, key: &str) {\n ensure_provider_entry(config, provider);\n if let Some(entry) = get_provider_entry_mut(config, provider) {\n entry.api_key = if key.is_empty() {\n None\n } else {\n Some(key.to_string())\n };\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use std::cell::RefCell;\n use std::collections::VecDeque;\n\n fn template_toml() -> String {\n toml::to_string(&Config::default()).unwrap()\n }\n\n // ── Mock wizard IO for flow testing ─────────────────────────\n\n #[derive(Debug, Clone)]\n enum MockAction {\n Confirm {\n prompt_contains: &'static str,\n result: bool,\n },\n Select {\n result: usize,\n },\n Input {\n result: &'static str,\n },\n Password {\n result: &'static str,\n },\n }\n\n struct MockWizard {\n actions: RefCell>,\n output: RefCell,\n }\n\n impl MockWizard {\n fn new(actions: Vec) -> Self {\n Self {\n actions: RefCell::new(actions.into()),\n output: RefCell::new(String::new()),\n }\n }\n\n fn output(&self) -> String {\n self.output.borrow().clone()\n }\n }\n\n impl WizardIo for MockWizard {\n fn confirm(&self, prompt: &str, _default: bool) -> Result {\n let action = self.actions.borrow_mut().pop_front();\n match action {\n Some(MockAction::Confirm {\n prompt_contains,\n result,\n }) => {\n assert!(\n prompt.contains(prompt_contains),\n \"confirm prompt '{}' did not contain '{}'\",\n prompt,\n prompt_contains\n );\n Ok(result)\n }\n _ => panic!(\"unexpected confirm call, prompt: {}\", prompt),\n }\n }\n\n fn select(&self, _prompt: &str, _items: &[&str], _default: usize) -> Result {\n let action = self.actions.borrow_mut().pop_front();\n match action {\n Some(MockAction::Select { result }) => Ok(result),\n _ => panic!(\"unexpected select call\"),\n }\n }\n\n fn input_with_default(&self, _prompt: &str, _default: &str) -> Result {\n let action = self.actions.borrow_mut().pop_front();\n match action {\n Some(MockAction::Input { result }) => Ok(result.to_string()),\n _ => panic!(\"unexpected input_with_default call\"),\n }\n }\n\n fn input_allow_empty(&self, _prompt: &str) -> Result {\n let action = self.actions.borrow_mut().pop_front();\n match action {\n Some(MockAction::Input { result }) => Ok(result.to_string()),\n _ => panic!(\"unexpected input_allow_empty call\"),\n }\n }\n\n fn password(&self, _prompt: &str) -> Result {\n let action = self.actions.borrow_mut().pop_front();\n match action {\n Some(MockAction::Password { result }) => Ok(result.to_string()),\n _ => panic!(\"unexpected password call\"),\n }\n }\n\n fn write_line(&self, line: &str) -> Result<()> {\n self.output.borrow_mut().push_str(line);\n self.output.borrow_mut().push('\\n');\n Ok(())\n }\n }\n\n // ── Unit tests ──────────────────────────────────────────────\n\n #[test]\n fn setup_creates_template_config_when_config_is_missing() {\n let tmp = tempfile::tempdir().unwrap();\n let config_path = tmp.path().join(\"config.toml\");\n\n let config = Config::default();\n let home = config_path.parent().unwrap();\n std::fs::create_dir_all(home).unwrap();\n loader::save_config(&config, &config_path).unwrap();\n\n assert_eq!(\n std::fs::read_to_string(&config_path).unwrap(),\n template_toml()\n );\n assert!(config_path.exists());\n }\n\n #[test]\n fn provider_helpers_set_and_get_fields() {\n let mut config = Config::default();\n set_provider_url(&mut config, \"openai\", \"https://custom.api/v1\");\n set_provider_api_key(&mut config, \"openai\", \"sk-test-key\");\n\n let entry = config.providers.openai.as_ref().unwrap();\n assert_eq!(entry.base_url.as_deref(), Some(\"https://custom.api/v1\"));\n assert_eq!(entry.api_key.as_deref(), Some(\"sk-test-key\"));\n }\n\n #[test]\n fn provider_helpers_handle_unknown_provider() {\n let mut config = Config::default();\n set_provider_url(&mut config, \"nonexistent\", \"https://example.com\");\n assert!(get_provider_url(&config, \"nonexistent\").is_none());\n }\n\n #[test]\n fn wizard_keeps_existing_config() {\n let tmp = tempfile::tempdir().unwrap();\n let config_path = tmp.path().join(\"config.toml\");\n\n let existing = Config::default();\n std::fs::create_dir_all(config_path.parent().unwrap()).unwrap();\n loader::save_config(&existing, &config_path).unwrap();\n\n let mock = MockWizard::new(vec![MockAction::Confirm {\n prompt_contains: \"Update\",\n result: false,\n }]);\n\n run_wizard(&mock, &config_path).unwrap();\n\n // Config should be unchanged\n assert_eq!(\n std::fs::read_to_string(&config_path).unwrap(),\n template_toml()\n );\n }\n\n #[test]\n fn wizard_fresh_setup() {\n let tmp = tempfile::tempdir().unwrap();\n let config_path = tmp.path().join(\"config.toml\");\n\n let mock = MockWizard::new(vec![\n // Step 2: Provider\n MockAction::Select { result: 1 }, // openai\n MockAction::Input { result: \"gpt-4o\" },\n MockAction::Input {\n result: \"https://api.openai.com/v1\",\n },\n MockAction::Password {\n result: \"sk-test-key\",\n },\n // Step 3: Telegram (skip)\n MockAction::Confirm {\n prompt_contains: \"Telegram\",\n result: false,\n },\n // Step 4: WebSocket (skip)\n MockAction::Confirm {\n prompt_contains: \"WebSocket\",\n result: false,\n },\n // Step 5: Save\n MockAction::Confirm {\n prompt_contains: \"Write\",\n result: true,\n },\n ]);\n\n run_wizard(&mock, &config_path).unwrap();\n\n assert!(config_path.exists());\n let saved: Config =\n toml::from_str(&std::fs::read_to_string(&config_path).unwrap()).unwrap();\n assert_eq!(saved.agent.provider.as_deref(), Some(\"openai\"));\n assert_eq!(saved.agent.model, \"gpt-4o\");\n }\n\n #[test]\n fn wizard_overwrite_existing_config() {\n let tmp = tempfile::tempdir().unwrap();\n let config_path = tmp.path().join(\"config.toml\");\n\n let existing = Config::default();\n std::fs::create_dir_all(config_path.parent().unwrap()).unwrap();\n loader::save_config(&existing, &config_path).unwrap();\n\n let mock = MockWizard::new(vec![\n // Step 1: Overwrite\n MockAction::Confirm {\n prompt_contains: \"Update\",\n result: true,\n },\n // Step 2: Provider\n MockAction::Select { result: 0 }, // anthropic\n MockAction::Input {\n result: \"claude-sonnet-4-20250514\",\n },\n MockAction::Input {\n result: \"https://api.anthropic.com\",\n },\n MockAction::Password {\n result: \"sk-ant-test\",\n },\n // Step 3: Telegram (skip)\n MockAction::Confirm {\n prompt_contains: \"Telegram\",\n result: false,\n },\n // Step 4: WebSocket (skip)\n MockAction::Confirm {\n prompt_contains: \"WebSocket\",\n result: false,\n },\n // Step 5: Save\n MockAction::Confirm {\n prompt_contains: \"Write\",\n result: true,\n },\n ]);\n\n run_wizard(&mock, &config_path).unwrap();\n\n let saved: Config =\n toml::from_str(&std::fs::read_to_string(&config_path).unwrap()).unwrap();\n assert_eq!(saved.agent.provider.as_deref(), Some(\"anthropic\"));\n assert_eq!(saved.agent.model, \"claude-sonnet-4-20250514\");\n }\n\n fn tg(token: &str) -> TelegramConfig {\n TelegramConfig {\n token: token.to_string(),\n allowed_users: Vec::new(),\n admin_users: Vec::new(),\n enabled: true,\n streaming: false,\n proxy: None,\n }\n }\n\n #[test]\n fn token_masking_short_tokens() {\n let mut config = Config::default();\n config.channels.telegram = Some(tg(\"ab\"));\n\n let mock = MockWizard::new(vec![]);\n show_config_summary(&mock, &config).unwrap();\n\n let output = mock.output();\n assert!(output.contains(\"(masked)\"));\n assert!(!output.contains(\"ab…\"));\n }\n\n #[test]\n fn token_masking_empty_token() {\n let mut config = Config::default();\n config.channels.telegram = Some(tg(\"\"));\n\n let mock = MockWizard::new(vec![]);\n show_config_summary(&mock, &config).unwrap();\n\n let output = mock.output();\n assert!(output.contains(\"(not set)\"));\n }\n}\n"}, "07a6db961685b9f1cf52402400009d5a55895f2c": {"t": 1785761085.3777492, "n": 26647, "h": "35511ec1c5a896dc", "c": "//! Setup command — interactive wizard for configuring Kestrel.\n\nuse anyhow::{bail, Context, Result};\nuse console::Term;\nuse dialoguer::{Confirm, Input, Password as PasswordInput, Select};\nuse kestrel_config::{\n loader, paths,\n schema::{Config, ProviderEntry, TelegramConfig, WebSocketConfig},\n};\nuse owo_colors::OwoColorize;\nuse std::net::SocketAddr;\nuse std::path::Path;\n\nconst PROVIDER_NAMES: &[&str] = &[\n \"anthropic\",\n \"openai\",\n \"openrouter\",\n \"ollama\",\n \"deepseek\",\n \"gemini\",\n \"groq\",\n \"moonshot\",\n \"minimax\",\n \"github_copilot\",\n \"openai_codex\",\n];\n\nconst TOTAL_STEPS: usize = 5;\n\n// ── Trait for interactive I/O (enables testability) ────────────\n\ntrait WizardIo {\n fn confirm(&self, prompt: &str, default: bool) -> Result;\n fn select(&self, prompt: &str, items: &[&str], default: usize) -> Result;\n fn input_with_default(&self, prompt: &str, default: &str) -> Result;\n fn input_allow_empty(&self, prompt: &str) -> Result;\n fn password(&self, prompt: &str) -> Result;\n fn write_line(&self, line: &str) -> Result<()>;\n}\n\n// ── Production implementation using Term + dialoguer ───────────\n\nstruct TermWizard<'a> {\n term: &'a Term,\n}\n\nimpl<'a> TermWizard<'a> {\n fn new(term: &'a Term) -> Self {\n Self { term }\n }\n}\n\nimpl WizardIo for TermWizard<'_> {\n fn confirm(&self, prompt: &str, default: bool) -> Result {\n Ok(Confirm::new()\n .with_prompt(prompt)\n .default(default)\n .interact_on(self.term)?)\n }\n\n fn select(&self, prompt: &str, items: &[&str], default: usize) -> Result {\n Ok(Select::new()\n .with_prompt(prompt)\n .items(items)\n .default(default)\n .interact_on(self.term)?)\n }\n\n fn input_with_default(&self, prompt: &str, default: &str) -> Result {\n Ok(Input::new()\n .with_prompt(prompt)\n .default(default.to_string())\n .interact_text_on(self.term)?)\n }\n\n fn input_allow_empty(&self, prompt: &str) -> Result {\n Ok(Input::::new()\n .with_prompt(prompt)\n .allow_empty(true)\n .interact_text_on(self.term)?)\n }\n\n fn password(&self, prompt: &str) -> Result {\n Ok(PasswordInput::new()\n .with_prompt(prompt)\n .interact_on(self.term)?)\n }\n\n fn write_line(&self, line: &str) -> Result<()> {\n self.term.write_line(line)?;\n Ok(())\n }\n}\n\n// ── Provider dispatch macro (single source of truth) ───────────\n\nmacro_rules! provider_field {\n ($config:expr, $provider:expr, mut) => {\n match $provider {\n \"anthropic\" => Some(&mut $config.providers.anthropic),\n \"openai\" => Some(&mut $config.providers.openai),\n \"openrouter\" => Some(&mut $config.providers.openrouter),\n \"ollama\" => Some(&mut $config.providers.ollama),\n \"deepseek\" => Some(&mut $config.providers.deepseek),\n \"gemini\" => Some(&mut $config.providers.gemini),\n \"groq\" => Some(&mut $config.providers.groq),\n \"moonshot\" => Some(&mut $config.providers.moonshot),\n \"minimax\" => Some(&mut $config.providers.minimax),\n \"github_copilot\" => Some(&mut $config.providers.github_copilot),\n \"openai_codex\" => Some(&mut $config.providers.openai_codex),\n _ => None,\n }\n };\n ($config:expr, $provider:expr) => {\n match $provider {\n \"anthropic\" => Some(&$config.providers.anthropic),\n \"openai\" => Some(&$config.providers.openai),\n \"openrouter\" => Some(&$config.providers.openrouter),\n \"ollama\" => Some(&$config.providers.ollama),\n \"deepseek\" => Some(&$config.providers.deepseek),\n \"gemini\" => Some(&$config.providers.gemini),\n \"groq\" => Some(&$config.providers.groq),\n \"moonshot\" => Some(&$config.providers.moonshot),\n \"minimax\" => Some(&$config.providers.minimax),\n \"github_copilot\" => Some(&$config.providers.github_copilot),\n \"openai_codex\" => Some(&$config.providers.openai_codex),\n _ => None,\n }\n };\n}\n\n// ── Entry point ────────────────────────────────────────────────\n\npub fn run(_config: Config) -> Result<()> {\n let term = Term::stdout();\n if !term.is_term() {\n bail!(\n \"Setup requires an interactive terminal. \\\n Run this command in a terminal, not in a pipe or CI environment.\"\n );\n }\n let io = TermWizard::new(&term);\n let config_path = paths::get_config_path()?;\n run_wizard(&io, &config_path)\n}\n\n// ── Wizard flow ────────────────────────────────────────────────\n\nfn run_wizard(io: &dyn WizardIo, config_path: &Path) -> Result<()> {\n print_banner(io)?;\n\n // ── Step 1: Check existing config ────────────────────────────\n print_step(io, 1, \"Existing Configuration\")?;\n\n let mut config = if config_path.exists() {\n match load_existing_config(config_path) {\n Ok(existing) => {\n show_config_summary(io, &existing)?;\n if io.confirm(\"Update existing config?\", true)? {\n existing\n } else {\n io.write_line(&format!(\n \" {} Keeping config at {}.\",\n \"✓\".green(),\n config_path.display()\n ))?;\n return Ok(());\n }\n }\n Err(e) => {\n io.write_line(&format!(\n \" {} Could not parse existing config: {}\",\n \"!\".yellow(),\n e\n ))?;\n if io.confirm(\"Start fresh with defaults?\", true)? {\n Config::default()\n } else {\n bail!(\"Setup cancelled.\");\n }\n }\n }\n } else {\n io.write_line(\" No config file found. Starting fresh.\")?;\n Config::default()\n };\n\n // ── Step 2: Provider configuration ───────────────────────────\n print_step(io, 2, \"Provider Configuration\")?;\n configure_provider(io, &mut config)?;\n\n // ── Step 3: Telegram channel ─────────────────────────────────\n print_step(io, 3, \"Telegram Channel\")?;\n configure_telegram(io, &mut config)?;\n\n // ── Step 4: WebSocket port ───────────────────────────────────\n print_step(io, 4, \"WebSocket Port\")?;\n configure_websocket(io, &mut config)?;\n\n // ── Step 5: Validate & write ─────────────────────────────────\n print_step(io, 5, \"Save Configuration\")?;\n\n io.write_line(&format!(\" Config path: {}\", config_path.display()))?;\n io.write_line(\"\")?;\n show_config_summary(io, &config)?;\n io.write_line(\"\")?;\n\n if !io.confirm(\"Write this configuration?\", true)? {\n io.write_line(&format!(\" {} Setup cancelled.\", \"!\".yellow()))?;\n return Ok(());\n }\n\n let home = config_path\n .parent()\n .context(\"Config path must have a parent directory\")?;\n\n std::fs::create_dir_all(home)\n .with_context(|| format!(\"Failed to create config home: {}\", home.display()))?;\n\n loader::save_config(&config, config_path)?;\n\n for dir in [\"skills\", \"sessions\", \"learning\"] {\n let path = home.join(dir);\n std::fs::create_dir_all(&path)\n .with_context(|| format!(\"Failed to create directory: {}\", path.display()))?;\n }\n\n io.write_line(\"\")?;\n io.write_line(&format!(\n \" {} Configuration saved to {}\",\n \"✓\".green(),\n config_path.display()\n ))?;\n io.write_line(&format!(\n \" {} Created directories: skills, sessions, learning\",\n \"✓\".green()\n ))?;\n io.write_line(&format!(\" {} Setup complete!\", \"✓\".green()))?;\n\n Ok(())\n}\n\nfn print_banner(io: &dyn WizardIo) -> Result<()> {\n io.write_line(\"\")?;\n io.write_line(&format!(\n \" {} {}\",\n \"▸\".cyan(),\n \"Kestrel Setup Wizard\".bold().cyan()\n ))?;\n io.write_line(\"\")?;\n Ok(())\n}\n\nfn print_step(io: &dyn WizardIo, step: usize, title: &str) -> Result<()> {\n io.write_line(\"\")?;\n io.write_line(&format!(\n \" {} Step {}/{}: {}\",\n \"▸\".cyan(),\n step,\n TOTAL_STEPS,\n title.bold()\n ))?;\n io.write_line(&format!(\" {}\", \"─\".repeat(40).dimmed()))?;\n Ok(())\n}\n\nfn load_existing_config(path: &Path) -> Result {\n let content = std::fs::read_to_string(path)\n .with_context(|| format!(\"Failed to read {}\", path.display()))?;\n let config: Config =\n toml::from_str(&content).with_context(|| format!(\"Failed to parse {}\", path.display()))?;\n Ok(config)\n}\n\nfn mask_token(token: &str) -> String {\n if token.is_empty() {\n \"(not set)\".to_string()\n } else if token.len() <= 3 {\n \"(masked)\".to_string()\n } else {\n format!(\"{}…(masked)\", &token[..3])\n }\n}\n\nfn show_config_summary(io: &dyn WizardIo, config: &Config) -> Result<()> {\n let provider = config.agent.provider.as_deref().unwrap_or(\"default\");\n io.write_line(&format!(\" Model: {}\", config.agent.model))?;\n io.write_line(&format!(\" Provider: {}\", provider))?;\n io.write_line(&format!(\" Temperature: {}\", config.agent.temperature))?;\n io.write_line(&format!(\" Max tokens: {}\", config.agent.max_tokens))?;\n io.write_line(&format!(\" Streaming: {}\", config.agent.streaming))?;\n\n if let Some(ref tg) = config.channels.telegram {\n io.write_line(&format!(\" Telegram: {}\", mask_token(&tg.token)))?;\n }\n\n if let Some(ref ws) = config.channels.websocket {\n if ws.enabled {\n io.write_line(&format!(\" WebSocket: {}\", ws.listen_addr))?;\n }\n }\n\n Ok(())\n}\n\nfn configure_provider(io: &dyn WizardIo, config: &mut Config) -> Result<()> {\n let default_idx = config\n .agent\n .provider\n .as_deref()\n .and_then(|p| PROVIDER_NAMES.iter().position(|&n| n == p))\n .unwrap_or(1); // default to \"openai\"\n\n let provider_name = io.select(\"Select LLM provider\", PROVIDER_NAMES, default_idx)?;\n\n let provider_key = PROVIDER_NAMES[provider_name];\n config.agent.provider = Some(provider_key.to_string());\n\n let default_model = match provider_key {\n \"anthropic\" => \"claude-sonnet-4-20250514\",\n \"openai\" => \"gpt-4o\",\n \"openrouter\" => \"anthropic/claude-sonnet-4-20250514\",\n \"ollama\" => \"llama3\",\n \"deepseek\" => \"deepseek-chat\",\n \"gemini\" => \"gemini-2.5-pro\",\n \"groq\" => \"llama-3.3-70b-versatile\",\n \"moonshot\" => \"moonshot-v1-8k\",\n \"minimax\" => \"MiniMax-Text-01\",\n \"github_copilot\" => \"gpt-4o\",\n \"openai_codex\" => \"codex-mini\",\n _ => \"gpt-4o\",\n };\n\n let current_model = if config.agent.model.is_empty() {\n default_model\n } else {\n &config.agent.model\n };\n\n let model: String = io.input_with_default(\"Model name\", current_model)?;\n config.agent.model = model;\n\n let default_url = match provider_key {\n \"anthropic\" => \"https://api.anthropic.com\",\n \"openai\" => \"https://api.openai.com/v1\",\n \"openrouter\" => \"https://openrouter.ai/api/v1\",\n \"ollama\" => \"http://localhost:11434\",\n \"deepseek\" => \"https://api.deepseek.com\",\n \"gemini\" => \"https://generativelanguage.googleapis.com/v1beta\",\n \"groq\" => \"https://api.groq.com/openai/v1\",\n \"moonshot\" => \"https://api.moonshot.cn/v1\",\n \"minimax\" => \"https://api.minimax.chat/v1\",\n \"github_copilot\" => \"https://api.githubcopilot.com\",\n \"openai_codex\" => \"https://api.openai.com/v1\",\n _ => \"\",\n };\n\n let current_url = get_provider_url(config, provider_key).unwrap_or(default_url);\n\n if !current_url.is_empty() {\n let base_url: String = io.input_with_default(\"Base URL\", current_url)?;\n set_provider_url(config, provider_key, &base_url);\n } else {\n let base_url: String = io.input_allow_empty(\"Base URL (leave empty for default)\")?;\n if !base_url.is_empty() {\n set_provider_url(config, provider_key, &base_url);\n }\n }\n\n let api_key: String = io.password(\"API key\")?;\n if !api_key.is_empty() {\n set_provider_api_key(config, provider_key, &api_key);\n }\n\n Ok(())\n}\n\nfn configure_telegram(io: &dyn WizardIo, config: &mut Config) -> Result<()> {\n let setup_tg = if config.channels.telegram.is_some() {\n io.confirm(\"Configure Telegram bot?\", true)?\n } else {\n io.confirm(\"Set up a Telegram bot?\", false)?\n };\n\n if !setup_tg {\n io.write_line(\" Skipped.\")?;\n return Ok(());\n }\n\n let current_token = config\n .channels\n .telegram\n .as_ref()\n .map(|tg| tg.token.as_str())\n .unwrap_or(\"\");\n\n let token: String = if current_token.is_empty() {\n io.input_allow_empty(\"Bot token (from @BotFather)\")?\n } else {\n io.input_with_default(\"Bot token (from @BotFather)\", current_token)?\n };\n\n if token.is_empty() {\n io.write_line(\" No token provided, skipping Telegram.\")?;\n return Ok(());\n }\n\n let allowed: String = loop {\n let input: String =\n io.input_allow_empty(\"Allowed user IDs (comma-separated, leave empty for all)\")?;\n if input.trim().is_empty() {\n break String::new();\n }\n let ids: Vec<&str> = input\n .split(',')\n .map(|s| s.trim())\n .filter(|s| !s.is_empty())\n .collect();\n let mut valid = true;\n for id in &ids {\n if id.parse::().is_err() {\n io.write_line(&format!(\n \" {} '{}' is not a valid user ID (must be a positive integer).\",\n \"!\".yellow(),\n id\n ))?;\n valid = false;\n break;\n }\n }\n if valid {\n break input;\n }\n };\n\n let allowed_users: Vec = if allowed.trim().is_empty() {\n Vec::new()\n } else {\n allowed\n .split(',')\n .map(|s| s.trim().to_string())\n .filter(|s| !s.is_empty())\n .collect()\n };\n\n // Preserve existing values for fields not explicitly configured in the wizard.\n let (admin_users, enabled, streaming, proxy) = config\n .channels\n .telegram\n .as_ref()\n .map(|tg| {\n (\n tg.admin_users.clone(),\n tg.enabled,\n tg.streaming,\n tg.proxy.clone(),\n )\n })\n .unwrap_or((Vec::new(), true, true, None));\n\n config.channels.telegram = Some(TelegramConfig {\n token,\n allowed_users,\n admin_users,\n enabled,\n streaming,\n proxy,\n });\n\n Ok(())\n}\n\nfn configure_websocket(io: &dyn WizardIo, config: &mut Config) -> Result<()> {\n let default_addr = config\n .channels\n .websocket\n .as_ref()\n .map(|ws| ws.listen_addr.as_str())\n .unwrap_or(\"127.0.0.1:8090\");\n\n let enable = io.confirm(\"Enable WebSocket channel?\", false)?;\n\n if !enable {\n config.channels.websocket = None;\n io.write_line(\" Skipped.\")?;\n return Ok(());\n }\n\n let listen_addr: String = loop {\n let input = io.input_with_default(\"Listen address\", default_addr)?;\n match input.parse::() {\n Ok(_) => break input,\n Err(e) => {\n io.write_line(&format!(\n \" {} Invalid address '{}': {}\",\n \"!\".yellow(),\n input,\n e\n ))?;\n }\n }\n };\n\n config.channels.websocket = Some(WebSocketConfig {\n enabled: true,\n listen_addr,\n auth: Default::default(),\n max_clients: 100,\n max_message_size: 1048576,\n });\n\n Ok(())\n}\n\n// ── Provider field helpers (using macro) ───────────────────────\n\nfn get_provider_entry_mut<'a>(\n config: &'a mut Config,\n provider: &str,\n) -> Option<&'a mut ProviderEntry> {\n provider_field!(config, provider, mut)?.as_mut()\n}\n\nfn ensure_provider_entry(config: &mut Config, provider: &str) {\n if let Some(field) = provider_field!(config, provider, mut) {\n field.get_or_insert_with(ProviderEntry::default);\n }\n}\n\nfn get_provider_url<'a>(config: &'a Config, provider: &str) -> Option<&'a str> {\n provider_field!(config, provider)?\n .as_ref()\n .and_then(|e| e.base_url.as_deref())\n}\n\nfn set_provider_url(config: &mut Config, provider: &str, url: &str) {\n ensure_provider_entry(config, provider);\n if let Some(entry) = get_provider_entry_mut(config, provider) {\n entry.base_url = if url.is_empty() {\n None\n } else {\n Some(url.to_string())\n };\n }\n}\n\nfn set_provider_api_key(config: &mut Config, provider: &str, key: &str) {\n ensure_provider_entry(config, provider);\n if let Some(entry) = get_provider_entry_mut(config, provider) {\n entry.api_key = if key.is_empty() {\n None\n } else {\n Some(key.to_string())\n };\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use std::cell::RefCell;\n use std::collections::VecDeque;\n\n fn template_toml() -> String {\n toml::to_string(&Config::default()).unwrap()\n }\n\n // ── Mock wizard IO for flow testing ─────────────────────────\n\n #[derive(Debug, Clone)]\n enum MockAction {\n Confirm {\n prompt_contains: &'static str,\n result: bool,\n },\n Select {\n result: usize,\n },\n Input {\n result: &'static str,\n },\n Password {\n result: &'static str,\n },\n }\n\n struct MockWizard {\n actions: RefCell>,\n output: RefCell,\n }\n\n impl MockWizard {\n fn new(actions: Vec) -> Self {\n Self {\n actions: RefCell::new(actions.into()),\n output: RefCell::new(String::new()),\n }\n }\n\n fn output(&self) -> String {\n self.output.borrow().clone()\n }\n }\n\n impl WizardIo for MockWizard {\n fn confirm(&self, prompt: &str, _default: bool) -> Result {\n let action = self.actions.borrow_mut().pop_front();\n match action {\n Some(MockAction::Confirm {\n prompt_contains,\n result,\n }) => {\n assert!(\n prompt.contains(prompt_contains),\n \"confirm prompt '{}' did not contain '{}'\",\n prompt,\n prompt_contains\n );\n Ok(result)\n }\n _ => panic!(\"unexpected confirm call, prompt: {}\", prompt),\n }\n }\n\n fn select(&self, _prompt: &str, _items: &[&str], _default: usize) -> Result {\n let action = self.actions.borrow_mut().pop_front();\n match action {\n Some(MockAction::Select { result }) => Ok(result),\n _ => panic!(\"unexpected select call\"),\n }\n }\n\n fn input_with_default(&self, _prompt: &str, _default: &str) -> Result {\n let action = self.actions.borrow_mut().pop_front();\n match action {\n Some(MockAction::Input { result }) => Ok(result.to_string()),\n _ => panic!(\"unexpected input_with_default call\"),\n }\n }\n\n fn input_allow_empty(&self, _prompt: &str) -> Result {\n let action = self.actions.borrow_mut().pop_front();\n match action {\n Some(MockAction::Input { result }) => Ok(result.to_string()),\n _ => panic!(\"unexpected input_allow_empty call\"),\n }\n }\n\n fn password(&self, _prompt: &str) -> Result {\n let action = self.actions.borrow_mut().pop_front();\n match action {\n Some(MockAction::Password { result }) => Ok(result.to_string()),\n _ => panic!(\"unexpected password call\"),\n }\n }\n\n fn write_line(&self, line: &str) -> Result<()> {\n self.output.borrow_mut().push_str(line);\n self.output.borrow_mut().push('\\n');\n Ok(())\n }\n }\n\n // ── Unit tests ──────────────────────────────────────────────\n\n #[test]\n fn setup_creates_template_config_when_config_is_missing() {\n let tmp = tempfile::tempdir().unwrap();\n let config_path = tmp.path().join(\"config.toml\");\n\n let config = Config::default();\n let home = config_path.parent().unwrap();\n std::fs::create_dir_all(home).unwrap();\n loader::save_config(&config, &config_path).unwrap();\n\n assert_eq!(\n std::fs::read_to_string(&config_path).unwrap(),\n template_toml()\n );\n assert!(config_path.exists());\n }\n\n #[test]\n fn provider_helpers_set_and_get_fields() {\n let mut config = Config::default();\n set_provider_url(&mut config, \"openai\", \"https://custom.api/v1\");\n set_provider_api_key(&mut config, \"openai\", \"sk-test-key\");\n\n let entry = config.providers.openai.as_ref().unwrap();\n assert_eq!(entry.base_url.as_deref(), Some(\"https://custom.api/v1\"));\n assert_eq!(entry.api_key.as_deref(), Some(\"sk-test-key\"));\n }\n\n #[test]\n fn provider_helpers_handle_unknown_provider() {\n let mut config = Config::default();\n set_provider_url(&mut config, \"nonexistent\", \"https://example.com\");\n assert!(get_provider_url(&config, \"nonexistent\").is_none());\n }\n\n #[test]\n fn wizard_keeps_existing_config() {\n let tmp = tempfile::tempdir().unwrap();\n let config_path = tmp.path().join(\"config.toml\");\n\n let existing = Config::default();\n std::fs::create_dir_all(config_path.parent().unwrap()).unwrap();\n loader::save_config(&existing, &config_path).unwrap();\n\n let mock = MockWizard::new(vec![MockAction::Confirm {\n prompt_contains: \"Update\",\n result: false,\n }]);\n\n run_wizard(&mock, &config_path).unwrap();\n\n // Config should be unchanged\n assert_eq!(\n std::fs::read_to_string(&config_path).unwrap(),\n template_toml()\n );\n }\n\n #[test]\n fn wizard_fresh_setup() {\n let tmp = tempfile::tempdir().unwrap();\n let config_path = tmp.path().join(\"config.toml\");\n\n let mock = MockWizard::new(vec![\n // Step 2: Provider\n MockAction::Select { result: 1 }, // openai\n MockAction::Input { result: \"gpt-4o\" },\n MockAction::Input {\n result: \"https://api.openai.com/v1\",\n },\n MockAction::Password {\n result: \"sk-test-key\",\n },\n // Step 3: Telegram (skip)\n MockAction::Confirm {\n prompt_contains: \"Telegram\",\n result: false,\n },\n // Step 4: WebSocket (skip)\n MockAction::Confirm {\n prompt_contains: \"WebSocket\",\n result: false,\n },\n // Step 5: Save\n MockAction::Confirm {\n prompt_contains: \"Write\",\n result: true,\n },\n ]);\n\n run_wizard(&mock, &config_path).unwrap();\n\n assert!(config_path.exists());\n let saved: Config =\n toml::from_str(&std::fs::read_to_string(&config_path).unwrap()).unwrap();\n assert_eq!(saved.agent.provider.as_deref(), Some(\"openai\"));\n assert_eq!(saved.agent.model, \"gpt-4o\");\n }\n\n #[test]\n fn wizard_overwrite_existing_config() {\n let tmp = tempfile::tempdir().unwrap();\n let config_path = tmp.path().join(\"config.toml\");\n\n let existing = Config::default();\n std::fs::create_dir_all(config_path.parent().unwrap()).unwrap();\n loader::save_config(&existing, &config_path).unwrap();\n\n let mock = MockWizard::new(vec![\n // Step 1: Overwrite\n MockAction::Confirm {\n prompt_contains: \"Update\",\n result: true,\n },\n // Step 2: Provider\n MockAction::Select { result: 0 }, // anthropic\n MockAction::Input {\n result: \"claude-sonnet-4-20250514\",\n },\n MockAction::Input {\n result: \"https://api.anthropic.com\",\n },\n MockAction::Password {\n result: \"sk-ant-test\",\n },\n // Step 3: Telegram (skip)\n MockAction::Confirm {\n prompt_contains: \"Telegram\",\n result: false,\n },\n // Step 4: WebSocket (skip)\n MockAction::Confirm {\n prompt_contains: \"WebSocket\",\n result: false,\n },\n // Step 5: Save\n MockAction::Confirm {\n prompt_contains: \"Write\",\n result: true,\n },\n ]);\n\n run_wizard(&mock, &config_path).unwrap();\n\n let saved: Config =\n toml::from_str(&std::fs::read_to_string(&config_path).unwrap()).unwrap();\n assert_eq!(saved.agent.provider.as_deref(), Some(\"anthropic\"));\n assert_eq!(saved.agent.model, \"claude-sonnet-4-20250514\");\n }\n\n fn tg(token: &str) -> TelegramConfig {\n TelegramConfig {\n token: token.to_string(),\n allowed_users: Vec::new(),\n admin_users: Vec::new(),\n enabled: true,\n streaming: false,\n proxy: None,\n }\n }\n\n #[test]\n fn token_masking_short_tokens() {\n let mut config = Config::default();\n config.channels.telegram = Some(tg(\"ab\"));\n\n let mock = MockWizard::new(vec![]);\n show_config_summary(&mock, &config).unwrap();\n\n let output = mock.output();\n assert!(output.contains(\"(masked)\"));\n assert!(!output.contains(\"ab…\"));\n }\n\n #[test]\n fn token_masking_empty_token() {\n let mut config = Config::default();\n config.channels.telegram = Some(tg(\"\"));\n\n let mock = MockWizard::new(vec![]);\n show_config_summary(&mock, &config).unwrap();\n\n let output = mock.output();\n assert!(output.contains(\"(not set)\"));\n }\n}\n"}, "01ab9113dec11c713da82e4ce7cbe7e04df87e06": {"t": 1785761088.1140792, "n": 26584, "h": "7029fa7ba1750d5b", "c": "//! Setup command — interactive wizard for configuring Kestrel.\n\nuse anyhow::{bail, Context, Result};\nuse console::Term;\nuse dialoguer::{Confirm, Input, Password as PasswordInput, Select};\nuse kestrel_config::{\n loader, paths,\n schema::{Config, ProviderEntry, TelegramConfig, WebSocketConfig},\n};\nuse owo_colors::OwoColorize;\nuse std::net::SocketAddr;\nuse std::path::Path;\n\nconst PROVIDER_NAMES: &[&str] = &[\n \"anthropic\",\n \"openai\",\n \"openrouter\",\n \"ollama\",\n \"deepseek\",\n \"gemini\",\n \"groq\",\n \"moonshot\",\n \"minimax\",\n \"github_copilot\",\n \"openai_codex\",\n];\n\nconst TOTAL_STEPS: usize = 5;\n\n// ── Trait for interactive I/O (enables testability) ────────────\n\ntrait WizardIo {\n fn confirm(&self, prompt: &str, default: bool) -> Result;\n fn select(&self, prompt: &str, items: &[&str], default: usize) -> Result;\n fn input_with_default(&self, prompt: &str, default: &str) -> Result;\n fn input_allow_empty(&self, prompt: &str) -> Result;\n fn password(&self, prompt: &str) -> Result;\n fn write_line(&self, line: &str) -> Result<()>;\n}\n\n// ── Production implementation using Term + dialoguer ───────────\n\nstruct TermWizard<'a> {\n term: &'a Term,\n}\n\nimpl<'a> TermWizard<'a> {\n fn new(term: &'a Term) -> Self {\n Self { term }\n }\n}\n\nimpl WizardIo for TermWizard<'_> {\n fn confirm(&self, prompt: &str, default: bool) -> Result {\n Ok(Confirm::new()\n .with_prompt(prompt)\n .default(default)\n .interact_on(self.term)?)\n }\n\n fn select(&self, prompt: &str, items: &[&str], default: usize) -> Result {\n Ok(Select::new()\n .with_prompt(prompt)\n .items(items)\n .default(default)\n .interact_on(self.term)?)\n }\n\n fn input_with_default(&self, prompt: &str, default: &str) -> Result {\n Ok(Input::new()\n .with_prompt(prompt)\n .default(default.to_string())\n .interact_text_on(self.term)?)\n }\n\n fn input_allow_empty(&self, prompt: &str) -> Result {\n Ok(Input::::new()\n .with_prompt(prompt)\n .allow_empty(true)\n .interact_text_on(self.term)?)\n }\n\n fn password(&self, prompt: &str) -> Result {\n Ok(PasswordInput::new()\n .with_prompt(prompt)\n .interact_on(self.term)?)\n }\n\n fn write_line(&self, line: &str) -> Result<()> {\n self.term.write_line(line)?;\n Ok(())\n }\n}\n\n// ── Provider dispatch macro (single source of truth) ───────────\n\nmacro_rules! provider_field {\n ($config:expr, $provider:expr, mut) => {\n match $provider {\n \"anthropic\" => Some(&mut $config.providers.anthropic),\n \"openai\" => Some(&mut $config.providers.openai),\n \"openrouter\" => Some(&mut $config.providers.openrouter),\n \"ollama\" => Some(&mut $config.providers.ollama),\n \"deepseek\" => Some(&mut $config.providers.deepseek),\n \"gemini\" => Some(&mut $config.providers.gemini),\n \"groq\" => Some(&mut $config.providers.groq),\n \"moonshot\" => Some(&mut $config.providers.moonshot),\n \"minimax\" => Some(&mut $config.providers.minimax),\n \"github_copilot\" => Some(&mut $config.providers.github_copilot),\n \"openai_codex\" => Some(&mut $config.providers.openai_codex),\n _ => None,\n }\n };\n ($config:expr, $provider:expr) => {\n match $provider {\n \"anthropic\" => Some(&$config.providers.anthropic),\n \"openai\" => Some(&$config.providers.openai),\n \"openrouter\" => Some(&$config.providers.openrouter),\n \"ollama\" => Some(&$config.providers.ollama),\n \"deepseek\" => Some(&$config.providers.deepseek),\n \"gemini\" => Some(&$config.providers.gemini),\n \"groq\" => Some(&$config.providers.groq),\n \"moonshot\" => Some(&$config.providers.moonshot),\n \"minimax\" => Some(&$config.providers.minimax),\n \"github_copilot\" => Some(&$config.providers.github_copilot),\n \"openai_codex\" => Some(&$config.providers.openai_codex),\n _ => None,\n }\n };\n}\n\n// ── Entry point ────────────────────────────────────────────────\n\npub fn run(_config: Config) -> Result<()> {\n let term = Term::stdout();\n if !term.is_term() {\n bail!(\n \"Setup requires an interactive terminal. \\\n Run this command in a terminal, not in a pipe or CI environment.\"\n );\n }\n let io = TermWizard::new(&term);\n let config_path = paths::get_config_path()?;\n run_wizard(&io, &config_path)\n}\n\n// ── Wizard flow ────────────────────────────────────────────────\n\nfn run_wizard(io: &dyn WizardIo, config_path: &Path) -> Result<()> {\n print_banner(io)?;\n\n // ── Step 1: Check existing config ────────────────────────────\n print_step(io, 1, \"Existing Configuration\")?;\n\n let mut config = if config_path.exists() {\n match load_existing_config(config_path) {\n Ok(existing) => {\n show_config_summary(io, &existing)?;\n if io.confirm(\"Update existing config?\", true)? {\n existing\n } else {\n io.write_line(&format!(\n \" {} Keeping config at {}.\",\n \"✓\".green(),\n config_path.display()\n ))?;\n return Ok(());\n }\n }\n Err(e) => {\n io.write_line(&format!(\n \" {} Could not parse existing config: {}\",\n \"!\".yellow(),\n e\n ))?;\n if io.confirm(\"Start fresh with defaults?\", true)? {\n Config::default()\n } else {\n bail!(\"Setup cancelled.\");\n }\n }\n }\n } else {\n io.write_line(\" No config file found. Starting fresh.\")?;\n Config::default()\n };\n\n // ── Step 2: Provider configuration ───────────────────────────\n print_step(io, 2, \"Provider Configuration\")?;\n configure_provider(io, &mut config)?;\n\n // ── Step 3: Telegram channel ─────────────────────────────────\n print_step(io, 3, \"Telegram Channel\")?;\n configure_telegram(io, &mut config)?;\n\n // ── Step 4: WebSocket port ───────────────────────────────────\n print_step(io, 4, \"WebSocket Port\")?;\n configure_websocket(io, &mut config)?;\n\n // ── Step 5: Validate & write ─────────────────────────────────\n print_step(io, 5, \"Save Configuration\")?;\n\n io.write_line(&format!(\" Config path: {}\", config_path.display()))?;\n io.write_line(\"\")?;\n show_config_summary(io, &config)?;\n io.write_line(\"\")?;\n\n if !io.confirm(\"Write this configuration?\", true)? {\n io.write_line(&format!(\" {} Setup cancelled.\", \"!\".yellow()))?;\n return Ok(());\n }\n\n let home = config_path\n .parent()\n .context(\"Config path must have a parent directory\")?;\n\n std::fs::create_dir_all(home)\n .with_context(|| format!(\"Failed to create config home: {}\", home.display()))?;\n\n loader::save_config(&config, config_path)?;\n\n for dir in [\"skills\", \"sessions\", \"learning\"] {\n let path = home.join(dir);\n std::fs::create_dir_all(&path)\n .with_context(|| format!(\"Failed to create directory: {}\", path.display()))?;\n }\n\n io.write_line(\"\")?;\n io.write_line(&format!(\n \" {} Configuration saved to {}\",\n \"✓\".green(),\n config_path.display()\n ))?;\n io.write_line(&format!(\n \" {} Created directories: skills, sessions, learning\",\n \"✓\".green()\n ))?;\n io.write_line(&format!(\" {} Setup complete!\", \"✓\".green()))?;\n\n Ok(())\n}\n\nfn print_banner(io: &dyn WizardIo) -> Result<()> {\n io.write_line(\"\")?;\n io.write_line(&format!(\n \" {} {}\",\n \"▸\".cyan(),\n \"Kestrel Setup Wizard\".bold().cyan()\n ))?;\n io.write_line(\"\")?;\n Ok(())\n}\n\nfn print_step(io: &dyn WizardIo, step: usize, title: &str) -> Result<()> {\n io.write_line(\"\")?;\n io.write_line(&format!(\n \" {} Step {}/{}: {}\",\n \"▸\".cyan(),\n step,\n TOTAL_STEPS,\n title.bold()\n ))?;\n io.write_line(&format!(\" {}\", \"─\".repeat(40).dimmed()))?;\n Ok(())\n}\n\nfn load_existing_config(path: &Path) -> Result {\n let content = std::fs::read_to_string(path)\n .with_context(|| format!(\"Failed to read {}\", path.display()))?;\n let config: Config =\n toml::from_str(&content).with_context(|| format!(\"Failed to parse {}\", path.display()))?;\n Ok(config)\n}\n\nfn mask_token(token: &str) -> String {\n if token.is_empty() {\n \"(not set)\".to_string()\n } else if token.len() <= 3 {\n \"(masked)\".to_string()\n } else {\n format!(\"{}…(masked)\", &token[..3])\n }\n}\n\nfn show_config_summary(io: &dyn WizardIo, config: &Config) -> Result<()> {\n let provider = config.agent.provider.as_deref().unwrap_or(\"default\");\n io.write_line(&format!(\" Model: {}\", config.agent.model))?;\n io.write_line(&format!(\" Provider: {}\", provider))?;\n io.write_line(&format!(\" Temperature: {}\", config.agent.temperature))?;\n io.write_line(&format!(\" Max tokens: {}\", config.agent.max_tokens))?;\n io.write_line(&format!(\" Streaming: {}\", config.agent.streaming))?;\n\n if let Some(ref tg) = config.channels.telegram {\n io.write_line(&format!(\" Telegram: {}\", mask_token(&tg.token)))?;\n }\n\n if let Some(ref ws) = config.channels.websocket {\n if ws.enabled {\n io.write_line(&format!(\" WebSocket: {}\", ws.listen_addr))?;\n }\n }\n\n Ok(())\n}\n\nfn configure_provider(io: &dyn WizardIo, config: &mut Config) -> Result<()> {\n let default_idx = config\n .agent\n .provider\n .as_deref()\n .and_then(|p| PROVIDER_NAMES.iter().position(|&n| n == p))\n .unwrap_or(1); // default to \"openai\"\n\n let provider_name = io.select(\"Select LLM provider\", PROVIDER_NAMES, default_idx)?;\n\n let provider_key = PROVIDER_NAMES[provider_name];\n config.agent.provider = Some(provider_key.to_string());\n\n let default_model = match provider_key {\n \"anthropic\" => \"claude-sonnet-4-20250514\",\n \"openai\" => \"gpt-4o\",\n \"openrouter\" => \"anthropic/claude-sonnet-4-20250514\",\n \"ollama\" => \"llama3\",\n \"deepseek\" => \"deepseek-chat\",\n \"gemini\" => \"gemini-2.5-pro\",\n \"groq\" => \"llama-3.3-70b-versatile\",\n \"moonshot\" => \"moonshot-v1-8k\",\n \"minimax\" => \"MiniMax-Text-01\",\n \"github_copilot\" => \"gpt-4o\",\n \"openai_codex\" => \"codex-mini\",\n _ => \"gpt-4o\",\n };\n\n let current_model = if config.agent.model.is_empty() {\n default_model\n } else {\n &config.agent.model\n };\n\n let model: String = io.input_with_default(\"Model name\", current_model)?;\n config.agent.model = model;\n\n let default_url = match provider_key {\n \"anthropic\" => \"https://api.anthropic.com\",\n \"openai\" => \"https://api.openai.com/v1\",\n \"openrouter\" => \"https://openrouter.ai/api/v1\",\n \"ollama\" => \"http://localhost:11434\",\n \"deepseek\" => \"https://api.deepseek.com\",\n \"gemini\" => \"https://generativelanguage.googleapis.com/v1beta\",\n \"groq\" => \"https://api.groq.com/openai/v1\",\n \"moonshot\" => \"https://api.moonshot.cn/v1\",\n \"minimax\" => \"https://api.minimax.chat/v1\",\n \"github_copilot\" => \"https://api.githubcopilot.com\",\n \"openai_codex\" => \"https://api.openai.com/v1\",\n _ => \"\",\n };\n\n let current_url = get_provider_url(config, provider_key).unwrap_or(default_url);\n\n if !current_url.is_empty() {\n let base_url: String = io.input_with_default(\"Base URL\", current_url)?;\n set_provider_url(config, provider_key, &base_url);\n } else {\n let base_url: String = io.input_allow_empty(\"Base URL (leave empty for default)\")?;\n if !base_url.is_empty() {\n set_provider_url(config, provider_key, &base_url);\n }\n }\n\n let api_key: String = io.password(\"API key\")?;\n if !api_key.is_empty() {\n set_provider_api_key(config, provider_key, &api_key);\n }\n\n Ok(())\n}\n\nfn configure_telegram(io: &dyn WizardIo, config: &mut Config) -> Result<()> {\n let setup_tg = if config.channels.telegram.is_some() {\n io.confirm(\"Configure Telegram bot?\", true)?\n } else {\n io.confirm(\"Set up a Telegram bot?\", false)?\n };\n\n if !setup_tg {\n io.write_line(\" Skipped.\")?;\n return Ok(());\n }\n\n let current_token = config\n .channels\n .telegram\n .as_ref()\n .map(|tg| tg.token.as_str())\n .unwrap_or(\"\");\n\n let token: String = if current_token.is_empty() {\n io.input_allow_empty(\"Bot token (from @BotFather)\")?\n } else {\n io.input_with_default(\"Bot token (from @BotFather)\", current_token)?\n };\n\n if token.is_empty() {\n io.write_line(\" No token provided, skipping Telegram.\")?;\n return Ok(());\n }\n\n let allowed: String = loop {\n let input: String =\n io.input_allow_empty(\"Allowed user IDs (comma-separated, leave empty for all)\")?;\n if input.trim().is_empty() {\n break String::new();\n }\n let ids: Vec<&str> = input\n .split(',')\n .map(|s| s.trim())\n .filter(|s| !s.is_empty())\n .collect();\n let mut valid = true;\n for id in &ids {\n if id.parse::().is_err() {\n io.write_line(&format!(\n \" {} '{}' is not a valid user ID (must be a positive integer).\",\n \"!\".yellow(),\n id\n ))?;\n valid = false;\n break;\n }\n }\n if valid {\n break input;\n }\n };\n\n let allowed_users: Vec = if allowed.trim().is_empty() {\n Vec::new()\n } else {\n allowed\n .split(',')\n .map(|s| s.trim().to_string())\n .filter(|s| !s.is_empty())\n .collect()\n };\n\n // Preserve existing values for fields not explicitly configured in the wizard.\n let (admin_users, enabled, streaming, proxy) = config\n .channels\n .telegram\n .as_ref()\n .map(|tg| {\n (\n tg.admin_users.clone(),\n tg.enabled,\n tg.streaming,\n tg.proxy.clone(),\n )\n })\n .unwrap_or((Vec::new(), true, true, None));\n\n config.channels.telegram = Some(TelegramConfig {\n token,\n allowed_users,\n admin_users,\n enabled,\n streaming,\n proxy,\n });\n\n Ok(())\n}\n\nfn configure_websocket(io: &dyn WizardIo, config: &mut Config) -> Result<()> {\n let default_addr = config\n .channels\n .websocket\n .as_ref()\n .map(|ws| ws.listen_addr.as_str())\n .unwrap_or(\"127.0.0.1:8090\");\n\n let enable = io.confirm(\"Enable WebSocket channel?\", false)?;\n\n if !enable {\n config.channels.websocket = None;\n io.write_line(\" Skipped.\")?;\n return Ok(());\n }\n\n let listen_addr: String = loop {\n let input = io.input_with_default(\"Listen address\", default_addr)?;\n match input.parse::() {\n Ok(_) => break input,\n Err(e) => {\n io.write_line(&format!(\n \" {} Invalid address '{}': {}\",\n \"!\".yellow(),\n input,\n e\n ))?;\n }\n }\n };\n\n config.channels.websocket = Some(WebSocketConfig {\n enabled: true,\n listen_addr,\n auth: Default::default(),\n max_clients: 100,\n max_message_size: 1048576,\n });\n\n Ok(())\n}\n\n// ── Provider field helpers (using macro) ───────────────────────\n\nfn get_provider_entry_mut<'a>(\n config: &'a mut Config,\n provider: &str,\n) -> Option<&'a mut ProviderEntry> {\n provider_field!(config, provider, mut)?.as_mut()\n}\n\nfn ensure_provider_entry(config: &mut Config, provider: &str) {\n if let Some(field) = provider_field!(config, provider, mut) {\n field.get_or_insert_with(ProviderEntry::default);\n }\n}\n\nfn get_provider_url<'a>(config: &'a Config, provider: &str) -> Option<&'a str> {\n provider_field!(config, provider)?\n .as_ref()\n .and_then(|e| e.base_url.as_deref())\n}\n\nfn set_provider_url(config: &mut Config, provider: &str, url: &str) {\n ensure_provider_entry(config, provider);\n if let Some(entry) = get_provider_entry_mut(config, provider) {\n entry.base_url = if url.is_empty() {\n None\n } else {\n Some(url.to_string())\n };\n }\n}\n\nfn set_provider_api_key(config: &mut Config, provider: &str, key: &str) {\n ensure_provider_entry(config, provider);\n if let Some(entry) = get_provider_entry_mut(config, provider) {\n entry.api_key = if key.is_empty() {\n None\n } else {\n Some(key.to_string())\n };\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use std::cell::RefCell;\n use std::collections::VecDeque;\n\n fn template_toml() -> String {\n toml::to_string(&Config::default()).unwrap()\n }\n\n // ── Mock wizard IO for flow testing ─────────────────────────\n\n #[derive(Debug, Clone)]\n enum MockAction {\n Confirm {\n prompt_contains: &'static str,\n result: bool,\n },\n Select { result: usize },\n Input { result: &'static str },\n Password { result: &'static str },\n }\n\n struct MockWizard {\n actions: RefCell>,\n output: RefCell,\n }\n\n impl MockWizard {\n fn new(actions: Vec) -> Self {\n Self {\n actions: RefCell::new(actions.into()),\n output: RefCell::new(String::new()),\n }\n }\n\n fn output(&self) -> String {\n self.output.borrow().clone()\n }\n }\n\n impl WizardIo for MockWizard {\n fn confirm(&self, prompt: &str, _default: bool) -> Result {\n let action = self.actions.borrow_mut().pop_front();\n match action {\n Some(MockAction::Confirm {\n prompt_contains,\n result,\n }) => {\n assert!(\n prompt.contains(prompt_contains),\n \"confirm prompt '{}' did not contain '{}'\",\n prompt,\n prompt_contains\n );\n Ok(result)\n }\n _ => panic!(\"unexpected confirm call, prompt: {}\", prompt),\n }\n }\n\n fn select(&self, _prompt: &str, _items: &[&str], _default: usize) -> Result {\n let action = self.actions.borrow_mut().pop_front();\n match action {\n Some(MockAction::Select { result }) => Ok(result),\n _ => panic!(\"unexpected select call\"),\n }\n }\n\n fn input_with_default(&self, _prompt: &str, _default: &str) -> Result {\n let action = self.actions.borrow_mut().pop_front();\n match action {\n Some(MockAction::Input { result }) => Ok(result.to_string()),\n _ => panic!(\"unexpected input_with_default call\"),\n }\n }\n\n fn input_allow_empty(&self, _prompt: &str) -> Result {\n let action = self.actions.borrow_mut().pop_front();\n match action {\n Some(MockAction::Input { result }) => Ok(result.to_string()),\n _ => panic!(\"unexpected input_allow_empty call\"),\n }\n }\n\n fn password(&self, _prompt: &str) -> Result {\n let action = self.actions.borrow_mut().pop_front();\n match action {\n Some(MockAction::Password { result }) => Ok(result.to_string()),\n _ => panic!(\"unexpected password call\"),\n }\n }\n\n fn write_line(&self, line: &str) -> Result<()> {\n self.output.borrow_mut().push_str(line);\n self.output.borrow_mut().push('\\n');\n Ok(())\n }\n }\n\n // ── Unit tests ──────────────────────────────────────────────\n\n #[test]\n fn setup_creates_template_config_when_config_is_missing() {\n let tmp = tempfile::tempdir().unwrap();\n let config_path = tmp.path().join(\"config.toml\");\n\n let config = Config::default();\n let home = config_path.parent().unwrap();\n std::fs::create_dir_all(home).unwrap();\n loader::save_config(&config, &config_path).unwrap();\n\n assert_eq!(\n std::fs::read_to_string(&config_path).unwrap(),\n template_toml()\n );\n assert!(config_path.exists());\n }\n\n #[test]\n fn provider_helpers_set_and_get_fields() {\n let mut config = Config::default();\n set_provider_url(&mut config, \"openai\", \"https://custom.api/v1\");\n set_provider_api_key(&mut config, \"openai\", \"sk-test-key\");\n\n let entry = config.providers.openai.as_ref().unwrap();\n assert_eq!(entry.base_url.as_deref(), Some(\"https://custom.api/v1\"));\n assert_eq!(entry.api_key.as_deref(), Some(\"sk-test-key\"));\n }\n\n #[test]\n fn provider_helpers_handle_unknown_provider() {\n let mut config = Config::default();\n set_provider_url(&mut config, \"nonexistent\", \"https://example.com\");\n assert!(get_provider_url(&config, \"nonexistent\").is_none());\n }\n\n #[test]\n fn wizard_keeps_existing_config() {\n let tmp = tempfile::tempdir().unwrap();\n let config_path = tmp.path().join(\"config.toml\");\n\n let existing = Config::default();\n std::fs::create_dir_all(config_path.parent().unwrap()).unwrap();\n loader::save_config(&existing, &config_path).unwrap();\n\n let mock = MockWizard::new(vec![MockAction::Confirm {\n prompt_contains: \"Update\",\n result: false,\n }]);\n\n run_wizard(&mock, &config_path).unwrap();\n\n // Config should be unchanged\n assert_eq!(\n std::fs::read_to_string(&config_path).unwrap(),\n template_toml()\n );\n }\n\n #[test]\n fn wizard_fresh_setup() {\n let tmp = tempfile::tempdir().unwrap();\n let config_path = tmp.path().join(\"config.toml\");\n\n let mock = MockWizard::new(vec![\n // Step 2: Provider\n MockAction::Select { result: 1 }, // openai\n MockAction::Input { result: \"gpt-4o\" },\n MockAction::Input {\n result: \"https://api.openai.com/v1\",\n },\n MockAction::Password {\n result: \"sk-test-key\",\n },\n // Step 3: Telegram (skip)\n MockAction::Confirm {\n prompt_contains: \"Telegram\",\n result: false,\n },\n // Step 4: WebSocket (skip)\n MockAction::Confirm {\n prompt_contains: \"WebSocket\",\n result: false,\n },\n // Step 5: Save\n MockAction::Confirm {\n prompt_contains: \"Write\",\n result: true,\n },\n ]);\n\n run_wizard(&mock, &config_path).unwrap();\n\n assert!(config_path.exists());\n let saved: Config =\n toml::from_str(&std::fs::read_to_string(&config_path).unwrap()).unwrap();\n assert_eq!(saved.agent.provider.as_deref(), Some(\"openai\"));\n assert_eq!(saved.agent.model, \"gpt-4o\");\n }\n\n #[test]\n fn wizard_overwrite_existing_config() {\n let tmp = tempfile::tempdir().unwrap();\n let config_path = tmp.path().join(\"config.toml\");\n\n let existing = Config::default();\n std::fs::create_dir_all(config_path.parent().unwrap()).unwrap();\n loader::save_config(&existing, &config_path).unwrap();\n\n let mock = MockWizard::new(vec![\n // Step 1: Overwrite\n MockAction::Confirm {\n prompt_contains: \"Update\",\n result: true,\n },\n // Step 2: Provider\n MockAction::Select { result: 0 }, // anthropic\n MockAction::Input {\n result: \"claude-sonnet-4-20250514\",\n },\n MockAction::Input {\n result: \"https://api.anthropic.com\",\n },\n MockAction::Password {\n result: \"sk-ant-test\",\n },\n // Step 3: Telegram (skip)\n MockAction::Confirm {\n prompt_contains: \"Telegram\",\n result: false,\n },\n // Step 4: WebSocket (skip)\n MockAction::Confirm {\n prompt_contains: \"WebSocket\",\n result: false,\n },\n // Step 5: Save\n MockAction::Confirm {\n prompt_contains: \"Write\",\n result: true,\n },\n ]);\n\n run_wizard(&mock, &config_path).unwrap();\n\n let saved: Config =\n toml::from_str(&std::fs::read_to_string(&config_path).unwrap()).unwrap();\n assert_eq!(saved.agent.provider.as_deref(), Some(\"anthropic\"));\n assert_eq!(saved.agent.model, \"claude-sonnet-4-20250514\");\n }\n\n fn tg(token: &str) -> TelegramConfig {\n TelegramConfig {\n token: token.to_string(),\n allowed_users: Vec::new(),\n admin_users: Vec::new(),\n enabled: true,\n streaming: false,\n proxy: None,\n }\n }\n\n #[test]\n fn token_masking_short_tokens() {\n let mut config = Config::default();\n config.channels.telegram = Some(tg(\"ab\"));\n\n let mock = MockWizard::new(vec![]);\n show_config_summary(&mock, &config).unwrap();\n\n let output = mock.output();\n assert!(output.contains(\"(masked)\"));\n assert!(!output.contains(\"ab…\"));\n }\n\n #[test]\n fn token_masking_empty_token() {\n let mut config = Config::default();\n config.channels.telegram = Some(tg(\"\"));\n\n let mock = MockWizard::new(vec![]);\n show_config_summary(&mock, &config).unwrap();\n\n let output = mock.output();\n assert!(output.contains(\"(not set)\"));\n }\n}\n"}, "7998911cdbb4f99ae64a7496a0bc63b784380915": {"t": 1785761089.5098805, "n": 26700, "h": "a1a6e383e8429377", "c": "//! Setup command — interactive wizard for configuring Kestrel.\n\nuse anyhow::{bail, Context, Result};\nuse console::Term;\nuse dialoguer::{Confirm, Input, Password as PasswordInput, Select};\nuse kestrel_config::{\n loader, paths,\n schema::{Config, ProviderEntry, TelegramConfig, WebSocketConfig},\n};\nuse owo_colors::OwoColorize;\nuse std::net::SocketAddr;\nuse std::path::Path;\n\nconst PROVIDER_NAMES: &[&str] = &[\n \"anthropic\",\n \"openai\",\n \"openrouter\",\n \"ollama\",\n \"deepseek\",\n \"gemini\",\n \"groq\",\n \"moonshot\",\n \"minimax\",\n \"github_copilot\",\n \"openai_codex\",\n];\n\nconst TOTAL_STEPS: usize = 5;\n\n// ── Trait for interactive I/O (enables testability) ────────────\n\ntrait WizardIo {\n fn confirm(&self, prompt: &str, default: bool) -> Result;\n fn select(&self, prompt: &str, items: &[&str], default: usize) -> Result;\n fn input_with_default(&self, prompt: &str, default: &str) -> Result;\n fn input_allow_empty(&self, prompt: &str) -> Result;\n fn password(&self, prompt: &str) -> Result;\n fn write_line(&self, line: &str) -> Result<()>;\n}\n\n// ── Production implementation using Term + dialoguer ───────────\n\nstruct TermWizard<'a> {\n term: &'a Term,\n}\n\nimpl<'a> TermWizard<'a> {\n fn new(term: &'a Term) -> Self {\n Self { term }\n }\n}\n\nimpl WizardIo for TermWizard<'_> {\n fn confirm(&self, prompt: &str, default: bool) -> Result {\n Ok(Confirm::new()\n .with_prompt(prompt)\n .default(default)\n .interact_on(self.term)?)\n }\n\n fn select(&self, prompt: &str, items: &[&str], default: usize) -> Result {\n Ok(Select::new()\n .with_prompt(prompt)\n .items(items)\n .default(default)\n .interact_on(self.term)?)\n }\n\n fn input_with_default(&self, prompt: &str, default: &str) -> Result {\n Ok(Input::new()\n .with_prompt(prompt)\n .default(default.to_string())\n .interact_text_on(self.term)?)\n }\n\n fn input_allow_empty(&self, prompt: &str) -> Result {\n Ok(Input::::new()\n .with_prompt(prompt)\n .allow_empty(true)\n .interact_text_on(self.term)?)\n }\n\n fn password(&self, prompt: &str) -> Result {\n Ok(PasswordInput::new()\n .with_prompt(prompt)\n .interact_on(self.term)?)\n }\n\n fn write_line(&self, line: &str) -> Result<()> {\n self.term.write_line(line)?;\n Ok(())\n }\n}\n\n// ── Provider dispatch macro (single source of truth) ───────────\n\nmacro_rules! provider_field {\n ($config:expr, $provider:expr, mut) => {\n match $provider {\n \"anthropic\" => Some(&mut $config.providers.anthropic),\n \"openai\" => Some(&mut $config.providers.openai),\n \"openrouter\" => Some(&mut $config.providers.openrouter),\n \"ollama\" => Some(&mut $config.providers.ollama),\n \"deepseek\" => Some(&mut $config.providers.deepseek),\n \"gemini\" => Some(&mut $config.providers.gemini),\n \"groq\" => Some(&mut $config.providers.groq),\n \"moonshot\" => Some(&mut $config.providers.moonshot),\n \"minimax\" => Some(&mut $config.providers.minimax),\n \"github_copilot\" => Some(&mut $config.providers.github_copilot),\n \"openai_codex\" => Some(&mut $config.providers.openai_codex),\n _ => None,\n }\n };\n ($config:expr, $provider:expr) => {\n match $provider {\n \"anthropic\" => Some(&$config.providers.anthropic),\n \"openai\" => Some(&$config.providers.openai),\n \"openrouter\" => Some(&$config.providers.openrouter),\n \"ollama\" => Some(&$config.providers.ollama),\n \"deepseek\" => Some(&$config.providers.deepseek),\n \"gemini\" => Some(&$config.providers.gemini),\n \"groq\" => Some(&$config.providers.groq),\n \"moonshot\" => Some(&$config.providers.moonshot),\n \"minimax\" => Some(&$config.providers.minimax),\n \"github_copilot\" => Some(&$config.providers.github_copilot),\n \"openai_codex\" => Some(&$config.providers.openai_codex),\n _ => None,\n }\n };\n}\n\n// ── Entry point ────────────────────────────────────────────────\n\npub fn run(_config: Config) -> Result<()> {\n let term = Term::stdout();\n if !term.is_term() {\n bail!(\n \"Setup requires an interactive terminal. \\\n Run this command in a terminal, not in a pipe or CI environment.\"\n );\n }\n let io = TermWizard::new(&term);\n let config_path = paths::get_config_path()?;\n run_wizard(&io, &config_path)\n}\n\n// ── Wizard flow ────────────────────────────────────────────────\n\nfn run_wizard(io: &dyn WizardIo, config_path: &Path) -> Result<()> {\n print_banner(io)?;\n\n // ── Step 1: Check existing config ────────────────────────────\n print_step(io, 1, \"Existing Configuration\")?;\n\n let mut config = if config_path.exists() {\n match load_existing_config(config_path) {\n Ok(existing) => {\n show_config_summary(io, &existing)?;\n if io.confirm(\"Update existing config?\", true)? {\n existing\n } else {\n io.write_line(&format!(\n \" {} Keeping config at {}.\",\n \"✓\".green(),\n config_path.display()\n ))?;\n return Ok(());\n }\n }\n Err(e) => {\n io.write_line(&format!(\n \" {} Could not parse existing config: {}\",\n \"!\".yellow(),\n e\n ))?;\n if io.confirm(\"Start fresh with defaults?\", true)? {\n Config::default()\n } else {\n bail!(\"Setup cancelled.\");\n }\n }\n }\n } else {\n io.write_line(\" No config file found. Starting fresh.\")?;\n Config::default()\n };\n\n // ── Step 2: Provider configuration ───────────────────────────\n print_step(io, 2, \"Provider Configuration\")?;\n configure_provider(io, &mut config)?;\n\n // ── Step 3: Telegram channel ─────────────────────────────────\n print_step(io, 3, \"Telegram Channel\")?;\n configure_telegram(io, &mut config)?;\n\n // ── Step 4: WebSocket port ───────────────────────────────────\n print_step(io, 4, \"WebSocket Port\")?;\n configure_websocket(io, &mut config)?;\n\n // ── Step 5: Validate & write ─────────────────────────────────\n print_step(io, 5, \"Save Configuration\")?;\n\n io.write_line(&format!(\" Config path: {}\", config_path.display()))?;\n io.write_line(\"\")?;\n show_config_summary(io, &config)?;\n io.write_line(\"\")?;\n\n if !io.confirm(\"Write this configuration?\", true)? {\n io.write_line(&format!(\" {} Setup cancelled.\", \"!\".yellow()))?;\n return Ok(());\n }\n\n let home = config_path\n .parent()\n .context(\"Config path must have a parent directory\")?;\n\n std::fs::create_dir_all(home)\n .with_context(|| format!(\"Failed to create config home: {}\", home.display()))?;\n\n loader::save_config(&config, config_path)?;\n\n for dir in [\"skills\", \"sessions\", \"learning\"] {\n let path = home.join(dir);\n std::fs::create_dir_all(&path)\n .with_context(|| format!(\"Failed to create directory: {}\", path.display()))?;\n }\n\n io.write_line(\"\")?;\n io.write_line(&format!(\n \" {} Configuration saved to {}\",\n \"✓\".green(),\n config_path.display()\n ))?;\n io.write_line(&format!(\n \" {} Created directories: skills, sessions, learning\",\n \"✓\".green()\n ))?;\n io.write_line(&format!(\" {} Setup complete!\", \"✓\".green()))?;\n\n Ok(())\n}\n\nfn print_banner(io: &dyn WizardIo) -> Result<()> {\n io.write_line(\"\")?;\n io.write_line(&format!(\n \" {} {}\",\n \"▸\".cyan(),\n \"Kestrel Setup Wizard\".bold().cyan()\n ))?;\n io.write_line(\"\")?;\n Ok(())\n}\n\nfn print_step(io: &dyn WizardIo, step: usize, title: &str) -> Result<()> {\n io.write_line(\"\")?;\n io.write_line(&format!(\n \" {} Step {}/{}: {}\",\n \"▸\".cyan(),\n step,\n TOTAL_STEPS,\n title.bold()\n ))?;\n io.write_line(&format!(\" {}\", \"─\".repeat(40).dimmed()))?;\n Ok(())\n}\n\nfn load_existing_config(path: &Path) -> Result {\n let content = std::fs::read_to_string(path)\n .with_context(|| format!(\"Failed to read {}\", path.display()))?;\n let config: Config =\n toml::from_str(&content).with_context(|| format!(\"Failed to parse {}\", path.display()))?;\n Ok(config)\n}\n\nfn mask_token(token: &str) -> String {\n if token.is_empty() {\n \"(not set)\".to_string()\n } else {\n let visible = token.len().min(3);\n format!(\"{}…(masked)\", &token[..visible])\n }\n}\n\nfn show_config_summary(io: &dyn WizardIo, config: &Config) -> Result<()> {\n let provider = config.agent.provider.as_deref().unwrap_or(\"default\");\n io.write_line(&format!(\" Model: {}\", config.agent.model))?;\n io.write_line(&format!(\" Provider: {}\", provider))?;\n io.write_line(&format!(\" Temperature: {}\", config.agent.temperature))?;\n io.write_line(&format!(\" Max tokens: {}\", config.agent.max_tokens))?;\n io.write_line(&format!(\" Streaming: {}\", config.agent.streaming))?;\n\n if let Some(ref tg) = config.channels.telegram {\n io.write_line(&format!(\" Telegram: {}\", mask_token(&tg.token)))?;\n }\n\n if let Some(ref ws) = config.channels.websocket {\n if ws.enabled {\n io.write_line(&format!(\" WebSocket: {}\", ws.listen_addr))?;\n }\n }\n\n Ok(())\n}\n\nfn configure_provider(io: &dyn WizardIo, config: &mut Config) -> Result<()> {\n let default_idx = config\n .agent\n .provider\n .as_deref()\n .and_then(|p| PROVIDER_NAMES.iter().position(|&n| n == p))\n .unwrap_or(1); // default to \"openai\"\n\n let provider_name = io.select(\"Select LLM provider\", PROVIDER_NAMES, default_idx)?;\n\n let provider_key = PROVIDER_NAMES[provider_name];\n config.agent.provider = Some(provider_key.to_string());\n\n let default_model = match provider_key {\n \"anthropic\" => \"claude-sonnet-4-20250514\",\n \"openai\" => \"gpt-4o\",\n \"openrouter\" => \"anthropic/claude-sonnet-4-20250514\",\n \"ollama\" => \"llama3\",\n \"deepseek\" => \"deepseek-chat\",\n \"gemini\" => \"gemini-2.5-pro\",\n \"groq\" => \"llama-3.3-70b-versatile\",\n \"moonshot\" => \"moonshot-v1-8k\",\n \"minimax\" => \"MiniMax-Text-01\",\n \"github_copilot\" => \"gpt-4o\",\n \"openai_codex\" => \"codex-mini\",\n _ => \"gpt-4o\",\n };\n\n let current_model = if config.agent.model.is_empty() {\n default_model\n } else {\n &config.agent.model\n };\n\n let model: String = io.input_with_default(\"Model name\", current_model)?;\n config.agent.model = model;\n\n let default_url = match provider_key {\n \"anthropic\" => \"https://api.anthropic.com\",\n \"openai\" => \"https://api.openai.com/v1\",\n \"openrouter\" => \"https://openrouter.ai/api/v1\",\n \"ollama\" => \"http://localhost:11434\",\n \"deepseek\" => \"https://api.deepseek.com\",\n \"gemini\" => \"https://generativelanguage.googleapis.com/v1beta\",\n \"groq\" => \"https://api.groq.com/openai/v1\",\n \"moonshot\" => \"https://api.moonshot.cn/v1\",\n \"minimax\" => \"https://api.minimax.chat/v1\",\n \"github_copilot\" => \"https://api.githubcopilot.com\",\n \"openai_codex\" => \"https://api.openai.com/v1\",\n _ => \"\",\n };\n\n let current_url = get_provider_url(config, provider_key).unwrap_or(default_url);\n\n if !current_url.is_empty() {\n let base_url: String = io.input_with_default(\"Base URL\", current_url)?;\n set_provider_url(config, provider_key, &base_url);\n } else {\n let base_url: String = io.input_allow_empty(\"Base URL (leave empty for default)\")?;\n if !base_url.is_empty() {\n set_provider_url(config, provider_key, &base_url);\n }\n }\n\n let api_key: String = io.password(\"API key\")?;\n if !api_key.is_empty() {\n set_provider_api_key(config, provider_key, &api_key);\n }\n\n Ok(())\n}\n\nfn configure_telegram(io: &dyn WizardIo, config: &mut Config) -> Result<()> {\n let setup_tg = if config.channels.telegram.is_some() {\n io.confirm(\"Configure Telegram bot?\", true)?\n } else {\n io.confirm(\"Set up a Telegram bot?\", false)?\n };\n\n if !setup_tg {\n io.write_line(\" Skipped.\")?;\n return Ok(());\n }\n\n let current_token = config\n .channels\n .telegram\n .as_ref()\n .map(|tg| tg.token.as_str())\n .unwrap_or(\"\");\n\n let token: String = if current_token.is_empty() {\n io.input_allow_empty(\"Bot token (from @BotFather)\")?\n } else {\n io.input_with_default(\"Bot token (from @BotFather)\", current_token)?\n };\n\n if token.is_empty() {\n io.write_line(\" No token provided, skipping Telegram.\")?;\n return Ok(());\n }\n\n let allowed: String = loop {\n let input: String =\n io.input_allow_empty(\"Allowed user IDs (comma-separated, leave empty for all)\")?;\n if input.trim().is_empty() {\n break String::new();\n }\n let ids: Vec<&str> = input\n .split(',')\n .map(|s| s.trim())\n .filter(|s| !s.is_empty())\n .collect();\n let mut valid = true;\n for id in &ids {\n if id.parse::().is_err() {\n io.write_line(&format!(\n \" {} '{}' is not a valid user ID (must be a positive integer).\",\n \"!\".yellow(),\n id\n ))?;\n valid = false;\n break;\n }\n }\n if valid {\n break input;\n }\n };\n\n let allowed_users: Vec = if allowed.trim().is_empty() {\n Vec::new()\n } else {\n allowed\n .split(',')\n .map(|s| s.trim().to_string())\n .filter(|s| !s.is_empty())\n .collect()\n };\n\n // Preserve existing values for fields not explicitly configured in the wizard.\n let (admin_users, enabled, streaming, proxy) = config\n .channels\n .telegram\n .as_ref()\n .map(|tg| {\n (\n tg.admin_users.clone(),\n tg.enabled,\n tg.streaming,\n tg.proxy.clone(),\n )\n })\n .unwrap_or((Vec::new(), true, true, None));\n\n config.channels.telegram = Some(TelegramConfig {\n token,\n allowed_users,\n admin_users,\n enabled,\n streaming,\n proxy,\n });\n\n Ok(())\n}\n\nfn configure_websocket(io: &dyn WizardIo, config: &mut Config) -> Result<()> {\n let default_addr = config\n .channels\n .websocket\n .as_ref()\n .map(|ws| ws.listen_addr.as_str())\n .unwrap_or(\"127.0.0.1:8090\");\n\n let enable = io.confirm(\"Enable WebSocket channel?\", false)?;\n\n if !enable {\n config.channels.websocket = None;\n io.write_line(\" Skipped.\")?;\n return Ok(());\n }\n\n let listen_addr: String = loop {\n let input = io.input_with_default(\"Listen address\", default_addr)?;\n match input.parse::() {\n Ok(_) => break input,\n Err(e) => {\n io.write_line(&format!(\n \" {} Invalid address '{}': {}\",\n \"!\".yellow(),\n input,\n e\n ))?;\n }\n }\n };\n\n config.channels.websocket = Some(WebSocketConfig {\n enabled: true,\n listen_addr,\n auth: Default::default(),\n max_clients: 100,\n max_message_size: 1048576,\n });\n\n Ok(())\n}\n\n// ── Provider field helpers (using macro) ───────────────────────\n\nfn get_provider_entry_mut<'a>(\n config: &'a mut Config,\n provider: &str,\n) -> Option<&'a mut ProviderEntry> {\n provider_field!(config, provider, mut)?.as_mut()\n}\n\nfn ensure_provider_entry(config: &mut Config, provider: &str) {\n if let Some(field) = provider_field!(config, provider, mut) {\n field.get_or_insert_with(ProviderEntry::default);\n }\n}\n\nfn get_provider_url<'a>(config: &'a Config, provider: &str) -> Option<&'a str> {\n provider_field!(config, provider)?.as_ref().and_then(|e| e.base_url.as_deref())\n}\n\nfn set_provider_url(config: &mut Config, provider: &str, url: &str) {\n ensure_provider_entry(config, provider);\n if let Some(entry) = get_provider_entry_mut(config, provider) {\n entry.base_url = if url.is_empty() {\n None\n } else {\n Some(url.to_string())\n };\n }\n}\n\nfn set_provider_api_key(config: &mut Config, provider: &str, key: &str) {\n ensure_provider_entry(config, provider);\n if let Some(entry) = get_provider_entry_mut(config, provider) {\n entry.api_key = if key.is_empty() {\n None\n } else {\n Some(key.to_string())\n };\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use std::cell::RefCell;\n use std::collections::VecDeque;\n\n fn template_toml() -> String {\n toml::to_string(&Config::default()).unwrap()\n }\n\n // ── Mock wizard IO for flow testing ─────────────────────────\n\n #[derive(Debug, Clone)]\n enum MockAction {\n Confirm {\n prompt_contains: &'static str,\n result: bool,\n },\n Select {\n result: usize,\n },\n Input {\n result: &'static str,\n },\n Password {\n result: &'static str,\n },\n }\n\n struct MockWizard {\n actions: RefCell>,\n output: RefCell,\n }\n\n impl MockWizard {\n fn new(actions: Vec) -> Self {\n Self {\n actions: RefCell::new(actions.into()),\n output: RefCell::new(String::new()),\n }\n }\n\n fn output(&self) -> String {\n self.output.borrow().clone()\n }\n }\n\n impl WizardIo for MockWizard {\n fn confirm(&self, prompt: &str, _default: bool) -> Result {\n let action = self.actions.borrow_mut().pop_front();\n match action {\n Some(MockAction::Confirm {\n prompt_contains,\n result,\n }) => {\n assert!(\n prompt.contains(prompt_contains),\n \"confirm prompt '{}' did not contain '{}'\",\n prompt,\n prompt_contains\n );\n Ok(result)\n }\n _ => panic!(\"unexpected confirm call, prompt: {}\", prompt),\n }\n }\n\n fn select(&self, _prompt: &str, _items: &[&str], _default: usize) -> Result {\n let action = self.actions.borrow_mut().pop_front();\n match action {\n Some(MockAction::Select { result }) => Ok(result),\n _ => panic!(\"unexpected select call\"),\n }\n }\n\n fn input_with_default(&self, _prompt: &str, _default: &str) -> Result {\n let action = self.actions.borrow_mut().pop_front();\n match action {\n Some(MockAction::Input { result }) => Ok(result.to_string()),\n _ => panic!(\"unexpected input_with_default call\"),\n }\n }\n\n fn input_allow_empty(&self, _prompt: &str) -> Result {\n let action = self.actions.borrow_mut().pop_front();\n match action {\n Some(MockAction::Input { result }) => Ok(result.to_string()),\n _ => panic!(\"unexpected input_allow_empty call\"),\n }\n }\n\n fn password(&self, _prompt: &str) -> Result {\n let action = self.actions.borrow_mut().pop_front();\n match action {\n Some(MockAction::Password { result }) => Ok(result.to_string()),\n _ => panic!(\"unexpected password call\"),\n }\n }\n\n fn write_line(&self, line: &str) -> Result<()> {\n self.output.borrow_mut().push_str(line);\n self.output.borrow_mut().push('\\n');\n Ok(())\n }\n }\n\n // ── Unit tests ──────────────────────────────────────────────\n\n #[test]\n fn setup_creates_template_config_when_config_is_missing() {\n let tmp = tempfile::tempdir().unwrap();\n let config_path = tmp.path().join(\"config.toml\");\n\n let config = Config::default();\n let home = config_path.parent().unwrap();\n std::fs::create_dir_all(home).unwrap();\n loader::save_config(&config, &config_path).unwrap();\n\n assert_eq!(\n std::fs::read_to_string(&config_path).unwrap(),\n template_toml()\n );\n assert!(config_path.exists());\n }\n\n #[test]\n fn provider_helpers_set_and_get_fields() {\n let mut config = Config::default();\n set_provider_url(&mut config, \"openai\", \"https://custom.api/v1\");\n set_provider_api_key(&mut config, \"openai\", \"sk-test-key\");\n\n let entry = config.providers.openai.as_ref().unwrap();\n assert_eq!(entry.base_url.as_deref(), Some(\"https://custom.api/v1\"));\n assert_eq!(entry.api_key.as_deref(), Some(\"sk-test-key\"));\n }\n\n #[test]\n fn provider_helpers_handle_unknown_provider() {\n let mut config = Config::default();\n set_provider_url(&mut config, \"nonexistent\", \"https://example.com\");\n assert!(get_provider_url(&config, \"nonexistent\").is_none());\n }\n\n #[test]\n fn wizard_keeps_existing_config() {\n let tmp = tempfile::tempdir().unwrap();\n let config_path = tmp.path().join(\"config.toml\");\n\n let existing = Config::default();\n std::fs::create_dir_all(config_path.parent().unwrap()).unwrap();\n loader::save_config(&existing, &config_path).unwrap();\n\n let mock = MockWizard::new(vec![MockAction::Confirm {\n prompt_contains: \"Update\",\n result: false,\n }]);\n\n run_wizard(&mock, &config_path).unwrap();\n\n // Config should be unchanged\n assert_eq!(\n std::fs::read_to_string(&config_path).unwrap(),\n template_toml()\n );\n }\n\n #[test]\n fn wizard_fresh_setup() {\n let tmp = tempfile::tempdir().unwrap();\n let config_path = tmp.path().join(\"config.toml\");\n\n let mock = MockWizard::new(vec![\n // Step 2: Provider\n MockAction::Select {\n result: 1,\n }, // openai\n MockAction::Input {\n result: \"gpt-4o\",\n },\n MockAction::Input {\n result: \"https://api.openai.com/v1\",\n },\n MockAction::Password {\n result: \"sk-test-key\",\n },\n // Step 3: Telegram (skip)\n MockAction::Confirm {\n prompt_contains: \"Telegram\",\n result: false,\n },\n // Step 4: WebSocket (skip)\n MockAction::Confirm {\n prompt_contains: \"WebSocket\",\n result: false,\n },\n // Step 5: Save\n MockAction::Confirm {\n prompt_contains: \"Write\",\n result: true,\n },\n ]);\n\n run_wizard(&mock, &config_path).unwrap();\n\n assert!(config_path.exists());\n let saved: Config =\n toml::from_str(&std::fs::read_to_string(&config_path).unwrap()).unwrap();\n assert_eq!(saved.agent.provider.as_deref(), Some(\"openai\"));\n assert_eq!(saved.agent.model, \"gpt-4o\");\n }\n\n #[test]\n fn wizard_overwrite_existing_config() {\n let tmp = tempfile::tempdir().unwrap();\n let config_path = tmp.path().join(\"config.toml\");\n\n let existing = Config::default();\n std::fs::create_dir_all(config_path.parent().unwrap()).unwrap();\n loader::save_config(&existing, &config_path).unwrap();\n\n let mock = MockWizard::new(vec![\n // Step 1: Overwrite\n MockAction::Confirm {\n prompt_contains: \"Update\",\n result: true,\n },\n // Step 2: Provider\n MockAction::Select {\n result: 0,\n }, // anthropic\n MockAction::Input {\n result: \"claude-sonnet-4-20250514\",\n },\n MockAction::Input {\n result: \"https://api.anthropic.com\",\n },\n MockAction::Password {\n result: \"sk-ant-test\",\n },\n // Step 3: Telegram (skip)\n MockAction::Confirm {\n prompt_contains: \"Telegram\",\n result: false,\n },\n // Step 4: WebSocket (skip)\n MockAction::Confirm {\n prompt_contains: \"WebSocket\",\n result: false,\n },\n // Step 5: Save\n MockAction::Confirm {\n prompt_contains: \"Write\",\n result: true,\n },\n ]);\n\n run_wizard(&mock, &config_path).unwrap();\n\n let saved: Config =\n toml::from_str(&std::fs::read_to_string(&config_path).unwrap()).unwrap();\n assert_eq!(saved.agent.provider.as_deref(), Some(\"anthropic\"));\n assert_eq!(saved.agent.model, \"claude-sonnet-4-20250514\");\n }\n\n fn tg(token: &str) -> TelegramConfig {\n TelegramConfig {\n token: token.to_string(),\n allowed_users: Vec::new(),\n admin_users: Vec::new(),\n enabled: true,\n streaming: false,\n proxy: None,\n }\n }\n\n #[test]\n fn token_masking_short_tokens() {\n let mut config = Config::default();\n config.channels.telegram = Some(tg(\"ab\"));\n\n let mock = MockWizard::new(vec![]);\n show_config_summary(&mock, &config).unwrap();\n\n let output = mock.output();\n assert!(output.contains(\"(masked)\"));\n assert!(!output.contains(\"ab…\"));\n }\n\n #[test]\n fn token_masking_empty_token() {\n let mut config = Config::default();\n config.channels.telegram = Some(tg(\"\"));\n\n let mock = MockWizard::new(vec![]);\n show_config_summary(&mock, &config).unwrap();\n\n let output = mock.output();\n assert!(output.contains(\"(not set)\"));\n }\n}\n"}, "8da6d1662458533709d8886783bd0c9c7096360b": {"t": 1785761091.0266836, "n": 26467, "h": "ef308c496d1db44c", "c": "//! Setup command — interactive wizard for configuring Kestrel.\n\nuse anyhow::{bail, Context, Result};\nuse console::Term;\nuse dialoguer::{Confirm, Input, Password as PasswordInput, Select};\nuse kestrel_config::{\n loader, paths,\n schema::{Config, ProviderEntry, TelegramConfig, WebSocketConfig},\n};\nuse owo_colors::OwoColorize;\nuse std::net::SocketAddr;\nuse std::path::Path;\n\nconst PROVIDER_NAMES: &[&str] = &[\n \"anthropic\",\n \"openai\",\n \"openrouter\",\n \"ollama\",\n \"deepseek\",\n \"gemini\",\n \"groq\",\n \"moonshot\",\n \"minimax\",\n \"github_copilot\",\n \"openai_codex\",\n];\n\nconst TOTAL_STEPS: usize = 5;\n\n// ── Trait for interactive I/O (enables testability) ────────────\n\ntrait WizardIo {\n fn confirm(&self, prompt: &str, default: bool) -> Result;\n fn select(&self, prompt: &str, items: &[&str], default: usize) -> Result;\n fn input_with_default(&self, prompt: &str, default: &str) -> Result;\n fn input_allow_empty(&self, prompt: &str) -> Result;\n fn password(&self, prompt: &str) -> Result;\n fn write_line(&self, line: &str) -> Result<()>;\n}\n\n// ── Production implementation using Term + dialoguer ───────────\n\nstruct TermWizard<'a> {\n term: &'a Term,\n}\n\nimpl<'a> TermWizard<'a> {\n fn new(term: &'a Term) -> Self {\n Self { term }\n }\n}\n\nimpl WizardIo for TermWizard<'_> {\n fn confirm(&self, prompt: &str, default: bool) -> Result {\n Ok(Confirm::new()\n .with_prompt(prompt)\n .default(default)\n .interact_on(self.term)?)\n }\n\n fn select(&self, prompt: &str, items: &[&str], default: usize) -> Result {\n Ok(Select::new()\n .with_prompt(prompt)\n .items(items)\n .default(default)\n .interact_on(self.term)?)\n }\n\n fn input_with_default(&self, prompt: &str, default: &str) -> Result {\n Ok(Input::new()\n .with_prompt(prompt)\n .default(default.to_string())\n .interact_text_on(self.term)?)\n }\n\n fn input_allow_empty(&self, prompt: &str) -> Result {\n Ok(Input::::new()\n .with_prompt(prompt)\n .allow_empty(true)\n .interact_text_on(self.term)?)\n }\n\n fn password(&self, prompt: &str) -> Result {\n Ok(PasswordInput::new()\n .with_prompt(prompt)\n .interact_on(self.term)?)\n }\n\n fn write_line(&self, line: &str) -> Result<()> {\n self.term.write_line(line)?;\n Ok(())\n }\n}\n\n// ── Provider dispatch macro (single source of truth) ───────────\n\nmacro_rules! provider_field {\n ($config:expr, $provider:expr, mut) => {\n match $provider {\n \"anthropic\" => &mut $config.providers.anthropic,\n \"openai\" => &mut $config.providers.openai,\n \"openrouter\" => &mut $config.providers.openrouter,\n \"ollama\" => &mut $config.providers.ollama,\n \"deepseek\" => &mut $config.providers.deepseek,\n \"gemini\" => &mut $config.providers.gemini,\n \"groq\" => &mut $config.providers.groq,\n \"moonshot\" => &mut $config.providers.moonshot,\n \"minimax\" => &mut $config.providers.minimax,\n \"github_copilot\" => &mut $config.providers.github_copilot,\n \"openai_codex\" => &mut $config.providers.openai_codex,\n _ => return None,\n }\n };\n ($config:expr, $provider:expr) => {\n match $provider {\n \"anthropic\" => &$config.providers.anthropic,\n \"openai\" => &$config.providers.openai,\n \"openrouter\" => &$config.providers.openrouter,\n \"ollama\" => &$config.providers.ollama,\n \"deepseek\" => &$config.providers.deepseek,\n \"gemini\" => &$config.providers.gemini,\n \"groq\" => &$config.providers.groq,\n \"moonshot\" => &$config.providers.moonshot,\n \"minimax\" => &$config.providers.minimax,\n \"github_copilot\" => &$config.providers.github_copilot,\n \"openai_codex\" => &$config.providers.openai_codex,\n _ => return None,\n }\n };\n}\n\n// ── Entry point ────────────────────────────────────────────────\n\npub fn run(_config: Config) -> Result<()> {\n let term = Term::stdout();\n if !term.features().is_atty() {\n bail!(\n \"Setup requires an interactive terminal. \\\n Run this command in a terminal, not in a pipe or CI environment.\"\n );\n }\n let io = TermWizard::new(&term);\n let config_path = paths::get_config_path()?;\n run_wizard(&io, &config_path)\n}\n\n// ── Wizard flow ────────────────────────────────────────────────\n\nfn run_wizard(io: &dyn WizardIo, config_path: &Path) -> Result<()> {\n print_banner(io)?;\n\n // ── Step 1: Check existing config ────────────────────────────\n print_step(io, 1, \"Existing Configuration\")?;\n\n let mut config = if config_path.exists() {\n match load_existing_config(config_path) {\n Ok(existing) => {\n show_config_summary(io, &existing)?;\n if io.confirm(\"Update existing config?\", true)? {\n existing\n } else {\n io.write_line(&format!(\n \" {} Keeping config at {}.\",\n \"✓\".green(),\n config_path.display()\n ))?;\n return Ok(());\n }\n }\n Err(e) => {\n io.write_line(&format!(\n \" {} Could not parse existing config: {}\",\n \"!\".yellow(),\n e\n ))?;\n if io.confirm(\"Start fresh with defaults?\", true)? {\n Config::default()\n } else {\n bail!(\"Setup cancelled.\");\n }\n }\n }\n } else {\n io.write_line(\" No config file found. Starting fresh.\")?;\n Config::default()\n };\n\n // ── Step 2: Provider configuration ───────────────────────────\n print_step(io, 2, \"Provider Configuration\")?;\n configure_provider(io, &mut config)?;\n\n // ── Step 3: Telegram channel ─────────────────────────────────\n print_step(io, 3, \"Telegram Channel\")?;\n configure_telegram(io, &mut config)?;\n\n // ── Step 4: WebSocket port ───────────────────────────────────\n print_step(io, 4, \"WebSocket Port\")?;\n configure_websocket(io, &mut config)?;\n\n // ── Step 5: Validate & write ─────────────────────────────────\n print_step(io, 5, \"Save Configuration\")?;\n\n io.write_line(&format!(\" Config path: {}\", config_path.display()))?;\n io.write_line(\"\")?;\n show_config_summary(io, &config)?;\n io.write_line(\"\")?;\n\n if !io.confirm(\"Write this configuration?\", true)? {\n io.write_line(&format!(\" {} Setup cancelled.\", \"!\".yellow()))?;\n return Ok(());\n }\n\n let home = config_path\n .parent()\n .context(\"Config path must have a parent directory\")?;\n\n std::fs::create_dir_all(home)\n .with_context(|| format!(\"Failed to create config home: {}\", home.display()))?;\n\n loader::save_config(&config, config_path)?;\n\n for dir in [\"skills\", \"sessions\", \"learning\"] {\n let path = home.join(dir);\n std::fs::create_dir_all(&path)\n .with_context(|| format!(\"Failed to create directory: {}\", path.display()))?;\n }\n\n io.write_line(\"\")?;\n io.write_line(&format!(\n \" {} Configuration saved to {}\",\n \"✓\".green(),\n config_path.display()\n ))?;\n io.write_line(&format!(\n \" {} Created directories: skills, sessions, learning\",\n \"✓\".green()\n ))?;\n io.write_line(&format!(\" {} Setup complete!\", \"✓\".green()))?;\n\n Ok(())\n}\n\nfn print_banner(io: &dyn WizardIo) -> Result<()> {\n io.write_line(\"\")?;\n io.write_line(&format!(\n \" {} {}\",\n \"▸\".cyan(),\n \"Kestrel Setup Wizard\".bold().cyan()\n ))?;\n io.write_line(\"\")?;\n Ok(())\n}\n\nfn print_step(io: &dyn WizardIo, step: usize, title: &str) -> Result<()> {\n io.write_line(\"\")?;\n io.write_line(&format!(\n \" {} Step {}/{}: {}\",\n \"▸\".cyan(),\n step,\n TOTAL_STEPS,\n title.bold()\n ))?;\n io.write_line(&format!(\" {}\", \"─\".repeat(40).dimmed()))?;\n Ok(())\n}\n\nfn load_existing_config(path: &Path) -> Result {\n let content = std::fs::read_to_string(path)\n .with_context(|| format!(\"Failed to read {}\", path.display()))?;\n let config: Config =\n toml::from_str(&content).with_context(|| format!(\"Failed to parse {}\", path.display()))?;\n Ok(config)\n}\n\nfn mask_token(token: &str) -> String {\n if token.is_empty() {\n \"(not set)\".to_string()\n } else {\n let visible = token.len().min(3);\n format!(\"{}…(masked)\", &token[..visible])\n }\n}\n\nfn show_config_summary(io: &dyn WizardIo, config: &Config) -> Result<()> {\n let provider = config.agent.provider.as_deref().unwrap_or(\"default\");\n io.write_line(&format!(\" Model: {}\", config.agent.model))?;\n io.write_line(&format!(\" Provider: {}\", provider))?;\n io.write_line(&format!(\" Temperature: {}\", config.agent.temperature))?;\n io.write_line(&format!(\" Max tokens: {}\", config.agent.max_tokens))?;\n io.write_line(&format!(\" Streaming: {}\", config.agent.streaming))?;\n\n if let Some(ref tg) = config.channels.telegram {\n io.write_line(&format!(\" Telegram: {}\", mask_token(&tg.token)))?;\n }\n\n if let Some(ref ws) = config.channels.websocket {\n if ws.enabled {\n io.write_line(&format!(\" WebSocket: {}\", ws.listen_addr))?;\n }\n }\n\n Ok(())\n}\n\nfn configure_provider(io: &dyn WizardIo, config: &mut Config) -> Result<()> {\n let default_idx = config\n .agent\n .provider\n .as_deref()\n .and_then(|p| PROVIDER_NAMES.iter().position(|&n| n == p))\n .unwrap_or(1); // default to \"openai\"\n\n let provider_name = io.select(\"Select LLM provider\", PROVIDER_NAMES, default_idx)?;\n\n let provider_key = PROVIDER_NAMES[provider_name];\n config.agent.provider = Some(provider_key.to_string());\n\n let default_model = match provider_key {\n \"anthropic\" => \"claude-sonnet-4-20250514\",\n \"openai\" => \"gpt-4o\",\n \"openrouter\" => \"anthropic/claude-sonnet-4-20250514\",\n \"ollama\" => \"llama3\",\n \"deepseek\" => \"deepseek-chat\",\n \"gemini\" => \"gemini-2.5-pro\",\n \"groq\" => \"llama-3.3-70b-versatile\",\n \"moonshot\" => \"moonshot-v1-8k\",\n \"minimax\" => \"MiniMax-Text-01\",\n \"github_copilot\" => \"gpt-4o\",\n \"openai_codex\" => \"codex-mini\",\n _ => \"gpt-4o\",\n };\n\n let current_model = if config.agent.model.is_empty() {\n default_model\n } else {\n &config.agent.model\n };\n\n let model: String = io.input_with_default(\"Model name\", current_model)?;\n config.agent.model = model;\n\n let default_url = match provider_key {\n \"anthropic\" => \"https://api.anthropic.com\",\n \"openai\" => \"https://api.openai.com/v1\",\n \"openrouter\" => \"https://openrouter.ai/api/v1\",\n \"ollama\" => \"http://localhost:11434\",\n \"deepseek\" => \"https://api.deepseek.com\",\n \"gemini\" => \"https://generativelanguage.googleapis.com/v1beta\",\n \"groq\" => \"https://api.groq.com/openai/v1\",\n \"moonshot\" => \"https://api.moonshot.cn/v1\",\n \"minimax\" => \"https://api.minimax.chat/v1\",\n \"github_copilot\" => \"https://api.githubcopilot.com\",\n \"openai_codex\" => \"https://api.openai.com/v1\",\n _ => \"\",\n };\n\n let current_url = get_provider_url(config, provider_key).unwrap_or(default_url);\n\n if !current_url.is_empty() {\n let base_url: String = io.input_with_default(\"Base URL\", current_url)?;\n set_provider_url(config, provider_key, &base_url);\n } else {\n let base_url: String = io.input_allow_empty(\"Base URL (leave empty for default)\")?;\n if !base_url.is_empty() {\n set_provider_url(config, provider_key, &base_url);\n }\n }\n\n let api_key: String = io.password(\"API key\")?;\n if !api_key.is_empty() {\n set_provider_api_key(config, provider_key, &api_key);\n }\n\n Ok(())\n}\n\nfn configure_telegram(io: &dyn WizardIo, config: &mut Config) -> Result<()> {\n let setup_tg = if config.channels.telegram.is_some() {\n io.confirm(\"Configure Telegram bot?\", true)?\n } else {\n io.confirm(\"Set up a Telegram bot?\", false)?\n };\n\n if !setup_tg {\n io.write_line(\" Skipped.\")?;\n return Ok(());\n }\n\n let current_token = config\n .channels\n .telegram\n .as_ref()\n .map(|tg| tg.token.as_str())\n .unwrap_or(\"\");\n\n let token: String = if current_token.is_empty() {\n io.input_allow_empty(\"Bot token (from @BotFather)\")?\n } else {\n io.input_with_default(\"Bot token (from @BotFather)\", current_token)?\n };\n\n if token.is_empty() {\n io.write_line(\" No token provided, skipping Telegram.\")?;\n return Ok(());\n }\n\n let allowed: String = loop {\n let input: String =\n io.input_allow_empty(\"Allowed user IDs (comma-separated, leave empty for all)\")?;\n if input.trim().is_empty() {\n break String::new();\n }\n let ids: Vec<&str> = input\n .split(',')\n .map(|s| s.trim())\n .filter(|s| !s.is_empty())\n .collect();\n let mut valid = true;\n for id in &ids {\n if id.parse::().is_err() {\n io.write_line(&format!(\n \" {} '{}' is not a valid user ID (must be a positive integer).\",\n \"!\".yellow(),\n id\n ))?;\n valid = false;\n break;\n }\n }\n if valid {\n break input;\n }\n };\n\n let allowed_users: Vec = if allowed.trim().is_empty() {\n Vec::new()\n } else {\n allowed\n .split(',')\n .map(|s| s.trim().to_string())\n .filter(|s| !s.is_empty())\n .collect()\n };\n\n // Preserve existing values for fields not explicitly configured in the wizard.\n let (admin_users, enabled, streaming, proxy) = config\n .channels\n .telegram\n .as_ref()\n .map(|tg| {\n (\n tg.admin_users.clone(),\n tg.enabled,\n tg.streaming,\n tg.proxy.clone(),\n )\n })\n .unwrap_or((Vec::new(), true, true, None));\n\n config.channels.telegram = Some(TelegramConfig {\n token,\n allowed_users,\n admin_users,\n enabled,\n streaming,\n proxy,\n });\n\n Ok(())\n}\n\nfn configure_websocket(io: &dyn WizardIo, config: &mut Config) -> Result<()> {\n let default_addr = config\n .channels\n .websocket\n .as_ref()\n .map(|ws| ws.listen_addr.as_str())\n .unwrap_or(\"127.0.0.1:8090\");\n\n let enable = io.confirm(\"Enable WebSocket channel?\", false)?;\n\n if !enable {\n config.channels.websocket = None;\n io.write_line(\" Skipped.\")?;\n return Ok(());\n }\n\n let listen_addr: String = loop {\n let input = io.input_with_default(\"Listen address\", default_addr)?;\n match input.parse::() {\n Ok(_) => break input,\n Err(e) => {\n io.write_line(&format!(\n \" {} Invalid address '{}': {}\",\n \"!\".yellow(),\n input,\n e\n ))?;\n }\n }\n };\n\n config.channels.websocket = Some(WebSocketConfig {\n enabled: true,\n listen_addr,\n auth: Default::default(),\n max_clients: 100,\n max_message_size: 1048576,\n });\n\n Ok(())\n}\n\n// ── Provider field helpers (using macro) ───────────────────────\n\nfn get_provider_entry_mut<'a>(\n config: &'a mut Config,\n provider: &str,\n) -> Option<&'a mut ProviderEntry> {\n let field: &mut Option = provider_field!(config, provider, mut);\n Some(field.as_mut()?)\n}\n\nfn ensure_provider_entry(config: &mut Config, provider: &str) {\n let field: &mut Option = provider_field!(config, provider, mut);\n field.get_or_insert_with(ProviderEntry::default);\n}\n\nfn get_provider_url<'a>(config: &'a Config, provider: &str) -> Option<&'a str> {\n let field: &Option = provider_field!(config, provider);\n field.as_ref().and_then(|e| e.base_url.as_deref())\n}\n\nfn set_provider_url(config: &mut Config, provider: &str, url: &str) {\n ensure_provider_entry(config, provider);\n if let Some(entry) = get_provider_entry_mut(config, provider) {\n entry.base_url = if url.is_empty() {\n None\n } else {\n Some(url.to_string())\n };\n }\n}\n\nfn set_provider_api_key(config: &mut Config, provider: &str, key: &str) {\n ensure_provider_entry(config, provider);\n if let Some(entry) = get_provider_entry_mut(config, provider) {\n entry.api_key = if key.is_empty() {\n None\n } else {\n Some(key.to_string())\n };\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use std::cell::RefCell;\n use std::collections::VecDeque;\n\n fn template_toml() -> String {\n toml::to_string(&Config::default()).unwrap()\n }\n\n // ── Mock wizard IO for flow testing ─────────────────────────\n\n #[derive(Debug, Clone)]\n enum MockAction {\n Confirm {\n prompt_contains: &'static str,\n result: bool,\n },\n Select { result: usize },\n Input { result: &'static str },\n Password { result: &'static str },\n }\n\n struct MockWizard {\n actions: RefCell>,\n output: RefCell,\n }\n\n impl MockWizard {\n fn new(actions: Vec) -> Self {\n Self {\n actions: RefCell::new(actions.into()),\n output: RefCell::new(String::new()),\n }\n }\n\n fn output(&self) -> String {\n self.output.borrow().clone()\n }\n }\n\n impl WizardIo for MockWizard {\n fn confirm(&self, prompt: &str, _default: bool) -> Result {\n let action = self.actions.borrow_mut().pop_front();\n match action {\n Some(MockAction::Confirm {\n prompt_contains,\n result,\n }) => {\n assert!(\n prompt.contains(prompt_contains),\n \"confirm prompt '{}' did not contain '{}'\",\n prompt,\n prompt_contains\n );\n Ok(result)\n }\n _ => panic!(\"unexpected confirm call, prompt: {}\", prompt),\n }\n }\n\n fn select(&self, _prompt: &str, _items: &[&str], _default: usize) -> Result {\n let action = self.actions.borrow_mut().pop_front();\n match action {\n Some(MockAction::Select { result }) => Ok(result),\n _ => panic!(\"unexpected select call\"),\n }\n }\n\n fn input_with_default(&self, _prompt: &str, _default: &str) -> Result {\n let action = self.actions.borrow_mut().pop_front();\n match action {\n Some(MockAction::Input { result }) => Ok(result.to_string()),\n _ => panic!(\"unexpected input_with_default call\"),\n }\n }\n\n fn input_allow_empty(&self, _prompt: &str) -> Result {\n let action = self.actions.borrow_mut().pop_front();\n match action {\n Some(MockAction::Input { result }) => Ok(result.to_string()),\n _ => panic!(\"unexpected input_allow_empty call\"),\n }\n }\n\n fn password(&self, _prompt: &str) -> Result {\n let action = self.actions.borrow_mut().pop_front();\n match action {\n Some(MockAction::Password { result }) => Ok(result.to_string()),\n _ => panic!(\"unexpected password call\"),\n }\n }\n\n fn write_line(&self, line: &str) -> Result<()> {\n self.output.borrow_mut().push_str(line);\n self.output.borrow_mut().push('\\n');\n Ok(())\n }\n }\n\n // ── Unit tests ──────────────────────────────────────────────\n\n #[test]\n fn setup_creates_template_config_when_config_is_missing() {\n let tmp = tempfile::tempdir().unwrap();\n let config_path = tmp.path().join(\"config.toml\");\n\n let config = Config::default();\n let home = config_path.parent().unwrap();\n std::fs::create_dir_all(home).unwrap();\n loader::save_config(&config, &config_path).unwrap();\n\n assert_eq!(\n std::fs::read_to_string(&config_path).unwrap(),\n template_toml()\n );\n assert!(config_path.exists());\n }\n\n #[test]\n fn provider_helpers_set_and_get_fields() {\n let mut config = Config::default();\n set_provider_url(&mut config, \"openai\", \"https://custom.api/v1\");\n set_provider_api_key(&mut config, \"openai\", \"sk-test-key\");\n\n let entry = config.providers.openai.as_ref().unwrap();\n assert_eq!(entry.base_url.as_deref(), Some(\"https://custom.api/v1\"));\n assert_eq!(entry.api_key.as_deref(), Some(\"sk-test-key\"));\n }\n\n #[test]\n fn provider_helpers_handle_unknown_provider() {\n let mut config = Config::default();\n set_provider_url(&mut config, \"nonexistent\", \"https://example.com\");\n assert!(get_provider_url(&config, \"nonexistent\").is_none());\n }\n\n #[test]\n fn wizard_keeps_existing_config() {\n let tmp = tempfile::tempdir().unwrap();\n let config_path = tmp.path().join(\"config.toml\");\n\n let existing = Config::default();\n std::fs::create_dir_all(config_path.parent().unwrap()).unwrap();\n loader::save_config(&existing, &config_path).unwrap();\n\n let mock = MockWizard::new(vec![MockAction::Confirm {\n prompt_contains: \"Update\",\n result: false,\n }]);\n\n run_wizard(&mock, &config_path).unwrap();\n\n // Config should be unchanged\n assert_eq!(\n std::fs::read_to_string(&config_path).unwrap(),\n template_toml()\n );\n }\n\n #[test]\n fn wizard_fresh_setup() {\n let tmp = tempfile::tempdir().unwrap();\n let config_path = tmp.path().join(\"config.toml\");\n\n let mock = MockWizard::new(vec![\n // Step 2: Provider\n MockAction::Select { result: 1 }, // openai\n MockAction::Input { result: \"gpt-4o\" },\n MockAction::Input { result: \"https://api.openai.com/v1\" },\n MockAction::Password { result: \"sk-test-key\" },\n // Step 3: Telegram (skip)\n MockAction::Confirm {\n prompt_contains: \"Telegram\",\n result: false,\n },\n // Step 4: WebSocket (skip)\n MockAction::Confirm {\n prompt_contains: \"WebSocket\",\n result: false,\n },\n // Step 5: Save\n MockAction::Confirm {\n prompt_contains: \"Write\",\n result: true,\n },\n ]);\n\n run_wizard(&mock, &config_path).unwrap();\n\n assert!(config_path.exists());\n let saved: Config =\n toml::from_str(&std::fs::read_to_string(&config_path).unwrap()).unwrap();\n assert_eq!(saved.agent.provider.as_deref(), Some(\"openai\"));\n assert_eq!(saved.agent.model, \"gpt-4o\");\n }\n\n #[test]\n fn wizard_overwrite_existing_config() {\n let tmp = tempfile::tempdir().unwrap();\n let config_path = tmp.path().join(\"config.toml\");\n\n let existing = Config::default();\n std::fs::create_dir_all(config_path.parent().unwrap()).unwrap();\n loader::save_config(&existing, &config_path).unwrap();\n\n let mock = MockWizard::new(vec![\n // Step 1: Overwrite\n MockAction::Confirm {\n prompt_contains: \"Update\",\n result: true,\n },\n // Step 2: Provider\n MockAction::Select { result: 0 }, // anthropic\n MockAction::Input {\n result: \"claude-sonnet-4-20250514\",\n },\n MockAction::Input {\n result: \"https://api.anthropic.com\",\n },\n MockAction::Password { result: \"sk-ant-test\" },\n // Step 3: Telegram (skip)\n MockAction::Confirm {\n prompt_contains: \"Telegram\",\n result: false,\n },\n // Step 4: WebSocket (skip)\n MockAction::Confirm {\n prompt_contains: \"WebSocket\",\n result: false,\n },\n // Step 5: Save\n MockAction::Confirm {\n prompt_contains: \"Write\",\n result: true,\n },\n ]);\n\n run_wizard(&mock, &config_path).unwrap();\n\n let saved: Config =\n toml::from_str(&std::fs::read_to_string(&config_path).unwrap()).unwrap();\n assert_eq!(saved.agent.provider.as_deref(), Some(\"anthropic\"));\n assert_eq!(saved.agent.model, \"claude-sonnet-4-20250514\");\n }\n\n fn tg(token: &str) -> TelegramConfig {\n TelegramConfig {\n token: token.to_string(),\n allowed_users: Vec::new(),\n admin_users: Vec::new(),\n enabled: true,\n streaming: false,\n proxy: None,\n }\n }\n\n #[test]\n fn token_masking_short_tokens() {\n let mut config = Config::default();\n config.channels.telegram = Some(tg(\"ab\"));\n\n let mock = MockWizard::new(vec![]);\n show_config_summary(&mock, &config).unwrap();\n\n let output = mock.output();\n assert!(output.contains(\"(masked)\"));\n assert!(!output.contains(\"ab…\"));\n }\n\n #[test]\n fn token_masking_empty_token() {\n let mut config = Config::default();\n config.channels.telegram = Some(tg(\"\"));\n\n let mock = MockWizard::new(vec![]);\n show_config_summary(&mock, &config).unwrap();\n\n let output = mock.output();\n assert!(output.contains(\"(not set)\"));\n }\n}\n"}, "1f462467a88f48c3261b4926e982bbd3382d7c7d": {"t": 1785761092.270733, "n": 17219, "h": "5821c4f23a4e731d", "c": "//! Setup command — interactive wizard for configuring Kestrel.\n\nuse anyhow::{Context, Result};\nuse console::Term;\nuse dialoguer::{Confirm, Input, Select};\nuse kestrel_config::{\n loader, paths,\n schema::{Config, ProviderEntry, TelegramConfig, WebSocketConfig},\n};\nuse owo_colors::OwoColorize;\nuse std::path::Path;\n\nconst PROVIDER_NAMES: &[&str] = &[\n \"anthropic\",\n \"openai\",\n \"openrouter\",\n \"ollama\",\n \"deepseek\",\n \"gemini\",\n \"groq\",\n \"moonshot\",\n \"minimax\",\n \"github_copilot\",\n \"openai_codex\",\n];\n\nconst TOTAL_STEPS: usize = 5;\n\npub fn run(_config: Config) -> Result<()> {\n let term = Term::stdout();\n run_wizard(&term)\n}\n\nfn run_wizard(term: &Term) -> Result<()> {\n print_banner(term)?;\n\n let config_path = paths::get_config_path()?;\n\n // ── Step 1: Check existing config ────────────────────────────\n print_step(term, 1, \"Existing Configuration\")?;\n\n let mut config = if config_path.exists() {\n match load_existing_config(&config_path) {\n Ok(existing) => {\n show_config_summary(term, &existing)?;\n if Confirm::new()\n .with_prompt(\"Update existing config?\")\n .default(true)\n .interact_on(term)?\n {\n existing\n } else {\n term.write_line(&format!(\n \" {} Keeping config at {}.\",\n \"✓\".green(),\n config_path.display()\n ))?;\n return Ok(());\n }\n }\n Err(e) => {\n term.write_line(&format!(\n \" {} Could not parse existing config: {}\",\n \"!\".yellow(),\n e\n ))?;\n if Confirm::new()\n .with_prompt(\"Start fresh with defaults?\")\n .default(true)\n .interact_on(term)?\n {\n Config::default()\n } else {\n anyhow::bail!(\"Setup cancelled.\");\n }\n }\n }\n } else {\n term.write_line(\" No config file found. Starting fresh.\")?;\n Config::default()\n };\n\n // ── Step 2: Provider configuration ───────────────────────────\n print_step(term, 2, \"Provider Configuration\")?;\n configure_provider(term, &mut config)?;\n\n // ── Step 3: Telegram channel ─────────────────────────────────\n print_step(term, 3, \"Telegram Channel\")?;\n configure_telegram(term, &mut config)?;\n\n // ── Step 4: WebSocket port ───────────────────────────────────\n print_step(term, 4, \"WebSocket Port\")?;\n configure_websocket(term, &mut config)?;\n\n // ── Step 5: Validate & write ─────────────────────────────────\n print_step(term, 5, \"Save Configuration\")?;\n\n term.write_line(&format!(\" Config path: {}\", config_path.display()))?;\n term.write_line(\"\")?;\n show_config_summary(term, &config)?;\n term.write_line(\"\")?;\n\n if !Confirm::new()\n .with_prompt(\"Write this configuration?\")\n .default(true)\n .interact_on(term)?\n {\n term.write_line(&format!(\" {} Setup cancelled.\", \"!\".yellow()))?;\n return Ok(());\n }\n\n let home = config_path\n .parent()\n .context(\"Config path must have a parent directory\")?;\n\n std::fs::create_dir_all(home)\n .with_context(|| format!(\"Failed to create config home: {}\", home.display()))?;\n\n loader::save_config(&config, &config_path)?;\n\n for dir in [\"skills\", \"sessions\", \"learning\"] {\n let path = home.join(dir);\n std::fs::create_dir_all(&path)\n .with_context(|| format!(\"Failed to create directory: {}\", path.display()))?;\n }\n\n term.write_line(\"\")?;\n term.write_line(&format!(\n \" {} Configuration saved to {}\",\n \"✓\".green(),\n config_path.display()\n ))?;\n term.write_line(&format!(\n \" {} Created directories: skills, sessions, learning\",\n \"✓\".green()\n ))?;\n term.write_line(&format!(\" {} Setup complete!\", \"✓\".green()))?;\n\n Ok(())\n}\n\nfn print_banner(term: &Term) -> Result<()> {\n term.write_line(\"\")?;\n term.write_line(&format!(\n \" {} {}\",\n \"▸\".cyan(),\n \"Kestrel Setup Wizard\".bold().cyan()\n ))?;\n term.write_line(\"\")?;\n Ok(())\n}\n\nfn print_step(term: &Term, step: usize, title: &str) -> Result<()> {\n term.write_line(\"\")?;\n term.write_line(&format!(\n \" {} Step {}/{}: {}\",\n \"▸\".cyan(),\n step,\n TOTAL_STEPS,\n title.bold()\n ))?;\n term.write_line(&format!(\" {}\", \"─\".repeat(40).dimmed()))?;\n Ok(())\n}\n\nfn load_existing_config(path: &Path) -> Result {\n let content = std::fs::read_to_string(path)\n .with_context(|| format!(\"Failed to read {}\", path.display()))?;\n let config: Config =\n toml::from_str(&content).with_context(|| format!(\"Failed to parse {}\", path.display()))?;\n Ok(config)\n}\n\nfn show_config_summary(term: &Term, config: &Config) -> Result<()> {\n let model = &config.agent.model;\n let provider = config.agent.provider.as_deref().unwrap_or(\"default\");\n term.write_line(&format!(\" Model: {}\", model))?;\n term.write_line(&format!(\" Provider: {}\", provider))?;\n term.write_line(&format!(\" Temperature: {}\", config.agent.temperature))?;\n term.write_line(&format!(\" Max tokens: {}\", config.agent.max_tokens))?;\n term.write_line(&format!(\" Streaming: {}\", config.agent.streaming))?;\n\n if let Some(ref tg) = config.channels.telegram {\n term.write_line(&format!(\n \" Telegram: {}…{}\",\n &tg.token[..tg.token.len().min(4)],\n if tg.token.len() > 4 { \"(masked)\" } else { \"\" }\n ))?;\n }\n\n if let Some(ref ws) = config.channels.websocket {\n if ws.enabled {\n term.write_line(&format!(\" WebSocket: {}\", ws.listen_addr))?;\n }\n }\n\n Ok(())\n}\n\nfn configure_provider(term: &Term, config: &mut Config) -> Result<()> {\n let default_idx = config\n .agent\n .provider\n .as_deref()\n .and_then(|p| PROVIDER_NAMES.iter().position(|&n| n == p))\n .unwrap_or(1); // default to \"openai\"\n\n let provider_name = Select::new()\n .with_prompt(\"Select LLM provider\")\n .items(PROVIDER_NAMES)\n .default(default_idx)\n .interact_on(term)?;\n\n let provider_key = PROVIDER_NAMES[provider_name];\n config.agent.provider = Some(provider_key.to_string());\n\n let default_model = match provider_key {\n \"anthropic\" => \"claude-sonnet-4-20250514\",\n \"openai\" => \"gpt-4o\",\n \"openrouter\" => \"anthropic/claude-sonnet-4-20250514\",\n \"ollama\" => \"llama3\",\n \"deepseek\" => \"deepseek-chat\",\n \"gemini\" => \"gemini-2.5-pro\",\n \"groq\" => \"llama-3.3-70b-versatile\",\n \"moonshot\" => \"moonshot-v1-8k\",\n \"minimax\" => \"MiniMax-Text-01\",\n \"github_copilot\" => \"gpt-4o\",\n \"openai_codex\" => \"codex-mini\",\n _ => \"gpt-4o\",\n };\n\n let current_model = if config.agent.model.is_empty() {\n default_model\n } else {\n &config.agent.model\n };\n\n let model: String = Input::new()\n .with_prompt(\"Model name\")\n .default(current_model.to_string())\n .interact_text_on(term)?;\n config.agent.model = model;\n\n let default_url = match provider_key {\n \"anthropic\" => \"https://api.anthropic.com\",\n \"openai\" => \"https://api.openai.com/v1\",\n \"openrouter\" => \"https://openrouter.ai/api/v1\",\n \"ollama\" => \"http://localhost:11434\",\n \"deepseek\" => \"https://api.deepseek.com\",\n \"gemini\" => \"https://generativelanguage.googleapis.com/v1beta\",\n \"groq\" => \"https://api.groq.com/openai/v1\",\n \"moonshot\" => \"https://api.moonshot.cn/v1\",\n \"minimax\" => \"https://api.minimax.chat/v1\",\n \"github_copilot\" => \"https://api.githubcopilot.com\",\n \"openai_codex\" => \"https://api.openai.com/v1\",\n _ => \"\",\n };\n\n let current_url = get_provider_url(config, provider_key).unwrap_or(default_url);\n\n if !current_url.is_empty() {\n let base_url: String = Input::new()\n .with_prompt(\"Base URL\")\n .default(current_url.to_string())\n .interact_text_on(term)?;\n set_provider_url(config, provider_key, &base_url);\n } else {\n let base_url: String = Input::new()\n .with_prompt(\"Base URL (leave empty for default)\")\n .allow_empty(true)\n .interact_text_on(term)?;\n if !base_url.is_empty() {\n set_provider_url(config, provider_key, &base_url);\n }\n }\n\n let api_key: String = Input::new()\n .with_prompt(\"API key (input hidden)\")\n .interact_text_on(term)?;\n if !api_key.is_empty() {\n set_provider_api_key(config, provider_key, &api_key);\n }\n\n Ok(())\n}\n\nfn configure_telegram(term: &Term, config: &mut Config) -> Result<()> {\n let setup_tg = if config.channels.telegram.is_some() {\n Confirm::new()\n .with_prompt(\"Configure Telegram bot?\")\n .default(true)\n .interact_on(term)?\n } else {\n Confirm::new()\n .with_prompt(\"Set up a Telegram bot?\")\n .default(false)\n .interact_on(term)?\n };\n\n if !setup_tg {\n term.write_line(\" Skipped.\")?;\n return Ok(());\n }\n\n let current_token = config\n .channels\n .telegram\n .as_ref()\n .map(|tg| tg.token.as_str())\n .unwrap_or(\"\");\n\n let token: String = Input::new()\n .with_prompt(\"Bot token (from @BotFather)\")\n .default(current_token.to_string())\n .interact_text_on(term)?;\n\n if token.is_empty() {\n term.write_line(\" No token provided, skipping Telegram.\")?;\n return Ok(());\n }\n\n let allowed: String = Input::new()\n .with_prompt(\"Allowed user IDs (comma-separated, leave empty for all)\")\n .allow_empty(true)\n .interact_text_on(term)?;\n\n let allowed_users: Vec = if allowed.trim().is_empty() {\n Vec::new()\n } else {\n allowed\n .split(',')\n .map(|s| s.trim().to_string())\n .filter(|s| !s.is_empty())\n .collect()\n };\n\n config.channels.telegram = Some(TelegramConfig {\n token,\n allowed_users,\n admin_users: Vec::new(),\n enabled: true,\n streaming: true,\n proxy: None,\n });\n\n Ok(())\n}\n\nfn configure_websocket(term: &Term, config: &mut Config) -> Result<()> {\n let default_addr = config\n .channels\n .websocket\n .as_ref()\n .map(|ws| ws.listen_addr.as_str())\n .unwrap_or(\"127.0.0.1:8090\");\n\n let enable = Confirm::new()\n .with_prompt(\"Enable WebSocket channel?\")\n .default(false)\n .interact_on(term)?;\n\n if !enable {\n config.channels.websocket = None;\n term.write_line(\" Skipped.\")?;\n return Ok(());\n }\n\n let listen_addr: String = Input::new()\n .with_prompt(\"Listen address\")\n .default(default_addr.to_string())\n .interact_text_on(term)?;\n\n config.channels.websocket = Some(WebSocketConfig {\n enabled: true,\n listen_addr,\n auth: Default::default(),\n max_clients: 100,\n max_message_size: 1048576,\n });\n\n Ok(())\n}\n\n// ── Provider field helpers ───────────────────────────────────\n\nfn get_provider_entry_mut<'a>(\n config: &'a mut Config,\n provider: &str,\n) -> Option<&'a mut ProviderEntry> {\n match provider {\n \"anthropic\" => config.providers.anthropic.as_mut(),\n \"openai\" => config.providers.openai.as_mut(),\n \"openrouter\" => config.providers.openrouter.as_mut(),\n \"ollama\" => config.providers.ollama.as_mut(),\n \"deepseek\" => config.providers.deepseek.as_mut(),\n \"gemini\" => config.providers.gemini.as_mut(),\n \"groq\" => config.providers.groq.as_mut(),\n \"moonshot\" => config.providers.moonshot.as_mut(),\n \"minimax\" => config.providers.minimax.as_mut(),\n \"github_copilot\" => config.providers.github_copilot.as_mut(),\n \"openai_codex\" => config.providers.openai_codex.as_mut(),\n _ => None,\n }\n}\n\nfn ensure_provider_entry(config: &mut Config, provider: &str) {\n match provider {\n \"anthropic\" => {\n config\n .providers\n .anthropic\n .get_or_insert_with(ProviderEntry::default);\n }\n \"openai\" => {\n config\n .providers\n .openai\n .get_or_insert_with(ProviderEntry::default);\n }\n \"openrouter\" => {\n config\n .providers\n .openrouter\n .get_or_insert_with(ProviderEntry::default);\n }\n \"ollama\" => {\n config\n .providers\n .ollama\n .get_or_insert_with(ProviderEntry::default);\n }\n \"deepseek\" => {\n config\n .providers\n .deepseek\n .get_or_insert_with(ProviderEntry::default);\n }\n \"gemini\" => {\n config\n .providers\n .gemini\n .get_or_insert_with(ProviderEntry::default);\n }\n \"groq\" => {\n config\n .providers\n .groq\n .get_or_insert_with(ProviderEntry::default);\n }\n \"moonshot\" => {\n config\n .providers\n .moonshot\n .get_or_insert_with(ProviderEntry::default);\n }\n \"minimax\" => {\n config\n .providers\n .minimax\n .get_or_insert_with(ProviderEntry::default);\n }\n \"github_copilot\" => {\n config\n .providers\n .github_copilot\n .get_or_insert_with(ProviderEntry::default);\n }\n \"openai_codex\" => {\n config\n .providers\n .openai_codex\n .get_or_insert_with(ProviderEntry::default);\n }\n _ => {}\n }\n}\n\nfn get_provider_url<'a>(config: &'a Config, provider: &str) -> Option<&'a str> {\n let entry = match provider {\n \"anthropic\" => config.providers.anthropic.as_ref(),\n \"openai\" => config.providers.openai.as_ref(),\n \"openrouter\" => config.providers.openrouter.as_ref(),\n \"ollama\" => config.providers.ollama.as_ref(),\n \"deepseek\" => config.providers.deepseek.as_ref(),\n \"gemini\" => config.providers.gemini.as_ref(),\n \"groq\" => config.providers.groq.as_ref(),\n \"moonshot\" => config.providers.moonshot.as_ref(),\n \"minimax\" => config.providers.minimax.as_ref(),\n \"github_copilot\" => config.providers.github_copilot.as_ref(),\n \"openai_codex\" => config.providers.openai_codex.as_ref(),\n _ => None,\n };\n entry.and_then(|e| e.base_url.as_deref())\n}\n\nfn set_provider_url(config: &mut Config, provider: &str, url: &str) {\n ensure_provider_entry(config, provider);\n if let Some(entry) = get_provider_entry_mut(config, provider) {\n entry.base_url = if url.is_empty() {\n None\n } else {\n Some(url.to_string())\n };\n }\n}\n\nfn set_provider_api_key(config: &mut Config, provider: &str, key: &str) {\n ensure_provider_entry(config, provider);\n if let Some(entry) = get_provider_entry_mut(config, provider) {\n entry.api_key = if key.is_empty() {\n None\n } else {\n Some(key.to_string())\n };\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use std::io;\n\n fn template_toml() -> String {\n toml::to_string(&Config::default()).unwrap()\n }\n\n #[test]\n fn setup_creates_template_config_when_config_is_missing() {\n let tmp = tempfile::tempdir().unwrap();\n let config_path = tmp.path().join(\"config.toml\");\n\n // Simulate: no existing config, wizard writes defaults\n let config = Config::default();\n let home = config_path.parent().unwrap();\n std::fs::create_dir_all(home).unwrap();\n loader::save_config(&config, &config_path).unwrap();\n\n assert_eq!(\n std::fs::read_to_string(&config_path).unwrap(),\n template_toml()\n );\n assert!(config_path.exists());\n }\n\n #[test]\n fn provider_helpers_set_and_get_fields() {\n let mut config = Config::default();\n set_provider_url(&mut config, \"openai\", \"https://custom.api/v1\");\n set_provider_api_key(&mut config, \"openai\", \"sk-test-key\");\n\n let entry = config.providers.openai.as_ref().unwrap();\n assert_eq!(entry.base_url.as_deref(), Some(\"https://custom.api/v1\"));\n assert_eq!(entry.api_key.as_deref(), Some(\"sk-test-key\"));\n }\n\n #[test]\n fn provider_helpers_handle_unknown_provider() {\n let mut config = Config::default();\n set_provider_url(&mut config, \"nonexistent\", \"https://example.com\");\n assert!(get_provider_url(&config, \"nonexistent\").is_none());\n }\n}\n"}, "3090bfaf0616f566a08362ff8cde865e759a7e67": {"t": 1785761093.5440767, "n": 17208, "h": "c5fa4573e33ecde7", "c": "//! Setup command — interactive wizard for configuring Kestrel.\n\nuse anyhow::{Context, Result};\nuse console::Term;\nuse dialoguer::{Confirm, Input, Select};\nuse kestrel_config::{\n loader, paths,\n schema::{Config, ProviderEntry, TelegramConfig, WebSocketConfig},\n};\nuse owo_colors::OwoColorize;\nuse std::path::Path;\n\nconst PROVIDER_NAMES: &[&str] = &[\n \"anthropic\",\n \"openai\",\n \"openrouter\",\n \"ollama\",\n \"deepseek\",\n \"gemini\",\n \"groq\",\n \"moonshot\",\n \"minimax\",\n \"github_copilot\",\n \"openai_codex\",\n];\n\nconst TOTAL_STEPS: usize = 5;\n\npub fn run(_config: Config) -> Result<()> {\n let term = Term::stdout();\n run_wizard(&term)\n}\n\nfn run_wizard(term: &Term) -> Result<()> {\n print_banner(term)?;\n\n let config_path = paths::get_config_path()?;\n\n // ── Step 1: Check existing config ────────────────────────────\n print_step(term, 1, \"Existing Configuration\")?;\n\n let mut config = if config_path.exists() {\n match load_existing_config(&config_path) {\n Ok(existing) => {\n show_config_summary(term, &existing)?;\n if Confirm::new()\n .with_prompt(\"Update existing config?\")\n .default(true)\n .interact_on(term)?\n {\n existing\n } else {\n term.write_line(&format!(\n \" {} Keeping config at {}.\",\n \"✓\".green(),\n config_path.display()\n ))?;\n return Ok(());\n }\n }\n Err(e) => {\n term.write_line(&format!(\n \" {} Could not parse existing config: {}\",\n \"!\".yellow(),\n e\n ))?;\n if Confirm::new()\n .with_prompt(\"Start fresh with defaults?\")\n .default(true)\n .interact_on(term)?\n {\n Config::default()\n } else {\n anyhow::bail!(\"Setup cancelled.\");\n }\n }\n }\n } else {\n term.write_line(\" No config file found. Starting fresh.\")?;\n Config::default()\n };\n\n // ── Step 2: Provider configuration ───────────────────────────\n print_step(term, 2, \"Provider Configuration\")?;\n configure_provider(term, &mut config)?;\n\n // ── Step 3: Telegram channel ─────────────────────────────────\n print_step(term, 3, \"Telegram Channel\")?;\n configure_telegram(term, &mut config)?;\n\n // ── Step 4: WebSocket port ───────────────────────────────────\n print_step(term, 4, \"WebSocket Port\")?;\n configure_websocket(term, &mut config)?;\n\n // ── Step 5: Validate & write ─────────────────────────────────\n print_step(term, 5, \"Save Configuration\")?;\n\n term.write_line(&format!(\" Config path: {}\", config_path.display()))?;\n term.write_line(\"\")?;\n show_config_summary(term, &config)?;\n term.write_line(\"\")?;\n\n if !Confirm::new()\n .with_prompt(\"Write this configuration?\")\n .default(true)\n .interact_on(term)?\n {\n term.write_line(&format!(\" {} Setup cancelled.\", \"!\".yellow()))?;\n return Ok(());\n }\n\n let home = config_path\n .parent()\n .context(\"Config path must have a parent directory\")?;\n\n std::fs::create_dir_all(home)\n .with_context(|| format!(\"Failed to create config home: {}\", home.display()))?;\n\n loader::save_config(&config, &config_path)?;\n\n for dir in [\"skills\", \"sessions\", \"learning\"] {\n let path = home.join(dir);\n std::fs::create_dir_all(&path)\n .with_context(|| format!(\"Failed to create directory: {}\", path.display()))?;\n }\n\n term.write_line(\"\")?;\n term.write_line(&format!(\n \" {} Configuration saved to {}\",\n \"✓\".green(),\n config_path.display()\n ))?;\n term.write_line(&format!(\n \" {} Created directories: skills, sessions, learning\",\n \"✓\".green()\n ))?;\n term.write_line(&format!(\" {} Setup complete!\", \"✓\".green()))?;\n\n Ok(())\n}\n\nfn print_banner(term: &Term) -> Result<()> {\n term.write_line(\"\")?;\n term.write_line(&format!(\n \" {} {}\",\n \"▸\".cyan(),\n \"Kestrel Setup Wizard\".bold().cyan()\n ))?;\n term.write_line(\"\")?;\n Ok(())\n}\n\nfn print_step(term: &Term, step: usize, title: &str) -> Result<()> {\n term.write_line(\"\")?;\n term.write_line(&format!(\n \" {} Step {}/{}: {}\",\n \"▸\".cyan(),\n step,\n TOTAL_STEPS,\n title.bold()\n ))?;\n term.write_line(&format!(\" {}\", \"─\".repeat(40).dimmed()))?;\n Ok(())\n}\n\nfn load_existing_config(path: &Path) -> Result {\n let content = std::fs::read_to_string(path)\n .with_context(|| format!(\"Failed to read {}\", path.display()))?;\n let config: Config =\n toml::from_str(&content).with_context(|| format!(\"Failed to parse {}\", path.display()))?;\n Ok(config)\n}\n\nfn show_config_summary(term: &Term, config: &Config) -> Result<()> {\n let model = &config.agent.model;\n let provider = config.agent.provider.as_deref().unwrap_or(\"default\");\n term.write_line(&format!(\" Model: {}\", model))?;\n term.write_line(&format!(\" Provider: {}\", provider))?;\n term.write_line(&format!(\" Temperature: {}\", config.agent.temperature))?;\n term.write_line(&format!(\" Max tokens: {}\", config.agent.max_tokens))?;\n term.write_line(&format!(\" Streaming: {}\", config.agent.streaming))?;\n\n if let Some(ref tg) = config.channels.telegram {\n term.write_line(&format!(\n \" Telegram: {}…{}\",\n &tg.token[..tg.token.len().min(4)],\n if tg.token.len() > 4 { \"(masked)\" } else { \"\" }\n ))?;\n }\n\n if let Some(ref ws) = config.channels.websocket {\n if ws.enabled {\n term.write_line(&format!(\" WebSocket: {}\", ws.listen_addr))?;\n }\n }\n\n Ok(())\n}\n\nfn configure_provider(term: &Term, config: &mut Config) -> Result<()> {\n let default_idx = config\n .agent\n .provider\n .as_deref()\n .and_then(|p| PROVIDER_NAMES.iter().position(|&n| n == p))\n .unwrap_or(1); // default to \"openai\"\n\n let provider_name = Select::new()\n .with_prompt(\"Select LLM provider\")\n .items(PROVIDER_NAMES)\n .default(default_idx)\n .interact_on(term)?;\n\n let provider_key = PROVIDER_NAMES[provider_name];\n config.agent.provider = Some(provider_key.to_string());\n\n let default_model = match provider_key {\n \"anthropic\" => \"claude-sonnet-4-20250514\",\n \"openai\" => \"gpt-4o\",\n \"openrouter\" => \"anthropic/claude-sonnet-4-20250514\",\n \"ollama\" => \"llama3\",\n \"deepseek\" => \"deepseek-chat\",\n \"gemini\" => \"gemini-2.5-pro\",\n \"groq\" => \"llama-3.3-70b-versatile\",\n \"moonshot\" => \"moonshot-v1-8k\",\n \"minimax\" => \"MiniMax-Text-01\",\n \"github_copilot\" => \"gpt-4o\",\n \"openai_codex\" => \"codex-mini\",\n _ => \"gpt-4o\",\n };\n\n let current_model = if config.agent.model.is_empty() {\n default_model\n } else {\n &config.agent.model\n };\n\n let model: String = Input::new()\n .with_prompt(\"Model name\")\n .default(current_model.to_string())\n .interact_text_on(term)?;\n config.agent.model = model;\n\n let default_url = match provider_key {\n \"anthropic\" => \"https://api.anthropic.com\",\n \"openai\" => \"https://api.openai.com/v1\",\n \"openrouter\" => \"https://openrouter.ai/api/v1\",\n \"ollama\" => \"http://localhost:11434\",\n \"deepseek\" => \"https://api.deepseek.com\",\n \"gemini\" => \"https://generativelanguage.googleapis.com/v1beta\",\n \"groq\" => \"https://api.groq.com/openai/v1\",\n \"moonshot\" => \"https://api.moonshot.cn/v1\",\n \"minimax\" => \"https://api.minimax.chat/v1\",\n \"github_copilot\" => \"https://api.githubcopilot.com\",\n \"openai_codex\" => \"https://api.openai.com/v1\",\n _ => \"\",\n };\n\n let current_url = get_provider_url(config, provider_key).unwrap_or(default_url);\n\n if !current_url.is_empty() {\n let base_url: String = Input::new()\n .with_prompt(\"Base URL\")\n .default(current_url.to_string())\n .interact_text_on(term)?;\n set_provider_url(config, provider_key, &base_url);\n } else {\n let base_url: String = Input::new()\n .with_prompt(\"Base URL (leave empty for default)\")\n .allow_empty(true)\n .interact_text_on(term)?;\n if !base_url.is_empty() {\n set_provider_url(config, provider_key, &base_url);\n }\n }\n\n let api_key: String = Input::new()\n .with_prompt(\"API key (input hidden)\")\n .interact_text_on(term)?;\n if !api_key.is_empty() {\n set_provider_api_key(config, provider_key, &api_key);\n }\n\n Ok(())\n}\n\nfn configure_telegram(term: &Term, config: &mut Config) -> Result<()> {\n let setup_tg = if config.channels.telegram.is_some() {\n Confirm::new()\n .with_prompt(\"Configure Telegram bot?\")\n .default(true)\n .interact_on(term)?\n } else {\n Confirm::new()\n .with_prompt(\"Set up a Telegram bot?\")\n .default(false)\n .interact_on(term)?\n };\n\n if !setup_tg {\n term.write_line(\" Skipped.\")?;\n return Ok(());\n }\n\n let current_token = config\n .channels\n .telegram\n .as_ref()\n .map(|tg| tg.token.as_str())\n .unwrap_or(\"\");\n\n let token: String = Input::new()\n .with_prompt(\"Bot token (from @BotFather)\")\n .default(current_token.to_string())\n .interact_text_on(term)?;\n\n if token.is_empty() {\n term.write_line(\" No token provided, skipping Telegram.\")?;\n return Ok(());\n }\n\n let allowed: String = Input::new()\n .with_prompt(\"Allowed user IDs (comma-separated, leave empty for all)\")\n .allow_empty(true)\n .interact_text_on(term)?;\n\n let allowed_users: Vec = if allowed.trim().is_empty() {\n Vec::new()\n } else {\n allowed\n .split(',')\n .map(|s| s.trim().to_string())\n .filter(|s| !s.is_empty())\n .collect()\n };\n\n config.channels.telegram = Some(TelegramConfig {\n token,\n allowed_users,\n admin_users: Vec::new(),\n enabled: true,\n streaming: true,\n proxy: None,\n });\n\n Ok(())\n}\n\nfn configure_websocket(term: &Term, config: &mut Config) -> Result<()> {\n let default_addr = config\n .channels\n .websocket\n .as_ref()\n .map(|ws| ws.listen_addr.as_str())\n .unwrap_or(\"127.0.0.1:8090\");\n\n let enable = Confirm::new()\n .with_prompt(\"Enable WebSocket channel?\")\n .default(false)\n .interact_on(term)?;\n\n if !enable {\n config.channels.websocket = None;\n term.write_line(\" Skipped.\")?;\n return Ok(());\n }\n\n let listen_addr: String = Input::new()\n .with_prompt(\"Listen address\")\n .default(default_addr.to_string())\n .interact_text_on(term)?;\n\n config.channels.websocket = Some(WebSocketConfig {\n enabled: true,\n listen_addr,\n auth: Default::default(),\n max_clients: 100,\n max_message_size: 1048576,\n });\n\n Ok(())\n}\n\n// ── Provider field helpers ───────────────────────────────────\n\nfn get_provider_entry_mut<'a>(config: &'a mut Config, provider: &str) -> Option<&'a mut ProviderEntry> {\n match provider {\n \"anthropic\" => config.providers.anthropic.as_mut(),\n \"openai\" => config.providers.openai.as_mut(),\n \"openrouter\" => config.providers.openrouter.as_mut(),\n \"ollama\" => config.providers.ollama.as_mut(),\n \"deepseek\" => config.providers.deepseek.as_mut(),\n \"gemini\" => config.providers.gemini.as_mut(),\n \"groq\" => config.providers.groq.as_mut(),\n \"moonshot\" => config.providers.moonshot.as_mut(),\n \"minimax\" => config.providers.minimax.as_mut(),\n \"github_copilot\" => config.providers.github_copilot.as_mut(),\n \"openai_codex\" => config.providers.openai_codex.as_mut(),\n _ => None,\n }\n}\n\nfn ensure_provider_entry(config: &mut Config, provider: &str) {\n match provider {\n \"anthropic\" => {\n config\n .providers\n .anthropic\n .get_or_insert_with(ProviderEntry::default);\n }\n \"openai\" => {\n config\n .providers\n .openai\n .get_or_insert_with(ProviderEntry::default);\n }\n \"openrouter\" => {\n config\n .providers\n .openrouter\n .get_or_insert_with(ProviderEntry::default);\n }\n \"ollama\" => {\n config\n .providers\n .ollama\n .get_or_insert_with(ProviderEntry::default);\n }\n \"deepseek\" => {\n config\n .providers\n .deepseek\n .get_or_insert_with(ProviderEntry::default);\n }\n \"gemini\" => {\n config\n .providers\n .gemini\n .get_or_insert_with(ProviderEntry::default);\n }\n \"groq\" => {\n config\n .providers\n .groq\n .get_or_insert_with(ProviderEntry::default);\n }\n \"moonshot\" => {\n config\n .providers\n .moonshot\n .get_or_insert_with(ProviderEntry::default);\n }\n \"minimax\" => {\n config\n .providers\n .minimax\n .get_or_insert_with(ProviderEntry::default);\n }\n \"github_copilot\" => {\n config\n .providers\n .github_copilot\n .get_or_insert_with(ProviderEntry::default);\n }\n \"openai_codex\" => {\n config\n .providers\n .openai_codex\n .get_or_insert_with(ProviderEntry::default);\n }\n _ => {}\n }\n}\n\nfn get_provider_url<'a>(config: &'a Config, provider: &str) -> Option<&'a str> {\n let entry = match provider {\n \"anthropic\" => config.providers.anthropic.as_ref(),\n \"openai\" => config.providers.openai.as_ref(),\n \"openrouter\" => config.providers.openrouter.as_ref(),\n \"ollama\" => config.providers.ollama.as_ref(),\n \"deepseek\" => config.providers.deepseek.as_ref(),\n \"gemini\" => config.providers.gemini.as_ref(),\n \"groq\" => config.providers.groq.as_ref(),\n \"moonshot\" => config.providers.moonshot.as_ref(),\n \"minimax\" => config.providers.minimax.as_ref(),\n \"github_copilot\" => config.providers.github_copilot.as_ref(),\n \"openai_codex\" => config.providers.openai_codex.as_ref(),\n _ => None,\n };\n entry.and_then(|e| e.base_url.as_deref())\n}\n\nfn set_provider_url(config: &mut Config, provider: &str, url: &str) {\n ensure_provider_entry(config, provider);\n if let Some(entry) = get_provider_entry_mut(config, provider) {\n entry.base_url = if url.is_empty() {\n None\n } else {\n Some(url.to_string())\n };\n }\n}\n\nfn set_provider_api_key(config: &mut Config, provider: &str, key: &str) {\n ensure_provider_entry(config, provider);\n if let Some(entry) = get_provider_entry_mut(config, provider) {\n entry.api_key = if key.is_empty() {\n None\n } else {\n Some(key.to_string())\n };\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use std::io;\n\n fn template_toml() -> String {\n toml::to_string(&Config::default()).unwrap()\n }\n\n #[test]\n fn setup_creates_template_config_when_config_is_missing() {\n let tmp = tempfile::tempdir().unwrap();\n let config_path = tmp.path().join(\"config.toml\");\n\n // Simulate: no existing config, wizard writes defaults\n let config = Config::default();\n let home = config_path.parent().unwrap();\n std::fs::create_dir_all(home).unwrap();\n loader::save_config(&config, &config_path).unwrap();\n\n assert_eq!(\n std::fs::read_to_string(&config_path).unwrap(),\n template_toml()\n );\n assert!(config_path.exists());\n }\n\n #[test]\n fn provider_helpers_set_and_get_fields() {\n let mut config = Config::default();\n set_provider_url(&mut config, \"openai\", \"https://custom.api/v1\");\n set_provider_api_key(&mut config, \"openai\", \"sk-test-key\");\n\n let entry = config.providers.openai.as_ref().unwrap();\n assert_eq!(entry.base_url.as_deref(), Some(\"https://custom.api/v1\"));\n assert_eq!(entry.api_key.as_deref(), Some(\"sk-test-key\"));\n }\n\n #[test]\n fn provider_helpers_handle_unknown_provider() {\n let mut config = Config::default();\n set_provider_url(&mut config, \"nonexistent\", \"https://example.com\");\n assert!(get_provider_url(&config, \"nonexistent\").is_none());\n }\n}\n"}, "4d472fb1daece1014c23734b8f7d9d6c0ac70e2d": {"t": 1785761094.8194332, "n": 16809, "h": "96154e5bb774b419", "c": "//! Setup command — interactive wizard for configuring Kestrel.\n\nuse anyhow::{Context, Result};\nuse console::Term;\nuse dialoguer::{Confirm, Input, Select};\nuse kestrel_config::{\n schema::{\n AgentDefaults, ChannelsConfig, Config, ProviderEntry, ProvidersConfig, TelegramConfig,\n WebSocketConfig,\n },\n paths, loader,\n};\nuse owo_colors::OwoColorize;\nuse std::path::Path;\n\nconst PROVIDER_NAMES: &[&str] = &[\n \"anthropic\",\n \"openai\",\n \"openrouter\",\n \"ollama\",\n \"deepseek\",\n \"gemini\",\n \"groq\",\n \"moonshot\",\n \"minimax\",\n \"github_copilot\",\n \"openai_codex\",\n];\n\nconst TOTAL_STEPS: usize = 5;\n\npub fn run(_config: Config) -> Result<()> {\n let term = Term::stdout();\n run_wizard(&term)\n}\n\nfn run_wizard(term: &Term) -> Result<()> {\n print_banner(term)?;\n\n let config_path = paths::get_config_path()?;\n\n // ── Step 1: Check existing config ────────────────────────────\n print_step(term, 1, \"Existing Configuration\")?;\n\n let mut config = if config_path.exists() {\n match load_existing_config(&config_path) {\n Ok(existing) => {\n show_config_summary(term, &existing)?;\n if Confirm::new()\n .with_prompt(\"Update existing config?\")\n .default(true)\n .interact_on(term)?\n {\n existing\n } else {\n term.write_line(&format!(\n \" {} Keeping config at {}.\",\n \"✓\".green(),\n config_path.display()\n ))?;\n return Ok(());\n }\n }\n Err(e) => {\n term.write_line(&format!(\n \" {} Could not parse existing config: {}\",\n \"!\".yellow(),\n e\n ))?;\n if Confirm::new()\n .with_prompt(\"Start fresh with defaults?\")\n .default(true)\n .interact_on(term)?\n {\n Config::default()\n } else {\n anyhow::bail!(\"Setup cancelled.\");\n }\n }\n }\n } else {\n term.write_line(\" No config file found. Starting fresh.\")?;\n Config::default()\n };\n\n // ── Step 2: Provider configuration ───────────────────────────\n print_step(term, 2, \"Provider Configuration\")?;\n configure_provider(term, &mut config)?;\n\n // ── Step 3: Telegram channel ─────────────────────────────────\n print_step(term, 3, \"Telegram Channel\")?;\n configure_telegram(term, &mut config)?;\n\n // ── Step 4: WebSocket port ───────────────────────────────────\n print_step(term, 4, \"WebSocket Port\")?;\n configure_websocket(term, &mut config)?;\n\n // ── Step 5: Validate & write ─────────────────────────────────\n print_step(term, 5, \"Save Configuration\")?;\n\n term.write_line(&format!(\n \" Config path: {}\",\n config_path.display()\n ))?;\n term.write_line(\"\")?;\n show_config_summary(term, &config)?;\n term.write_line(\"\")?;\n\n if !Confirm::new()\n .with_prompt(\"Write this configuration?\")\n .default(true)\n .interact_on(term)?\n {\n term.write_line(&format!(\" {} Setup cancelled.\", \"!\".yellow()))?;\n return Ok(());\n }\n\n let home = config_path\n .parent()\n .context(\"Config path must have a parent directory\")?;\n\n std::fs::create_dir_all(home)\n .with_context(|| format!(\"Failed to create config home: {}\", home.display()))?;\n\n loader::save_config(&config, &config_path)?;\n\n for dir in [\"skills\", \"sessions\", \"learning\"] {\n let path = home.join(dir);\n std::fs::create_dir_all(&path)\n .with_context(|| format!(\"Failed to create directory: {}\", path.display()))?;\n }\n\n term.write_line(\"\")?;\n term.write_line(&format!(\n \" {} Configuration saved to {}\",\n \"✓\".green(),\n config_path.display()\n ))?;\n term.write_line(&format!(\n \" {} Created directories: skills, sessions, learning\",\n \"✓\".green()\n ))?;\n term.write_line(&format!(\" {} Setup complete!\", \"✓\".green()))?;\n\n Ok(())\n}\n\nfn print_banner(term: &Term) -> Result<()> {\n term.write_line(\"\")?;\n term.write_line(&format!(\n \" {} {}\",\n \"▸\".cyan(),\n \"Kestrel Setup Wizard\".bold().cyan()\n ))?;\n term.write_line(\"\")?;\n Ok(())\n}\n\nfn print_step(term: &Term, step: usize, title: &str) -> Result<()> {\n term.write_line(\"\")?;\n term.write_line(&format!(\n \" {} Step {}/{}: {}\",\n \"▸\".cyan(),\n step,\n TOTAL_STEPS,\n title.bold()\n ))?;\n term.write_line(&format!(\n \" {}\",\n \"─\".repeat(40).dimmed()\n ))?;\n Ok(())\n}\n\nfn load_existing_config(path: &Path) -> Result {\n let content = std::fs::read_to_string(path)\n .with_context(|| format!(\"Failed to read {}\", path.display()))?;\n let config: Config = toml::from_str(&content)\n .with_context(|| format!(\"Failed to parse {}\", path.display()))?;\n Ok(config)\n}\n\nfn show_config_summary(term: &Term, config: &Config) -> Result<()> {\n let model = &config.agent.model;\n let provider = config.agent.provider.as_deref().unwrap_or(\"default\");\n term.write_line(&format!(\" Model: {}\", model))?;\n term.write_line(&format!(\" Provider: {}\", provider))?;\n term.write_line(&format!(\n \" Temperature: {}\",\n config.agent.temperature\n ))?;\n term.write_line(&format!(\n \" Max tokens: {}\",\n config.agent.max_tokens\n ))?;\n term.write_line(&format!(\n \" Streaming: {}\",\n config.agent.streaming\n ))?;\n\n if let Some(ref tg) = config.channels.telegram {\n term.write_line(&format!(\n \" Telegram: {}…{}\",\n &tg.token[..tg.token.len().min(4)],\n if tg.token.len() > 4 { \"(masked)\" } else { \"\" }\n ))?;\n }\n\n if let Some(ref ws) = config.channels.websocket {\n if ws.enabled {\n term.write_line(&format!(\" WebSocket: {}\", ws.listen_addr))?;\n }\n }\n\n Ok(())\n}\n\nfn configure_provider(term: &Term, config: &mut Config) -> Result<()> {\n let default_idx = config\n .agent\n .provider\n .as_deref()\n .and_then(|p| PROVIDER_NAMES.iter().position(|&n| n == p))\n .unwrap_or(1); // default to \"openai\"\n\n let provider_name = Select::new()\n .with_prompt(\"Select LLM provider\")\n .items(PROVIDER_NAMES)\n .default(default_idx)\n .interact_on(term)?;\n\n let provider_key = PROVIDER_NAMES[provider_name];\n config.agent.provider = Some(provider_key.to_string());\n\n let default_model = match provider_key {\n \"anthropic\" => \"claude-sonnet-4-20250514\",\n \"openai\" => \"gpt-4o\",\n \"openrouter\" => \"anthropic/claude-sonnet-4-20250514\",\n \"ollama\" => \"llama3\",\n \"deepseek\" => \"deepseek-chat\",\n \"gemini\" => \"gemini-2.5-pro\",\n \"groq\" => \"llama-3.3-70b-versatile\",\n \"moonshot\" => \"moonshot-v1-8k\",\n \"minimax\" => \"MiniMax-Text-01\",\n \"github_copilot\" => \"gpt-4o\",\n \"openai_codex\" => \"codex-mini\",\n _ => \"gpt-4o\",\n };\n\n let current_model = if config.agent.model.is_empty() {\n default_model\n } else {\n &config.agent.model\n };\n\n let model: String = Input::new()\n .with_prompt(\"Model name\")\n .default(current_model.to_string())\n .interact_text_on(term)?;\n config.agent.model = model;\n\n let default_url = match provider_key {\n \"anthropic\" => \"https://api.anthropic.com\",\n \"openai\" => \"https://api.openai.com/v1\",\n \"openrouter\" => \"https://openrouter.ai/api/v1\",\n \"ollama\" => \"http://localhost:11434\",\n \"deepseek\" => \"https://api.deepseek.com\",\n \"gemini\" => \"https://generativelanguage.googleapis.com/v1beta\",\n \"groq\" => \"https://api.groq.com/openai/v1\",\n \"moonshot\" => \"https://api.moonshot.cn/v1\",\n \"minimax\" => \"https://api.minimax.chat/v1\",\n \"github_copilot\" => \"https://api.githubcopilot.com\",\n \"openai_codex\" => \"https://api.openai.com/v1\",\n _ => \"\",\n };\n\n let current_url = get_provider_url(config, provider_key).unwrap_or(default_url);\n\n if !current_url.is_empty() {\n let base_url: String = Input::new()\n .with_prompt(\"Base URL\")\n .default(current_url.to_string())\n .interact_text_on(term)?;\n set_provider_url(config, provider_key, &base_url);\n } else {\n let base_url: String = Input::new()\n .with_prompt(\"Base URL (leave empty for default)\")\n .allow_empty(true)\n .interact_text_on(term)?;\n if !base_url.is_empty() {\n set_provider_url(config, provider_key, &base_url);\n }\n }\n\n let api_key: String = Input::new()\n .with_prompt(\"API key (input hidden)\")\n .interact_text_on(term)?;\n if !api_key.is_empty() {\n set_provider_api_key(config, provider_key, &api_key);\n }\n\n Ok(())\n}\n\nfn configure_telegram(term: &Term, config: &mut Config) -> Result<()> {\n let setup_tg = if config.channels.telegram.is_some() {\n Confirm::new()\n .with_prompt(\"Configure Telegram bot?\")\n .default(true)\n .interact_on(term)?\n } else {\n Confirm::new()\n .with_prompt(\"Set up a Telegram bot?\")\n .default(false)\n .interact_on(term)?\n };\n\n if !setup_tg {\n term.write_line(\" Skipped.\")?;\n return Ok(());\n }\n\n let current_token = config\n .channels\n .telegram\n .as_ref()\n .map(|tg| tg.token.as_str())\n .unwrap_or(\"\");\n\n let token: String = Input::new()\n .with_prompt(\"Bot token (from @BotFather)\")\n .default(current_token.to_string())\n .interact_text_on(term)?;\n\n if token.is_empty() {\n term.write_line(\" No token provided, skipping Telegram.\")?;\n return Ok(());\n }\n\n let allowed: String = Input::new()\n .with_prompt(\"Allowed user IDs (comma-separated, leave empty for all)\")\n .allow_empty(true)\n .interact_text_on(term)?;\n\n let allowed_users: Vec = if allowed.trim().is_empty() {\n Vec::new()\n } else {\n allowed\n .split(',')\n .map(|s| s.trim().to_string())\n .filter(|s| !s.is_empty())\n .collect()\n };\n\n config.channels.telegram = Some(TelegramConfig {\n token,\n allowed_users,\n admin_users: Vec::new(),\n enabled: true,\n streaming: true,\n proxy: None,\n });\n\n Ok(())\n}\n\nfn configure_websocket(term: &Term, config: &mut Config) -> Result<()> {\n let default_addr = config\n .channels\n .websocket\n .as_ref()\n .map(|ws| ws.listen_addr.as_str())\n .unwrap_or(\"127.0.0.1:8090\");\n\n let enable = Confirm::new()\n .with_prompt(\"Enable WebSocket channel?\")\n .default(false)\n .interact_on(term)?;\n\n if !enable {\n config.channels.websocket = None;\n term.write_line(\" Skipped.\")?;\n return Ok(());\n }\n\n let listen_addr: String = Input::new()\n .with_prompt(\"Listen address\")\n .default(default_addr.to_string())\n .interact_text_on(term)?;\n\n config.channels.websocket = Some(WebSocketConfig {\n enabled: true,\n listen_addr,\n auth: Default::default(),\n max_clients: 100,\n max_message_size: 1048576,\n });\n\n Ok(())\n}\n\n// ── Provider field helpers ───────────────────────────────────\n\nfn get_provider_entry_mut(config: &mut Config, provider: &str) -> Option<&mut ProviderEntry> {\n match provider {\n \"anthropic\" => config.providers.anthropic.as_mut(),\n \"openai\" => config.providers.openai.as_mut(),\n \"openrouter\" => config.providers.openrouter.as_mut(),\n \"ollama\" => config.providers.ollama.as_mut(),\n \"deepseek\" => config.providers.deepseek.as_mut(),\n \"gemini\" => config.providers.gemini.as_mut(),\n \"groq\" => config.providers.groq.as_mut(),\n \"moonshot\" => config.providers.moonshot.as_mut(),\n \"minimax\" => config.providers.minimax.as_mut(),\n \"github_copilot\" => config.providers.github_copilot.as_mut(),\n \"openai_codex\" => config.providers.openai_codex.as_mut(),\n _ => None,\n }\n}\n\nfn ensure_provider_entry(config: &mut Config, provider: &str) {\n match provider {\n \"anthropic\" => {\n config.providers.anthropic.get_or_insert_with(ProviderEntry::default);\n }\n \"openai\" => {\n config.providers.openai.get_or_insert_with(ProviderEntry::default);\n }\n \"openrouter\" => {\n config.providers.openrouter.get_or_insert_with(ProviderEntry::default);\n }\n \"ollama\" => {\n config.providers.ollama.get_or_insert_with(ProviderEntry::default);\n }\n \"deepseek\" => {\n config.providers.deepseek.get_or_insert_with(ProviderEntry::default);\n }\n \"gemini\" => {\n config.providers.gemini.get_or_insert_with(ProviderEntry::default);\n }\n \"groq\" => {\n config.providers.groq.get_or_insert_with(ProviderEntry::default);\n }\n \"moonshot\" => {\n config.providers.moonshot.get_or_insert_with(ProviderEntry::default);\n }\n \"minimax\" => {\n config.providers.minimax.get_or_insert_with(ProviderEntry::default);\n }\n \"github_copilot\" => {\n config.providers.github_copilot.get_or_insert_with(ProviderEntry::default);\n }\n \"openai_codex\" => {\n config.providers.openai_codex.get_or_insert_with(ProviderEntry::default);\n }\n _ => {}\n }\n}\n\nfn get_provider_url(config: &Config, provider: &str) -> Option<&str> {\n let entry = match provider {\n \"anthropic\" => config.providers.anthropic.as_ref(),\n \"openai\" => config.providers.openai.as_ref(),\n \"openrouter\" => config.providers.openrouter.as_ref(),\n \"ollama\" => config.providers.ollama.as_ref(),\n \"deepseek\" => config.providers.deepseek.as_ref(),\n \"gemini\" => config.providers.gemini.as_ref(),\n \"groq\" => config.providers.groq.as_ref(),\n \"moonshot\" => config.providers.moonshot.as_ref(),\n \"minimax\" => config.providers.minimax.as_ref(),\n \"github_copilot\" => config.providers.github_copilot.as_ref(),\n \"openai_codex\" => config.providers.openai_codex.as_ref(),\n _ => None,\n };\n entry.and_then(|e| e.base_url.as_deref())\n}\n\nfn set_provider_url(config: &mut Config, provider: &str, url: &str) {\n ensure_provider_entry(config, provider);\n if let Some(entry) = get_provider_entry_mut(config, provider) {\n entry.base_url = if url.is_empty() {\n None\n } else {\n Some(url.to_string())\n };\n }\n}\n\nfn set_provider_api_key(config: &mut Config, provider: &str, key: &str) {\n ensure_provider_entry(config, provider);\n if let Some(entry) = get_provider_entry_mut(config, provider) {\n entry.api_key = if key.is_empty() {\n None\n } else {\n Some(key.to_string())\n };\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use std::io;\n\n fn template_toml() -> String {\n toml::to_string(&Config::default()).unwrap()\n }\n\n #[test]\n fn setup_creates_template_config_when_config_is_missing() {\n let tmp = tempfile::tempdir().unwrap();\n let config_path = tmp.path().join(\"config.toml\");\n\n // Simulate: no existing config, wizard writes defaults\n let config = Config::default();\n let home = config_path.parent().unwrap();\n std::fs::create_dir_all(home).unwrap();\n loader::save_config(&config, &config_path).unwrap();\n\n assert_eq!(\n std::fs::read_to_string(&config_path).unwrap(),\n template_toml()\n );\n assert!(config_path.exists());\n }\n\n #[test]\n fn provider_helpers_set_and_get_fields() {\n let mut config = Config::default();\n set_provider_url(&mut config, \"openai\", \"https://custom.api/v1\");\n set_provider_api_key(&mut config, \"openai\", \"sk-test-key\");\n\n let entry = config.providers.openai.as_ref().unwrap();\n assert_eq!(entry.base_url.as_deref(), Some(\"https://custom.api/v1\"));\n assert_eq!(entry.api_key.as_deref(), Some(\"sk-test-key\"));\n }\n\n #[test]\n fn provider_helpers_handle_unknown_provider() {\n let mut config = Config::default();\n set_provider_url(&mut config, \"nonexistent\", \"https://example.com\");\n assert!(get_provider_url(&config, \"nonexistent\").is_none());\n }\n}\n"}, "d3b02b131736c8aa487dda7102f347ace07893ac": {"t": 1785761096.0267396, "n": 6212, "h": "6413a0c07d7a791b", "c": "//! Setup command — write the template config and initialize home directories.\n\nuse anyhow::{Context, Result};\nuse kestrel_config::Config;\nuse std::io::{self, BufRead, Write};\nuse std::path::Path;\n\n/// Run the setup command.\npub fn run(_config: Config) -> Result<()> {\n let template = Config::default();\n let config_path = kestrel_config::paths::get_config_path()?;\n let stdin = io::stdin();\n let stdout = io::stdout();\n let mut input = stdin.lock();\n let mut output = stdout.lock();\n\n run_with_io(&config_path, &template, &mut input, &mut output)\n}\n\nfn run_with_io(\n config_path: &Path,\n template: &Config,\n input: &mut R,\n output: &mut W,\n) -> Result<()> {\n writeln!(output, \"=== Kestrel Setup ===\\n\")?;\n writeln!(output, \"Template configuration:\")?;\n writeln!(output, \" Model: {}\", template.agent.model)?;\n writeln!(output, \" Temperature: {}\", template.agent.temperature)?;\n writeln!(output, \" Max tokens: {}\", template.agent.max_tokens)?;\n writeln!(output, \" Streaming: {}\", template.agent.streaming)?;\n writeln!(output)?;\n\n if config_path.exists() && !confirm_overwrite(config_path, input, output)? {\n writeln!(\n output,\n \"Keeping existing config at {}.\",\n config_path.display()\n )?;\n return Ok(());\n }\n\n initialize_home(config_path, template)?;\n\n writeln!(\n output,\n \"Saved template config to: {}\",\n config_path.display()\n )?;\n writeln!(\n output,\n \"Created default directories: skills, sessions, learning\"\n )?;\n writeln!(output, \"Setup complete.\")?;\n\n Ok(())\n}\n\nfn confirm_overwrite(\n config_path: &Path,\n input: &mut R,\n output: &mut W,\n) -> Result {\n writeln!(\n output,\n \"Config file already exists at {}.\",\n config_path.display()\n )?;\n write!(output, \"Overwrite with template config? [y/N]: \")?;\n output.flush()?;\n\n let mut answer = String::new();\n input.read_line(&mut answer)?;\n\n Ok(answer.trim().eq_ignore_ascii_case(\"y\"))\n}\n\nfn initialize_home(config_path: &Path, template: &Config) -> Result<()> {\n let home = config_path\n .parent()\n .context(\"Config path must have a parent directory\")?;\n\n std::fs::create_dir_all(home)\n .with_context(|| format!(\"Failed to create config home: {}\", home.display()))?;\n kestrel_config::loader::save_config(template, config_path)?;\n\n for dir in [\"skills\", \"sessions\", \"learning\"] {\n let path = home.join(dir);\n std::fs::create_dir_all(&path)\n .with_context(|| format!(\"Failed to create directory: {}\", path.display()))?;\n }\n\n Ok(())\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n fn template_toml() -> String {\n toml::to_string(&Config::default()).unwrap()\n }\n\n #[test]\n fn setup_keeps_existing_config_when_user_declines_overwrite() {\n let tmp = tempfile::tempdir().unwrap();\n let config_path = tmp.path().join(\"config.toml\");\n std::fs::write(&config_path, \"[agent]\\nmodel = \\\"existing-model\\\"\\n\").unwrap();\n\n let mut input = io::Cursor::new(b\"n\\n\");\n let mut output = Vec::new();\n\n run_with_io(&config_path, &Config::default(), &mut input, &mut output).unwrap();\n\n assert_eq!(\n std::fs::read_to_string(&config_path).unwrap(),\n \"[agent]\\nmodel = \\\"existing-model\\\"\\n\"\n );\n assert!(!tmp.path().join(\"skills\").exists());\n\n let output = String::from_utf8(output).unwrap();\n assert!(output.contains(\"Overwrite with template config? [y/N]:\"));\n assert!(output.contains(\"Keeping existing config\"));\n }\n\n #[test]\n fn setup_keeps_existing_config_when_user_presses_enter() {\n let tmp = tempfile::tempdir().unwrap();\n let config_path = tmp.path().join(\"config.toml\");\n std::fs::write(&config_path, \"[agent]\\nmodel = \\\"existing-model\\\"\\n\").unwrap();\n\n let mut input = io::Cursor::new(b\"\\n\");\n let mut output = Vec::new();\n\n run_with_io(&config_path, &Config::default(), &mut input, &mut output).unwrap();\n\n assert_eq!(\n std::fs::read_to_string(&config_path).unwrap(),\n \"[agent]\\nmodel = \\\"existing-model\\\"\\n\"\n );\n assert!(!tmp.path().join(\"sessions\").exists());\n\n let output = String::from_utf8(output).unwrap();\n assert!(output.contains(\"Keeping existing config\"));\n }\n\n #[test]\n fn setup_overwrites_existing_config_and_creates_default_directories() {\n let tmp = tempfile::tempdir().unwrap();\n let config_path = tmp.path().join(\"config.toml\");\n std::fs::write(&config_path, \"[agent]\\nmodel = \\\"existing-model\\\"\\n\").unwrap();\n\n let mut input = io::Cursor::new(b\"y\\n\");\n let mut output = Vec::new();\n\n run_with_io(&config_path, &Config::default(), &mut input, &mut output).unwrap();\n\n assert_eq!(\n std::fs::read_to_string(&config_path).unwrap(),\n template_toml()\n );\n assert!(tmp.path().join(\"skills\").is_dir());\n assert!(tmp.path().join(\"sessions\").is_dir());\n assert!(tmp.path().join(\"learning\").is_dir());\n\n let output = String::from_utf8(output).unwrap();\n assert!(output.contains(\"Saved template config\"));\n }\n\n #[test]\n fn setup_creates_template_config_and_directories_when_config_is_missing() {\n let tmp = tempfile::tempdir().unwrap();\n let config_path = tmp.path().join(\"config.toml\");\n\n let mut input = io::Cursor::new(Vec::::new());\n let mut output = Vec::new();\n\n run_with_io(&config_path, &Config::default(), &mut input, &mut output).unwrap();\n\n assert_eq!(\n std::fs::read_to_string(&config_path).unwrap(),\n template_toml()\n );\n assert!(tmp.path().join(\"skills\").is_dir());\n assert!(tmp.path().join(\"sessions\").is_dir());\n assert!(tmp.path().join(\"learning\").is_dir());\n\n let output = String::from_utf8(output).unwrap();\n assert!(!output.contains(\"Overwrite with template config? [y/N]:\"));\n assert!(output.contains(\"Setup complete.\"));\n }\n}\n"}, "ff84cd9ec75834c24786dbeebdd8dc3dfed5c6e0": {"t": 1785761097.2152216, "n": 6198, "h": "1a217846a2833e5a", "c": "//! Setup command — write the template config and initialize home directories.\n\nuse anyhow::{Context, Result};\nuse kestrel_config::Config;\nuse std::io::{self, BufRead, Write};\nuse std::path::Path;\n\n/// Run the setup command.\npub fn run(_config: Config) -> Result<()> {\n let template = Config::default();\n let config_path = kestrel_config::paths::get_config_path()?;\n let stdin = io::stdin();\n let stdout = io::stdout();\n let mut input = stdin.lock();\n let mut output = stdout.lock();\n\n run_with_io(&config_path, &template, &mut input, &mut output)\n}\n\nfn run_with_io(\n config_path: &Path,\n template: &Config,\n input: &mut R,\n output: &mut W,\n) -> Result<()> {\n writeln!(output, \"=== Kestrel Setup ===\\n\")?;\n writeln!(output, \"Template configuration:\")?;\n writeln!(output, \" Model: {}\", template.agent.model)?;\n writeln!(output, \" Temperature: {}\", template.agent.temperature)?;\n writeln!(output, \" Max tokens: {}\", template.agent.max_tokens)?;\n writeln!(output, \" Streaming: {}\", template.agent.streaming)?;\n writeln!(output)?;\n\n if config_path.exists() && !confirm_overwrite(config_path, input, output)? {\n writeln!(\n output,\n \"Keeping existing config at {}.\",\n config_path.display()\n )?;\n return Ok(());\n }\n\n initialize_home(config_path, template)?;\n\n writeln!(\n output,\n \"Saved template config to: {}\",\n config_path.display()\n )?;\n writeln!(\n output,\n \"Created default directories: skills, sessions, learning\"\n )?;\n writeln!(output, \"Setup complete.\")?;\n\n Ok(())\n}\n\nfn confirm_overwrite(\n config_path: &Path,\n input: &mut R,\n output: &mut W,\n) -> Result {\n writeln!(\n output,\n \"Config file already exists at {}.\",\n config_path.display()\n )?;\n write!(output, \"Overwrite with template config? [y/N]: \")?;\n output.flush()?;\n\n let mut answer = String::new();\n input.read_line(&mut answer)?;\n\n Ok(answer.trim().eq_ignore_ascii_case(\"y\"))\n}\n\nfn initialize_home(config_path: &Path, template: &Config) -> Result<()> {\n let home = config_path\n .parent()\n .context(\"Config path must have a parent directory\")?;\n\n std::fs::create_dir_all(home)\n .with_context(|| format!(\"Failed to create config home: {}\", home.display()))?;\n kestrel_config::loader::save_config(template, config_path)?;\n\n for dir in [\"skills\", \"sessions\", \"learning\"] {\n let path = home.join(dir);\n std::fs::create_dir_all(&path)\n .with_context(|| format!(\"Failed to create directory: {}\", path.display()))?;\n }\n\n Ok(())\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n fn template_yaml() -> String {\n serde_yaml::to_string(&Config::default()).unwrap()\n }\n\n #[test]\n fn setup_keeps_existing_config_when_user_declines_overwrite() {\n let tmp = tempfile::tempdir().unwrap();\n let config_path = tmp.path().join(\"config.yaml\");\n std::fs::write(&config_path, \"agent:\\n model: existing-model\\n\").unwrap();\n\n let mut input = io::Cursor::new(b\"n\\n\");\n let mut output = Vec::new();\n\n run_with_io(&config_path, &Config::default(), &mut input, &mut output).unwrap();\n\n assert_eq!(\n std::fs::read_to_string(&config_path).unwrap(),\n \"agent:\\n model: existing-model\\n\"\n );\n assert!(!tmp.path().join(\"skills\").exists());\n\n let output = String::from_utf8(output).unwrap();\n assert!(output.contains(\"Overwrite with template config? [y/N]:\"));\n assert!(output.contains(\"Keeping existing config\"));\n }\n\n #[test]\n fn setup_keeps_existing_config_when_user_presses_enter() {\n let tmp = tempfile::tempdir().unwrap();\n let config_path = tmp.path().join(\"config.yaml\");\n std::fs::write(&config_path, \"agent:\\n model: existing-model\\n\").unwrap();\n\n let mut input = io::Cursor::new(b\"\\n\");\n let mut output = Vec::new();\n\n run_with_io(&config_path, &Config::default(), &mut input, &mut output).unwrap();\n\n assert_eq!(\n std::fs::read_to_string(&config_path).unwrap(),\n \"agent:\\n model: existing-model\\n\"\n );\n assert!(!tmp.path().join(\"sessions\").exists());\n\n let output = String::from_utf8(output).unwrap();\n assert!(output.contains(\"Keeping existing config\"));\n }\n\n #[test]\n fn setup_overwrites_existing_config_and_creates_default_directories() {\n let tmp = tempfile::tempdir().unwrap();\n let config_path = tmp.path().join(\"config.yaml\");\n std::fs::write(&config_path, \"agent:\\n model: existing-model\\n\").unwrap();\n\n let mut input = io::Cursor::new(b\"y\\n\");\n let mut output = Vec::new();\n\n run_with_io(&config_path, &Config::default(), &mut input, &mut output).unwrap();\n\n assert_eq!(\n std::fs::read_to_string(&config_path).unwrap(),\n template_yaml()\n );\n assert!(tmp.path().join(\"skills\").is_dir());\n assert!(tmp.path().join(\"sessions\").is_dir());\n assert!(tmp.path().join(\"learning\").is_dir());\n\n let output = String::from_utf8(output).unwrap();\n assert!(output.contains(\"Saved template config\"));\n }\n\n #[test]\n fn setup_creates_template_config_and_directories_when_config_is_missing() {\n let tmp = tempfile::tempdir().unwrap();\n let config_path = tmp.path().join(\"config.yaml\");\n\n let mut input = io::Cursor::new(Vec::::new());\n let mut output = Vec::new();\n\n run_with_io(&config_path, &Config::default(), &mut input, &mut output).unwrap();\n\n assert_eq!(\n std::fs::read_to_string(&config_path).unwrap(),\n template_yaml()\n );\n assert!(tmp.path().join(\"skills\").is_dir());\n assert!(tmp.path().join(\"sessions\").is_dir());\n assert!(tmp.path().join(\"learning\").is_dir());\n\n let output = String::from_utf8(output).unwrap();\n assert!(!output.contains(\"Overwrite with template config? [y/N]:\"));\n assert!(output.contains(\"Setup complete.\"));\n }\n}\n"}, "2badc8d8e2a03fa1b89e2982f1cc16b7a9752e68": {"t": 1785761099.629697, "n": 6198, "h": "9db315ebced1854c", "c": "//! Setup command — write the template config and initialize home directories.\n\nuse anyhow::{Context, Result};\nuse nanobot_config::Config;\nuse std::io::{self, BufRead, Write};\nuse std::path::Path;\n\n/// Run the setup command.\npub fn run(_config: Config) -> Result<()> {\n let template = Config::default();\n let config_path = nanobot_config::paths::get_config_path()?;\n let stdin = io::stdin();\n let stdout = io::stdout();\n let mut input = stdin.lock();\n let mut output = stdout.lock();\n\n run_with_io(&config_path, &template, &mut input, &mut output)\n}\n\nfn run_with_io(\n config_path: &Path,\n template: &Config,\n input: &mut R,\n output: &mut W,\n) -> Result<()> {\n writeln!(output, \"=== Nanobot Setup ===\\n\")?;\n writeln!(output, \"Template configuration:\")?;\n writeln!(output, \" Model: {}\", template.agent.model)?;\n writeln!(output, \" Temperature: {}\", template.agent.temperature)?;\n writeln!(output, \" Max tokens: {}\", template.agent.max_tokens)?;\n writeln!(output, \" Streaming: {}\", template.agent.streaming)?;\n writeln!(output)?;\n\n if config_path.exists() && !confirm_overwrite(config_path, input, output)? {\n writeln!(\n output,\n \"Keeping existing config at {}.\",\n config_path.display()\n )?;\n return Ok(());\n }\n\n initialize_home(config_path, template)?;\n\n writeln!(\n output,\n \"Saved template config to: {}\",\n config_path.display()\n )?;\n writeln!(\n output,\n \"Created default directories: skills, sessions, learning\"\n )?;\n writeln!(output, \"Setup complete.\")?;\n\n Ok(())\n}\n\nfn confirm_overwrite(\n config_path: &Path,\n input: &mut R,\n output: &mut W,\n) -> Result {\n writeln!(\n output,\n \"Config file already exists at {}.\",\n config_path.display()\n )?;\n write!(output, \"Overwrite with template config? [y/N]: \")?;\n output.flush()?;\n\n let mut answer = String::new();\n input.read_line(&mut answer)?;\n\n Ok(answer.trim().eq_ignore_ascii_case(\"y\"))\n}\n\nfn initialize_home(config_path: &Path, template: &Config) -> Result<()> {\n let home = config_path\n .parent()\n .context(\"Config path must have a parent directory\")?;\n\n std::fs::create_dir_all(home)\n .with_context(|| format!(\"Failed to create config home: {}\", home.display()))?;\n nanobot_config::loader::save_config(template, config_path)?;\n\n for dir in [\"skills\", \"sessions\", \"learning\"] {\n let path = home.join(dir);\n std::fs::create_dir_all(&path)\n .with_context(|| format!(\"Failed to create directory: {}\", path.display()))?;\n }\n\n Ok(())\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n fn template_yaml() -> String {\n serde_yaml::to_string(&Config::default()).unwrap()\n }\n\n #[test]\n fn setup_keeps_existing_config_when_user_declines_overwrite() {\n let tmp = tempfile::tempdir().unwrap();\n let config_path = tmp.path().join(\"config.yaml\");\n std::fs::write(&config_path, \"agent:\\n model: existing-model\\n\").unwrap();\n\n let mut input = io::Cursor::new(b\"n\\n\");\n let mut output = Vec::new();\n\n run_with_io(&config_path, &Config::default(), &mut input, &mut output).unwrap();\n\n assert_eq!(\n std::fs::read_to_string(&config_path).unwrap(),\n \"agent:\\n model: existing-model\\n\"\n );\n assert!(!tmp.path().join(\"skills\").exists());\n\n let output = String::from_utf8(output).unwrap();\n assert!(output.contains(\"Overwrite with template config? [y/N]:\"));\n assert!(output.contains(\"Keeping existing config\"));\n }\n\n #[test]\n fn setup_keeps_existing_config_when_user_presses_enter() {\n let tmp = tempfile::tempdir().unwrap();\n let config_path = tmp.path().join(\"config.yaml\");\n std::fs::write(&config_path, \"agent:\\n model: existing-model\\n\").unwrap();\n\n let mut input = io::Cursor::new(b\"\\n\");\n let mut output = Vec::new();\n\n run_with_io(&config_path, &Config::default(), &mut input, &mut output).unwrap();\n\n assert_eq!(\n std::fs::read_to_string(&config_path).unwrap(),\n \"agent:\\n model: existing-model\\n\"\n );\n assert!(!tmp.path().join(\"sessions\").exists());\n\n let output = String::from_utf8(output).unwrap();\n assert!(output.contains(\"Keeping existing config\"));\n }\n\n #[test]\n fn setup_overwrites_existing_config_and_creates_default_directories() {\n let tmp = tempfile::tempdir().unwrap();\n let config_path = tmp.path().join(\"config.yaml\");\n std::fs::write(&config_path, \"agent:\\n model: existing-model\\n\").unwrap();\n\n let mut input = io::Cursor::new(b\"y\\n\");\n let mut output = Vec::new();\n\n run_with_io(&config_path, &Config::default(), &mut input, &mut output).unwrap();\n\n assert_eq!(\n std::fs::read_to_string(&config_path).unwrap(),\n template_yaml()\n );\n assert!(tmp.path().join(\"skills\").is_dir());\n assert!(tmp.path().join(\"sessions\").is_dir());\n assert!(tmp.path().join(\"learning\").is_dir());\n\n let output = String::from_utf8(output).unwrap();\n assert!(output.contains(\"Saved template config\"));\n }\n\n #[test]\n fn setup_creates_template_config_and_directories_when_config_is_missing() {\n let tmp = tempfile::tempdir().unwrap();\n let config_path = tmp.path().join(\"config.yaml\");\n\n let mut input = io::Cursor::new(Vec::::new());\n let mut output = Vec::new();\n\n run_with_io(&config_path, &Config::default(), &mut input, &mut output).unwrap();\n\n assert_eq!(\n std::fs::read_to_string(&config_path).unwrap(),\n template_yaml()\n );\n assert!(tmp.path().join(\"skills\").is_dir());\n assert!(tmp.path().join(\"sessions\").is_dir());\n assert!(tmp.path().join(\"learning\").is_dir());\n\n let output = String::from_utf8(output).unwrap();\n assert!(!output.contains(\"Overwrite with template config? [y/N]:\"));\n assert!(output.contains(\"Setup complete.\"));\n }\n}\n"}, "20cf6a8e20791f33c0fea9ac6ecce2f880a72d67": {"t": 1785761100.851016, "n": 1874, "h": "fd4f70260c09bfe7", "c": "//! Setup command — interactive configuration wizard.\n\nuse anyhow::Result;\nuse nanobot_config::Config;\n\n/// Run the interactive setup wizard.\npub fn run(config: Config) -> Result<()> {\n println!(\"=== Nanobot Setup ===\\n\");\n\n println!(\"Current configuration:\");\n println!(\" Model: {}\", config.agent.model);\n println!(\" Temperature: {}\", config.agent.temperature);\n println!(\" Max tokens: {}\", config.agent.max_tokens);\n println!(\" Streaming: {}\", config.agent.streaming);\n println!();\n\n println!(\"Providers:\");\n let providers = [\n (\"Anthropic\", config.providers.anthropic.is_some()),\n (\"OpenAI\", config.providers.openai.is_some()),\n (\"DeepSeek\", config.providers.deepseek.is_some()),\n (\"Groq\", config.providers.groq.is_some()),\n (\"OpenRouter\", config.providers.openrouter.is_some()),\n (\"Ollama\", config.providers.ollama.is_some()),\n ];\n for (name, configured) in &providers {\n let status = if *configured {\n \"configured\"\n } else {\n \"not configured\"\n };\n println!(\" {}: {}\", name, status);\n }\n println!();\n\n println!(\"Channels:\");\n let channels = [\n (\"Telegram\", config.channels.telegram.is_some()),\n (\"Discord\", config.channels.discord.is_some()),\n ];\n for (name, configured) in &channels {\n let status = if *configured {\n \"configured\"\n } else {\n \"not configured\"\n };\n println!(\" {}: {}\", name, status);\n }\n println!();\n\n // Save config\n let config_path = nanobot_config::paths::get_config_path()?;\n println!(\"Saving configuration to: {}\", config_path.display());\n nanobot_config::loader::save_config(&config, &config_path)?;\n\n println!(\"Setup complete.\");\n println!(\"Edit {} to customize further.\", config_path.display());\n\n Ok(())\n}\n"}}, "crates/kestrel-providers/src/registry.rs": {"a8b74d3814794dc39e778d44448b6787b51e406b": {"t": 1785746259.3196766, "n": 15294, "h": "3efeed81dc210a0c", "c": "//! Provider registry — direct provider lookup by name.\n//!\n//! Users explicitly select both provider and model. No keyword guessing\n//! or prefix-based routing.\n\nuse crate::anthropic::{AnthropicConfig, AnthropicProvider};\nuse crate::base::LlmProvider;\nuse crate::openai_compat::{OpenAiCompatConfig, OpenAiCompatProvider};\nuse anyhow::Result;\nuse kestrel_config::schema::Config;\nuse std::collections::HashMap;\nuse std::sync::Arc;\nuse tokio::sync::Semaphore;\nuse tracing::{debug, info};\n\n/// Registry of LLM providers.\n#[derive(Clone)]\npub struct ProviderRegistry {\n providers: HashMap>,\n default_provider: Option,\n /// Semaphore limiting concurrent in-flight provider requests.\n concurrency_limit: Arc,\n}\n\nimpl ProviderRegistry {\n /// Create a new empty registry.\n pub fn new() -> Self {\n Self {\n providers: HashMap::new(),\n default_provider: None,\n concurrency_limit: Arc::new(Semaphore::new(4)),\n }\n }\n\n /// Create a registry with a custom concurrency limit for in-flight requests.\n pub fn with_concurrency_limit(limit: usize) -> Self {\n Self {\n providers: HashMap::new(),\n default_provider: None,\n concurrency_limit: Arc::new(Semaphore::new(limit)),\n }\n }\n\n /// Acquire a permit from the concurrency semaphore.\n /// Returns a permit that releases on drop.\n pub async fn acquire_permit(&self) -> tokio::sync::OwnedSemaphorePermit {\n self.concurrency_limit\n .clone()\n .acquire_owned()\n .await\n .expect(\"concurrency semaphore should not be closed\")\n }\n\n /// Build the registry from a Config.\n pub fn from_config(config: &Config) -> Result {\n let mut registry = Self::new();\n\n // Register Anthropic provider if configured\n if let Some(entry) = &config.providers.anthropic {\n if let Some(api_key) = &entry.api_key {\n let provider = AnthropicProvider::new(AnthropicConfig {\n api_key: api_key.clone(),\n model: entry\n .model\n .clone()\n .unwrap_or_else(|| \"claude-sonnet-4-20250514\".to_string()),\n api_version: None,\n base_url: entry.base_url.clone(),\n enable_cache_control: entry.enable_cache_control,\n })?;\n registry.register(\"anthropic\", provider);\n info!(\"Registered Anthropic provider\");\n }\n }\n\n // Register OpenAI provider if configured\n if let Some(entry) = &config.providers.openai {\n if let Some(api_key) = &entry.api_key {\n let provider = OpenAiCompatProvider::new(OpenAiCompatConfig {\n api_key: api_key.clone(),\n base_url: entry\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://api.openai.com/v1\".to_string()),\n model: entry.model.clone().unwrap_or_default(),\n organization: None,\n no_proxy: entry.no_proxy.unwrap_or(false),\n })?;\n registry.register(\"openai\", provider);\n info!(\"Registered OpenAI provider\");\n }\n }\n\n // Register DeepSeek provider\n if let Some(entry) = &config.providers.deepseek {\n if let Some(api_key) = &entry.api_key {\n let provider = OpenAiCompatProvider::new(OpenAiCompatConfig {\n api_key: api_key.clone(),\n base_url: entry\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://api.deepseek.com/v1\".to_string()),\n model: entry.model.clone().unwrap_or_default(),\n organization: None,\n no_proxy: entry.no_proxy.unwrap_or(false),\n })?;\n registry.register(\"deepseek\", provider);\n info!(\"Registered DeepSeek provider\");\n }\n }\n\n // Register Groq provider\n if let Some(entry) = &config.providers.groq {\n if let Some(api_key) = &entry.api_key {\n let provider = OpenAiCompatProvider::new(OpenAiCompatConfig {\n api_key: api_key.clone(),\n base_url: entry\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://api.groq.com/openai/v1\".to_string()),\n model: entry.model.clone().unwrap_or_default(),\n organization: None,\n no_proxy: entry.no_proxy.unwrap_or(false),\n })?;\n registry.register(\"groq\", provider);\n info!(\"Registered Groq provider\");\n }\n }\n\n // Register OpenRouter provider\n if let Some(entry) = &config.providers.openrouter {\n if let Some(api_key) = &entry.api_key {\n let provider = OpenAiCompatProvider::new(OpenAiCompatConfig {\n api_key: api_key.clone(),\n base_url: entry\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://openrouter.ai/api/v1\".to_string()),\n model: entry.model.clone().unwrap_or_default(),\n organization: None,\n no_proxy: entry.no_proxy.unwrap_or(false),\n })?;\n registry.register(\"openrouter\", provider);\n info!(\"Registered OpenRouter provider\");\n }\n }\n\n // Register Ollama provider (localhost — always skip proxy)\n if let Some(entry) = &config.providers.ollama {\n let provider = OpenAiCompatProvider::new(OpenAiCompatConfig {\n api_key: entry.api_key.clone().unwrap_or_default(),\n base_url: entry\n .base_url\n .clone()\n .unwrap_or_else(|| \"http://localhost:11434/v1\".to_string()),\n model: entry.model.clone().unwrap_or_default(),\n organization: None,\n no_proxy: true,\n })?;\n registry.register(\"ollama\", provider);\n info!(\"Registered Ollama provider\");\n }\n\n // Register OpenCode Go provider (OpenAI-compatible endpoint)\n if let Some(entry) = &config.providers.opencode_go {\n if let Some(api_key) = &entry.api_key {\n let provider = OpenAiCompatProvider::new(OpenAiCompatConfig {\n api_key: api_key.clone(),\n base_url: entry\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://opencode.ai/zen/go/v1\".to_string()),\n model: entry.model.clone().unwrap_or_default(),\n organization: None,\n no_proxy: entry.no_proxy.unwrap_or(false),\n })?;\n registry.register(\"opencode_go\", provider);\n info!(\"Registered OpenCode Go provider\");\n }\n }\n\n // Register GLM Coding Plan provider (智谱 — OpenAI-compatible endpoint)\n if let Some(entry) = &config.providers.glm_coding_plan {\n if let Some(api_key) = &entry.api_key {\n let provider = OpenAiCompatProvider::new(OpenAiCompatConfig {\n api_key: api_key.clone(),\n base_url: entry.base_url.clone().unwrap_or_else(|| {\n \"https://open.bigmodel.cn/api/coding/paas/v4\".to_string()\n }),\n model: entry.model.clone().unwrap_or_default(),\n organization: None,\n no_proxy: entry.no_proxy.unwrap_or(false),\n })?;\n registry.register(\"glm_coding_plan\", provider);\n info!(\"Registered GLM Coding Plan provider\");\n }\n }\n\n // Register custom providers\n for custom in &config.custom_providers {\n let provider = OpenAiCompatProvider::new(OpenAiCompatConfig {\n api_key: custom.api_key.clone().unwrap_or_default(),\n base_url: custom.base_url.clone(),\n model: custom.model_patterns.first().cloned().unwrap_or_default(),\n organization: None,\n no_proxy: custom.no_proxy.unwrap_or(false),\n })?;\n registry.register(&custom.name, provider);\n info!(\"Registered custom provider: {}\", custom.name);\n }\n\n // Set default provider from explicit config, otherwise pick first registered.\n let default = if let Some(ref name) = config.agent.provider {\n if registry.providers.contains_key(name) {\n Some(name.clone())\n } else {\n registry.providers.keys().next().cloned()\n }\n } else {\n registry.providers.keys().next().cloned()\n };\n registry.default_provider = default;\n if let Some(ref name) = registry.default_provider {\n debug!(\"Default provider: {}\", name);\n }\n\n Ok(registry)\n }\n\n /// Register a provider.\n pub fn register(&mut self, name: &str, provider: impl LlmProvider + 'static) {\n self.providers.insert(name.to_string(), Arc::new(provider));\n }\n\n /// Set the default provider by name.\n pub fn set_default(&mut self, name: &str) {\n if self.providers.contains_key(name) {\n self.default_provider = Some(name.to_string());\n }\n }\n\n /// Get a provider by name, falling back to the default provider.\n pub fn get_provider_by_name(&self, name: &str) -> Option> {\n self.providers.get(name).cloned().or_else(|| {\n self.default_provider\n .as_ref()\n .and_then(|n| self.providers.get(n).cloned())\n })\n }\n\n /// Get the provider for the given provider name string.\n ///\n /// `provider_name` must be an exact provider name (e.g. \"opencode_go\", \"anthropic\").\n /// Returns the provider, or the default provider as fallback.\n pub fn get_provider(&self, provider_name: &str) -> Option> {\n self.get_provider_by_name(provider_name)\n }\n\n /// List all registered provider names.\n pub fn provider_names(&self) -> Vec {\n self.providers.keys().cloned().collect()\n }\n\n /// Return the name of the default provider, if any.\n pub fn default_provider_name(&self) -> Option<&str> {\n self.default_provider.as_deref()\n }\n}\n\nimpl Default for ProviderRegistry {\n fn default() -> Self {\n Self::new()\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::base::{\n BoxStream, CompletionChunk, CompletionRequest, CompletionResponse, LlmProvider,\n };\n use async_trait::async_trait;\n\n struct MockProvider {\n provider_name: String,\n supported_model: String,\n }\n\n impl MockProvider {\n fn new(name: &str, supported: &str) -> Self {\n Self {\n provider_name: name.to_string(),\n supported_model: supported.to_string(),\n }\n }\n }\n\n #[async_trait]\n impl LlmProvider for MockProvider {\n fn name(&self) -> &str {\n &self.provider_name\n }\n fn default_model(&self) -> &str {\n &self.supported_model\n }\n async fn complete(\n &self,\n _request: CompletionRequest,\n ) -> anyhow::Result {\n Ok(CompletionResponse {\n content: Some(\"mock\".to_string()),\n reasoning_content: None,\n tool_calls: None,\n usage: None,\n finish_reason: None,\n })\n }\n async fn complete_stream(&self, request: CompletionRequest) -> anyhow::Result {\n let response = self.complete(request).await?;\n let chunk = CompletionChunk {\n delta: response.content,\n reasoning_content: None,\n tool_call_deltas: None,\n usage: None,\n done: true,\n };\n Ok(Box::pin(futures::stream::once(async move { Ok(chunk) })))\n }\n fn supports_model(&self, model: &str) -> bool {\n model.contains(&self.supported_model)\n }\n }\n\n #[test]\n fn test_registry_new() {\n let reg = ProviderRegistry::new();\n assert!(reg.provider_names().is_empty());\n }\n\n #[test]\n fn test_registry_register() {\n let mut reg = ProviderRegistry::new();\n reg.register(\"mock\", MockProvider::new(\"mock\", \"test\"));\n let names = reg.provider_names();\n assert_eq!(names.len(), 1);\n assert!(names.contains(&\"mock\".to_string()));\n }\n\n #[test]\n fn test_get_provider_by_name() {\n let mut reg = ProviderRegistry::new();\n reg.register(\"anthropic\", MockProvider::new(\"anthropic\", \"claude\"));\n reg.register(\"openai\", MockProvider::new(\"openai\", \"gpt\"));\n\n let p = reg.get_provider_by_name(\"anthropic\");\n assert!(p.is_some());\n assert_eq!(p.unwrap().name(), \"anthropic\");\n\n // Unknown name returns None (no default set).\n assert!(reg.get_provider_by_name(\"unknown\").is_none());\n }\n\n #[test]\n fn test_get_provider_by_name_fallback_to_default() {\n let mut reg = ProviderRegistry::new();\n reg.register(\"anthropic\", MockProvider::new(\"anthropic\", \"claude\"));\n reg.register(\"openai\", MockProvider::new(\"openai\", \"gpt\"));\n reg.set_default(\"openai\");\n\n // Unknown name falls back to default.\n let p = reg.get_provider_by_name(\"nonexistent\");\n assert!(p.is_some());\n assert_eq!(p.unwrap().name(), \"openai\");\n }\n\n #[test]\n fn test_get_provider() {\n let mut reg = ProviderRegistry::new();\n reg.register(\"opencode_go\", MockProvider::new(\"opencode_go\", \"glm\"));\n\n let p = reg.get_provider(\"opencode_go\");\n assert!(p.is_some());\n assert_eq!(p.unwrap().name(), \"opencode_go\");\n }\n\n #[test]\n fn test_registry_default_provider() {\n let reg = ProviderRegistry::default();\n assert!(reg.provider_names().is_empty());\n assert!(reg.default_provider.is_none());\n }\n\n #[test]\n fn test_set_default_validates() {\n let mut reg = ProviderRegistry::new();\n reg.register(\"anthropic\", MockProvider::new(\"anthropic\", \"claude\"));\n reg.set_default(\"nonexistent\");\n assert!(reg.default_provider.is_none());\n reg.set_default(\"anthropic\");\n assert_eq!(reg.default_provider.as_deref(), Some(\"anthropic\"));\n }\n\n #[test]\n fn test_default_provider_name() {\n let mut reg = ProviderRegistry::new();\n assert!(reg.default_provider_name().is_none());\n reg.register(\"openai\", MockProvider::new(\"openai\", \"gpt\"));\n reg.set_default(\"openai\");\n assert_eq!(reg.default_provider_name(), Some(\"openai\"));\n }\n}\n"}, "2dca9f6d33af4d736ec4639b521fbcd32400d0f9": {"t": 1785761066.4266858, "n": 15294, "h": "3efeed81dc210a0c", "c": "//! Provider registry — direct provider lookup by name.\n//!\n//! Users explicitly select both provider and model. No keyword guessing\n//! or prefix-based routing.\n\nuse crate::anthropic::{AnthropicConfig, AnthropicProvider};\nuse crate::base::LlmProvider;\nuse crate::openai_compat::{OpenAiCompatConfig, OpenAiCompatProvider};\nuse anyhow::Result;\nuse kestrel_config::schema::Config;\nuse std::collections::HashMap;\nuse std::sync::Arc;\nuse tokio::sync::Semaphore;\nuse tracing::{debug, info};\n\n/// Registry of LLM providers.\n#[derive(Clone)]\npub struct ProviderRegistry {\n providers: HashMap>,\n default_provider: Option,\n /// Semaphore limiting concurrent in-flight provider requests.\n concurrency_limit: Arc,\n}\n\nimpl ProviderRegistry {\n /// Create a new empty registry.\n pub fn new() -> Self {\n Self {\n providers: HashMap::new(),\n default_provider: None,\n concurrency_limit: Arc::new(Semaphore::new(4)),\n }\n }\n\n /// Create a registry with a custom concurrency limit for in-flight requests.\n pub fn with_concurrency_limit(limit: usize) -> Self {\n Self {\n providers: HashMap::new(),\n default_provider: None,\n concurrency_limit: Arc::new(Semaphore::new(limit)),\n }\n }\n\n /// Acquire a permit from the concurrency semaphore.\n /// Returns a permit that releases on drop.\n pub async fn acquire_permit(&self) -> tokio::sync::OwnedSemaphorePermit {\n self.concurrency_limit\n .clone()\n .acquire_owned()\n .await\n .expect(\"concurrency semaphore should not be closed\")\n }\n\n /// Build the registry from a Config.\n pub fn from_config(config: &Config) -> Result {\n let mut registry = Self::new();\n\n // Register Anthropic provider if configured\n if let Some(entry) = &config.providers.anthropic {\n if let Some(api_key) = &entry.api_key {\n let provider = AnthropicProvider::new(AnthropicConfig {\n api_key: api_key.clone(),\n model: entry\n .model\n .clone()\n .unwrap_or_else(|| \"claude-sonnet-4-20250514\".to_string()),\n api_version: None,\n base_url: entry.base_url.clone(),\n enable_cache_control: entry.enable_cache_control,\n })?;\n registry.register(\"anthropic\", provider);\n info!(\"Registered Anthropic provider\");\n }\n }\n\n // Register OpenAI provider if configured\n if let Some(entry) = &config.providers.openai {\n if let Some(api_key) = &entry.api_key {\n let provider = OpenAiCompatProvider::new(OpenAiCompatConfig {\n api_key: api_key.clone(),\n base_url: entry\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://api.openai.com/v1\".to_string()),\n model: entry.model.clone().unwrap_or_default(),\n organization: None,\n no_proxy: entry.no_proxy.unwrap_or(false),\n })?;\n registry.register(\"openai\", provider);\n info!(\"Registered OpenAI provider\");\n }\n }\n\n // Register DeepSeek provider\n if let Some(entry) = &config.providers.deepseek {\n if let Some(api_key) = &entry.api_key {\n let provider = OpenAiCompatProvider::new(OpenAiCompatConfig {\n api_key: api_key.clone(),\n base_url: entry\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://api.deepseek.com/v1\".to_string()),\n model: entry.model.clone().unwrap_or_default(),\n organization: None,\n no_proxy: entry.no_proxy.unwrap_or(false),\n })?;\n registry.register(\"deepseek\", provider);\n info!(\"Registered DeepSeek provider\");\n }\n }\n\n // Register Groq provider\n if let Some(entry) = &config.providers.groq {\n if let Some(api_key) = &entry.api_key {\n let provider = OpenAiCompatProvider::new(OpenAiCompatConfig {\n api_key: api_key.clone(),\n base_url: entry\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://api.groq.com/openai/v1\".to_string()),\n model: entry.model.clone().unwrap_or_default(),\n organization: None,\n no_proxy: entry.no_proxy.unwrap_or(false),\n })?;\n registry.register(\"groq\", provider);\n info!(\"Registered Groq provider\");\n }\n }\n\n // Register OpenRouter provider\n if let Some(entry) = &config.providers.openrouter {\n if let Some(api_key) = &entry.api_key {\n let provider = OpenAiCompatProvider::new(OpenAiCompatConfig {\n api_key: api_key.clone(),\n base_url: entry\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://openrouter.ai/api/v1\".to_string()),\n model: entry.model.clone().unwrap_or_default(),\n organization: None,\n no_proxy: entry.no_proxy.unwrap_or(false),\n })?;\n registry.register(\"openrouter\", provider);\n info!(\"Registered OpenRouter provider\");\n }\n }\n\n // Register Ollama provider (localhost — always skip proxy)\n if let Some(entry) = &config.providers.ollama {\n let provider = OpenAiCompatProvider::new(OpenAiCompatConfig {\n api_key: entry.api_key.clone().unwrap_or_default(),\n base_url: entry\n .base_url\n .clone()\n .unwrap_or_else(|| \"http://localhost:11434/v1\".to_string()),\n model: entry.model.clone().unwrap_or_default(),\n organization: None,\n no_proxy: true,\n })?;\n registry.register(\"ollama\", provider);\n info!(\"Registered Ollama provider\");\n }\n\n // Register OpenCode Go provider (OpenAI-compatible endpoint)\n if let Some(entry) = &config.providers.opencode_go {\n if let Some(api_key) = &entry.api_key {\n let provider = OpenAiCompatProvider::new(OpenAiCompatConfig {\n api_key: api_key.clone(),\n base_url: entry\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://opencode.ai/zen/go/v1\".to_string()),\n model: entry.model.clone().unwrap_or_default(),\n organization: None,\n no_proxy: entry.no_proxy.unwrap_or(false),\n })?;\n registry.register(\"opencode_go\", provider);\n info!(\"Registered OpenCode Go provider\");\n }\n }\n\n // Register GLM Coding Plan provider (智谱 — OpenAI-compatible endpoint)\n if let Some(entry) = &config.providers.glm_coding_plan {\n if let Some(api_key) = &entry.api_key {\n let provider = OpenAiCompatProvider::new(OpenAiCompatConfig {\n api_key: api_key.clone(),\n base_url: entry.base_url.clone().unwrap_or_else(|| {\n \"https://open.bigmodel.cn/api/coding/paas/v4\".to_string()\n }),\n model: entry.model.clone().unwrap_or_default(),\n organization: None,\n no_proxy: entry.no_proxy.unwrap_or(false),\n })?;\n registry.register(\"glm_coding_plan\", provider);\n info!(\"Registered GLM Coding Plan provider\");\n }\n }\n\n // Register custom providers\n for custom in &config.custom_providers {\n let provider = OpenAiCompatProvider::new(OpenAiCompatConfig {\n api_key: custom.api_key.clone().unwrap_or_default(),\n base_url: custom.base_url.clone(),\n model: custom.model_patterns.first().cloned().unwrap_or_default(),\n organization: None,\n no_proxy: custom.no_proxy.unwrap_or(false),\n })?;\n registry.register(&custom.name, provider);\n info!(\"Registered custom provider: {}\", custom.name);\n }\n\n // Set default provider from explicit config, otherwise pick first registered.\n let default = if let Some(ref name) = config.agent.provider {\n if registry.providers.contains_key(name) {\n Some(name.clone())\n } else {\n registry.providers.keys().next().cloned()\n }\n } else {\n registry.providers.keys().next().cloned()\n };\n registry.default_provider = default;\n if let Some(ref name) = registry.default_provider {\n debug!(\"Default provider: {}\", name);\n }\n\n Ok(registry)\n }\n\n /// Register a provider.\n pub fn register(&mut self, name: &str, provider: impl LlmProvider + 'static) {\n self.providers.insert(name.to_string(), Arc::new(provider));\n }\n\n /// Set the default provider by name.\n pub fn set_default(&mut self, name: &str) {\n if self.providers.contains_key(name) {\n self.default_provider = Some(name.to_string());\n }\n }\n\n /// Get a provider by name, falling back to the default provider.\n pub fn get_provider_by_name(&self, name: &str) -> Option> {\n self.providers.get(name).cloned().or_else(|| {\n self.default_provider\n .as_ref()\n .and_then(|n| self.providers.get(n).cloned())\n })\n }\n\n /// Get the provider for the given provider name string.\n ///\n /// `provider_name` must be an exact provider name (e.g. \"opencode_go\", \"anthropic\").\n /// Returns the provider, or the default provider as fallback.\n pub fn get_provider(&self, provider_name: &str) -> Option> {\n self.get_provider_by_name(provider_name)\n }\n\n /// List all registered provider names.\n pub fn provider_names(&self) -> Vec {\n self.providers.keys().cloned().collect()\n }\n\n /// Return the name of the default provider, if any.\n pub fn default_provider_name(&self) -> Option<&str> {\n self.default_provider.as_deref()\n }\n}\n\nimpl Default for ProviderRegistry {\n fn default() -> Self {\n Self::new()\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::base::{\n BoxStream, CompletionChunk, CompletionRequest, CompletionResponse, LlmProvider,\n };\n use async_trait::async_trait;\n\n struct MockProvider {\n provider_name: String,\n supported_model: String,\n }\n\n impl MockProvider {\n fn new(name: &str, supported: &str) -> Self {\n Self {\n provider_name: name.to_string(),\n supported_model: supported.to_string(),\n }\n }\n }\n\n #[async_trait]\n impl LlmProvider for MockProvider {\n fn name(&self) -> &str {\n &self.provider_name\n }\n fn default_model(&self) -> &str {\n &self.supported_model\n }\n async fn complete(\n &self,\n _request: CompletionRequest,\n ) -> anyhow::Result {\n Ok(CompletionResponse {\n content: Some(\"mock\".to_string()),\n reasoning_content: None,\n tool_calls: None,\n usage: None,\n finish_reason: None,\n })\n }\n async fn complete_stream(&self, request: CompletionRequest) -> anyhow::Result {\n let response = self.complete(request).await?;\n let chunk = CompletionChunk {\n delta: response.content,\n reasoning_content: None,\n tool_call_deltas: None,\n usage: None,\n done: true,\n };\n Ok(Box::pin(futures::stream::once(async move { Ok(chunk) })))\n }\n fn supports_model(&self, model: &str) -> bool {\n model.contains(&self.supported_model)\n }\n }\n\n #[test]\n fn test_registry_new() {\n let reg = ProviderRegistry::new();\n assert!(reg.provider_names().is_empty());\n }\n\n #[test]\n fn test_registry_register() {\n let mut reg = ProviderRegistry::new();\n reg.register(\"mock\", MockProvider::new(\"mock\", \"test\"));\n let names = reg.provider_names();\n assert_eq!(names.len(), 1);\n assert!(names.contains(&\"mock\".to_string()));\n }\n\n #[test]\n fn test_get_provider_by_name() {\n let mut reg = ProviderRegistry::new();\n reg.register(\"anthropic\", MockProvider::new(\"anthropic\", \"claude\"));\n reg.register(\"openai\", MockProvider::new(\"openai\", \"gpt\"));\n\n let p = reg.get_provider_by_name(\"anthropic\");\n assert!(p.is_some());\n assert_eq!(p.unwrap().name(), \"anthropic\");\n\n // Unknown name returns None (no default set).\n assert!(reg.get_provider_by_name(\"unknown\").is_none());\n }\n\n #[test]\n fn test_get_provider_by_name_fallback_to_default() {\n let mut reg = ProviderRegistry::new();\n reg.register(\"anthropic\", MockProvider::new(\"anthropic\", \"claude\"));\n reg.register(\"openai\", MockProvider::new(\"openai\", \"gpt\"));\n reg.set_default(\"openai\");\n\n // Unknown name falls back to default.\n let p = reg.get_provider_by_name(\"nonexistent\");\n assert!(p.is_some());\n assert_eq!(p.unwrap().name(), \"openai\");\n }\n\n #[test]\n fn test_get_provider() {\n let mut reg = ProviderRegistry::new();\n reg.register(\"opencode_go\", MockProvider::new(\"opencode_go\", \"glm\"));\n\n let p = reg.get_provider(\"opencode_go\");\n assert!(p.is_some());\n assert_eq!(p.unwrap().name(), \"opencode_go\");\n }\n\n #[test]\n fn test_registry_default_provider() {\n let reg = ProviderRegistry::default();\n assert!(reg.provider_names().is_empty());\n assert!(reg.default_provider.is_none());\n }\n\n #[test]\n fn test_set_default_validates() {\n let mut reg = ProviderRegistry::new();\n reg.register(\"anthropic\", MockProvider::new(\"anthropic\", \"claude\"));\n reg.set_default(\"nonexistent\");\n assert!(reg.default_provider.is_none());\n reg.set_default(\"anthropic\");\n assert_eq!(reg.default_provider.as_deref(), Some(\"anthropic\"));\n }\n\n #[test]\n fn test_default_provider_name() {\n let mut reg = ProviderRegistry::new();\n assert!(reg.default_provider_name().is_none());\n reg.register(\"openai\", MockProvider::new(\"openai\", \"gpt\"));\n reg.set_default(\"openai\");\n assert_eq!(reg.default_provider_name(), Some(\"openai\"));\n }\n}\n"}, "1fb18127c547a3e0ba15d2d5ef1cba02efc75a43": {"t": 1785761067.6156285, "n": 15272, "h": "997e3832c1ea5fa8", "c": "//! Provider registry — direct provider lookup by name.\n//!\n//! Users explicitly select both provider and model. No keyword guessing\n//! or prefix-based routing.\n\nuse crate::anthropic::{AnthropicConfig, AnthropicProvider};\nuse crate::base::LlmProvider;\nuse crate::openai_compat::{OpenAiCompatConfig, OpenAiCompatProvider};\nuse anyhow::Result;\nuse kestrel_config::schema::Config;\nuse std::collections::HashMap;\nuse std::sync::Arc;\nuse tokio::sync::Semaphore;\nuse tracing::{debug, info};\n\n/// Registry of LLM providers.\n#[derive(Clone)]\npub struct ProviderRegistry {\n providers: HashMap>,\n default_provider: Option,\n /// Semaphore limiting concurrent in-flight provider requests.\n concurrency_limit: Arc,\n}\n\nimpl ProviderRegistry {\n /// Create a new empty registry.\n pub fn new() -> Self {\n Self {\n providers: HashMap::new(),\n default_provider: None,\n concurrency_limit: Arc::new(Semaphore::new(4)),\n }\n }\n\n /// Create a registry with a custom concurrency limit for in-flight requests.\n pub fn with_concurrency_limit(limit: usize) -> Self {\n Self {\n providers: HashMap::new(),\n default_provider: None,\n concurrency_limit: Arc::new(Semaphore::new(limit)),\n }\n }\n\n /// Acquire a permit from the concurrency semaphore.\n /// Returns a permit that releases on drop.\n pub async fn acquire_permit(&self) -> tokio::sync::OwnedSemaphorePermit {\n self.concurrency_limit\n .clone()\n .acquire_owned()\n .await\n .expect(\"concurrency semaphore should not be closed\")\n }\n\n /// Build the registry from a Config.\n pub fn from_config(config: &Config) -> Result {\n let mut registry = Self::new();\n\n // Register Anthropic provider if configured\n if let Some(entry) = &config.providers.anthropic {\n if let Some(api_key) = &entry.api_key {\n let provider = AnthropicProvider::new(AnthropicConfig {\n api_key: api_key.clone(),\n model: entry\n .model\n .clone()\n .unwrap_or_else(|| \"claude-sonnet-4-20250514\".to_string()),\n api_version: None,\n base_url: entry.base_url.clone(),\n enable_cache_control: true,\n })?;\n registry.register(\"anthropic\", provider);\n info!(\"Registered Anthropic provider\");\n }\n }\n\n // Register OpenAI provider if configured\n if let Some(entry) = &config.providers.openai {\n if let Some(api_key) = &entry.api_key {\n let provider = OpenAiCompatProvider::new(OpenAiCompatConfig {\n api_key: api_key.clone(),\n base_url: entry\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://api.openai.com/v1\".to_string()),\n model: entry.model.clone().unwrap_or_default(),\n organization: None,\n no_proxy: entry.no_proxy.unwrap_or(false),\n })?;\n registry.register(\"openai\", provider);\n info!(\"Registered OpenAI provider\");\n }\n }\n\n // Register DeepSeek provider\n if let Some(entry) = &config.providers.deepseek {\n if let Some(api_key) = &entry.api_key {\n let provider = OpenAiCompatProvider::new(OpenAiCompatConfig {\n api_key: api_key.clone(),\n base_url: entry\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://api.deepseek.com/v1\".to_string()),\n model: entry.model.clone().unwrap_or_default(),\n organization: None,\n no_proxy: entry.no_proxy.unwrap_or(false),\n })?;\n registry.register(\"deepseek\", provider);\n info!(\"Registered DeepSeek provider\");\n }\n }\n\n // Register Groq provider\n if let Some(entry) = &config.providers.groq {\n if let Some(api_key) = &entry.api_key {\n let provider = OpenAiCompatProvider::new(OpenAiCompatConfig {\n api_key: api_key.clone(),\n base_url: entry\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://api.groq.com/openai/v1\".to_string()),\n model: entry.model.clone().unwrap_or_default(),\n organization: None,\n no_proxy: entry.no_proxy.unwrap_or(false),\n })?;\n registry.register(\"groq\", provider);\n info!(\"Registered Groq provider\");\n }\n }\n\n // Register OpenRouter provider\n if let Some(entry) = &config.providers.openrouter {\n if let Some(api_key) = &entry.api_key {\n let provider = OpenAiCompatProvider::new(OpenAiCompatConfig {\n api_key: api_key.clone(),\n base_url: entry\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://openrouter.ai/api/v1\".to_string()),\n model: entry.model.clone().unwrap_or_default(),\n organization: None,\n no_proxy: entry.no_proxy.unwrap_or(false),\n })?;\n registry.register(\"openrouter\", provider);\n info!(\"Registered OpenRouter provider\");\n }\n }\n\n // Register Ollama provider (localhost — always skip proxy)\n if let Some(entry) = &config.providers.ollama {\n let provider = OpenAiCompatProvider::new(OpenAiCompatConfig {\n api_key: entry.api_key.clone().unwrap_or_default(),\n base_url: entry\n .base_url\n .clone()\n .unwrap_or_else(|| \"http://localhost:11434/v1\".to_string()),\n model: entry.model.clone().unwrap_or_default(),\n organization: None,\n no_proxy: true,\n })?;\n registry.register(\"ollama\", provider);\n info!(\"Registered Ollama provider\");\n }\n\n // Register OpenCode Go provider (OpenAI-compatible endpoint)\n if let Some(entry) = &config.providers.opencode_go {\n if let Some(api_key) = &entry.api_key {\n let provider = OpenAiCompatProvider::new(OpenAiCompatConfig {\n api_key: api_key.clone(),\n base_url: entry\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://opencode.ai/zen/go/v1\".to_string()),\n model: entry.model.clone().unwrap_or_default(),\n organization: None,\n no_proxy: entry.no_proxy.unwrap_or(false),\n })?;\n registry.register(\"opencode_go\", provider);\n info!(\"Registered OpenCode Go provider\");\n }\n }\n\n // Register GLM Coding Plan provider (智谱 — OpenAI-compatible endpoint)\n if let Some(entry) = &config.providers.glm_coding_plan {\n if let Some(api_key) = &entry.api_key {\n let provider = OpenAiCompatProvider::new(OpenAiCompatConfig {\n api_key: api_key.clone(),\n base_url: entry.base_url.clone().unwrap_or_else(|| {\n \"https://open.bigmodel.cn/api/coding/paas/v4\".to_string()\n }),\n model: entry.model.clone().unwrap_or_default(),\n organization: None,\n no_proxy: entry.no_proxy.unwrap_or(false),\n })?;\n registry.register(\"glm_coding_plan\", provider);\n info!(\"Registered GLM Coding Plan provider\");\n }\n }\n\n // Register custom providers\n for custom in &config.custom_providers {\n let provider = OpenAiCompatProvider::new(OpenAiCompatConfig {\n api_key: custom.api_key.clone().unwrap_or_default(),\n base_url: custom.base_url.clone(),\n model: custom.model_patterns.first().cloned().unwrap_or_default(),\n organization: None,\n no_proxy: custom.no_proxy.unwrap_or(false),\n })?;\n registry.register(&custom.name, provider);\n info!(\"Registered custom provider: {}\", custom.name);\n }\n\n // Set default provider from explicit config, otherwise pick first registered.\n let default = if let Some(ref name) = config.agent.provider {\n if registry.providers.contains_key(name) {\n Some(name.clone())\n } else {\n registry.providers.keys().next().cloned()\n }\n } else {\n registry.providers.keys().next().cloned()\n };\n registry.default_provider = default;\n if let Some(ref name) = registry.default_provider {\n debug!(\"Default provider: {}\", name);\n }\n\n Ok(registry)\n }\n\n /// Register a provider.\n pub fn register(&mut self, name: &str, provider: impl LlmProvider + 'static) {\n self.providers.insert(name.to_string(), Arc::new(provider));\n }\n\n /// Set the default provider by name.\n pub fn set_default(&mut self, name: &str) {\n if self.providers.contains_key(name) {\n self.default_provider = Some(name.to_string());\n }\n }\n\n /// Get a provider by name, falling back to the default provider.\n pub fn get_provider_by_name(&self, name: &str) -> Option> {\n self.providers.get(name).cloned().or_else(|| {\n self.default_provider\n .as_ref()\n .and_then(|n| self.providers.get(n).cloned())\n })\n }\n\n /// Get the provider for the given provider name string.\n ///\n /// `provider_name` must be an exact provider name (e.g. \"opencode_go\", \"anthropic\").\n /// Returns the provider, or the default provider as fallback.\n pub fn get_provider(&self, provider_name: &str) -> Option> {\n self.get_provider_by_name(provider_name)\n }\n\n /// List all registered provider names.\n pub fn provider_names(&self) -> Vec {\n self.providers.keys().cloned().collect()\n }\n\n /// Return the name of the default provider, if any.\n pub fn default_provider_name(&self) -> Option<&str> {\n self.default_provider.as_deref()\n }\n}\n\nimpl Default for ProviderRegistry {\n fn default() -> Self {\n Self::new()\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::base::{\n BoxStream, CompletionChunk, CompletionRequest, CompletionResponse, LlmProvider,\n };\n use async_trait::async_trait;\n\n struct MockProvider {\n provider_name: String,\n supported_model: String,\n }\n\n impl MockProvider {\n fn new(name: &str, supported: &str) -> Self {\n Self {\n provider_name: name.to_string(),\n supported_model: supported.to_string(),\n }\n }\n }\n\n #[async_trait]\n impl LlmProvider for MockProvider {\n fn name(&self) -> &str {\n &self.provider_name\n }\n fn default_model(&self) -> &str {\n &self.supported_model\n }\n async fn complete(\n &self,\n _request: CompletionRequest,\n ) -> anyhow::Result {\n Ok(CompletionResponse {\n content: Some(\"mock\".to_string()),\n reasoning_content: None,\n tool_calls: None,\n usage: None,\n finish_reason: None,\n })\n }\n async fn complete_stream(&self, request: CompletionRequest) -> anyhow::Result {\n let response = self.complete(request).await?;\n let chunk = CompletionChunk {\n delta: response.content,\n reasoning_content: None,\n tool_call_deltas: None,\n usage: None,\n done: true,\n };\n Ok(Box::pin(futures::stream::once(async move { Ok(chunk) })))\n }\n fn supports_model(&self, model: &str) -> bool {\n model.contains(&self.supported_model)\n }\n }\n\n #[test]\n fn test_registry_new() {\n let reg = ProviderRegistry::new();\n assert!(reg.provider_names().is_empty());\n }\n\n #[test]\n fn test_registry_register() {\n let mut reg = ProviderRegistry::new();\n reg.register(\"mock\", MockProvider::new(\"mock\", \"test\"));\n let names = reg.provider_names();\n assert_eq!(names.len(), 1);\n assert!(names.contains(&\"mock\".to_string()));\n }\n\n #[test]\n fn test_get_provider_by_name() {\n let mut reg = ProviderRegistry::new();\n reg.register(\"anthropic\", MockProvider::new(\"anthropic\", \"claude\"));\n reg.register(\"openai\", MockProvider::new(\"openai\", \"gpt\"));\n\n let p = reg.get_provider_by_name(\"anthropic\");\n assert!(p.is_some());\n assert_eq!(p.unwrap().name(), \"anthropic\");\n\n // Unknown name returns None (no default set).\n assert!(reg.get_provider_by_name(\"unknown\").is_none());\n }\n\n #[test]\n fn test_get_provider_by_name_fallback_to_default() {\n let mut reg = ProviderRegistry::new();\n reg.register(\"anthropic\", MockProvider::new(\"anthropic\", \"claude\"));\n reg.register(\"openai\", MockProvider::new(\"openai\", \"gpt\"));\n reg.set_default(\"openai\");\n\n // Unknown name falls back to default.\n let p = reg.get_provider_by_name(\"nonexistent\");\n assert!(p.is_some());\n assert_eq!(p.unwrap().name(), \"openai\");\n }\n\n #[test]\n fn test_get_provider() {\n let mut reg = ProviderRegistry::new();\n reg.register(\"opencode_go\", MockProvider::new(\"opencode_go\", \"glm\"));\n\n let p = reg.get_provider(\"opencode_go\");\n assert!(p.is_some());\n assert_eq!(p.unwrap().name(), \"opencode_go\");\n }\n\n #[test]\n fn test_registry_default_provider() {\n let reg = ProviderRegistry::default();\n assert!(reg.provider_names().is_empty());\n assert!(reg.default_provider.is_none());\n }\n\n #[test]\n fn test_set_default_validates() {\n let mut reg = ProviderRegistry::new();\n reg.register(\"anthropic\", MockProvider::new(\"anthropic\", \"claude\"));\n reg.set_default(\"nonexistent\");\n assert!(reg.default_provider.is_none());\n reg.set_default(\"anthropic\");\n assert_eq!(reg.default_provider.as_deref(), Some(\"anthropic\"));\n }\n\n #[test]\n fn test_default_provider_name() {\n let mut reg = ProviderRegistry::new();\n assert!(reg.default_provider_name().is_none());\n reg.register(\"openai\", MockProvider::new(\"openai\", \"gpt\"));\n reg.set_default(\"openai\");\n assert_eq!(reg.default_provider_name(), Some(\"openai\"));\n }\n}\n"}, "b3d4beecf03bcfb3cbc021e725b8044ea5f4d4b1": {"t": 1785761069.3725905, "n": 15224, "h": "25f8c18901d45b37", "c": "//! Provider registry — direct provider lookup by name.\n//!\n//! Users explicitly select both provider and model. No keyword guessing\n//! or prefix-based routing.\n\nuse crate::anthropic::{AnthropicConfig, AnthropicProvider};\nuse crate::base::LlmProvider;\nuse crate::openai_compat::{OpenAiCompatConfig, OpenAiCompatProvider};\nuse anyhow::Result;\nuse kestrel_config::schema::Config;\nuse std::collections::HashMap;\nuse std::sync::Arc;\nuse tokio::sync::Semaphore;\nuse tracing::{debug, info};\n\n/// Registry of LLM providers.\n#[derive(Clone)]\npub struct ProviderRegistry {\n providers: HashMap>,\n default_provider: Option,\n /// Semaphore limiting concurrent in-flight provider requests.\n concurrency_limit: Arc,\n}\n\nimpl ProviderRegistry {\n /// Create a new empty registry.\n pub fn new() -> Self {\n Self {\n providers: HashMap::new(),\n default_provider: None,\n concurrency_limit: Arc::new(Semaphore::new(4)),\n }\n }\n\n /// Create a registry with a custom concurrency limit for in-flight requests.\n pub fn with_concurrency_limit(limit: usize) -> Self {\n Self {\n providers: HashMap::new(),\n default_provider: None,\n concurrency_limit: Arc::new(Semaphore::new(limit)),\n }\n }\n\n /// Acquire a permit from the concurrency semaphore.\n /// Returns a permit that releases on drop.\n pub async fn acquire_permit(&self) -> tokio::sync::OwnedSemaphorePermit {\n self.concurrency_limit\n .clone()\n .acquire_owned()\n .await\n .expect(\"concurrency semaphore should not be closed\")\n }\n\n /// Build the registry from a Config.\n pub fn from_config(config: &Config) -> Result {\n let mut registry = Self::new();\n\n // Register Anthropic provider if configured\n if let Some(entry) = &config.providers.anthropic {\n if let Some(api_key) = &entry.api_key {\n let provider = AnthropicProvider::new(AnthropicConfig {\n api_key: api_key.clone(),\n model: entry\n .model\n .clone()\n .unwrap_or_else(|| \"claude-sonnet-4-20250514\".to_string()),\n api_version: None,\n base_url: entry.base_url.clone(),\n })?;\n registry.register(\"anthropic\", provider);\n info!(\"Registered Anthropic provider\");\n }\n }\n\n // Register OpenAI provider if configured\n if let Some(entry) = &config.providers.openai {\n if let Some(api_key) = &entry.api_key {\n let provider = OpenAiCompatProvider::new(OpenAiCompatConfig {\n api_key: api_key.clone(),\n base_url: entry\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://api.openai.com/v1\".to_string()),\n model: entry.model.clone().unwrap_or_default(),\n organization: None,\n no_proxy: entry.no_proxy.unwrap_or(false),\n })?;\n registry.register(\"openai\", provider);\n info!(\"Registered OpenAI provider\");\n }\n }\n\n // Register DeepSeek provider\n if let Some(entry) = &config.providers.deepseek {\n if let Some(api_key) = &entry.api_key {\n let provider = OpenAiCompatProvider::new(OpenAiCompatConfig {\n api_key: api_key.clone(),\n base_url: entry\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://api.deepseek.com/v1\".to_string()),\n model: entry.model.clone().unwrap_or_default(),\n organization: None,\n no_proxy: entry.no_proxy.unwrap_or(false),\n })?;\n registry.register(\"deepseek\", provider);\n info!(\"Registered DeepSeek provider\");\n }\n }\n\n // Register Groq provider\n if let Some(entry) = &config.providers.groq {\n if let Some(api_key) = &entry.api_key {\n let provider = OpenAiCompatProvider::new(OpenAiCompatConfig {\n api_key: api_key.clone(),\n base_url: entry\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://api.groq.com/openai/v1\".to_string()),\n model: entry.model.clone().unwrap_or_default(),\n organization: None,\n no_proxy: entry.no_proxy.unwrap_or(false),\n })?;\n registry.register(\"groq\", provider);\n info!(\"Registered Groq provider\");\n }\n }\n\n // Register OpenRouter provider\n if let Some(entry) = &config.providers.openrouter {\n if let Some(api_key) = &entry.api_key {\n let provider = OpenAiCompatProvider::new(OpenAiCompatConfig {\n api_key: api_key.clone(),\n base_url: entry\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://openrouter.ai/api/v1\".to_string()),\n model: entry.model.clone().unwrap_or_default(),\n organization: None,\n no_proxy: entry.no_proxy.unwrap_or(false),\n })?;\n registry.register(\"openrouter\", provider);\n info!(\"Registered OpenRouter provider\");\n }\n }\n\n // Register Ollama provider (localhost — always skip proxy)\n if let Some(entry) = &config.providers.ollama {\n let provider = OpenAiCompatProvider::new(OpenAiCompatConfig {\n api_key: entry.api_key.clone().unwrap_or_default(),\n base_url: entry\n .base_url\n .clone()\n .unwrap_or_else(|| \"http://localhost:11434/v1\".to_string()),\n model: entry.model.clone().unwrap_or_default(),\n organization: None,\n no_proxy: true,\n })?;\n registry.register(\"ollama\", provider);\n info!(\"Registered Ollama provider\");\n }\n\n // Register OpenCode Go provider (OpenAI-compatible endpoint)\n if let Some(entry) = &config.providers.opencode_go {\n if let Some(api_key) = &entry.api_key {\n let provider = OpenAiCompatProvider::new(OpenAiCompatConfig {\n api_key: api_key.clone(),\n base_url: entry\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://opencode.ai/zen/go/v1\".to_string()),\n model: entry.model.clone().unwrap_or_default(),\n organization: None,\n no_proxy: entry.no_proxy.unwrap_or(false),\n })?;\n registry.register(\"opencode_go\", provider);\n info!(\"Registered OpenCode Go provider\");\n }\n }\n\n // Register GLM Coding Plan provider (智谱 — OpenAI-compatible endpoint)\n if let Some(entry) = &config.providers.glm_coding_plan {\n if let Some(api_key) = &entry.api_key {\n let provider = OpenAiCompatProvider::new(OpenAiCompatConfig {\n api_key: api_key.clone(),\n base_url: entry.base_url.clone().unwrap_or_else(|| {\n \"https://open.bigmodel.cn/api/coding/paas/v4\".to_string()\n }),\n model: entry.model.clone().unwrap_or_default(),\n organization: None,\n no_proxy: entry.no_proxy.unwrap_or(false),\n })?;\n registry.register(\"glm_coding_plan\", provider);\n info!(\"Registered GLM Coding Plan provider\");\n }\n }\n\n // Register custom providers\n for custom in &config.custom_providers {\n let provider = OpenAiCompatProvider::new(OpenAiCompatConfig {\n api_key: custom.api_key.clone().unwrap_or_default(),\n base_url: custom.base_url.clone(),\n model: custom.model_patterns.first().cloned().unwrap_or_default(),\n organization: None,\n no_proxy: custom.no_proxy.unwrap_or(false),\n })?;\n registry.register(&custom.name, provider);\n info!(\"Registered custom provider: {}\", custom.name);\n }\n\n // Set default provider from explicit config, otherwise pick first registered.\n let default = if let Some(ref name) = config.agent.provider {\n if registry.providers.contains_key(name) {\n Some(name.clone())\n } else {\n registry.providers.keys().next().cloned()\n }\n } else {\n registry.providers.keys().next().cloned()\n };\n registry.default_provider = default;\n if let Some(ref name) = registry.default_provider {\n debug!(\"Default provider: {}\", name);\n }\n\n Ok(registry)\n }\n\n /// Register a provider.\n pub fn register(&mut self, name: &str, provider: impl LlmProvider + 'static) {\n self.providers.insert(name.to_string(), Arc::new(provider));\n }\n\n /// Set the default provider by name.\n pub fn set_default(&mut self, name: &str) {\n if self.providers.contains_key(name) {\n self.default_provider = Some(name.to_string());\n }\n }\n\n /// Get a provider by name, falling back to the default provider.\n pub fn get_provider_by_name(&self, name: &str) -> Option> {\n self.providers.get(name).cloned().or_else(|| {\n self.default_provider\n .as_ref()\n .and_then(|n| self.providers.get(n).cloned())\n })\n }\n\n /// Get the provider for the given provider name string.\n ///\n /// `provider_name` must be an exact provider name (e.g. \"opencode_go\", \"anthropic\").\n /// Returns the provider, or the default provider as fallback.\n pub fn get_provider(&self, provider_name: &str) -> Option> {\n self.get_provider_by_name(provider_name)\n }\n\n /// List all registered provider names.\n pub fn provider_names(&self) -> Vec {\n self.providers.keys().cloned().collect()\n }\n\n /// Return the name of the default provider, if any.\n pub fn default_provider_name(&self) -> Option<&str> {\n self.default_provider.as_deref()\n }\n}\n\nimpl Default for ProviderRegistry {\n fn default() -> Self {\n Self::new()\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::base::{\n BoxStream, CompletionChunk, CompletionRequest, CompletionResponse, LlmProvider,\n };\n use async_trait::async_trait;\n\n struct MockProvider {\n provider_name: String,\n supported_model: String,\n }\n\n impl MockProvider {\n fn new(name: &str, supported: &str) -> Self {\n Self {\n provider_name: name.to_string(),\n supported_model: supported.to_string(),\n }\n }\n }\n\n #[async_trait]\n impl LlmProvider for MockProvider {\n fn name(&self) -> &str {\n &self.provider_name\n }\n fn default_model(&self) -> &str {\n &self.supported_model\n }\n async fn complete(\n &self,\n _request: CompletionRequest,\n ) -> anyhow::Result {\n Ok(CompletionResponse {\n content: Some(\"mock\".to_string()),\n reasoning_content: None,\n tool_calls: None,\n usage: None,\n finish_reason: None,\n })\n }\n async fn complete_stream(&self, request: CompletionRequest) -> anyhow::Result {\n let response = self.complete(request).await?;\n let chunk = CompletionChunk {\n delta: response.content,\n reasoning_content: None,\n tool_call_deltas: None,\n usage: None,\n done: true,\n };\n Ok(Box::pin(futures::stream::once(async move { Ok(chunk) })))\n }\n fn supports_model(&self, model: &str) -> bool {\n model.contains(&self.supported_model)\n }\n }\n\n #[test]\n fn test_registry_new() {\n let reg = ProviderRegistry::new();\n assert!(reg.provider_names().is_empty());\n }\n\n #[test]\n fn test_registry_register() {\n let mut reg = ProviderRegistry::new();\n reg.register(\"mock\", MockProvider::new(\"mock\", \"test\"));\n let names = reg.provider_names();\n assert_eq!(names.len(), 1);\n assert!(names.contains(&\"mock\".to_string()));\n }\n\n #[test]\n fn test_get_provider_by_name() {\n let mut reg = ProviderRegistry::new();\n reg.register(\"anthropic\", MockProvider::new(\"anthropic\", \"claude\"));\n reg.register(\"openai\", MockProvider::new(\"openai\", \"gpt\"));\n\n let p = reg.get_provider_by_name(\"anthropic\");\n assert!(p.is_some());\n assert_eq!(p.unwrap().name(), \"anthropic\");\n\n // Unknown name returns None (no default set).\n assert!(reg.get_provider_by_name(\"unknown\").is_none());\n }\n\n #[test]\n fn test_get_provider_by_name_fallback_to_default() {\n let mut reg = ProviderRegistry::new();\n reg.register(\"anthropic\", MockProvider::new(\"anthropic\", \"claude\"));\n reg.register(\"openai\", MockProvider::new(\"openai\", \"gpt\"));\n reg.set_default(\"openai\");\n\n // Unknown name falls back to default.\n let p = reg.get_provider_by_name(\"nonexistent\");\n assert!(p.is_some());\n assert_eq!(p.unwrap().name(), \"openai\");\n }\n\n #[test]\n fn test_get_provider() {\n let mut reg = ProviderRegistry::new();\n reg.register(\"opencode_go\", MockProvider::new(\"opencode_go\", \"glm\"));\n\n let p = reg.get_provider(\"opencode_go\");\n assert!(p.is_some());\n assert_eq!(p.unwrap().name(), \"opencode_go\");\n }\n\n #[test]\n fn test_registry_default_provider() {\n let reg = ProviderRegistry::default();\n assert!(reg.provider_names().is_empty());\n assert!(reg.default_provider.is_none());\n }\n\n #[test]\n fn test_set_default_validates() {\n let mut reg = ProviderRegistry::new();\n reg.register(\"anthropic\", MockProvider::new(\"anthropic\", \"claude\"));\n reg.set_default(\"nonexistent\");\n assert!(reg.default_provider.is_none());\n reg.set_default(\"anthropic\");\n assert_eq!(reg.default_provider.as_deref(), Some(\"anthropic\"));\n }\n\n #[test]\n fn test_default_provider_name() {\n let mut reg = ProviderRegistry::new();\n assert!(reg.default_provider_name().is_none());\n reg.register(\"openai\", MockProvider::new(\"openai\", \"gpt\"));\n reg.set_default(\"openai\");\n assert_eq!(reg.default_provider_name(), Some(\"openai\"));\n }\n}\n"}, "5265d056ac60948d16828c8fe9f629430f4112a0": {"t": 1785761072.7270203, "n": 15251, "h": "7dca5c65d2294cd3", "c": "//! Provider registry — direct provider lookup by name.\n//!\n//! Users explicitly select both provider and model. No keyword guessing\n//! or prefix-based routing.\n\nuse crate::anthropic::{AnthropicConfig, AnthropicProvider};\nuse crate::base::LlmProvider;\nuse crate::openai_compat::{OpenAiCompatConfig, OpenAiCompatProvider};\nuse anyhow::Result;\nuse kestrel_config::schema::Config;\nuse std::collections::HashMap;\nuse std::sync::Arc;\nuse tokio::sync::Semaphore;\nuse tracing::{debug, info};\n\n/// Registry of LLM providers.\n#[derive(Clone)]\npub struct ProviderRegistry {\n providers: HashMap>,\n default_provider: Option,\n /// Semaphore limiting concurrent in-flight provider requests.\n concurrency_limit: Arc,\n}\n\nimpl ProviderRegistry {\n /// Create a new empty registry.\n pub fn new() -> Self {\n Self {\n providers: HashMap::new(),\n default_provider: None,\n concurrency_limit: Arc::new(Semaphore::new(4)),\n }\n }\n\n /// Create a registry with a custom concurrency limit for in-flight requests.\n pub fn with_concurrency_limit(limit: usize) -> Self {\n Self {\n providers: HashMap::new(),\n default_provider: None,\n concurrency_limit: Arc::new(Semaphore::new(limit)),\n }\n }\n\n /// Acquire a permit from the concurrency semaphore.\n /// Returns a permit that releases on drop.\n pub async fn acquire_permit(&self) -> tokio::sync::OwnedSemaphorePermit {\n self.concurrency_limit\n .clone()\n .acquire_owned()\n .await\n .expect(\"concurrency semaphore should not be closed\")\n }\n\n /// Build the registry from a Config.\n pub fn from_config(config: &Config) -> Result {\n let mut registry = Self::new();\n\n // Register Anthropic provider if configured\n if let Some(entry) = &config.providers.anthropic {\n if let Some(api_key) = &entry.api_key {\n let provider = AnthropicProvider::new(AnthropicConfig {\n api_key: api_key.clone(),\n model: entry\n .model\n .clone()\n .unwrap_or_else(|| \"claude-sonnet-4-20250514\".to_string()),\n api_version: None,\n base_url: entry.base_url.clone(),\n })?;\n registry.register(\"anthropic\", provider);\n info!(\"Registered Anthropic provider\");\n }\n }\n\n // Register OpenAI provider if configured\n if let Some(entry) = &config.providers.openai {\n if let Some(api_key) = &entry.api_key {\n let provider = OpenAiCompatProvider::new(OpenAiCompatConfig {\n api_key: api_key.clone(),\n base_url: entry\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://api.openai.com/v1\".to_string()),\n model: entry.model.clone().unwrap_or_default(),\n organization: None,\n no_proxy: entry.no_proxy.unwrap_or(false),\n })?;\n registry.register(\"openai\", provider);\n info!(\"Registered OpenAI provider\");\n }\n }\n\n // Register DeepSeek provider\n if let Some(entry) = &config.providers.deepseek {\n if let Some(api_key) = &entry.api_key {\n let provider = OpenAiCompatProvider::new(OpenAiCompatConfig {\n api_key: api_key.clone(),\n base_url: entry\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://api.deepseek.com/v1\".to_string()),\n model: entry.model.clone().unwrap_or_default(),\n organization: None,\n no_proxy: entry.no_proxy.unwrap_or(false),\n })?;\n registry.register(\"deepseek\", provider);\n info!(\"Registered DeepSeek provider\");\n }\n }\n\n // Register Groq provider\n if let Some(entry) = &config.providers.groq {\n if let Some(api_key) = &entry.api_key {\n let provider = OpenAiCompatProvider::new(OpenAiCompatConfig {\n api_key: api_key.clone(),\n base_url: entry\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://api.groq.com/openai/v1\".to_string()),\n model: entry.model.clone().unwrap_or_default(),\n organization: None,\n no_proxy: entry.no_proxy.unwrap_or(false),\n })?;\n registry.register(\"groq\", provider);\n info!(\"Registered Groq provider\");\n }\n }\n\n // Register OpenRouter provider\n if let Some(entry) = &config.providers.openrouter {\n if let Some(api_key) = &entry.api_key {\n let provider = OpenAiCompatProvider::new(OpenAiCompatConfig {\n api_key: api_key.clone(),\n base_url: entry\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://openrouter.ai/api/v1\".to_string()),\n model: entry.model.clone().unwrap_or_default(),\n organization: None,\n no_proxy: entry.no_proxy.unwrap_or(false),\n })?;\n registry.register(\"openrouter\", provider);\n info!(\"Registered OpenRouter provider\");\n }\n }\n\n // Register Ollama provider (localhost — always skip proxy)\n if let Some(entry) = &config.providers.ollama {\n let provider = OpenAiCompatProvider::new(OpenAiCompatConfig {\n api_key: entry.api_key.clone().unwrap_or_default(),\n base_url: entry\n .base_url\n .clone()\n .unwrap_or_else(|| \"http://localhost:11434/v1\".to_string()),\n model: entry.model.clone().unwrap_or_default(),\n organization: None,\n no_proxy: true,\n })?;\n registry.register(\"ollama\", provider);\n info!(\"Registered Ollama provider\");\n }\n\n // Register OpenCode Go provider (OpenAI-compatible endpoint)\n if let Some(entry) = &config.providers.opencode_go {\n if let Some(api_key) = &entry.api_key {\n let provider = OpenAiCompatProvider::new(OpenAiCompatConfig {\n api_key: api_key.clone(),\n base_url: entry\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://opencode.ai/zen/go/v1\".to_string()),\n model: entry.model.clone().unwrap_or_default(),\n organization: None,\n no_proxy: entry.no_proxy.unwrap_or(false),\n })?;\n registry.register(\"opencode_go\", provider);\n info!(\"Registered OpenCode Go provider\");\n }\n }\n\n // Register GLM Coding Plan provider (智谱 — OpenAI-compatible endpoint)\n if let Some(entry) = &config.providers.glm_coding_plan {\n if let Some(api_key) = &entry.api_key {\n let provider = OpenAiCompatProvider::new(OpenAiCompatConfig {\n api_key: api_key.clone(),\n base_url: entry\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://open.bigmodel.cn/api/coding/paas/v4\".to_string()),\n model: entry.model.clone().unwrap_or_default(),\n organization: None,\n no_proxy: entry.no_proxy.unwrap_or(false),\n })?;\n registry.register(\"glm_coding_plan\", provider);\n info!(\"Registered GLM Coding Plan provider\");\n }\n }\n\n // Register custom providers\n for custom in &config.custom_providers {\n let provider = OpenAiCompatProvider::new(OpenAiCompatConfig {\n api_key: custom.api_key.clone().unwrap_or_default(),\n base_url: custom.base_url.clone(),\n model: custom.model_patterns.first().cloned().unwrap_or_default(),\n organization: None,\n no_proxy: custom.no_proxy.unwrap_or(false),\n })?;\n registry.register(&custom.name, provider);\n info!(\"Registered custom provider: {}\", custom.name);\n }\n\n // Set default provider from explicit config, otherwise pick first registered.\n let default = if let Some(ref name) = config.agent.provider {\n if registry.providers.contains_key(name) {\n Some(name.clone())\n } else {\n registry.providers.keys().next().cloned()\n }\n } else {\n registry.providers.keys().next().cloned()\n };\n registry.default_provider = default;\n if let Some(ref name) = registry.default_provider {\n debug!(\"Default provider: {}\", name);\n }\n\n Ok(registry)\n }\n\n /// Register a provider.\n pub fn register(&mut self, name: &str, provider: impl LlmProvider + 'static) {\n self.providers.insert(name.to_string(), Arc::new(provider));\n }\n\n /// Set the default provider by name.\n pub fn set_default(&mut self, name: &str) {\n if self.providers.contains_key(name) {\n self.default_provider = Some(name.to_string());\n }\n }\n\n /// Get a provider by name, falling back to the default provider.\n pub fn get_provider_by_name(&self, name: &str) -> Option> {\n self.providers.get(name).cloned().or_else(|| {\n self.default_provider\n .as_ref()\n .and_then(|n| self.providers.get(n).cloned())\n })\n }\n\n /// Get the provider for the given provider name string.\n ///\n /// `provider_name` must be an exact provider name (e.g. \"opencode_go\", \"anthropic\").\n /// Returns the provider, or the default provider as fallback.\n pub fn get_provider(&self, provider_name: &str) -> Option> {\n self.get_provider_by_name(provider_name)\n }\n\n /// List all registered provider names.\n pub fn provider_names(&self) -> Vec {\n self.providers.keys().cloned().collect()\n }\n\n /// Return the name of the default provider, if any.\n pub fn default_provider_name(&self) -> Option<&str> {\n self.default_provider.as_deref()\n }\n}\n\nimpl Default for ProviderRegistry {\n fn default() -> Self {\n Self::new()\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::base::{\n BoxStream, CompletionChunk, CompletionRequest, CompletionResponse, LlmProvider,\n };\n use async_trait::async_trait;\n\n struct MockProvider {\n provider_name: String,\n supported_model: String,\n }\n\n impl MockProvider {\n fn new(name: &str, supported: &str) -> Self {\n Self {\n provider_name: name.to_string(),\n supported_model: supported.to_string(),\n }\n }\n }\n\n #[async_trait]\n impl LlmProvider for MockProvider {\n fn name(&self) -> &str {\n &self.provider_name\n }\n fn default_model(&self) -> &str {\n &self.supported_model\n }\n async fn complete(\n &self,\n _request: CompletionRequest,\n ) -> anyhow::Result {\n Ok(CompletionResponse {\n content: Some(\"mock\".to_string()),\n reasoning_content: None,\n tool_calls: None,\n usage: None,\n finish_reason: None,\n })\n }\n async fn complete_stream(&self, request: CompletionRequest) -> anyhow::Result {\n let response = self.complete(request).await?;\n let chunk = CompletionChunk {\n delta: response.content,\n reasoning_content: None,\n tool_call_deltas: None,\n usage: None,\n done: true,\n };\n Ok(Box::pin(futures::stream::once(async move { Ok(chunk) })))\n }\n fn supports_model(&self, model: &str) -> bool {\n model.contains(&self.supported_model)\n }\n }\n\n #[test]\n fn test_registry_new() {\n let reg = ProviderRegistry::new();\n assert!(reg.provider_names().is_empty());\n }\n\n #[test]\n fn test_registry_register() {\n let mut reg = ProviderRegistry::new();\n reg.register(\"mock\", MockProvider::new(\"mock\", \"test\"));\n let names = reg.provider_names();\n assert_eq!(names.len(), 1);\n assert!(names.contains(&\"mock\".to_string()));\n }\n\n #[test]\n fn test_get_provider_by_name() {\n let mut reg = ProviderRegistry::new();\n reg.register(\"anthropic\", MockProvider::new(\"anthropic\", \"claude\"));\n reg.register(\"openai\", MockProvider::new(\"openai\", \"gpt\"));\n\n let p = reg.get_provider_by_name(\"anthropic\");\n assert!(p.is_some());\n assert_eq!(p.unwrap().name(), \"anthropic\");\n\n // Unknown name returns None (no default set).\n assert!(reg.get_provider_by_name(\"unknown\").is_none());\n }\n\n #[test]\n fn test_get_provider_by_name_fallback_to_default() {\n let mut reg = ProviderRegistry::new();\n reg.register(\"anthropic\", MockProvider::new(\"anthropic\", \"claude\"));\n reg.register(\"openai\", MockProvider::new(\"openai\", \"gpt\"));\n reg.set_default(\"openai\");\n\n // Unknown name falls back to default.\n let p = reg.get_provider_by_name(\"nonexistent\");\n assert!(p.is_some());\n assert_eq!(p.unwrap().name(), \"openai\");\n }\n\n #[test]\n fn test_get_provider() {\n let mut reg = ProviderRegistry::new();\n reg.register(\"opencode_go\", MockProvider::new(\"opencode_go\", \"glm\"));\n\n let p = reg.get_provider(\"opencode_go\");\n assert!(p.is_some());\n assert_eq!(p.unwrap().name(), \"opencode_go\");\n }\n\n #[test]\n fn test_registry_default_provider() {\n let reg = ProviderRegistry::default();\n assert!(reg.provider_names().is_empty());\n assert!(reg.default_provider.is_none());\n }\n\n #[test]\n fn test_set_default_validates() {\n let mut reg = ProviderRegistry::new();\n reg.register(\"anthropic\", MockProvider::new(\"anthropic\", \"claude\"));\n reg.set_default(\"nonexistent\");\n assert!(reg.default_provider.is_none());\n reg.set_default(\"anthropic\");\n assert_eq!(reg.default_provider.as_deref(), Some(\"anthropic\"));\n }\n\n #[test]\n fn test_default_provider_name() {\n let mut reg = ProviderRegistry::new();\n assert!(reg.default_provider_name().is_none());\n reg.register(\"openai\", MockProvider::new(\"openai\", \"gpt\"));\n reg.set_default(\"openai\");\n assert_eq!(reg.default_provider_name(), Some(\"openai\"));\n }\n}\n"}, "d946239cad027612d384aa4e5564dd96a97f2454": {"t": 1785761074.111045, "n": 14382, "h": "8c7f78fdc8a6118a", "c": "//! Provider registry — direct provider lookup by name.\n//!\n//! Users explicitly select both provider and model. No keyword guessing\n//! or prefix-based routing.\n\nuse crate::anthropic::{AnthropicConfig, AnthropicProvider};\nuse crate::base::LlmProvider;\nuse crate::openai_compat::{OpenAiCompatConfig, OpenAiCompatProvider};\nuse anyhow::Result;\nuse kestrel_config::schema::Config;\nuse std::collections::HashMap;\nuse std::sync::Arc;\nuse tokio::sync::Semaphore;\nuse tracing::{debug, info};\n\n/// Registry of LLM providers.\n#[derive(Clone)]\npub struct ProviderRegistry {\n providers: HashMap>,\n default_provider: Option,\n /// Semaphore limiting concurrent in-flight provider requests.\n concurrency_limit: Arc,\n}\n\nimpl ProviderRegistry {\n /// Create a new empty registry.\n pub fn new() -> Self {\n Self {\n providers: HashMap::new(),\n default_provider: None,\n concurrency_limit: Arc::new(Semaphore::new(4)),\n }\n }\n\n /// Create a registry with a custom concurrency limit for in-flight requests.\n pub fn with_concurrency_limit(limit: usize) -> Self {\n Self {\n providers: HashMap::new(),\n default_provider: None,\n concurrency_limit: Arc::new(Semaphore::new(limit)),\n }\n }\n\n /// Acquire a permit from the concurrency semaphore.\n /// Returns a permit that releases on drop.\n pub async fn acquire_permit(&self) -> tokio::sync::OwnedSemaphorePermit {\n self.concurrency_limit\n .clone()\n .acquire_owned()\n .await\n .expect(\"concurrency semaphore should not be closed\")\n }\n\n /// Build the registry from a Config.\n pub fn from_config(config: &Config) -> Result {\n let mut registry = Self::new();\n\n // Register Anthropic provider if configured\n if let Some(entry) = &config.providers.anthropic {\n if let Some(api_key) = &entry.api_key {\n let provider = AnthropicProvider::new(AnthropicConfig {\n api_key: api_key.clone(),\n model: entry\n .model\n .clone()\n .unwrap_or_else(|| \"claude-sonnet-4-20250514\".to_string()),\n api_version: None,\n base_url: entry.base_url.clone(),\n })?;\n registry.register(\"anthropic\", provider);\n info!(\"Registered Anthropic provider\");\n }\n }\n\n // Register OpenAI provider if configured\n if let Some(entry) = &config.providers.openai {\n if let Some(api_key) = &entry.api_key {\n let provider = OpenAiCompatProvider::new(OpenAiCompatConfig {\n api_key: api_key.clone(),\n base_url: entry\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://api.openai.com/v1\".to_string()),\n model: entry.model.clone().unwrap_or_default(),\n organization: None,\n no_proxy: entry.no_proxy.unwrap_or(false),\n })?;\n registry.register(\"openai\", provider);\n info!(\"Registered OpenAI provider\");\n }\n }\n\n // Register DeepSeek provider\n if let Some(entry) = &config.providers.deepseek {\n if let Some(api_key) = &entry.api_key {\n let provider = OpenAiCompatProvider::new(OpenAiCompatConfig {\n api_key: api_key.clone(),\n base_url: entry\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://api.deepseek.com/v1\".to_string()),\n model: entry.model.clone().unwrap_or_default(),\n organization: None,\n no_proxy: entry.no_proxy.unwrap_or(false),\n })?;\n registry.register(\"deepseek\", provider);\n info!(\"Registered DeepSeek provider\");\n }\n }\n\n // Register Groq provider\n if let Some(entry) = &config.providers.groq {\n if let Some(api_key) = &entry.api_key {\n let provider = OpenAiCompatProvider::new(OpenAiCompatConfig {\n api_key: api_key.clone(),\n base_url: entry\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://api.groq.com/openai/v1\".to_string()),\n model: entry.model.clone().unwrap_or_default(),\n organization: None,\n no_proxy: entry.no_proxy.unwrap_or(false),\n })?;\n registry.register(\"groq\", provider);\n info!(\"Registered Groq provider\");\n }\n }\n\n // Register OpenRouter provider\n if let Some(entry) = &config.providers.openrouter {\n if let Some(api_key) = &entry.api_key {\n let provider = OpenAiCompatProvider::new(OpenAiCompatConfig {\n api_key: api_key.clone(),\n base_url: entry\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://openrouter.ai/api/v1\".to_string()),\n model: entry.model.clone().unwrap_or_default(),\n organization: None,\n no_proxy: entry.no_proxy.unwrap_or(false),\n })?;\n registry.register(\"openrouter\", provider);\n info!(\"Registered OpenRouter provider\");\n }\n }\n\n // Register Ollama provider (localhost — always skip proxy)\n if let Some(entry) = &config.providers.ollama {\n let provider = OpenAiCompatProvider::new(OpenAiCompatConfig {\n api_key: entry.api_key.clone().unwrap_or_default(),\n base_url: entry\n .base_url\n .clone()\n .unwrap_or_else(|| \"http://localhost:11434/v1\".to_string()),\n model: entry.model.clone().unwrap_or_default(),\n organization: None,\n no_proxy: true,\n })?;\n registry.register(\"ollama\", provider);\n info!(\"Registered Ollama provider\");\n }\n\n // Register OpenCode Go provider (OpenAI-compatible endpoint)\n if let Some(entry) = &config.providers.opencode_go {\n if let Some(api_key) = &entry.api_key {\n let provider = OpenAiCompatProvider::new(OpenAiCompatConfig {\n api_key: api_key.clone(),\n base_url: entry\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://opencode.ai/zen/go/v1\".to_string()),\n model: entry.model.clone().unwrap_or_default(),\n organization: None,\n no_proxy: entry.no_proxy.unwrap_or(false),\n })?;\n registry.register(\"opencode_go\", provider);\n info!(\"Registered OpenCode Go provider\");\n }\n }\n\n // Register custom providers\n for custom in &config.custom_providers {\n let provider = OpenAiCompatProvider::new(OpenAiCompatConfig {\n api_key: custom.api_key.clone().unwrap_or_default(),\n base_url: custom.base_url.clone(),\n model: custom.model_patterns.first().cloned().unwrap_or_default(),\n organization: None,\n no_proxy: custom.no_proxy.unwrap_or(false),\n })?;\n registry.register(&custom.name, provider);\n info!(\"Registered custom provider: {}\", custom.name);\n }\n\n // Set default provider from explicit config, otherwise pick first registered.\n let default = if let Some(ref name) = config.agent.provider {\n if registry.providers.contains_key(name) {\n Some(name.clone())\n } else {\n registry.providers.keys().next().cloned()\n }\n } else {\n registry.providers.keys().next().cloned()\n };\n registry.default_provider = default;\n if let Some(ref name) = registry.default_provider {\n debug!(\"Default provider: {}\", name);\n }\n\n Ok(registry)\n }\n\n /// Register a provider.\n pub fn register(&mut self, name: &str, provider: impl LlmProvider + 'static) {\n self.providers.insert(name.to_string(), Arc::new(provider));\n }\n\n /// Set the default provider by name.\n pub fn set_default(&mut self, name: &str) {\n if self.providers.contains_key(name) {\n self.default_provider = Some(name.to_string());\n }\n }\n\n /// Get a provider by name, falling back to the default provider.\n pub fn get_provider_by_name(&self, name: &str) -> Option> {\n self.providers.get(name).cloned().or_else(|| {\n self.default_provider\n .as_ref()\n .and_then(|n| self.providers.get(n).cloned())\n })\n }\n\n /// Get the provider for the given provider name string.\n ///\n /// `provider_name` must be an exact provider name (e.g. \"opencode_go\", \"anthropic\").\n /// Returns the provider, or the default provider as fallback.\n pub fn get_provider(&self, provider_name: &str) -> Option> {\n self.get_provider_by_name(provider_name)\n }\n\n /// List all registered provider names.\n pub fn provider_names(&self) -> Vec {\n self.providers.keys().cloned().collect()\n }\n\n /// Return the name of the default provider, if any.\n pub fn default_provider_name(&self) -> Option<&str> {\n self.default_provider.as_deref()\n }\n}\n\nimpl Default for ProviderRegistry {\n fn default() -> Self {\n Self::new()\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::base::{\n BoxStream, CompletionChunk, CompletionRequest, CompletionResponse, LlmProvider,\n };\n use async_trait::async_trait;\n\n struct MockProvider {\n provider_name: String,\n supported_model: String,\n }\n\n impl MockProvider {\n fn new(name: &str, supported: &str) -> Self {\n Self {\n provider_name: name.to_string(),\n supported_model: supported.to_string(),\n }\n }\n }\n\n #[async_trait]\n impl LlmProvider for MockProvider {\n fn name(&self) -> &str {\n &self.provider_name\n }\n fn default_model(&self) -> &str {\n &self.supported_model\n }\n async fn complete(\n &self,\n _request: CompletionRequest,\n ) -> anyhow::Result {\n Ok(CompletionResponse {\n content: Some(\"mock\".to_string()),\n reasoning_content: None,\n tool_calls: None,\n usage: None,\n finish_reason: None,\n })\n }\n async fn complete_stream(&self, request: CompletionRequest) -> anyhow::Result {\n let response = self.complete(request).await?;\n let chunk = CompletionChunk {\n delta: response.content,\n reasoning_content: None,\n tool_call_deltas: None,\n usage: None,\n done: true,\n };\n Ok(Box::pin(futures::stream::once(async move { Ok(chunk) })))\n }\n fn supports_model(&self, model: &str) -> bool {\n model.contains(&self.supported_model)\n }\n }\n\n #[test]\n fn test_registry_new() {\n let reg = ProviderRegistry::new();\n assert!(reg.provider_names().is_empty());\n }\n\n #[test]\n fn test_registry_register() {\n let mut reg = ProviderRegistry::new();\n reg.register(\"mock\", MockProvider::new(\"mock\", \"test\"));\n let names = reg.provider_names();\n assert_eq!(names.len(), 1);\n assert!(names.contains(&\"mock\".to_string()));\n }\n\n #[test]\n fn test_get_provider_by_name() {\n let mut reg = ProviderRegistry::new();\n reg.register(\"anthropic\", MockProvider::new(\"anthropic\", \"claude\"));\n reg.register(\"openai\", MockProvider::new(\"openai\", \"gpt\"));\n\n let p = reg.get_provider_by_name(\"anthropic\");\n assert!(p.is_some());\n assert_eq!(p.unwrap().name(), \"anthropic\");\n\n // Unknown name returns None (no default set).\n assert!(reg.get_provider_by_name(\"unknown\").is_none());\n }\n\n #[test]\n fn test_get_provider_by_name_fallback_to_default() {\n let mut reg = ProviderRegistry::new();\n reg.register(\"anthropic\", MockProvider::new(\"anthropic\", \"claude\"));\n reg.register(\"openai\", MockProvider::new(\"openai\", \"gpt\"));\n reg.set_default(\"openai\");\n\n // Unknown name falls back to default.\n let p = reg.get_provider_by_name(\"nonexistent\");\n assert!(p.is_some());\n assert_eq!(p.unwrap().name(), \"openai\");\n }\n\n #[test]\n fn test_get_provider() {\n let mut reg = ProviderRegistry::new();\n reg.register(\"opencode_go\", MockProvider::new(\"opencode_go\", \"glm\"));\n\n let p = reg.get_provider(\"opencode_go\");\n assert!(p.is_some());\n assert_eq!(p.unwrap().name(), \"opencode_go\");\n }\n\n #[test]\n fn test_registry_default_provider() {\n let reg = ProviderRegistry::default();\n assert!(reg.provider_names().is_empty());\n assert!(reg.default_provider.is_none());\n }\n\n #[test]\n fn test_set_default_validates() {\n let mut reg = ProviderRegistry::new();\n reg.register(\"anthropic\", MockProvider::new(\"anthropic\", \"claude\"));\n reg.set_default(\"nonexistent\");\n assert!(reg.default_provider.is_none());\n reg.set_default(\"anthropic\");\n assert_eq!(reg.default_provider.as_deref(), Some(\"anthropic\"));\n }\n\n #[test]\n fn test_default_provider_name() {\n let mut reg = ProviderRegistry::new();\n assert!(reg.default_provider_name().is_none());\n reg.register(\"openai\", MockProvider::new(\"openai\", \"gpt\"));\n reg.set_default(\"openai\");\n assert_eq!(reg.default_provider_name(), Some(\"openai\"));\n }\n}\n"}, "94a0176690cae1da4f8f4febdbae4b2efc9a322f": {"t": 1785761075.423841, "n": 14300, "h": "28404c7f67c5a729", "c": "//! Provider registry — direct provider lookup by name.\n//!\n//! Users explicitly select both provider and model. No keyword guessing\n//! or prefix-based routing.\n\nuse crate::anthropic::{AnthropicConfig, AnthropicProvider};\nuse crate::base::LlmProvider;\nuse crate::openai_compat::{OpenAiCompatConfig, OpenAiCompatProvider};\nuse anyhow::Result;\nuse kestrel_config::schema::Config;\nuse std::collections::HashMap;\nuse std::sync::Arc;\nuse tokio::sync::Semaphore;\nuse tracing::{debug, info};\n\n/// Registry of LLM providers.\n#[derive(Clone)]\npub struct ProviderRegistry {\n providers: HashMap>,\n default_provider: Option,\n /// Semaphore limiting concurrent in-flight provider requests.\n concurrency_limit: Arc,\n}\n\nimpl ProviderRegistry {\n /// Create a new empty registry.\n pub fn new() -> Self {\n Self {\n providers: HashMap::new(),\n default_provider: None,\n concurrency_limit: Arc::new(Semaphore::new(4)),\n }\n }\n\n /// Create a registry with a custom concurrency limit for in-flight requests.\n pub fn with_concurrency_limit(limit: usize) -> Self {\n Self {\n providers: HashMap::new(),\n default_provider: None,\n concurrency_limit: Arc::new(Semaphore::new(limit)),\n }\n }\n\n /// Acquire a permit from the concurrency semaphore.\n /// Returns a permit that releases on drop.\n pub async fn acquire_permit(&self) -> tokio::sync::OwnedSemaphorePermit {\n self.concurrency_limit\n .clone()\n .acquire_owned()\n .await\n .expect(\"concurrency semaphore should not be closed\")\n }\n\n /// Build the registry from a Config.\n pub fn from_config(config: &Config) -> Result {\n let mut registry = Self::new();\n\n // Register Anthropic provider if configured\n if let Some(entry) = &config.providers.anthropic {\n if let Some(api_key) = &entry.api_key {\n let provider = AnthropicProvider::new(AnthropicConfig {\n api_key: api_key.clone(),\n model: entry\n .model\n .clone()\n .unwrap_or_else(|| \"claude-sonnet-4-20250514\".to_string()),\n api_version: None,\n base_url: entry.base_url.clone(),\n })?;\n registry.register(\"anthropic\", provider);\n info!(\"Registered Anthropic provider\");\n }\n }\n\n // Register OpenAI provider if configured\n if let Some(entry) = &config.providers.openai {\n if let Some(api_key) = &entry.api_key {\n let provider = OpenAiCompatProvider::new(OpenAiCompatConfig {\n api_key: api_key.clone(),\n base_url: entry\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://api.openai.com/v1\".to_string()),\n model: entry.model.clone().unwrap_or_default(),\n organization: None,\n no_proxy: entry.no_proxy.unwrap_or(false),\n })?;\n registry.register(\"openai\", provider);\n info!(\"Registered OpenAI provider\");\n }\n }\n\n // Register DeepSeek provider\n if let Some(entry) = &config.providers.deepseek {\n if let Some(api_key) = &entry.api_key {\n let provider = OpenAiCompatProvider::new(OpenAiCompatConfig {\n api_key: api_key.clone(),\n base_url: entry\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://api.deepseek.com/v1\".to_string()),\n model: entry.model.clone().unwrap_or_default(),\n organization: None,\n no_proxy: entry.no_proxy.unwrap_or(false),\n })?;\n registry.register(\"deepseek\", provider);\n info!(\"Registered DeepSeek provider\");\n }\n }\n\n // Register Groq provider\n if let Some(entry) = &config.providers.groq {\n if let Some(api_key) = &entry.api_key {\n let provider = OpenAiCompatProvider::new(OpenAiCompatConfig {\n api_key: api_key.clone(),\n base_url: entry\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://api.groq.com/openai/v1\".to_string()),\n model: entry.model.clone().unwrap_or_default(),\n organization: None,\n no_proxy: entry.no_proxy.unwrap_or(false),\n })?;\n registry.register(\"groq\", provider);\n info!(\"Registered Groq provider\");\n }\n }\n\n // Register OpenRouter provider\n if let Some(entry) = &config.providers.openrouter {\n if let Some(api_key) = &entry.api_key {\n let provider = OpenAiCompatProvider::new(OpenAiCompatConfig {\n api_key: api_key.clone(),\n base_url: entry\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://openrouter.ai/api/v1\".to_string()),\n model: entry.model.clone().unwrap_or_default(),\n organization: None,\n no_proxy: entry.no_proxy.unwrap_or(false),\n })?;\n registry.register(\"openrouter\", provider);\n info!(\"Registered OpenRouter provider\");\n }\n }\n\n // Register Ollama provider (localhost — always skip proxy)\n if let Some(entry) = &config.providers.ollama {\n let provider = OpenAiCompatProvider::new(OpenAiCompatConfig {\n api_key: entry.api_key.clone().unwrap_or_default(),\n base_url: entry\n .base_url\n .clone()\n .unwrap_or_else(|| \"http://localhost:11434/v1\".to_string()),\n model: entry.model.clone().unwrap_or_default(),\n organization: None,\n no_proxy: true,\n })?;\n registry.register(\"ollama\", provider);\n info!(\"Registered Ollama provider\");\n }\n\n // Register OpenCode Go provider (OpenAI-compatible endpoint)\n if let Some(entry) = &config.providers.opencode_go {\n if let Some(api_key) = &entry.api_key {\n let provider = OpenAiCompatProvider::new(OpenAiCompatConfig {\n api_key: api_key.clone(),\n base_url: entry\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://opencode.ai/zen/go/v1\".to_string()),\n model: entry.model.clone().unwrap_or_default(),\n organization: None,\n no_proxy: entry.no_proxy.unwrap_or(false),\n })?;\n registry.register(\"opencode_go\", provider);\n info!(\"Registered OpenCode Go provider\");\n }\n }\n\n // Register custom providers\n for custom in &config.custom_providers {\n let provider = OpenAiCompatProvider::new(OpenAiCompatConfig {\n api_key: custom.api_key.clone().unwrap_or_default(),\n base_url: custom.base_url.clone(),\n model: custom.model_patterns.first().cloned().unwrap_or_default(),\n organization: None,\n no_proxy: custom.no_proxy.unwrap_or(false),\n })?;\n registry.register(&custom.name, provider);\n info!(\"Registered custom provider: {}\", custom.name);\n }\n\n // Set default provider from explicit config, otherwise pick first registered.\n let default = if let Some(ref name) = config.agent.provider {\n if registry.providers.contains_key(name) {\n Some(name.clone())\n } else {\n registry.providers.keys().next().cloned()\n }\n } else {\n registry.providers.keys().next().cloned()\n };\n registry.default_provider = default;\n if let Some(ref name) = registry.default_provider {\n debug!(\"Default provider: {}\", name);\n }\n\n Ok(registry)\n }\n\n /// Register a provider.\n pub fn register(&mut self, name: &str, provider: impl LlmProvider + 'static) {\n self.providers.insert(name.to_string(), Arc::new(provider));\n }\n\n /// Set the default provider by name.\n pub fn set_default(&mut self, name: &str) {\n if self.providers.contains_key(name) {\n self.default_provider = Some(name.to_string());\n }\n }\n\n /// Get a provider by name, falling back to the default provider.\n pub fn get_provider_by_name(&self, name: &str) -> Option> {\n self.providers.get(name).cloned().or_else(|| {\n self.default_provider\n .as_ref()\n .and_then(|n| self.providers.get(n).cloned())\n })\n }\n\n /// Get the provider for the given provider name string.\n ///\n /// `provider_name` must be an exact provider name (e.g. \"opencode_go\", \"anthropic\").\n /// Returns the provider, or the default provider as fallback.\n pub fn get_provider(&self, provider_name: &str) -> Option> {\n self.get_provider_by_name(provider_name)\n }\n\n /// List all registered provider names.\n pub fn provider_names(&self) -> Vec {\n self.providers.keys().cloned().collect()\n }\n\n /// Return the name of the default provider, if any.\n pub fn default_provider_name(&self) -> Option<&str> {\n self.default_provider.as_deref()\n }\n}\n\nimpl Default for ProviderRegistry {\n fn default() -> Self {\n Self::new()\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::base::{\n BoxStream, CompletionChunk, CompletionRequest, CompletionResponse, LlmProvider,\n };\n use async_trait::async_trait;\n\n struct MockProvider {\n provider_name: String,\n supported_model: String,\n }\n\n impl MockProvider {\n fn new(name: &str, supported: &str) -> Self {\n Self {\n provider_name: name.to_string(),\n supported_model: supported.to_string(),\n }\n }\n }\n\n #[async_trait]\n impl LlmProvider for MockProvider {\n fn name(&self) -> &str {\n &self.provider_name\n }\n fn default_model(&self) -> &str {\n &self.supported_model\n }\n async fn complete(\n &self,\n _request: CompletionRequest,\n ) -> anyhow::Result {\n Ok(CompletionResponse {\n content: Some(\"mock\".to_string()),\n tool_calls: None,\n usage: None,\n finish_reason: None,\n })\n }\n async fn complete_stream(&self, request: CompletionRequest) -> anyhow::Result {\n let response = self.complete(request).await?;\n let chunk = CompletionChunk {\n delta: response.content,\n tool_call_deltas: None,\n usage: None,\n done: true,\n };\n Ok(Box::pin(futures::stream::once(async move { Ok(chunk) })))\n }\n fn supports_model(&self, model: &str) -> bool {\n model.contains(&self.supported_model)\n }\n }\n\n #[test]\n fn test_registry_new() {\n let reg = ProviderRegistry::new();\n assert!(reg.provider_names().is_empty());\n }\n\n #[test]\n fn test_registry_register() {\n let mut reg = ProviderRegistry::new();\n reg.register(\"mock\", MockProvider::new(\"mock\", \"test\"));\n let names = reg.provider_names();\n assert_eq!(names.len(), 1);\n assert!(names.contains(&\"mock\".to_string()));\n }\n\n #[test]\n fn test_get_provider_by_name() {\n let mut reg = ProviderRegistry::new();\n reg.register(\"anthropic\", MockProvider::new(\"anthropic\", \"claude\"));\n reg.register(\"openai\", MockProvider::new(\"openai\", \"gpt\"));\n\n let p = reg.get_provider_by_name(\"anthropic\");\n assert!(p.is_some());\n assert_eq!(p.unwrap().name(), \"anthropic\");\n\n // Unknown name returns None (no default set).\n assert!(reg.get_provider_by_name(\"unknown\").is_none());\n }\n\n #[test]\n fn test_get_provider_by_name_fallback_to_default() {\n let mut reg = ProviderRegistry::new();\n reg.register(\"anthropic\", MockProvider::new(\"anthropic\", \"claude\"));\n reg.register(\"openai\", MockProvider::new(\"openai\", \"gpt\"));\n reg.set_default(\"openai\");\n\n // Unknown name falls back to default.\n let p = reg.get_provider_by_name(\"nonexistent\");\n assert!(p.is_some());\n assert_eq!(p.unwrap().name(), \"openai\");\n }\n\n #[test]\n fn test_get_provider() {\n let mut reg = ProviderRegistry::new();\n reg.register(\"opencode_go\", MockProvider::new(\"opencode_go\", \"glm\"));\n\n let p = reg.get_provider(\"opencode_go\");\n assert!(p.is_some());\n assert_eq!(p.unwrap().name(), \"opencode_go\");\n }\n\n #[test]\n fn test_registry_default_provider() {\n let reg = ProviderRegistry::default();\n assert!(reg.provider_names().is_empty());\n assert!(reg.default_provider.is_none());\n }\n\n #[test]\n fn test_set_default_validates() {\n let mut reg = ProviderRegistry::new();\n reg.register(\"anthropic\", MockProvider::new(\"anthropic\", \"claude\"));\n reg.set_default(\"nonexistent\");\n assert!(reg.default_provider.is_none());\n reg.set_default(\"anthropic\");\n assert_eq!(reg.default_provider.as_deref(), Some(\"anthropic\"));\n }\n\n #[test]\n fn test_default_provider_name() {\n let mut reg = ProviderRegistry::new();\n assert!(reg.default_provider_name().is_none());\n reg.register(\"openai\", MockProvider::new(\"openai\", \"gpt\"));\n reg.set_default(\"openai\");\n assert_eq!(reg.default_provider_name(), Some(\"openai\"));\n }\n}\n"}, "50e35397b1cc698e9f52afb723d87ad2249efe13": {"t": 1785761076.7332883, "n": 20971, "h": "59d820e6b49b9748", "c": "//! Provider registry — resolves model names to appropriate providers.\n//!\n//! Matches the Python `providers/registry.py` keyword-based model matching.\n\nuse crate::anthropic::{AnthropicConfig, AnthropicProvider};\nuse crate::base::LlmProvider;\nuse crate::openai_compat::{OpenAiCompatConfig, OpenAiCompatProvider};\nuse anyhow::Result;\nuse kestrel_config::schema::Config;\nuse std::collections::HashMap;\nuse std::sync::Arc;\nuse tokio::sync::Semaphore;\nuse tracing::{debug, info};\n\n/// Model keyword to provider name mapping.\n/// This mirrors the Python registry's MODEL_KEYWORDS map.\nconst MODEL_KEYWORD_MAP: &[(&str, &str)] = &[\n (\"claude\", \"anthropic\"),\n (\"anthropic\", \"anthropic\"),\n (\"gpt\", \"openai\"),\n (\"o1\", \"openai\"),\n (\"o3\", \"openai\"),\n (\"o4\", \"openai\"),\n (\"chatgpt\", \"openai\"),\n (\"deepseek-v4\", \"openrouter\"),\n (\"deepseek\", \"deepseek\"),\n (\"gemini\", \"gemini\"),\n (\"groq\", \"groq\"),\n (\"moonshot\", \"moonshot\"),\n (\"kimi\", \"moonshot\"),\n (\"minimax\", \"minimax\"),\n (\"llama\", \"ollama\"),\n (\"mistral\", \"ollama\"),\n (\"qwen\", \"ollama\"),\n (\"codestral\", \"ollama\"),\n (\"opencode-go\", \"opencode_go\"),\n (\"opencode_go\", \"opencode_go\"),\n];\n\n/// Registry of LLM providers.\n#[derive(Clone)]\npub struct ProviderRegistry {\n providers: HashMap>,\n default_provider: Option,\n /// Semaphore limiting concurrent in-flight provider requests.\n concurrency_limit: Arc,\n}\n\nimpl ProviderRegistry {\n /// Create a new empty registry.\n pub fn new() -> Self {\n Self {\n providers: HashMap::new(),\n default_provider: None,\n concurrency_limit: Arc::new(Semaphore::new(4)),\n }\n }\n\n /// Create a registry with a custom concurrency limit for in-flight requests.\n pub fn with_concurrency_limit(limit: usize) -> Self {\n Self {\n providers: HashMap::new(),\n default_provider: None,\n concurrency_limit: Arc::new(Semaphore::new(limit)),\n }\n }\n\n /// Acquire a permit from the concurrency semaphore.\n /// Returns a permit that releases on drop.\n pub async fn acquire_permit(&self) -> tokio::sync::OwnedSemaphorePermit {\n self.concurrency_limit\n .clone()\n .acquire_owned()\n .await\n .expect(\"concurrency semaphore should not be closed\")\n }\n\n /// Build the registry from a Config.\n pub fn from_config(config: &Config) -> Result {\n let mut registry = Self::new();\n\n // Register Anthropic provider if configured\n if let Some(entry) = &config.providers.anthropic {\n if let Some(api_key) = &entry.api_key {\n let provider = AnthropicProvider::new(AnthropicConfig {\n api_key: api_key.clone(),\n model: entry\n .model\n .clone()\n .unwrap_or_else(|| \"claude-sonnet-4-20250514\".to_string()),\n api_version: None,\n base_url: entry.base_url.clone(),\n })?;\n registry.register(\"anthropic\", provider);\n info!(\"Registered Anthropic provider\");\n }\n }\n\n // Register OpenAI provider if configured\n if let Some(entry) = &config.providers.openai {\n if let Some(api_key) = &entry.api_key {\n let provider = OpenAiCompatProvider::new(OpenAiCompatConfig {\n api_key: api_key.clone(),\n base_url: entry\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://api.openai.com/v1\".to_string()),\n model: entry.model.clone().unwrap_or_default(),\n organization: None,\n no_proxy: entry.no_proxy.unwrap_or(false),\n })?;\n registry.register(\"openai\", provider);\n info!(\"Registered OpenAI provider\");\n }\n }\n\n // Register DeepSeek provider\n if let Some(entry) = &config.providers.deepseek {\n if let Some(api_key) = &entry.api_key {\n let provider = OpenAiCompatProvider::new(OpenAiCompatConfig {\n api_key: api_key.clone(),\n base_url: entry\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://api.deepseek.com/v1\".to_string()),\n model: entry.model.clone().unwrap_or_default(),\n organization: None,\n no_proxy: entry.no_proxy.unwrap_or(false),\n })?;\n registry.register(\"deepseek\", provider);\n info!(\"Registered DeepSeek provider\");\n }\n }\n\n // Register Groq provider\n if let Some(entry) = &config.providers.groq {\n if let Some(api_key) = &entry.api_key {\n let provider = OpenAiCompatProvider::new(OpenAiCompatConfig {\n api_key: api_key.clone(),\n base_url: entry\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://api.groq.com/openai/v1\".to_string()),\n model: entry.model.clone().unwrap_or_default(),\n organization: None,\n no_proxy: entry.no_proxy.unwrap_or(false),\n })?;\n registry.register(\"groq\", provider);\n info!(\"Registered Groq provider\");\n }\n }\n\n // Register OpenRouter provider\n if let Some(entry) = &config.providers.openrouter {\n if let Some(api_key) = &entry.api_key {\n let provider = OpenAiCompatProvider::new(OpenAiCompatConfig {\n api_key: api_key.clone(),\n base_url: entry\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://openrouter.ai/api/v1\".to_string()),\n model: entry.model.clone().unwrap_or_default(),\n organization: None,\n no_proxy: entry.no_proxy.unwrap_or(false),\n })?;\n registry.register(\"openrouter\", provider);\n info!(\"Registered OpenRouter provider\");\n }\n }\n\n // Register Ollama provider (localhost — always skip proxy)\n if let Some(entry) = &config.providers.ollama {\n let provider = OpenAiCompatProvider::new(OpenAiCompatConfig {\n api_key: entry.api_key.clone().unwrap_or_default(),\n base_url: entry\n .base_url\n .clone()\n .unwrap_or_else(|| \"http://localhost:11434/v1\".to_string()),\n model: entry.model.clone().unwrap_or_default(),\n organization: None,\n no_proxy: true,\n })?;\n registry.register(\"ollama\", provider);\n info!(\"Registered Ollama provider\");\n }\n\n // Register OpenCode Go provider (OpenAI-compatible endpoint)\n if let Some(entry) = &config.providers.opencode_go {\n if let Some(api_key) = &entry.api_key {\n let provider = OpenAiCompatProvider::new(OpenAiCompatConfig {\n api_key: api_key.clone(),\n base_url: entry\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://opencode.ai/zen/go/v1\".to_string()),\n model: entry.model.clone().unwrap_or_default(),\n organization: None,\n no_proxy: entry.no_proxy.unwrap_or(false),\n })?;\n registry.register(\"opencode_go\", provider);\n info!(\"Registered OpenCode Go provider\");\n }\n }\n\n // Register custom providers\n for custom in &config.custom_providers {\n let provider = OpenAiCompatProvider::new(OpenAiCompatConfig {\n api_key: custom.api_key.clone().unwrap_or_default(),\n base_url: custom.base_url.clone(),\n model: custom.model_patterns.first().cloned().unwrap_or_default(),\n organization: None,\n no_proxy: custom.no_proxy.unwrap_or(false),\n })?;\n registry.register(&custom.name, provider);\n info!(\"Registered custom provider: {}\", custom.name);\n }\n\n // Set default provider based on agent model\n let model = &config.agent.model;\n let default = registry\n .resolve_provider_name(model)\n .or_else(|| registry.providers.keys().next().cloned());\n registry.default_provider = default;\n if let Some(ref name) = registry.default_provider {\n debug!(\"Default provider for model '{}': {}\", model, name);\n }\n\n Ok(registry)\n }\n\n /// Register a provider.\n pub fn register(&mut self, name: &str, provider: impl LlmProvider + 'static) {\n self.providers.insert(name.to_string(), Arc::new(provider));\n }\n\n /// Set the default provider by name.\n pub fn set_default(&mut self, name: &str) {\n if self.providers.contains_key(name) {\n self.default_provider = Some(name.to_string());\n }\n }\n\n /// Resolve a model name to a provider name.\n ///\n /// Resolution order:\n /// 1. Explicit provider prefix (`opencode_go/model-name`) → that provider\n /// 2. Keyword matching against [`MODEL_KEYWORD_MAP`]\n /// 3. Fallback to the default provider\n pub fn resolve_provider_name(&self, model: &str) -> Option {\n // 1. Check for explicit provider prefix first (e.g. \"opencode_go/deepseek-v4-flash\").\n // This takes precedence over keyword matching to avoid mis-routing.\n if let Some((prefix, rest)) = model.split_once('/') {\n if !rest.is_empty() && self.providers.contains_key(prefix) {\n return Some(prefix.to_string());\n }\n }\n\n // 2. Fall back to keyword matching.\n let lower = model.to_lowercase();\n for (keyword, provider_name) in MODEL_KEYWORD_MAP {\n if lower.contains(keyword) && self.providers.contains_key(*provider_name) {\n return Some(provider_name.to_string());\n }\n }\n\n // 3. Default provider.\n self.default_provider.clone()\n }\n\n /// Strip the provider prefix from a qualified model name.\n ///\n /// If `model` contains a `/` and the part before it matches a known provider\n /// (either by keyword or exact name), returns the part after the `/`.\n /// Otherwise returns `model` unchanged.\n ///\n /// Examples:\n /// - `\"opencode-go/kimi-k2.6\"` → `\"kimi-k2.6\"` (matches \"opencode-go\" keyword)\n /// - `\"opencode_go/glm-5.1\"` → `\"glm-5.1\"` (matches \"opencode_go\" keyword)\n /// - `\"claude-sonnet-4-6\"` → `\"claude-sonnet-4-6\"` (no slash, unchanged)\n /// - `\"deepseek/deepseek-v4-flash\"` → `\"deepseek-v4-flash\"` (matches \"deepseek\" keyword)\n pub fn strip_provider_prefix(&self, model: &str) -> String {\n let Some((prefix, rest)) = model.split_once('/') else {\n return model.to_string();\n };\n if rest.is_empty() {\n return model.to_string();\n }\n let lower_prefix = prefix.to_lowercase();\n for (keyword, provider_name) in MODEL_KEYWORD_MAP {\n if (lower_prefix == *keyword || lower_prefix == *provider_name)\n && self.providers.contains_key(*provider_name)\n {\n return rest.to_string();\n }\n }\n // Also check if prefix is an exact provider name (handles custom providers).\n if self.providers.contains_key(prefix) {\n return rest.to_string();\n }\n model.to_string()\n }\n\n /// Resolve a model name to a provider name, with an explicit provider override.\n ///\n /// Resolution order:\n /// 1. Explicit `provider_override` parameter (from `agent.provider` config)\n /// 2. Explicit provider prefix in model string\n /// 3. Keyword matching against [`MODEL_KEYWORD_MAP`]\n /// 4. Fallback to the default provider\n pub fn resolve_provider_name_with_override(\n &self,\n model: &str,\n provider_override: Option<&str>,\n ) -> Option {\n // 0. Explicit provider override takes absolute precedence.\n if let Some(name) = provider_override {\n if self.providers.contains_key(name) {\n return Some(name.to_string());\n }\n }\n self.resolve_provider_name(model)\n }\n\n /// Get a provider for a given model, with optional explicit provider override.\n pub fn get_provider_with_override(\n &self,\n model: &str,\n provider_override: Option<&str>,\n ) -> Option> {\n if let Some(name) = self.resolve_provider_name_with_override(model, provider_override) {\n self.providers.get(&name).cloned()\n } else {\n self.default_provider\n .as_ref()\n .and_then(|name| self.providers.get(name).cloned())\n }\n }\n\n /// Get a provider for a given model.\n pub fn get_provider(&self, model: &str) -> Option> {\n if let Some(name) = self.resolve_provider_name(model) {\n self.providers.get(&name).cloned()\n } else {\n self.default_provider\n .as_ref()\n .and_then(|name| self.providers.get(name).cloned())\n }\n }\n\n /// Get a provider by name.\n pub fn get_provider_by_name(&self, name: &str) -> Option> {\n self.providers.get(name).cloned()\n }\n\n /// List all registered provider names.\n pub fn provider_names(&self) -> Vec {\n self.providers.keys().cloned().collect()\n }\n}\n\nimpl Default for ProviderRegistry {\n fn default() -> Self {\n Self::new()\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::base::{\n BoxStream, CompletionChunk, CompletionRequest, CompletionResponse, LlmProvider,\n };\n use async_trait::async_trait;\n\n struct MockProvider {\n provider_name: String,\n supported_model: String,\n }\n\n impl MockProvider {\n fn new(name: &str, supported: &str) -> Self {\n Self {\n provider_name: name.to_string(),\n supported_model: supported.to_string(),\n }\n }\n }\n\n #[async_trait]\n impl LlmProvider for MockProvider {\n fn name(&self) -> &str {\n &self.provider_name\n }\n fn default_model(&self) -> &str {\n &self.supported_model\n }\n async fn complete(\n &self,\n _request: CompletionRequest,\n ) -> anyhow::Result {\n Ok(CompletionResponse {\n content: Some(\"mock\".to_string()),\n tool_calls: None,\n usage: None,\n finish_reason: None,\n })\n }\n async fn complete_stream(&self, request: CompletionRequest) -> anyhow::Result {\n let response = self.complete(request).await?;\n let chunk = CompletionChunk {\n delta: response.content,\n tool_call_deltas: None,\n usage: None,\n done: true,\n };\n Ok(Box::pin(futures::stream::once(async move { Ok(chunk) })))\n }\n fn supports_model(&self, model: &str) -> bool {\n model.contains(&self.supported_model)\n }\n }\n\n #[test]\n fn test_registry_new() {\n let reg = ProviderRegistry::new();\n assert!(reg.provider_names().is_empty());\n }\n\n #[test]\n fn test_registry_register() {\n let mut reg = ProviderRegistry::new();\n reg.register(\"mock\", MockProvider::new(\"mock\", \"test\"));\n let names = reg.provider_names();\n assert_eq!(names.len(), 1);\n assert!(names.contains(&\"mock\".to_string()));\n }\n\n #[test]\n fn test_registry_resolve_provider_name() {\n let mut reg = ProviderRegistry::new();\n reg.register(\"anthropic\", MockProvider::new(\"anthropic\", \"claude\"));\n reg.register(\"openai\", MockProvider::new(\"openai\", \"gpt\"));\n\n // \"claude-3.5-sonnet\" should resolve to \"anthropic\"\n let resolved = reg.resolve_provider_name(\"claude-3.5-sonnet\");\n assert_eq!(resolved.as_deref(), Some(\"anthropic\"));\n\n // \"gpt-4o\" should resolve to \"openai\"\n let resolved = reg.resolve_provider_name(\"gpt-4o\");\n assert_eq!(resolved.as_deref(), Some(\"openai\"));\n }\n\n #[test]\n fn test_resolve_provider_prefix_takes_precedence_over_keywords() {\n let mut reg = ProviderRegistry::new();\n reg.register(\"opencode_go\", MockProvider::new(\"opencode_go\", \"glm\"));\n reg.register(\"openrouter\", MockProvider::new(\"openrouter\", \"deepseek\"));\n\n // \"opencode_go/deepseek-v4-flash\" should resolve to opencode_go,\n // NOT openrouter (even though \"deepseek-v4\" keyword matches openrouter).\n let resolved = reg.resolve_provider_name(\"opencode_go/deepseek-v4-flash\");\n assert_eq!(resolved.as_deref(), Some(\"opencode_go\"));\n }\n\n #[test]\n fn test_resolve_provider_override() {\n let mut reg = ProviderRegistry::new();\n reg.register(\"anthropic\", MockProvider::new(\"anthropic\", \"claude\"));\n reg.register(\"openai\", MockProvider::new(\"openai\", \"gpt\"));\n\n // Override takes absolute precedence over keyword matching.\n let resolved = reg.resolve_provider_name_with_override(\"claude-sonnet-4-6\", Some(\"openai\"));\n assert_eq!(resolved.as_deref(), Some(\"openai\"));\n }\n\n #[test]\n fn test_resolve_provider_override_ignored_when_not_registered() {\n let mut reg = ProviderRegistry::new();\n reg.register(\"anthropic\", MockProvider::new(\"anthropic\", \"claude\"));\n\n // Unknown override falls back to keyword matching.\n let resolved =\n reg.resolve_provider_name_with_override(\"claude-sonnet-4-6\", Some(\"nonexistent\"));\n assert_eq!(resolved.as_deref(), Some(\"anthropic\"));\n }\n\n #[test]\n fn test_registry_default_provider() {\n let reg = ProviderRegistry::default();\n assert!(reg.provider_names().is_empty());\n assert!(reg.default_provider.is_none());\n }\n\n #[test]\n fn test_strip_provider_prefix_with_keyword() {\n let mut reg = ProviderRegistry::new();\n reg.register(\"opencode_go\", MockProvider::new(\"opencode_go\", \"glm\"));\n\n // \"opencode-go/kimi-k2.6\" → \"kimi-k2.6\"\n assert_eq!(\n reg.strip_provider_prefix(\"opencode-go/kimi-k2.6\"),\n \"kimi-k2.6\"\n );\n }\n\n #[test]\n fn test_strip_provider_prefix_with_provider_name() {\n let mut reg = ProviderRegistry::new();\n reg.register(\"opencode_go\", MockProvider::new(\"opencode_go\", \"glm\"));\n\n // \"opencode_go/glm-5.1\" → \"glm-5.1\"\n assert_eq!(reg.strip_provider_prefix(\"opencode_go/glm-5.1\"), \"glm-5.1\");\n }\n\n #[test]\n fn test_strip_provider_prefix_no_slash() {\n let mut reg = ProviderRegistry::new();\n reg.register(\"openai\", MockProvider::new(\"openai\", \"gpt\"));\n\n // No slash → unchanged\n assert_eq!(reg.strip_provider_prefix(\"gpt-4o\"), \"gpt-4o\");\n }\n\n #[test]\n fn test_strip_provider_prefix_unknown_prefix() {\n let mut reg = ProviderRegistry::new();\n reg.register(\"openai\", MockProvider::new(\"openai\", \"gpt\"));\n\n // Unknown prefix with slash → unchanged\n assert_eq!(reg.strip_provider_prefix(\"unknown/model\"), \"unknown/model\");\n }\n\n #[test]\n fn test_strip_provider_prefix_deepseek() {\n let mut reg = ProviderRegistry::new();\n reg.register(\"deepseek\", MockProvider::new(\"deepseek\", \"deepseek\"));\n reg.register(\"openrouter\", MockProvider::new(\"openrouter\", \"deepseek\"));\n\n // \"deepseek/deepseek-v4-flash\" → \"deepseek-v4-flash\"\n assert_eq!(\n reg.strip_provider_prefix(\"deepseek/deepseek-v4-flash\"),\n \"deepseek-v4-flash\"\n );\n }\n\n #[test]\n fn test_strip_provider_prefix_empty_after_slash() {\n let mut reg = ProviderRegistry::new();\n reg.register(\"opencode_go\", MockProvider::new(\"opencode_go\", \"glm\"));\n\n // Empty after slash → unchanged\n assert_eq!(reg.strip_provider_prefix(\"opencode-go/\"), \"opencode-go/\");\n }\n\n #[test]\n fn test_strip_provider_prefix_custom_provider() {\n let mut reg = ProviderRegistry::new();\n reg.register(\"my_custom\", MockProvider::new(\"my_custom\", \"test\"));\n\n // Custom provider by exact name match\n assert_eq!(\n reg.strip_provider_prefix(\"my_custom/mymodel-v1\"),\n \"mymodel-v1\"\n );\n }\n}\n"}, "96dbaa0c5467305ac0091974527ce5b544cd14f7": {"t": 1785761078.4043274, "n": 17427, "h": "3b3cc845449b3915", "c": "//! Provider registry — resolves model names to appropriate providers.\n//!\n//! Matches the Python `providers/registry.py` keyword-based model matching.\n\nuse crate::anthropic::{AnthropicConfig, AnthropicProvider};\nuse crate::base::LlmProvider;\nuse crate::openai_compat::{OpenAiCompatConfig, OpenAiCompatProvider};\nuse anyhow::Result;\nuse kestrel_config::schema::Config;\nuse std::collections::HashMap;\nuse std::sync::Arc;\nuse tokio::sync::Semaphore;\nuse tracing::{debug, info};\n\n/// Model keyword to provider name mapping.\n/// This mirrors the Python registry's MODEL_KEYWORDS map.\nconst MODEL_KEYWORD_MAP: &[(&str, &str)] = &[\n (\"claude\", \"anthropic\"),\n (\"anthropic\", \"anthropic\"),\n (\"gpt\", \"openai\"),\n (\"o1\", \"openai\"),\n (\"o3\", \"openai\"),\n (\"o4\", \"openai\"),\n (\"chatgpt\", \"openai\"),\n (\"deepseek-v4\", \"openrouter\"),\n (\"deepseek\", \"deepseek\"),\n (\"gemini\", \"gemini\"),\n (\"groq\", \"groq\"),\n (\"moonshot\", \"moonshot\"),\n (\"kimi\", \"moonshot\"),\n (\"minimax\", \"minimax\"),\n (\"llama\", \"ollama\"),\n (\"mistral\", \"ollama\"),\n (\"qwen\", \"ollama\"),\n (\"codestral\", \"ollama\"),\n (\"opencode-go\", \"opencode_go\"),\n (\"opencode_go\", \"opencode_go\"),\n];\n\n/// Registry of LLM providers.\n#[derive(Clone)]\npub struct ProviderRegistry {\n providers: HashMap>,\n default_provider: Option,\n /// Semaphore limiting concurrent in-flight provider requests.\n concurrency_limit: Arc,\n}\n\nimpl ProviderRegistry {\n /// Create a new empty registry.\n pub fn new() -> Self {\n Self {\n providers: HashMap::new(),\n default_provider: None,\n concurrency_limit: Arc::new(Semaphore::new(4)),\n }\n }\n\n /// Create a registry with a custom concurrency limit for in-flight requests.\n pub fn with_concurrency_limit(limit: usize) -> Self {\n Self {\n providers: HashMap::new(),\n default_provider: None,\n concurrency_limit: Arc::new(Semaphore::new(limit)),\n }\n }\n\n /// Acquire a permit from the concurrency semaphore.\n /// Returns a permit that releases on drop.\n pub async fn acquire_permit(&self) -> tokio::sync::OwnedSemaphorePermit {\n self.concurrency_limit\n .clone()\n .acquire_owned()\n .await\n .expect(\"concurrency semaphore should not be closed\")\n }\n\n /// Build the registry from a Config.\n pub fn from_config(config: &Config) -> Result {\n let mut registry = Self::new();\n\n // Register Anthropic provider if configured\n if let Some(entry) = &config.providers.anthropic {\n if let Some(api_key) = &entry.api_key {\n let provider = AnthropicProvider::new(AnthropicConfig {\n api_key: api_key.clone(),\n model: entry\n .model\n .clone()\n .unwrap_or_else(|| \"claude-sonnet-4-20250514\".to_string()),\n api_version: None,\n base_url: entry.base_url.clone(),\n })?;\n registry.register(\"anthropic\", provider);\n info!(\"Registered Anthropic provider\");\n }\n }\n\n // Register OpenAI provider if configured\n if let Some(entry) = &config.providers.openai {\n if let Some(api_key) = &entry.api_key {\n let provider = OpenAiCompatProvider::new(OpenAiCompatConfig {\n api_key: api_key.clone(),\n base_url: entry\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://api.openai.com/v1\".to_string()),\n model: entry.model.clone().unwrap_or_default(),\n organization: None,\n no_proxy: entry.no_proxy.unwrap_or(false),\n })?;\n registry.register(\"openai\", provider);\n info!(\"Registered OpenAI provider\");\n }\n }\n\n // Register DeepSeek provider\n if let Some(entry) = &config.providers.deepseek {\n if let Some(api_key) = &entry.api_key {\n let provider = OpenAiCompatProvider::new(OpenAiCompatConfig {\n api_key: api_key.clone(),\n base_url: entry\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://api.deepseek.com/v1\".to_string()),\n model: entry.model.clone().unwrap_or_default(),\n organization: None,\n no_proxy: entry.no_proxy.unwrap_or(false),\n })?;\n registry.register(\"deepseek\", provider);\n info!(\"Registered DeepSeek provider\");\n }\n }\n\n // Register Groq provider\n if let Some(entry) = &config.providers.groq {\n if let Some(api_key) = &entry.api_key {\n let provider = OpenAiCompatProvider::new(OpenAiCompatConfig {\n api_key: api_key.clone(),\n base_url: entry\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://api.groq.com/openai/v1\".to_string()),\n model: entry.model.clone().unwrap_or_default(),\n organization: None,\n no_proxy: entry.no_proxy.unwrap_or(false),\n })?;\n registry.register(\"groq\", provider);\n info!(\"Registered Groq provider\");\n }\n }\n\n // Register OpenRouter provider\n if let Some(entry) = &config.providers.openrouter {\n if let Some(api_key) = &entry.api_key {\n let provider = OpenAiCompatProvider::new(OpenAiCompatConfig {\n api_key: api_key.clone(),\n base_url: entry\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://openrouter.ai/api/v1\".to_string()),\n model: entry.model.clone().unwrap_or_default(),\n organization: None,\n no_proxy: entry.no_proxy.unwrap_or(false),\n })?;\n registry.register(\"openrouter\", provider);\n info!(\"Registered OpenRouter provider\");\n }\n }\n\n // Register Ollama provider (localhost — always skip proxy)\n if let Some(entry) = &config.providers.ollama {\n let provider = OpenAiCompatProvider::new(OpenAiCompatConfig {\n api_key: entry.api_key.clone().unwrap_or_default(),\n base_url: entry\n .base_url\n .clone()\n .unwrap_or_else(|| \"http://localhost:11434/v1\".to_string()),\n model: entry.model.clone().unwrap_or_default(),\n organization: None,\n no_proxy: true,\n })?;\n registry.register(\"ollama\", provider);\n info!(\"Registered Ollama provider\");\n }\n\n // Register OpenCode Go provider (OpenAI-compatible endpoint)\n if let Some(entry) = &config.providers.opencode_go {\n if let Some(api_key) = &entry.api_key {\n let provider = OpenAiCompatProvider::new(OpenAiCompatConfig {\n api_key: api_key.clone(),\n base_url: entry\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://opencode.ai/zen/go/v1\".to_string()),\n model: entry.model.clone().unwrap_or_default(),\n organization: None,\n no_proxy: entry.no_proxy.unwrap_or(false),\n })?;\n registry.register(\"opencode_go\", provider);\n info!(\"Registered OpenCode Go provider\");\n }\n }\n\n // Register custom providers\n for custom in &config.custom_providers {\n let provider = OpenAiCompatProvider::new(OpenAiCompatConfig {\n api_key: custom.api_key.clone().unwrap_or_default(),\n base_url: custom.base_url.clone(),\n model: custom.model_patterns.first().cloned().unwrap_or_default(),\n organization: None,\n no_proxy: custom.no_proxy.unwrap_or(false),\n })?;\n registry.register(&custom.name, provider);\n info!(\"Registered custom provider: {}\", custom.name);\n }\n\n // Set default provider based on agent model\n let model = &config.agent.model;\n let default = registry\n .resolve_provider_name(model)\n .map(|s| s.to_string())\n .or_else(|| registry.providers.keys().next().cloned());\n registry.default_provider = default;\n if let Some(ref name) = registry.default_provider {\n debug!(\"Default provider for model '{}': {}\", model, name);\n }\n\n Ok(registry)\n }\n\n /// Register a provider.\n pub fn register(&mut self, name: &str, provider: impl LlmProvider + 'static) {\n self.providers.insert(name.to_string(), Arc::new(provider));\n }\n\n /// Set the default provider by name.\n pub fn set_default(&mut self, name: &str) {\n if self.providers.contains_key(name) {\n self.default_provider = Some(name.to_string());\n }\n }\n\n /// Resolve a model name to a provider name.\n pub fn resolve_provider_name(&self, model: &str) -> Option<&str> {\n let lower = model.to_lowercase();\n for (keyword, provider_name) in MODEL_KEYWORD_MAP {\n if lower.contains(keyword) && self.providers.contains_key(*provider_name) {\n return Some(provider_name);\n }\n }\n self.default_provider.as_deref()\n }\n\n /// Strip the provider prefix from a qualified model name.\n ///\n /// If `model` contains a `/` and the part before it matches a known provider\n /// (either by keyword or exact name), returns the part after the `/`.\n /// Otherwise returns `model` unchanged.\n ///\n /// Examples:\n /// - `\"opencode-go/kimi-k2.6\"` → `\"kimi-k2.6\"` (matches \"opencode-go\" keyword)\n /// - `\"opencode_go/glm-5.1\"` → `\"glm-5.1\"` (matches \"opencode_go\" keyword)\n /// - `\"claude-sonnet-4-6\"` → `\"claude-sonnet-4-6\"` (no slash, unchanged)\n /// - `\"deepseek/deepseek-v4-flash\"` → `\"deepseek-v4-flash\"` (matches \"deepseek\" keyword)\n pub fn strip_provider_prefix(&self, model: &str) -> String {\n let Some((prefix, rest)) = model.split_once('/') else {\n return model.to_string();\n };\n if rest.is_empty() {\n return model.to_string();\n }\n let lower_prefix = prefix.to_lowercase();\n for (keyword, provider_name) in MODEL_KEYWORD_MAP {\n if (lower_prefix == *keyword || lower_prefix == *provider_name)\n && self.providers.contains_key(*provider_name)\n {\n return rest.to_string();\n }\n }\n // Also check if prefix is an exact provider name (handles custom providers).\n if self.providers.contains_key(prefix) {\n return rest.to_string();\n }\n model.to_string()\n }\n\n /// Get a provider for a given model.\n pub fn get_provider(&self, model: &str) -> Option> {\n if let Some(name) = self.resolve_provider_name(model) {\n self.providers.get(name).cloned()\n } else {\n self.default_provider\n .as_ref()\n .and_then(|name| self.providers.get(name).cloned())\n }\n }\n\n /// Get a provider by name.\n pub fn get_provider_by_name(&self, name: &str) -> Option> {\n self.providers.get(name).cloned()\n }\n\n /// List all registered provider names.\n pub fn provider_names(&self) -> Vec {\n self.providers.keys().cloned().collect()\n }\n}\n\nimpl Default for ProviderRegistry {\n fn default() -> Self {\n Self::new()\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::base::{\n BoxStream, CompletionChunk, CompletionRequest, CompletionResponse, LlmProvider,\n };\n use async_trait::async_trait;\n\n struct MockProvider {\n provider_name: String,\n supported_model: String,\n }\n\n impl MockProvider {\n fn new(name: &str, supported: &str) -> Self {\n Self {\n provider_name: name.to_string(),\n supported_model: supported.to_string(),\n }\n }\n }\n\n #[async_trait]\n impl LlmProvider for MockProvider {\n fn name(&self) -> &str {\n &self.provider_name\n }\n fn default_model(&self) -> &str {\n &self.supported_model\n }\n async fn complete(\n &self,\n _request: CompletionRequest,\n ) -> anyhow::Result {\n Ok(CompletionResponse {\n content: Some(\"mock\".to_string()),\n tool_calls: None,\n usage: None,\n finish_reason: None,\n })\n }\n async fn complete_stream(&self, request: CompletionRequest) -> anyhow::Result {\n let response = self.complete(request).await?;\n let chunk = CompletionChunk {\n delta: response.content,\n tool_call_deltas: None,\n usage: None,\n done: true,\n };\n Ok(Box::pin(futures::stream::once(async move { Ok(chunk) })))\n }\n fn supports_model(&self, model: &str) -> bool {\n model.contains(&self.supported_model)\n }\n }\n\n #[test]\n fn test_registry_new() {\n let reg = ProviderRegistry::new();\n assert!(reg.provider_names().is_empty());\n }\n\n #[test]\n fn test_registry_register() {\n let mut reg = ProviderRegistry::new();\n reg.register(\"mock\", MockProvider::new(\"mock\", \"test\"));\n let names = reg.provider_names();\n assert_eq!(names.len(), 1);\n assert!(names.contains(&\"mock\".to_string()));\n }\n\n #[test]\n fn test_registry_resolve_provider_name() {\n let mut reg = ProviderRegistry::new();\n reg.register(\"anthropic\", MockProvider::new(\"anthropic\", \"claude\"));\n reg.register(\"openai\", MockProvider::new(\"openai\", \"gpt\"));\n\n // \"claude-3.5-sonnet\" should resolve to \"anthropic\"\n let resolved = reg.resolve_provider_name(\"claude-3.5-sonnet\");\n assert_eq!(resolved, Some(\"anthropic\"));\n\n // \"gpt-4o\" should resolve to \"openai\"\n let resolved = reg.resolve_provider_name(\"gpt-4o\");\n assert_eq!(resolved, Some(\"openai\"));\n }\n\n #[test]\n fn test_registry_default_provider() {\n let reg = ProviderRegistry::default();\n assert!(reg.provider_names().is_empty());\n assert!(reg.default_provider.is_none());\n }\n\n #[test]\n fn test_strip_provider_prefix_with_keyword() {\n let mut reg = ProviderRegistry::new();\n reg.register(\"opencode_go\", MockProvider::new(\"opencode_go\", \"glm\"));\n\n // \"opencode-go/kimi-k2.6\" → \"kimi-k2.6\"\n assert_eq!(\n reg.strip_provider_prefix(\"opencode-go/kimi-k2.6\"),\n \"kimi-k2.6\"\n );\n }\n\n #[test]\n fn test_strip_provider_prefix_with_provider_name() {\n let mut reg = ProviderRegistry::new();\n reg.register(\"opencode_go\", MockProvider::new(\"opencode_go\", \"glm\"));\n\n // \"opencode_go/glm-5.1\" → \"glm-5.1\"\n assert_eq!(reg.strip_provider_prefix(\"opencode_go/glm-5.1\"), \"glm-5.1\");\n }\n\n #[test]\n fn test_strip_provider_prefix_no_slash() {\n let mut reg = ProviderRegistry::new();\n reg.register(\"openai\", MockProvider::new(\"openai\", \"gpt\"));\n\n // No slash → unchanged\n assert_eq!(reg.strip_provider_prefix(\"gpt-4o\"), \"gpt-4o\");\n }\n\n #[test]\n fn test_strip_provider_prefix_unknown_prefix() {\n let mut reg = ProviderRegistry::new();\n reg.register(\"openai\", MockProvider::new(\"openai\", \"gpt\"));\n\n // Unknown prefix with slash → unchanged\n assert_eq!(reg.strip_provider_prefix(\"unknown/model\"), \"unknown/model\");\n }\n\n #[test]\n fn test_strip_provider_prefix_deepseek() {\n let mut reg = ProviderRegistry::new();\n reg.register(\"deepseek\", MockProvider::new(\"deepseek\", \"deepseek\"));\n reg.register(\"openrouter\", MockProvider::new(\"openrouter\", \"deepseek\"));\n\n // \"deepseek/deepseek-v4-flash\" → \"deepseek-v4-flash\"\n assert_eq!(\n reg.strip_provider_prefix(\"deepseek/deepseek-v4-flash\"),\n \"deepseek-v4-flash\"\n );\n }\n\n #[test]\n fn test_strip_provider_prefix_empty_after_slash() {\n let mut reg = ProviderRegistry::new();\n reg.register(\"opencode_go\", MockProvider::new(\"opencode_go\", \"glm\"));\n\n // Empty after slash → unchanged\n assert_eq!(reg.strip_provider_prefix(\"opencode-go/\"), \"opencode-go/\");\n }\n\n #[test]\n fn test_strip_provider_prefix_custom_provider() {\n let mut reg = ProviderRegistry::new();\n reg.register(\"my_custom\", MockProvider::new(\"my_custom\", \"test\"));\n\n // Custom provider by exact name match\n assert_eq!(\n reg.strip_provider_prefix(\"my_custom/mymodel-v1\"),\n \"mymodel-v1\"\n );\n }\n}\n"}, "1a607078034c895fc9b9af713e0dcdb5ab43ac64": {"t": 1785761079.676624, "n": 13554, "h": "ecf41557da2ce55e", "c": "//! Provider registry — resolves model names to appropriate providers.\n//!\n//! Matches the Python `providers/registry.py` keyword-based model matching.\n\nuse crate::anthropic::{AnthropicConfig, AnthropicProvider};\nuse crate::base::LlmProvider;\nuse crate::openai_compat::{OpenAiCompatConfig, OpenAiCompatProvider};\nuse anyhow::Result;\nuse kestrel_config::schema::Config;\nuse std::collections::HashMap;\nuse std::sync::Arc;\nuse tokio::sync::Semaphore;\nuse tracing::{debug, info};\n\n/// Model keyword to provider name mapping.\n/// This mirrors the Python registry's MODEL_KEYWORDS map.\nconst MODEL_KEYWORD_MAP: &[(&str, &str)] = &[\n (\"claude\", \"anthropic\"),\n (\"anthropic\", \"anthropic\"),\n (\"gpt\", \"openai\"),\n (\"o1\", \"openai\"),\n (\"o3\", \"openai\"),\n (\"o4\", \"openai\"),\n (\"chatgpt\", \"openai\"),\n (\"deepseek-v4\", \"openrouter\"),\n (\"deepseek\", \"deepseek\"),\n (\"gemini\", \"gemini\"),\n (\"groq\", \"groq\"),\n (\"moonshot\", \"moonshot\"),\n (\"kimi\", \"moonshot\"),\n (\"minimax\", \"minimax\"),\n (\"llama\", \"ollama\"),\n (\"mistral\", \"ollama\"),\n (\"qwen\", \"ollama\"),\n (\"codestral\", \"ollama\"),\n (\"opencode-go\", \"opencode_go\"),\n (\"opencode_go\", \"opencode_go\"),\n];\n\n/// Registry of LLM providers.\n#[derive(Clone)]\npub struct ProviderRegistry {\n providers: HashMap>,\n default_provider: Option,\n /// Semaphore limiting concurrent in-flight provider requests.\n concurrency_limit: Arc,\n}\n\nimpl ProviderRegistry {\n /// Create a new empty registry.\n pub fn new() -> Self {\n Self {\n providers: HashMap::new(),\n default_provider: None,\n concurrency_limit: Arc::new(Semaphore::new(4)),\n }\n }\n\n /// Create a registry with a custom concurrency limit for in-flight requests.\n pub fn with_concurrency_limit(limit: usize) -> Self {\n Self {\n providers: HashMap::new(),\n default_provider: None,\n concurrency_limit: Arc::new(Semaphore::new(limit)),\n }\n }\n\n /// Acquire a permit from the concurrency semaphore.\n /// Returns a permit that releases on drop.\n pub async fn acquire_permit(&self) -> tokio::sync::OwnedSemaphorePermit {\n self.concurrency_limit\n .clone()\n .acquire_owned()\n .await\n .expect(\"concurrency semaphore should not be closed\")\n }\n\n /// Build the registry from a Config.\n pub fn from_config(config: &Config) -> Result {\n let mut registry = Self::new();\n\n // Register Anthropic provider if configured\n if let Some(entry) = &config.providers.anthropic {\n if let Some(api_key) = &entry.api_key {\n let provider = AnthropicProvider::new(AnthropicConfig {\n api_key: api_key.clone(),\n model: entry\n .model\n .clone()\n .unwrap_or_else(|| \"claude-sonnet-4-20250514\".to_string()),\n api_version: None,\n base_url: entry.base_url.clone(),\n })?;\n registry.register(\"anthropic\", provider);\n info!(\"Registered Anthropic provider\");\n }\n }\n\n // Register OpenAI provider if configured\n if let Some(entry) = &config.providers.openai {\n if let Some(api_key) = &entry.api_key {\n let provider = OpenAiCompatProvider::new(OpenAiCompatConfig {\n api_key: api_key.clone(),\n base_url: entry\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://api.openai.com/v1\".to_string()),\n model: entry.model.clone().unwrap_or_default(),\n organization: None,\n no_proxy: entry.no_proxy.unwrap_or(false),\n })?;\n registry.register(\"openai\", provider);\n info!(\"Registered OpenAI provider\");\n }\n }\n\n // Register DeepSeek provider\n if let Some(entry) = &config.providers.deepseek {\n if let Some(api_key) = &entry.api_key {\n let provider = OpenAiCompatProvider::new(OpenAiCompatConfig {\n api_key: api_key.clone(),\n base_url: entry\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://api.deepseek.com/v1\".to_string()),\n model: entry.model.clone().unwrap_or_default(),\n organization: None,\n no_proxy: entry.no_proxy.unwrap_or(false),\n })?;\n registry.register(\"deepseek\", provider);\n info!(\"Registered DeepSeek provider\");\n }\n }\n\n // Register Groq provider\n if let Some(entry) = &config.providers.groq {\n if let Some(api_key) = &entry.api_key {\n let provider = OpenAiCompatProvider::new(OpenAiCompatConfig {\n api_key: api_key.clone(),\n base_url: entry\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://api.groq.com/openai/v1\".to_string()),\n model: entry.model.clone().unwrap_or_default(),\n organization: None,\n no_proxy: entry.no_proxy.unwrap_or(false),\n })?;\n registry.register(\"groq\", provider);\n info!(\"Registered Groq provider\");\n }\n }\n\n // Register OpenRouter provider\n if let Some(entry) = &config.providers.openrouter {\n if let Some(api_key) = &entry.api_key {\n let provider = OpenAiCompatProvider::new(OpenAiCompatConfig {\n api_key: api_key.clone(),\n base_url: entry\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://openrouter.ai/api/v1\".to_string()),\n model: entry.model.clone().unwrap_or_default(),\n organization: None,\n no_proxy: entry.no_proxy.unwrap_or(false),\n })?;\n registry.register(\"openrouter\", provider);\n info!(\"Registered OpenRouter provider\");\n }\n }\n\n // Register Ollama provider (localhost — always skip proxy)\n if let Some(entry) = &config.providers.ollama {\n let provider = OpenAiCompatProvider::new(OpenAiCompatConfig {\n api_key: entry.api_key.clone().unwrap_or_default(),\n base_url: entry\n .base_url\n .clone()\n .unwrap_or_else(|| \"http://localhost:11434/v1\".to_string()),\n model: entry.model.clone().unwrap_or_default(),\n organization: None,\n no_proxy: true,\n })?;\n registry.register(\"ollama\", provider);\n info!(\"Registered Ollama provider\");\n }\n\n // Register OpenCode Go provider (OpenAI-compatible endpoint)\n if let Some(entry) = &config.providers.opencode_go {\n if let Some(api_key) = &entry.api_key {\n let provider = OpenAiCompatProvider::new(OpenAiCompatConfig {\n api_key: api_key.clone(),\n base_url: entry\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://opencode.ai/zen/go/v1\".to_string()),\n model: entry.model.clone().unwrap_or_default(),\n organization: None,\n no_proxy: entry.no_proxy.unwrap_or(false),\n })?;\n registry.register(\"opencode_go\", provider);\n info!(\"Registered OpenCode Go provider\");\n }\n }\n\n // Register custom providers\n for custom in &config.custom_providers {\n let provider = OpenAiCompatProvider::new(OpenAiCompatConfig {\n api_key: custom.api_key.clone().unwrap_or_default(),\n base_url: custom.base_url.clone(),\n model: custom.model_patterns.first().cloned().unwrap_or_default(),\n organization: None,\n no_proxy: custom.no_proxy.unwrap_or(false),\n })?;\n registry.register(&custom.name, provider);\n info!(\"Registered custom provider: {}\", custom.name);\n }\n\n // Set default provider based on agent model\n let model = &config.agent.model;\n let default = registry\n .resolve_provider_name(model)\n .map(|s| s.to_string())\n .or_else(|| registry.providers.keys().next().cloned());\n registry.default_provider = default;\n if let Some(ref name) = registry.default_provider {\n debug!(\"Default provider for model '{}': {}\", model, name);\n }\n\n Ok(registry)\n }\n\n /// Register a provider.\n pub fn register(&mut self, name: &str, provider: impl LlmProvider + 'static) {\n self.providers.insert(name.to_string(), Arc::new(provider));\n }\n\n /// Set the default provider by name.\n pub fn set_default(&mut self, name: &str) {\n if self.providers.contains_key(name) {\n self.default_provider = Some(name.to_string());\n }\n }\n\n /// Resolve a model name to a provider name.\n pub fn resolve_provider_name(&self, model: &str) -> Option<&str> {\n let lower = model.to_lowercase();\n for (keyword, provider_name) in MODEL_KEYWORD_MAP {\n if lower.contains(keyword) && self.providers.contains_key(*provider_name) {\n return Some(provider_name);\n }\n }\n self.default_provider.as_deref()\n }\n\n /// Get a provider for a given model.\n pub fn get_provider(&self, model: &str) -> Option> {\n if let Some(name) = self.resolve_provider_name(model) {\n self.providers.get(name).cloned()\n } else {\n self.default_provider\n .as_ref()\n .and_then(|name| self.providers.get(name).cloned())\n }\n }\n\n /// Get a provider by name.\n pub fn get_provider_by_name(&self, name: &str) -> Option> {\n self.providers.get(name).cloned()\n }\n\n /// List all registered provider names.\n pub fn provider_names(&self) -> Vec {\n self.providers.keys().cloned().collect()\n }\n}\n\nimpl Default for ProviderRegistry {\n fn default() -> Self {\n Self::new()\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::base::{\n BoxStream, CompletionChunk, CompletionRequest, CompletionResponse, LlmProvider,\n };\n use async_trait::async_trait;\n\n struct MockProvider {\n provider_name: String,\n supported_model: String,\n }\n\n impl MockProvider {\n fn new(name: &str, supported: &str) -> Self {\n Self {\n provider_name: name.to_string(),\n supported_model: supported.to_string(),\n }\n }\n }\n\n #[async_trait]\n impl LlmProvider for MockProvider {\n fn name(&self) -> &str {\n &self.provider_name\n }\n fn default_model(&self) -> &str {\n &self.supported_model\n }\n async fn complete(\n &self,\n _request: CompletionRequest,\n ) -> anyhow::Result {\n Ok(CompletionResponse {\n content: Some(\"mock\".to_string()),\n tool_calls: None,\n usage: None,\n finish_reason: None,\n })\n }\n async fn complete_stream(&self, request: CompletionRequest) -> anyhow::Result {\n let response = self.complete(request).await?;\n let chunk = CompletionChunk {\n delta: response.content,\n tool_call_deltas: None,\n usage: None,\n done: true,\n };\n Ok(Box::pin(futures::stream::once(async move { Ok(chunk) })))\n }\n fn supports_model(&self, model: &str) -> bool {\n model.contains(&self.supported_model)\n }\n }\n\n #[test]\n fn test_registry_new() {\n let reg = ProviderRegistry::new();\n assert!(reg.provider_names().is_empty());\n }\n\n #[test]\n fn test_registry_register() {\n let mut reg = ProviderRegistry::new();\n reg.register(\"mock\", MockProvider::new(\"mock\", \"test\"));\n let names = reg.provider_names();\n assert_eq!(names.len(), 1);\n assert!(names.contains(&\"mock\".to_string()));\n }\n\n #[test]\n fn test_registry_resolve_provider_name() {\n let mut reg = ProviderRegistry::new();\n reg.register(\"anthropic\", MockProvider::new(\"anthropic\", \"claude\"));\n reg.register(\"openai\", MockProvider::new(\"openai\", \"gpt\"));\n\n // \"claude-3.5-sonnet\" should resolve to \"anthropic\"\n let resolved = reg.resolve_provider_name(\"claude-3.5-sonnet\");\n assert_eq!(resolved, Some(\"anthropic\"));\n\n // \"gpt-4o\" should resolve to \"openai\"\n let resolved = reg.resolve_provider_name(\"gpt-4o\");\n assert_eq!(resolved, Some(\"openai\"));\n }\n\n #[test]\n fn test_registry_default_provider() {\n let reg = ProviderRegistry::default();\n assert!(reg.provider_names().is_empty());\n assert!(reg.default_provider.is_none());\n }\n}\n"}, "449d9bb4e0201ce481d16a81633fe097f37848f2": {"t": 1785761081.0315025, "n": 12648, "h": "3ba01ab96c448d6d", "c": "//! Provider registry — resolves model names to appropriate providers.\n//!\n//! Matches the Python `providers/registry.py` keyword-based model matching.\n\nuse crate::anthropic::{AnthropicConfig, AnthropicProvider};\nuse crate::base::LlmProvider;\nuse crate::openai_compat::{OpenAiCompatConfig, OpenAiCompatProvider};\nuse anyhow::Result;\nuse kestrel_config::schema::Config;\nuse std::collections::HashMap;\nuse std::sync::Arc;\nuse tokio::sync::Semaphore;\nuse tracing::{debug, info};\n\n/// Model keyword to provider name mapping.\n/// This mirrors the Python registry's MODEL_KEYWORDS map.\nconst MODEL_KEYWORD_MAP: &[(&str, &str)] = &[\n (\"claude\", \"anthropic\"),\n (\"anthropic\", \"anthropic\"),\n (\"gpt\", \"openai\"),\n (\"o1\", \"openai\"),\n (\"o3\", \"openai\"),\n (\"o4\", \"openai\"),\n (\"chatgpt\", \"openai\"),\n (\"deepseek-v4\", \"openrouter\"),\n (\"deepseek\", \"deepseek\"),\n (\"gemini\", \"gemini\"),\n (\"groq\", \"groq\"),\n (\"moonshot\", \"moonshot\"),\n (\"kimi\", \"moonshot\"),\n (\"minimax\", \"minimax\"),\n (\"llama\", \"ollama\"),\n (\"mistral\", \"ollama\"),\n (\"qwen\", \"ollama\"),\n (\"codestral\", \"ollama\"),\n];\n\n/// Registry of LLM providers.\n#[derive(Clone)]\npub struct ProviderRegistry {\n providers: HashMap>,\n default_provider: Option,\n /// Semaphore limiting concurrent in-flight provider requests.\n concurrency_limit: Arc,\n}\n\nimpl ProviderRegistry {\n /// Create a new empty registry.\n pub fn new() -> Self {\n Self {\n providers: HashMap::new(),\n default_provider: None,\n concurrency_limit: Arc::new(Semaphore::new(4)),\n }\n }\n\n /// Create a registry with a custom concurrency limit for in-flight requests.\n pub fn with_concurrency_limit(limit: usize) -> Self {\n Self {\n providers: HashMap::new(),\n default_provider: None,\n concurrency_limit: Arc::new(Semaphore::new(limit)),\n }\n }\n\n /// Acquire a permit from the concurrency semaphore.\n /// Returns a permit that releases on drop.\n pub async fn acquire_permit(&self) -> tokio::sync::OwnedSemaphorePermit {\n self.concurrency_limit\n .clone()\n .acquire_owned()\n .await\n .expect(\"concurrency semaphore should not be closed\")\n }\n\n /// Build the registry from a Config.\n pub fn from_config(config: &Config) -> Result {\n let mut registry = Self::new();\n\n // Register Anthropic provider if configured\n if let Some(entry) = &config.providers.anthropic {\n if let Some(api_key) = &entry.api_key {\n let provider = AnthropicProvider::new(AnthropicConfig {\n api_key: api_key.clone(),\n model: entry\n .model\n .clone()\n .unwrap_or_else(|| \"claude-sonnet-4-20250514\".to_string()),\n api_version: None,\n base_url: entry.base_url.clone(),\n })?;\n registry.register(\"anthropic\", provider);\n info!(\"Registered Anthropic provider\");\n }\n }\n\n // Register OpenAI provider if configured\n if let Some(entry) = &config.providers.openai {\n if let Some(api_key) = &entry.api_key {\n let provider = OpenAiCompatProvider::new(OpenAiCompatConfig {\n api_key: api_key.clone(),\n base_url: entry\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://api.openai.com/v1\".to_string()),\n model: entry.model.clone().unwrap_or_default(),\n organization: None,\n no_proxy: entry.no_proxy.unwrap_or(false),\n })?;\n registry.register(\"openai\", provider);\n info!(\"Registered OpenAI provider\");\n }\n }\n\n // Register DeepSeek provider\n if let Some(entry) = &config.providers.deepseek {\n if let Some(api_key) = &entry.api_key {\n let provider = OpenAiCompatProvider::new(OpenAiCompatConfig {\n api_key: api_key.clone(),\n base_url: entry\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://api.deepseek.com/v1\".to_string()),\n model: entry.model.clone().unwrap_or_default(),\n organization: None,\n no_proxy: entry.no_proxy.unwrap_or(false),\n })?;\n registry.register(\"deepseek\", provider);\n info!(\"Registered DeepSeek provider\");\n }\n }\n\n // Register Groq provider\n if let Some(entry) = &config.providers.groq {\n if let Some(api_key) = &entry.api_key {\n let provider = OpenAiCompatProvider::new(OpenAiCompatConfig {\n api_key: api_key.clone(),\n base_url: entry\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://api.groq.com/openai/v1\".to_string()),\n model: entry.model.clone().unwrap_or_default(),\n organization: None,\n no_proxy: entry.no_proxy.unwrap_or(false),\n })?;\n registry.register(\"groq\", provider);\n info!(\"Registered Groq provider\");\n }\n }\n\n // Register OpenRouter provider\n if let Some(entry) = &config.providers.openrouter {\n if let Some(api_key) = &entry.api_key {\n let provider = OpenAiCompatProvider::new(OpenAiCompatConfig {\n api_key: api_key.clone(),\n base_url: entry\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://openrouter.ai/api/v1\".to_string()),\n model: entry.model.clone().unwrap_or_default(),\n organization: None,\n no_proxy: entry.no_proxy.unwrap_or(false),\n })?;\n registry.register(\"openrouter\", provider);\n info!(\"Registered OpenRouter provider\");\n }\n }\n\n // Register Ollama provider (localhost — always skip proxy)\n if let Some(entry) = &config.providers.ollama {\n let provider = OpenAiCompatProvider::new(OpenAiCompatConfig {\n api_key: entry.api_key.clone().unwrap_or_default(),\n base_url: entry\n .base_url\n .clone()\n .unwrap_or_else(|| \"http://localhost:11434/v1\".to_string()),\n model: entry.model.clone().unwrap_or_default(),\n organization: None,\n no_proxy: true,\n })?;\n registry.register(\"ollama\", provider);\n info!(\"Registered Ollama provider\");\n }\n\n // Register custom providers\n for custom in &config.custom_providers {\n let provider = OpenAiCompatProvider::new(OpenAiCompatConfig {\n api_key: custom.api_key.clone().unwrap_or_default(),\n base_url: custom.base_url.clone(),\n model: custom.model_patterns.first().cloned().unwrap_or_default(),\n organization: None,\n no_proxy: custom.no_proxy.unwrap_or(false),\n })?;\n registry.register(&custom.name, provider);\n info!(\"Registered custom provider: {}\", custom.name);\n }\n\n // Set default provider based on agent model\n let model = &config.agent.model;\n let default = registry\n .resolve_provider_name(model)\n .map(|s| s.to_string())\n .or_else(|| registry.providers.keys().next().cloned());\n registry.default_provider = default;\n if let Some(ref name) = registry.default_provider {\n debug!(\"Default provider for model '{}': {}\", model, name);\n }\n\n Ok(registry)\n }\n\n /// Register a provider.\n pub fn register(&mut self, name: &str, provider: impl LlmProvider + 'static) {\n self.providers.insert(name.to_string(), Arc::new(provider));\n }\n\n /// Set the default provider by name.\n pub fn set_default(&mut self, name: &str) {\n if self.providers.contains_key(name) {\n self.default_provider = Some(name.to_string());\n }\n }\n\n /// Resolve a model name to a provider name.\n pub fn resolve_provider_name(&self, model: &str) -> Option<&str> {\n let lower = model.to_lowercase();\n for (keyword, provider_name) in MODEL_KEYWORD_MAP {\n if lower.contains(keyword) && self.providers.contains_key(*provider_name) {\n return Some(provider_name);\n }\n }\n self.default_provider.as_deref()\n }\n\n /// Get a provider for a given model.\n pub fn get_provider(&self, model: &str) -> Option> {\n if let Some(name) = self.resolve_provider_name(model) {\n self.providers.get(name).cloned()\n } else {\n self.default_provider\n .as_ref()\n .and_then(|name| self.providers.get(name).cloned())\n }\n }\n\n /// Get a provider by name.\n pub fn get_provider_by_name(&self, name: &str) -> Option> {\n self.providers.get(name).cloned()\n }\n\n /// List all registered provider names.\n pub fn provider_names(&self) -> Vec {\n self.providers.keys().cloned().collect()\n }\n}\n\nimpl Default for ProviderRegistry {\n fn default() -> Self {\n Self::new()\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::base::{\n BoxStream, CompletionChunk, CompletionRequest, CompletionResponse, LlmProvider,\n };\n use async_trait::async_trait;\n\n struct MockProvider {\n provider_name: String,\n supported_model: String,\n }\n\n impl MockProvider {\n fn new(name: &str, supported: &str) -> Self {\n Self {\n provider_name: name.to_string(),\n supported_model: supported.to_string(),\n }\n }\n }\n\n #[async_trait]\n impl LlmProvider for MockProvider {\n fn name(&self) -> &str {\n &self.provider_name\n }\n fn default_model(&self) -> &str {\n &self.supported_model\n }\n async fn complete(\n &self,\n _request: CompletionRequest,\n ) -> anyhow::Result {\n Ok(CompletionResponse {\n content: Some(\"mock\".to_string()),\n tool_calls: None,\n usage: None,\n finish_reason: None,\n })\n }\n async fn complete_stream(&self, request: CompletionRequest) -> anyhow::Result {\n let response = self.complete(request).await?;\n let chunk = CompletionChunk {\n delta: response.content,\n tool_call_deltas: None,\n usage: None,\n done: true,\n };\n Ok(Box::pin(futures::stream::once(async move { Ok(chunk) })))\n }\n fn supports_model(&self, model: &str) -> bool {\n model.contains(&self.supported_model)\n }\n }\n\n #[test]\n fn test_registry_new() {\n let reg = ProviderRegistry::new();\n assert!(reg.provider_names().is_empty());\n }\n\n #[test]\n fn test_registry_register() {\n let mut reg = ProviderRegistry::new();\n reg.register(\"mock\", MockProvider::new(\"mock\", \"test\"));\n let names = reg.provider_names();\n assert_eq!(names.len(), 1);\n assert!(names.contains(&\"mock\".to_string()));\n }\n\n #[test]\n fn test_registry_resolve_provider_name() {\n let mut reg = ProviderRegistry::new();\n reg.register(\"anthropic\", MockProvider::new(\"anthropic\", \"claude\"));\n reg.register(\"openai\", MockProvider::new(\"openai\", \"gpt\"));\n\n // \"claude-3.5-sonnet\" should resolve to \"anthropic\"\n let resolved = reg.resolve_provider_name(\"claude-3.5-sonnet\");\n assert_eq!(resolved, Some(\"anthropic\"));\n\n // \"gpt-4o\" should resolve to \"openai\"\n let resolved = reg.resolve_provider_name(\"gpt-4o\");\n assert_eq!(resolved, Some(\"openai\"));\n }\n\n #[test]\n fn test_registry_default_provider() {\n let reg = ProviderRegistry::default();\n assert!(reg.provider_names().is_empty());\n assert!(reg.default_provider.is_none());\n }\n}\n"}, "8df084a7ef39b203876e628de53d0ee6a2b1da65": {"t": 1785761082.2479463, "n": 12613, "h": "17cf79a9a410adfe", "c": "//! Provider registry — resolves model names to appropriate providers.\n//!\n//! Matches the Python `providers/registry.py` keyword-based model matching.\n\nuse crate::anthropic::{AnthropicConfig, AnthropicProvider};\nuse crate::base::LlmProvider;\nuse crate::openai_compat::{OpenAiCompatConfig, OpenAiCompatProvider};\nuse anyhow::Result;\nuse kestrel_config::schema::Config;\nuse std::collections::HashMap;\nuse std::sync::Arc;\nuse tokio::sync::Semaphore;\nuse tracing::{debug, info};\n\n/// Model keyword to provider name mapping.\n/// This mirrors the Python registry's MODEL_KEYWORDS map.\nconst MODEL_KEYWORD_MAP: &[(&str, &str)] = &[\n (\"claude\", \"anthropic\"),\n (\"anthropic\", \"anthropic\"),\n (\"gpt\", \"openai\"),\n (\"o1\", \"openai\"),\n (\"o3\", \"openai\"),\n (\"o4\", \"openai\"),\n (\"chatgpt\", \"openai\"),\n (\"deepseek\", \"deepseek\"),\n (\"gemini\", \"gemini\"),\n (\"groq\", \"groq\"),\n (\"moonshot\", \"moonshot\"),\n (\"kimi\", \"moonshot\"),\n (\"minimax\", \"minimax\"),\n (\"llama\", \"ollama\"),\n (\"mistral\", \"ollama\"),\n (\"qwen\", \"ollama\"),\n (\"codestral\", \"ollama\"),\n];\n\n/// Registry of LLM providers.\n#[derive(Clone)]\npub struct ProviderRegistry {\n providers: HashMap>,\n default_provider: Option,\n /// Semaphore limiting concurrent in-flight provider requests.\n concurrency_limit: Arc,\n}\n\nimpl ProviderRegistry {\n /// Create a new empty registry.\n pub fn new() -> Self {\n Self {\n providers: HashMap::new(),\n default_provider: None,\n concurrency_limit: Arc::new(Semaphore::new(4)),\n }\n }\n\n /// Create a registry with a custom concurrency limit for in-flight requests.\n pub fn with_concurrency_limit(limit: usize) -> Self {\n Self {\n providers: HashMap::new(),\n default_provider: None,\n concurrency_limit: Arc::new(Semaphore::new(limit)),\n }\n }\n\n /// Acquire a permit from the concurrency semaphore.\n /// Returns a permit that releases on drop.\n pub async fn acquire_permit(&self) -> tokio::sync::OwnedSemaphorePermit {\n self.concurrency_limit\n .clone()\n .acquire_owned()\n .await\n .expect(\"concurrency semaphore should not be closed\")\n }\n\n /// Build the registry from a Config.\n pub fn from_config(config: &Config) -> Result {\n let mut registry = Self::new();\n\n // Register Anthropic provider if configured\n if let Some(entry) = &config.providers.anthropic {\n if let Some(api_key) = &entry.api_key {\n let provider = AnthropicProvider::new(AnthropicConfig {\n api_key: api_key.clone(),\n model: entry\n .model\n .clone()\n .unwrap_or_else(|| \"claude-sonnet-4-20250514\".to_string()),\n api_version: None,\n base_url: entry.base_url.clone(),\n })?;\n registry.register(\"anthropic\", provider);\n info!(\"Registered Anthropic provider\");\n }\n }\n\n // Register OpenAI provider if configured\n if let Some(entry) = &config.providers.openai {\n if let Some(api_key) = &entry.api_key {\n let provider = OpenAiCompatProvider::new(OpenAiCompatConfig {\n api_key: api_key.clone(),\n base_url: entry\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://api.openai.com/v1\".to_string()),\n model: entry.model.clone().unwrap_or_default(),\n organization: None,\n no_proxy: entry.no_proxy.unwrap_or(false),\n })?;\n registry.register(\"openai\", provider);\n info!(\"Registered OpenAI provider\");\n }\n }\n\n // Register DeepSeek provider\n if let Some(entry) = &config.providers.deepseek {\n if let Some(api_key) = &entry.api_key {\n let provider = OpenAiCompatProvider::new(OpenAiCompatConfig {\n api_key: api_key.clone(),\n base_url: entry\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://api.deepseek.com/v1\".to_string()),\n model: entry.model.clone().unwrap_or_default(),\n organization: None,\n no_proxy: entry.no_proxy.unwrap_or(false),\n })?;\n registry.register(\"deepseek\", provider);\n info!(\"Registered DeepSeek provider\");\n }\n }\n\n // Register Groq provider\n if let Some(entry) = &config.providers.groq {\n if let Some(api_key) = &entry.api_key {\n let provider = OpenAiCompatProvider::new(OpenAiCompatConfig {\n api_key: api_key.clone(),\n base_url: entry\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://api.groq.com/openai/v1\".to_string()),\n model: entry.model.clone().unwrap_or_default(),\n organization: None,\n no_proxy: entry.no_proxy.unwrap_or(false),\n })?;\n registry.register(\"groq\", provider);\n info!(\"Registered Groq provider\");\n }\n }\n\n // Register OpenRouter provider\n if let Some(entry) = &config.providers.openrouter {\n if let Some(api_key) = &entry.api_key {\n let provider = OpenAiCompatProvider::new(OpenAiCompatConfig {\n api_key: api_key.clone(),\n base_url: entry\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://openrouter.ai/api/v1\".to_string()),\n model: entry.model.clone().unwrap_or_default(),\n organization: None,\n no_proxy: entry.no_proxy.unwrap_or(false),\n })?;\n registry.register(\"openrouter\", provider);\n info!(\"Registered OpenRouter provider\");\n }\n }\n\n // Register Ollama provider (localhost — always skip proxy)\n if let Some(entry) = &config.providers.ollama {\n let provider = OpenAiCompatProvider::new(OpenAiCompatConfig {\n api_key: entry.api_key.clone().unwrap_or_default(),\n base_url: entry\n .base_url\n .clone()\n .unwrap_or_else(|| \"http://localhost:11434/v1\".to_string()),\n model: entry.model.clone().unwrap_or_default(),\n organization: None,\n no_proxy: true,\n })?;\n registry.register(\"ollama\", provider);\n info!(\"Registered Ollama provider\");\n }\n\n // Register custom providers\n for custom in &config.custom_providers {\n let provider = OpenAiCompatProvider::new(OpenAiCompatConfig {\n api_key: custom.api_key.clone().unwrap_or_default(),\n base_url: custom.base_url.clone(),\n model: custom.model_patterns.first().cloned().unwrap_or_default(),\n organization: None,\n no_proxy: custom.no_proxy.unwrap_or(false),\n })?;\n registry.register(&custom.name, provider);\n info!(\"Registered custom provider: {}\", custom.name);\n }\n\n // Set default provider based on agent model\n let model = &config.agent.model;\n let default = registry\n .resolve_provider_name(model)\n .map(|s| s.to_string())\n .or_else(|| registry.providers.keys().next().cloned());\n registry.default_provider = default;\n if let Some(ref name) = registry.default_provider {\n debug!(\"Default provider for model '{}': {}\", model, name);\n }\n\n Ok(registry)\n }\n\n /// Register a provider.\n pub fn register(&mut self, name: &str, provider: impl LlmProvider + 'static) {\n self.providers.insert(name.to_string(), Arc::new(provider));\n }\n\n /// Set the default provider by name.\n pub fn set_default(&mut self, name: &str) {\n if self.providers.contains_key(name) {\n self.default_provider = Some(name.to_string());\n }\n }\n\n /// Resolve a model name to a provider name.\n pub fn resolve_provider_name(&self, model: &str) -> Option<&str> {\n let lower = model.to_lowercase();\n for (keyword, provider_name) in MODEL_KEYWORD_MAP {\n if lower.contains(keyword) && self.providers.contains_key(*provider_name) {\n return Some(provider_name);\n }\n }\n self.default_provider.as_deref()\n }\n\n /// Get a provider for a given model.\n pub fn get_provider(&self, model: &str) -> Option> {\n if let Some(name) = self.resolve_provider_name(model) {\n self.providers.get(name).cloned()\n } else {\n self.default_provider\n .as_ref()\n .and_then(|name| self.providers.get(name).cloned())\n }\n }\n\n /// Get a provider by name.\n pub fn get_provider_by_name(&self, name: &str) -> Option> {\n self.providers.get(name).cloned()\n }\n\n /// List all registered provider names.\n pub fn provider_names(&self) -> Vec {\n self.providers.keys().cloned().collect()\n }\n}\n\nimpl Default for ProviderRegistry {\n fn default() -> Self {\n Self::new()\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::base::{\n BoxStream, CompletionChunk, CompletionRequest, CompletionResponse, LlmProvider,\n };\n use async_trait::async_trait;\n\n struct MockProvider {\n provider_name: String,\n supported_model: String,\n }\n\n impl MockProvider {\n fn new(name: &str, supported: &str) -> Self {\n Self {\n provider_name: name.to_string(),\n supported_model: supported.to_string(),\n }\n }\n }\n\n #[async_trait]\n impl LlmProvider for MockProvider {\n fn name(&self) -> &str {\n &self.provider_name\n }\n fn default_model(&self) -> &str {\n &self.supported_model\n }\n async fn complete(\n &self,\n _request: CompletionRequest,\n ) -> anyhow::Result {\n Ok(CompletionResponse {\n content: Some(\"mock\".to_string()),\n tool_calls: None,\n usage: None,\n finish_reason: None,\n })\n }\n async fn complete_stream(&self, request: CompletionRequest) -> anyhow::Result {\n let response = self.complete(request).await?;\n let chunk = CompletionChunk {\n delta: response.content,\n tool_call_deltas: None,\n usage: None,\n done: true,\n };\n Ok(Box::pin(futures::stream::once(async move { Ok(chunk) })))\n }\n fn supports_model(&self, model: &str) -> bool {\n model.contains(&self.supported_model)\n }\n }\n\n #[test]\n fn test_registry_new() {\n let reg = ProviderRegistry::new();\n assert!(reg.provider_names().is_empty());\n }\n\n #[test]\n fn test_registry_register() {\n let mut reg = ProviderRegistry::new();\n reg.register(\"mock\", MockProvider::new(\"mock\", \"test\"));\n let names = reg.provider_names();\n assert_eq!(names.len(), 1);\n assert!(names.contains(&\"mock\".to_string()));\n }\n\n #[test]\n fn test_registry_resolve_provider_name() {\n let mut reg = ProviderRegistry::new();\n reg.register(\"anthropic\", MockProvider::new(\"anthropic\", \"claude\"));\n reg.register(\"openai\", MockProvider::new(\"openai\", \"gpt\"));\n\n // \"claude-3.5-sonnet\" should resolve to \"anthropic\"\n let resolved = reg.resolve_provider_name(\"claude-3.5-sonnet\");\n assert_eq!(resolved, Some(\"anthropic\"));\n\n // \"gpt-4o\" should resolve to \"openai\"\n let resolved = reg.resolve_provider_name(\"gpt-4o\");\n assert_eq!(resolved, Some(\"openai\"));\n }\n\n #[test]\n fn test_registry_default_provider() {\n let reg = ProviderRegistry::default();\n assert!(reg.provider_names().is_empty());\n assert!(reg.default_provider.is_none());\n }\n}\n"}, "1c0838bc8bdad07147440bb63a24f7a700e25f9c": {"t": 1785761084.5137577, "n": 11752, "h": "11dce3e0c007c667", "c": "//! Provider registry — resolves model names to appropriate providers.\n//!\n//! Matches the Python `providers/registry.py` keyword-based model matching.\n\nuse crate::anthropic::{AnthropicConfig, AnthropicProvider};\nuse crate::base::LlmProvider;\nuse crate::openai_compat::{OpenAiCompatConfig, OpenAiCompatProvider};\nuse anyhow::Result;\nuse kestrel_config::schema::Config;\nuse std::collections::HashMap;\nuse std::sync::Arc;\nuse tracing::{debug, info};\n\n/// Model keyword to provider name mapping.\n/// This mirrors the Python registry's MODEL_KEYWORDS map.\nconst MODEL_KEYWORD_MAP: &[(&str, &str)] = &[\n (\"claude\", \"anthropic\"),\n (\"anthropic\", \"anthropic\"),\n (\"gpt\", \"openai\"),\n (\"o1\", \"openai\"),\n (\"o3\", \"openai\"),\n (\"o4\", \"openai\"),\n (\"chatgpt\", \"openai\"),\n (\"deepseek\", \"deepseek\"),\n (\"gemini\", \"gemini\"),\n (\"groq\", \"groq\"),\n (\"moonshot\", \"moonshot\"),\n (\"kimi\", \"moonshot\"),\n (\"minimax\", \"minimax\"),\n (\"llama\", \"ollama\"),\n (\"mistral\", \"ollama\"),\n (\"qwen\", \"ollama\"),\n (\"codestral\", \"ollama\"),\n];\n\n/// Registry of LLM providers.\n#[derive(Clone)]\npub struct ProviderRegistry {\n providers: HashMap>,\n default_provider: Option,\n}\n\nimpl ProviderRegistry {\n /// Create a new empty registry.\n pub fn new() -> Self {\n Self {\n providers: HashMap::new(),\n default_provider: None,\n }\n }\n\n /// Build the registry from a Config.\n pub fn from_config(config: &Config) -> Result {\n let mut registry = Self::new();\n\n // Register Anthropic provider if configured\n if let Some(entry) = &config.providers.anthropic {\n if let Some(api_key) = &entry.api_key {\n let provider = AnthropicProvider::new(AnthropicConfig {\n api_key: api_key.clone(),\n model: entry\n .model\n .clone()\n .unwrap_or_else(|| \"claude-sonnet-4-20250514\".to_string()),\n api_version: None,\n base_url: entry.base_url.clone(),\n })?;\n registry.register(\"anthropic\", provider);\n info!(\"Registered Anthropic provider\");\n }\n }\n\n // Register OpenAI provider if configured\n if let Some(entry) = &config.providers.openai {\n if let Some(api_key) = &entry.api_key {\n let provider = OpenAiCompatProvider::new(OpenAiCompatConfig {\n api_key: api_key.clone(),\n base_url: entry\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://api.openai.com/v1\".to_string()),\n model: entry.model.clone().unwrap_or_default(),\n organization: None,\n no_proxy: entry.no_proxy.unwrap_or(false),\n })?;\n registry.register(\"openai\", provider);\n info!(\"Registered OpenAI provider\");\n }\n }\n\n // Register DeepSeek provider\n if let Some(entry) = &config.providers.deepseek {\n if let Some(api_key) = &entry.api_key {\n let provider = OpenAiCompatProvider::new(OpenAiCompatConfig {\n api_key: api_key.clone(),\n base_url: entry\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://api.deepseek.com/v1\".to_string()),\n model: entry.model.clone().unwrap_or_default(),\n organization: None,\n no_proxy: entry.no_proxy.unwrap_or(false),\n })?;\n registry.register(\"deepseek\", provider);\n info!(\"Registered DeepSeek provider\");\n }\n }\n\n // Register Groq provider\n if let Some(entry) = &config.providers.groq {\n if let Some(api_key) = &entry.api_key {\n let provider = OpenAiCompatProvider::new(OpenAiCompatConfig {\n api_key: api_key.clone(),\n base_url: entry\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://api.groq.com/openai/v1\".to_string()),\n model: entry.model.clone().unwrap_or_default(),\n organization: None,\n no_proxy: entry.no_proxy.unwrap_or(false),\n })?;\n registry.register(\"groq\", provider);\n info!(\"Registered Groq provider\");\n }\n }\n\n // Register OpenRouter provider\n if let Some(entry) = &config.providers.openrouter {\n if let Some(api_key) = &entry.api_key {\n let provider = OpenAiCompatProvider::new(OpenAiCompatConfig {\n api_key: api_key.clone(),\n base_url: entry\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://openrouter.ai/api/v1\".to_string()),\n model: entry.model.clone().unwrap_or_default(),\n organization: None,\n no_proxy: entry.no_proxy.unwrap_or(false),\n })?;\n registry.register(\"openrouter\", provider);\n info!(\"Registered OpenRouter provider\");\n }\n }\n\n // Register Ollama provider (localhost — always skip proxy)\n if let Some(entry) = &config.providers.ollama {\n let provider = OpenAiCompatProvider::new(OpenAiCompatConfig {\n api_key: entry.api_key.clone().unwrap_or_default(),\n base_url: entry\n .base_url\n .clone()\n .unwrap_or_else(|| \"http://localhost:11434/v1\".to_string()),\n model: entry.model.clone().unwrap_or_default(),\n organization: None,\n no_proxy: true,\n })?;\n registry.register(\"ollama\", provider);\n info!(\"Registered Ollama provider\");\n }\n\n // Register custom providers\n for custom in &config.custom_providers {\n let provider = OpenAiCompatProvider::new(OpenAiCompatConfig {\n api_key: custom.api_key.clone().unwrap_or_default(),\n base_url: custom.base_url.clone(),\n model: custom.model_patterns.first().cloned().unwrap_or_default(),\n organization: None,\n no_proxy: custom.no_proxy.unwrap_or(false),\n })?;\n registry.register(&custom.name, provider);\n info!(\"Registered custom provider: {}\", custom.name);\n }\n\n // Set default provider based on agent model\n let model = &config.agent.model;\n let default = registry\n .resolve_provider_name(model)\n .map(|s| s.to_string())\n .or_else(|| registry.providers.keys().next().cloned());\n registry.default_provider = default;\n if let Some(ref name) = registry.default_provider {\n debug!(\"Default provider for model '{}': {}\", model, name);\n }\n\n Ok(registry)\n }\n\n /// Register a provider.\n pub fn register(&mut self, name: &str, provider: impl LlmProvider + 'static) {\n self.providers.insert(name.to_string(), Arc::new(provider));\n }\n\n /// Set the default provider by name.\n pub fn set_default(&mut self, name: &str) {\n if self.providers.contains_key(name) {\n self.default_provider = Some(name.to_string());\n }\n }\n\n /// Resolve a model name to a provider name.\n pub fn resolve_provider_name(&self, model: &str) -> Option<&str> {\n let lower = model.to_lowercase();\n for (keyword, provider_name) in MODEL_KEYWORD_MAP {\n if lower.contains(keyword) && self.providers.contains_key(*provider_name) {\n return Some(provider_name);\n }\n }\n self.default_provider.as_deref()\n }\n\n /// Get a provider for a given model.\n pub fn get_provider(&self, model: &str) -> Option> {\n if let Some(name) = self.resolve_provider_name(model) {\n self.providers.get(name).cloned()\n } else {\n self.default_provider\n .as_ref()\n .and_then(|name| self.providers.get(name).cloned())\n }\n }\n\n /// Get a provider by name.\n pub fn get_provider_by_name(&self, name: &str) -> Option> {\n self.providers.get(name).cloned()\n }\n\n /// List all registered provider names.\n pub fn provider_names(&self) -> Vec {\n self.providers.keys().cloned().collect()\n }\n}\n\nimpl Default for ProviderRegistry {\n fn default() -> Self {\n Self::new()\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::base::{\n BoxStream, CompletionChunk, CompletionRequest, CompletionResponse, LlmProvider,\n };\n use async_trait::async_trait;\n\n struct MockProvider {\n provider_name: String,\n supported_model: String,\n }\n\n impl MockProvider {\n fn new(name: &str, supported: &str) -> Self {\n Self {\n provider_name: name.to_string(),\n supported_model: supported.to_string(),\n }\n }\n }\n\n #[async_trait]\n impl LlmProvider for MockProvider {\n fn name(&self) -> &str {\n &self.provider_name\n }\n fn default_model(&self) -> &str {\n &self.supported_model\n }\n async fn complete(\n &self,\n _request: CompletionRequest,\n ) -> anyhow::Result {\n Ok(CompletionResponse {\n content: Some(\"mock\".to_string()),\n tool_calls: None,\n usage: None,\n finish_reason: None,\n })\n }\n async fn complete_stream(&self, request: CompletionRequest) -> anyhow::Result {\n let response = self.complete(request).await?;\n let chunk = CompletionChunk {\n delta: response.content,\n tool_call_deltas: None,\n usage: None,\n done: true,\n };\n Ok(Box::pin(futures::stream::once(async move { Ok(chunk) })))\n }\n fn supports_model(&self, model: &str) -> bool {\n model.contains(&self.supported_model)\n }\n }\n\n #[test]\n fn test_registry_new() {\n let reg = ProviderRegistry::new();\n assert!(reg.provider_names().is_empty());\n }\n\n #[test]\n fn test_registry_register() {\n let mut reg = ProviderRegistry::new();\n reg.register(\"mock\", MockProvider::new(\"mock\", \"test\"));\n let names = reg.provider_names();\n assert_eq!(names.len(), 1);\n assert!(names.contains(&\"mock\".to_string()));\n }\n\n #[test]\n fn test_registry_resolve_provider_name() {\n let mut reg = ProviderRegistry::new();\n reg.register(\"anthropic\", MockProvider::new(\"anthropic\", \"claude\"));\n reg.register(\"openai\", MockProvider::new(\"openai\", \"gpt\"));\n\n // \"claude-3.5-sonnet\" should resolve to \"anthropic\"\n let resolved = reg.resolve_provider_name(\"claude-3.5-sonnet\");\n assert_eq!(resolved, Some(\"anthropic\"));\n\n // \"gpt-4o\" should resolve to \"openai\"\n let resolved = reg.resolve_provider_name(\"gpt-4o\");\n assert_eq!(resolved, Some(\"openai\"));\n }\n\n #[test]\n fn test_registry_default_provider() {\n let reg = ProviderRegistry::default();\n assert!(reg.provider_names().is_empty());\n assert!(reg.default_provider.is_none());\n }\n}\n"}, "ff84cd9ec75834c24786dbeebdd8dc3dfed5c6e0": {"t": 1785761085.819026, "n": 11621, "h": "27ba936db736a352", "c": "//! Provider registry — resolves model names to appropriate providers.\n//!\n//! Matches the Python `providers/registry.py` keyword-based model matching.\n\nuse crate::anthropic::{AnthropicConfig, AnthropicProvider};\nuse crate::base::LlmProvider;\nuse crate::openai_compat::{OpenAiCompatConfig, OpenAiCompatProvider};\nuse anyhow::Result;\nuse kestrel_config::schema::Config;\nuse std::collections::HashMap;\nuse std::sync::Arc;\nuse tracing::{debug, info};\n\n/// Model keyword to provider name mapping.\n/// This mirrors the Python registry's MODEL_KEYWORDS map.\nconst MODEL_KEYWORD_MAP: &[(&str, &str)] = &[\n (\"claude\", \"anthropic\"),\n (\"anthropic\", \"anthropic\"),\n (\"gpt\", \"openai\"),\n (\"o1\", \"openai\"),\n (\"o3\", \"openai\"),\n (\"o4\", \"openai\"),\n (\"chatgpt\", \"openai\"),\n (\"deepseek\", \"deepseek\"),\n (\"gemini\", \"gemini\"),\n (\"groq\", \"groq\"),\n (\"moonshot\", \"moonshot\"),\n (\"kimi\", \"moonshot\"),\n (\"minimax\", \"minimax\"),\n (\"llama\", \"ollama\"),\n (\"mistral\", \"ollama\"),\n (\"qwen\", \"ollama\"),\n (\"codestral\", \"ollama\"),\n];\n\n/// Registry of LLM providers.\n#[derive(Clone)]\npub struct ProviderRegistry {\n providers: HashMap>,\n default_provider: Option,\n}\n\nimpl ProviderRegistry {\n /// Create a new empty registry.\n pub fn new() -> Self {\n Self {\n providers: HashMap::new(),\n default_provider: None,\n }\n }\n\n /// Build the registry from a Config.\n pub fn from_config(config: &Config) -> Result {\n let mut registry = Self::new();\n\n // Register Anthropic provider if configured\n if let Some(entry) = &config.providers.anthropic {\n if let Some(api_key) = &entry.api_key {\n let provider = AnthropicProvider::new(AnthropicConfig {\n api_key: api_key.clone(),\n model: entry\n .model\n .clone()\n .unwrap_or_else(|| \"claude-sonnet-4-20250514\".to_string()),\n api_version: None,\n base_url: entry.base_url.clone(),\n })?;\n registry.register(\"anthropic\", provider);\n info!(\"Registered Anthropic provider\");\n }\n }\n\n // Register OpenAI provider if configured\n if let Some(entry) = &config.providers.openai {\n if let Some(api_key) = &entry.api_key {\n let provider = OpenAiCompatProvider::new(OpenAiCompatConfig {\n api_key: api_key.clone(),\n base_url: entry\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://api.openai.com/v1\".to_string()),\n model: entry.model.clone().unwrap_or_default(),\n organization: None,\n no_proxy: entry.no_proxy.unwrap_or(false),\n })?;\n registry.register(\"openai\", provider);\n info!(\"Registered OpenAI provider\");\n }\n }\n\n // Register DeepSeek provider\n if let Some(entry) = &config.providers.deepseek {\n if let Some(api_key) = &entry.api_key {\n let provider = OpenAiCompatProvider::new(OpenAiCompatConfig {\n api_key: api_key.clone(),\n base_url: entry\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://api.deepseek.com/v1\".to_string()),\n model: entry.model.clone().unwrap_or_default(),\n organization: None,\n no_proxy: entry.no_proxy.unwrap_or(false),\n })?;\n registry.register(\"deepseek\", provider);\n info!(\"Registered DeepSeek provider\");\n }\n }\n\n // Register Groq provider\n if let Some(entry) = &config.providers.groq {\n if let Some(api_key) = &entry.api_key {\n let provider = OpenAiCompatProvider::new(OpenAiCompatConfig {\n api_key: api_key.clone(),\n base_url: entry\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://api.groq.com/openai/v1\".to_string()),\n model: entry.model.clone().unwrap_or_default(),\n organization: None,\n no_proxy: entry.no_proxy.unwrap_or(false),\n })?;\n registry.register(\"groq\", provider);\n info!(\"Registered Groq provider\");\n }\n }\n\n // Register OpenRouter provider\n if let Some(entry) = &config.providers.openrouter {\n if let Some(api_key) = &entry.api_key {\n let provider = OpenAiCompatProvider::new(OpenAiCompatConfig {\n api_key: api_key.clone(),\n base_url: entry\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://openrouter.ai/api/v1\".to_string()),\n model: entry.model.clone().unwrap_or_default(),\n organization: None,\n no_proxy: entry.no_proxy.unwrap_or(false),\n })?;\n registry.register(\"openrouter\", provider);\n info!(\"Registered OpenRouter provider\");\n }\n }\n\n // Register Ollama provider (localhost — always skip proxy)\n if let Some(entry) = &config.providers.ollama {\n let provider = OpenAiCompatProvider::new(OpenAiCompatConfig {\n api_key: entry.api_key.clone().unwrap_or_default(),\n base_url: entry\n .base_url\n .clone()\n .unwrap_or_else(|| \"http://localhost:11434/v1\".to_string()),\n model: entry.model.clone().unwrap_or_default(),\n organization: None,\n no_proxy: true,\n })?;\n registry.register(\"ollama\", provider);\n info!(\"Registered Ollama provider\");\n }\n\n // Register custom providers\n for custom in &config.custom_providers {\n let provider = OpenAiCompatProvider::new(OpenAiCompatConfig {\n api_key: custom.api_key.clone().unwrap_or_default(),\n base_url: custom.base_url.clone(),\n model: String::new(),\n organization: None,\n no_proxy: custom.no_proxy.unwrap_or(false),\n })?;\n registry.register(&custom.name, provider);\n info!(\"Registered custom provider: {}\", custom.name);\n }\n\n // Set default provider based on agent model\n let model = &config.agent.model;\n let default = registry\n .resolve_provider_name(model)\n .map(|s| s.to_string())\n .or_else(|| registry.providers.keys().next().cloned());\n registry.default_provider = default;\n if let Some(ref name) = registry.default_provider {\n debug!(\"Default provider for model '{}': {}\", model, name);\n }\n\n Ok(registry)\n }\n\n /// Register a provider.\n pub fn register(&mut self, name: &str, provider: impl LlmProvider + 'static) {\n self.providers.insert(name.to_string(), Arc::new(provider));\n }\n\n /// Set the default provider by name.\n pub fn set_default(&mut self, name: &str) {\n if self.providers.contains_key(name) {\n self.default_provider = Some(name.to_string());\n }\n }\n\n /// Resolve a model name to a provider name.\n pub fn resolve_provider_name(&self, model: &str) -> Option<&str> {\n let lower = model.to_lowercase();\n for (keyword, provider_name) in MODEL_KEYWORD_MAP {\n if lower.contains(keyword) && self.providers.contains_key(*provider_name) {\n return Some(provider_name);\n }\n }\n self.default_provider.as_deref()\n }\n\n /// Get a provider for a given model.\n pub fn get_provider(&self, model: &str) -> Option> {\n if let Some(name) = self.resolve_provider_name(model) {\n self.providers.get(name).cloned()\n } else {\n self.default_provider\n .as_ref()\n .and_then(|name| self.providers.get(name).cloned())\n }\n }\n\n /// Get a provider by name.\n pub fn get_provider_by_name(&self, name: &str) -> Option> {\n self.providers.get(name).cloned()\n }\n\n /// List all registered provider names.\n pub fn provider_names(&self) -> Vec {\n self.providers.keys().cloned().collect()\n }\n}\n\nimpl Default for ProviderRegistry {\n fn default() -> Self {\n Self::new()\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::base::{\n BoxStream, CompletionChunk, CompletionRequest, CompletionResponse, LlmProvider,\n };\n use async_trait::async_trait;\n\n struct MockProvider {\n provider_name: String,\n supported_model: String,\n }\n\n impl MockProvider {\n fn new(name: &str, supported: &str) -> Self {\n Self {\n provider_name: name.to_string(),\n supported_model: supported.to_string(),\n }\n }\n }\n\n #[async_trait]\n impl LlmProvider for MockProvider {\n fn name(&self) -> &str {\n &self.provider_name\n }\n async fn complete(\n &self,\n _request: CompletionRequest,\n ) -> anyhow::Result {\n Ok(CompletionResponse {\n content: Some(\"mock\".to_string()),\n tool_calls: None,\n usage: None,\n finish_reason: None,\n })\n }\n async fn complete_stream(&self, request: CompletionRequest) -> anyhow::Result {\n let response = self.complete(request).await?;\n let chunk = CompletionChunk {\n delta: response.content,\n tool_call_deltas: None,\n usage: None,\n done: true,\n };\n Ok(Box::pin(futures::stream::once(async move { Ok(chunk) })))\n }\n fn supports_model(&self, model: &str) -> bool {\n model.contains(&self.supported_model)\n }\n }\n\n #[test]\n fn test_registry_new() {\n let reg = ProviderRegistry::new();\n assert!(reg.provider_names().is_empty());\n }\n\n #[test]\n fn test_registry_register() {\n let mut reg = ProviderRegistry::new();\n reg.register(\"mock\", MockProvider::new(\"mock\", \"test\"));\n let names = reg.provider_names();\n assert_eq!(names.len(), 1);\n assert!(names.contains(&\"mock\".to_string()));\n }\n\n #[test]\n fn test_registry_resolve_provider_name() {\n let mut reg = ProviderRegistry::new();\n reg.register(\"anthropic\", MockProvider::new(\"anthropic\", \"claude\"));\n reg.register(\"openai\", MockProvider::new(\"openai\", \"gpt\"));\n\n // \"claude-3.5-sonnet\" should resolve to \"anthropic\"\n let resolved = reg.resolve_provider_name(\"claude-3.5-sonnet\");\n assert_eq!(resolved, Some(\"anthropic\"));\n\n // \"gpt-4o\" should resolve to \"openai\"\n let resolved = reg.resolve_provider_name(\"gpt-4o\");\n assert_eq!(resolved, Some(\"openai\"));\n }\n\n #[test]\n fn test_registry_default_provider() {\n let reg = ProviderRegistry::default();\n assert!(reg.provider_names().is_empty());\n assert!(reg.default_provider.is_none());\n }\n}\n"}}}}