use std::sync::Arc;
use aonyx_core::{ChatRequest, LlmProvider, Message, Role};
use dialoguer::{theme::ColorfulTheme, Confirm, Input, Password, Select};
use futures::StreamExt;
use crate::config::Config;
use crate::secrets;
const PROVIDERS: &[(&str, &str, bool)] = &[
("anthropic", "Anthropic (Claude)", true),
("openai", "OpenAI", true),
("openrouter", "OpenRouter", true),
("ollama", "Ollama (local)", false),
("lm-studio", "LM Studio (local)", false),
(
"claude-code",
"Claude Code (no key — uses the `claude` CLI)",
false,
),
];
pub async fn run_provider_wizard() -> anyhow::Result<()> {
let theme = ColorfulTheme::default();
println!("aonyx setup — configure your LLM provider\n");
let mut config = Config::load_raw()?;
let labels: Vec<&str> = PROVIDERS.iter().map(|p| p.1).collect();
let default_idx = PROVIDERS
.iter()
.position(|p| p.0 == config.provider)
.unwrap_or(0);
let idx = Select::with_theme(&theme)
.with_prompt("Provider")
.items(&labels)
.default(default_idx)
.interact()?;
let (provider, _label, needs_key) = PROVIDERS[idx];
config.provider = provider.to_string();
if needs_key {
if let Some((field, env_var)) = key_slots(provider) {
let key: String = Password::with_theme(&theme)
.with_prompt(format!(
"{env_var} (input hidden — leave empty to use $env)"
))
.allow_empty_password(true)
.interact()?;
if key.trim().is_empty() {
println!(" · no key entered — will read ${env_var} at runtime");
clear_key_field(&mut config, field);
} else {
store_key(&mut config, field, key.trim(), &theme)?;
}
}
}
match provider {
"ollama" => {
config.ollama_base_url = Some(prompt_default(
&theme,
"Ollama base URL",
config
.ollama_base_url
.clone()
.unwrap_or_else(|| aonyx_llm::OLLAMA_DEFAULT_BASE_URL.to_string()),
)?);
}
"lm-studio" => {
config.lm_studio_base_url = Some(prompt_default(
&theme,
"LM Studio base URL",
config.lm_studio_base_url.clone().unwrap_or_else(|| {
aonyx_llm::lm_studio::LM_STUDIO_DEFAULT_BASE_URL.to_string()
}),
)?);
}
"claude-code" => {
config.claude_code_binary = Some(prompt_default(
&theme,
"Path to the `claude` binary",
config
.claude_code_binary
.clone()
.unwrap_or_else(|| aonyx_llm::CLAUDE_DEFAULT_BIN.to_string()),
)?);
}
_ => {}
}
config.model = prompt_default(&theme, "Model", default_model(provider, &config.model))?;
if provider != "claude-code"
&& Confirm::with_theme(&theme)
.with_prompt("Test the connection now?")
.default(true)
.interact()?
{
match crate::build_provider(&config) {
Ok(p) => match test_connection(&p, &config.model).await {
Ok(()) => println!(" ✓ connection OK"),
Err(e) => {
println!(" ✗ connection failed: {e}");
if !Confirm::with_theme(&theme)
.with_prompt("Save the config anyway?")
.default(true)
.interact()?
{
println!("aborted — nothing written.");
return Ok(());
}
}
},
Err(e) => println!(" ✗ could not build provider: {e} (will retry at runtime)"),
}
}
config.save()?;
println!("\n✓ wrote {}", Config::config_path()?.display());
println!(" run `aonyx` to start a session.");
Ok(())
}
fn default_model(provider: &str, current: &str) -> String {
match provider {
"anthropic" => "claude-sonnet-4-5-20250929".to_string(),
"openai" => "gpt-4o".to_string(),
"openrouter" => "anthropic/claude-3.5-sonnet".to_string(),
"ollama" => "llama3.1:8b".to_string(),
"lm-studio" => "local-model".to_string(),
_ => current.to_string(),
}
}
fn key_slots(provider: &str) -> Option<(&'static str, &'static str)> {
match provider {
"anthropic" => Some(("anthropic_api_key", "ANTHROPIC_API_KEY")),
"openai" => Some(("openai_api_key", "OPENAI_API_KEY")),
"openrouter" => Some(("openrouter_api_key", "OPENROUTER_API_KEY")),
_ => None,
}
}
fn prompt_default(theme: &ColorfulTheme, prompt: &str, default: String) -> anyhow::Result<String> {
Ok(Input::<String>::with_theme(theme)
.with_prompt(prompt)
.default(default)
.interact_text()?)
}
fn store_key(
config: &mut Config,
field: &str,
key: &str,
theme: &ColorfulTheme,
) -> anyhow::Result<()> {
match secrets::set(field, key) {
Ok(()) => {
println!(" ✓ stored in the OS keyring");
clear_key_field(config, field);
}
Err(e) => {
println!(" ⚠ keyring unavailable ({e})");
let plain = Confirm::with_theme(theme)
.with_prompt("Store the key in ~/.aonyx/config.toml as plaintext instead?")
.default(false)
.interact()?;
if plain {
set_key_field(config, field, key);
println!(" ✓ stored in config.toml (plaintext)");
} else {
clear_key_field(config, field);
println!(
" · skipped — export ${} to use this provider",
key_slots_env(field)
);
}
}
}
Ok(())
}
fn set_key_field(c: &mut Config, field: &str, key: &str) {
match field {
"anthropic_api_key" => c.anthropic_api_key = Some(key.to_string()),
"openai_api_key" => c.openai_api_key = Some(key.to_string()),
"openrouter_api_key" => c.openrouter_api_key = Some(key.to_string()),
_ => {}
}
}
fn clear_key_field(c: &mut Config, field: &str) {
match field {
"anthropic_api_key" => c.anthropic_api_key = None,
"openai_api_key" => c.openai_api_key = None,
"openrouter_api_key" => c.openrouter_api_key = None,
_ => {}
}
}
fn key_slots_env(field: &str) -> &'static str {
match field {
"anthropic_api_key" => "ANTHROPIC_API_KEY",
"openai_api_key" => "OPENAI_API_KEY",
"openrouter_api_key" => "OPENROUTER_API_KEY",
_ => "",
}
}
async fn test_connection(provider: &Arc<dyn LlmProvider>, model: &str) -> anyhow::Result<()> {
let req = ChatRequest {
model: model.to_string(),
messages: vec![Message::new(Role::User, "ping")],
tools: Vec::new(),
temperature: None,
max_tokens: Some(16),
};
let mut stream = provider
.chat_stream(req)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
match stream.next().await {
Some(Ok(_)) => Ok(()),
Some(Err(e)) => Err(anyhow::anyhow!("{e}")),
None => Ok(()),
}
}
pub async fn run_telegram_wizard() -> anyhow::Result<()> {
let theme = ColorfulTheme::default();
println!("aonyx setup telegram — configure the Telegram bot\n");
let mut config = Config::load_raw()?;
let token: String = Password::with_theme(&theme)
.with_prompt("Bot token from @BotFather (hidden — empty to keep current / use $env)")
.allow_empty_password(true)
.interact()?;
if token.trim().is_empty() {
println!(" · no token entered — will read $TELEGRAM_BOT_TOKEN at runtime");
} else {
match secrets::set("telegram_bot_token", token.trim()) {
Ok(()) => println!(" ✓ token stored in the OS keyring"),
Err(e) => println!(" ⚠ keyring unavailable ({e}) — export TELEGRAM_BOT_TOKEN instead"),
}
}
let current = config
.telegram_allowed_chats
.iter()
.map(|i| i.to_string())
.collect::<Vec<_>>()
.join(",");
let chats: String = Input::<String>::with_theme(&theme)
.with_prompt("Allowed chat ids, comma-separated (empty = allow everyone)")
.allow_empty(true)
.default(current)
.interact_text()?;
config.telegram_allowed_chats = parse_chat_ids(&chats);
config.save()?;
println!("\n✓ wrote {}", Config::config_path()?.display());
if config.telegram_allowed_chats.is_empty() {
println!(" ⚠ no allow-list — the bot will answer ANY chat. Add ids to lock it down.");
}
if cfg!(feature = "telegram") {
println!(" run `aonyx serve telegram` to start the bot.");
} else {
println!(
" this build lacks Telegram support — reinstall with \
`--features telegram` to run the bot."
);
}
Ok(())
}
pub async fn run_discord_wizard() -> anyhow::Result<()> {
let theme = ColorfulTheme::default();
println!("aonyx setup discord — configure the Discord bot\n");
println!(
" note: enable the MESSAGE CONTENT intent for your bot at\n \
https://discord.com/developers/applications → Bot → Privileged Gateway Intents\n"
);
let mut config = Config::load_raw()?;
let token: String = Password::with_theme(&theme)
.with_prompt("Bot token (hidden — empty to keep current / use $env)")
.allow_empty_password(true)
.interact()?;
if token.trim().is_empty() {
println!(" · no token entered — will read $DISCORD_BOT_TOKEN at runtime");
} else {
match secrets::set("discord_bot_token", token.trim()) {
Ok(()) => println!(" ✓ token stored in the OS keyring"),
Err(e) => println!(" ⚠ keyring unavailable ({e}) — export DISCORD_BOT_TOKEN instead"),
}
}
let current = config
.discord_allowed_channels
.iter()
.map(|i| i.to_string())
.collect::<Vec<_>>()
.join(",");
let chans: String = Input::<String>::with_theme(&theme)
.with_prompt("Allowed channel ids, comma-separated (empty = allow everywhere)")
.allow_empty(true)
.default(current)
.interact_text()?;
config.discord_allowed_channels = parse_chat_ids(&chans);
config.save()?;
println!("\n✓ wrote {}", Config::config_path()?.display());
if config.discord_allowed_channels.is_empty() {
println!(" ⚠ no allow-list — the bot will answer ANY channel it can see.");
}
if cfg!(feature = "discord") {
println!(" run `aonyx serve discord` to start the bot.");
} else {
println!(
" this build lacks Discord support — reinstall with \
`--features discord` to run the bot."
);
}
Ok(())
}
fn parse_chat_ids(s: &str) -> Vec<i64> {
s.split(',')
.filter_map(|p| p.trim().parse::<i64>().ok())
.collect()
}
#[cfg(test)]
mod tests {
use super::parse_chat_ids;
#[test]
fn parses_and_skips_junk() {
assert_eq!(parse_chat_ids("123, -456 ,abc,, 789"), vec![123, -456, 789]);
assert!(parse_chat_ids("").is_empty());
}
}