#![allow(clippy::collapsible_if)] // TODO: Remove once Rust 2024 let-chains are available //! OWLEN CLI - Chat TUI client mod bootstrap; mod commands; mod mcp; use anyhow::{Result, anyhow}; use clap::{Parser, Subcommand}; use commands::{ cloud::{CloudCommand, run_cloud_command}, providers::{ModelsArgs, ProvidersCommand, run_models_command, run_providers_command}, tools::{ToolsCommand, run_tools_command}, }; use mcp::{McpCommand, run_mcp_command}; use owlen_core::config::{ self as core_config, Config, DEFAULT_OLLAMA_CLOUD_HOURLY_QUOTA, DEFAULT_OLLAMA_CLOUD_WEEKLY_QUOTA, DEFAULT_PROVIDER_CONTEXT_WINDOW_TOKENS, DEFAULT_PROVIDER_LIST_TTL_SECS, LEGACY_OLLAMA_CLOUD_API_KEY_ENV, LEGACY_OLLAMA_CLOUD_BASE_URL, LEGACY_OWLEN_OLLAMA_CLOUD_API_KEY_ENV, McpMode, OLLAMA_API_KEY_ENV, OLLAMA_CLOUD_BASE_URL, OLLAMA_CLOUD_ENDPOINT_KEY, }; use owlen_core::mode::Mode; use owlen_tui::config; use serde_json::{Number as JsonNumber, Value as JsonValue}; use std::env; /// Owlen - Terminal UI for LLM chat #[derive(Parser, Debug)] #[command(name = "owlen")] #[command(about = "Terminal UI for LLM chat via MCP", long_about = None)] struct Args { /// Start in code mode (enables all tools) #[arg(long, short = 'c')] code: bool, /// Disable automatic transcript compression for this session #[arg(long)] no_auto_compress: bool, #[command(subcommand)] command: Option, } #[derive(Debug, Subcommand)] enum OwlenCommand { /// Inspect or upgrade configuration files #[command(subcommand)] Config(ConfigCommand), /// Manage Ollama Cloud credentials #[command(subcommand)] Cloud(CloudCommand), /// Manage model providers #[command(subcommand)] Providers(ProvidersCommand), /// List models exposed by configured providers Models(ModelsArgs), /// Manage MCP server registrations #[command(subcommand)] Mcp(McpCommand), /// Manage MCP tool presets #[command(subcommand)] Tools(ToolsCommand), /// Show manual steps for updating Owlen to the latest revision Upgrade, } #[derive(Debug, Subcommand)] enum ConfigCommand { /// Automatically upgrade legacy configuration values and ensure validity Doctor, /// Print the resolved configuration file path Path, /// Create a fresh configuration file using the latest defaults Init { /// Overwrite the existing configuration if present. #[arg(long)] force: bool, }, } async fn run_command(command: OwlenCommand) -> Result<()> { match command { OwlenCommand::Config(config_cmd) => run_config_command(config_cmd), OwlenCommand::Cloud(cloud_cmd) => run_cloud_command(cloud_cmd).await, OwlenCommand::Providers(provider_cmd) => run_providers_command(provider_cmd).await, OwlenCommand::Models(args) => run_models_command(args).await, OwlenCommand::Mcp(mcp_cmd) => run_mcp_command(mcp_cmd), OwlenCommand::Tools(tools_cmd) => run_tools_command(tools_cmd), OwlenCommand::Upgrade => { println!( "To update Owlen from source:\n git pull\n cargo install --path crates/owlen-cli --force" ); println!( "If you installed from the AUR, use your package manager (e.g., yay -S owlen-git)." ); Ok(()) } } } fn run_config_command(command: ConfigCommand) -> Result<()> { match command { ConfigCommand::Doctor => run_config_doctor(), ConfigCommand::Path => { let path = core_config::default_config_path(); println!("{}", path.display()); Ok(()) } ConfigCommand::Init { force } => run_config_init(force), } } fn run_config_init(force: bool) -> Result<()> { let config_path = core_config::default_config_path(); if config_path.exists() && !force { return Err(anyhow!( "Configuration already exists at {}. Re-run with --force to overwrite.", config_path.display() )); } let mut config = Config::default(); let _ = config.refresh_mcp_servers(None); config.validate()?; config::save_config(&config)?; println!("Wrote default configuration to {}.", config_path.display()); Ok(()) } fn run_config_doctor() -> Result<()> { let config_path = core_config::default_config_path(); let existed = config_path.exists(); let mut config = config::try_load_config().unwrap_or_default(); let _ = config.refresh_mcp_servers(None); let mut changes = Vec::new(); let mut warnings = Vec::new(); if !existed { changes.push("created configuration file from defaults".to_string()); } if config.provider(&config.general.default_provider).is_none() { config.general.default_provider = "ollama_local".to_string(); changes.push("default provider missing; reset to 'ollama_local'".to_string()); } for key in ["ollama_local", "ollama_cloud", "openai", "anthropic"] { if !config.providers.contains_key(key) { core_config::ensure_provider_config_mut(&mut config, key); changes.push(format!("added default configuration for provider '{key}'")); } } if let Some(local) = config.providers.get_mut("ollama_local") { if ensure_numeric_extra_with_change( &mut local.extra, "list_ttl_secs", DEFAULT_PROVIDER_LIST_TTL_SECS, ) { changes.push("added providers.ollama_local.list_ttl_secs (default 60)".to_string()); } if ensure_numeric_extra_with_change( &mut local.extra, "default_context_window", u64::from(DEFAULT_PROVIDER_CONTEXT_WINDOW_TOKENS), ) { changes.push(format!( "added providers.ollama_local.default_context_window (default {})", DEFAULT_PROVIDER_CONTEXT_WINDOW_TOKENS )); } if local.provider_type.trim().is_empty() || local.provider_type != "ollama" { local.provider_type = "ollama".to_string(); changes.push("normalised providers.ollama_local.provider_type to 'ollama'".to_string()); } } if let Some(cloud) = config.providers.get_mut("ollama_cloud") { if cloud.provider_type.trim().is_empty() || !cloud.provider_type.eq_ignore_ascii_case("ollama_cloud") { cloud.provider_type = "ollama_cloud".to_string(); changes.push( "normalised providers.ollama_cloud.provider_type to 'ollama_cloud'".to_string(), ); } let previous_base_url = cloud.base_url.clone(); match cloud .base_url .as_ref() .map(|value| value.trim_end_matches('/')) { None => { cloud.base_url = Some(OLLAMA_CLOUD_BASE_URL.to_string()); } Some(current) if current.eq_ignore_ascii_case(LEGACY_OLLAMA_CLOUD_BASE_URL) => { cloud.base_url = Some(OLLAMA_CLOUD_BASE_URL.to_string()); } _ => {} } if cloud.base_url != previous_base_url { changes.push( "normalised providers.ollama_cloud.base_url to https://ollama.com".to_string(), ); } let original_api_key_env = cloud.api_key_env.clone(); let needs_env_update = cloud .api_key_env .as_ref() .map(|value| value.trim().is_empty()) .unwrap_or(true); if needs_env_update { cloud.api_key_env = Some(OLLAMA_API_KEY_ENV.to_string()); } if let Some(ref value) = original_api_key_env { if value.eq_ignore_ascii_case(LEGACY_OLLAMA_CLOUD_API_KEY_ENV) || value.eq_ignore_ascii_case(LEGACY_OWLEN_OLLAMA_CLOUD_API_KEY_ENV) { cloud.api_key_env = Some(OLLAMA_API_KEY_ENV.to_string()); } } if cloud.api_key_env != original_api_key_env { changes .push("updated providers.ollama_cloud.api_key_env to 'OLLAMA_API_KEY'".to_string()); } if ensure_string_extra_with_change( &mut cloud.extra, OLLAMA_CLOUD_ENDPOINT_KEY, OLLAMA_CLOUD_BASE_URL, ) { changes.push( "added providers.ollama_cloud.extra.cloud_endpoint (default https://ollama.com)" .to_string(), ); } if ensure_numeric_extra_with_change( &mut cloud.extra, "hourly_quota_tokens", DEFAULT_OLLAMA_CLOUD_HOURLY_QUOTA, ) { changes.push(format!( "added providers.ollama_cloud.hourly_quota_tokens (default {})", DEFAULT_OLLAMA_CLOUD_HOURLY_QUOTA )); } if ensure_numeric_extra_with_change( &mut cloud.extra, "weekly_quota_tokens", DEFAULT_OLLAMA_CLOUD_WEEKLY_QUOTA, ) { changes.push(format!( "added providers.ollama_cloud.weekly_quota_tokens (default {})", DEFAULT_OLLAMA_CLOUD_WEEKLY_QUOTA )); } if ensure_numeric_extra_with_change( &mut cloud.extra, "list_ttl_secs", DEFAULT_PROVIDER_LIST_TTL_SECS, ) { changes.push("added providers.ollama_cloud.list_ttl_secs (default 60)".to_string()); } if ensure_numeric_extra_with_change( &mut cloud.extra, "default_context_window", u64::from(DEFAULT_PROVIDER_CONTEXT_WINDOW_TOKENS), ) { changes.push(format!( "added providers.ollama_cloud.default_context_window (default {})", DEFAULT_PROVIDER_CONTEXT_WINDOW_TOKENS )); } } let canonical_env = env::var(OLLAMA_API_KEY_ENV) .ok() .filter(|value| !value.trim().is_empty()); let legacy_env = env::var(LEGACY_OLLAMA_CLOUD_API_KEY_ENV) .ok() .filter(|value| !value.trim().is_empty()); let legacy_alt_env = env::var(LEGACY_OWLEN_OLLAMA_CLOUD_API_KEY_ENV) .ok() .filter(|value| !value.trim().is_empty()); if canonical_env.is_some() { if legacy_env.is_some() { warnings.push(format!( "Both {OLLAMA_API_KEY_ENV} and {LEGACY_OLLAMA_CLOUD_API_KEY_ENV} are set; Owlen will prefer {OLLAMA_API_KEY_ENV}." )); } if legacy_alt_env.is_some() { warnings.push(format!( "Both {OLLAMA_API_KEY_ENV} and {LEGACY_OWLEN_OLLAMA_CLOUD_API_KEY_ENV} are set; Owlen will prefer {OLLAMA_API_KEY_ENV}." )); } } else { if legacy_env.is_some() { warnings.push(format!( "Legacy environment variable {LEGACY_OLLAMA_CLOUD_API_KEY_ENV} is set. Rename it to {OLLAMA_API_KEY_ENV} to match the latest configuration schema." )); } if legacy_alt_env.is_some() { warnings.push(format!( "Legacy environment variable {LEGACY_OWLEN_OLLAMA_CLOUD_API_KEY_ENV} is set. Rename it to {OLLAMA_API_KEY_ENV} to match the latest configuration schema." )); } } let mut ensure_default_enabled = true; if !config.providers.values().any(|cfg| cfg.enabled) { let entry = core_config::ensure_provider_config_mut(&mut config, "ollama_local"); if !entry.enabled { entry.enabled = true; changes.push("no providers were enabled; enabled 'ollama_local'".to_string()); } if config.general.default_provider != "ollama_local" { config.general.default_provider = "ollama_local".to_string(); changes.push( "default provider reset to 'ollama_local' because no providers were enabled" .to_string(), ); } ensure_default_enabled = false; } if ensure_default_enabled { let default_id = config.general.default_provider.clone(); if let Some(default_cfg) = config.providers.get(&default_id) { if !default_cfg.enabled { if let Some(new_default) = config .providers .iter() .filter(|(id, cfg)| cfg.enabled && *id != &default_id) .map(|(id, _)| id.clone()) .min() { config.general.default_provider = new_default.clone(); changes.push(format!( "default provider '{default_id}' was disabled; switched default to '{new_default}'" )); } else { let entry = core_config::ensure_provider_config_mut(&mut config, "ollama_local"); if !entry.enabled { entry.enabled = true; changes.push( "enabled 'ollama_local' because default provider was disabled" .to_string(), ); } if config.general.default_provider != "ollama_local" { config.general.default_provider = "ollama_local".to_string(); changes.push( "default provider reset to 'ollama_local' because previous default was disabled" .to_string(), ); } } } } } match config.mcp.mode { McpMode::Legacy => { config.mcp.mode = McpMode::LocalOnly; config.mcp.warn_on_legacy = true; changes.push("converted [mcp].mode = 'legacy' to 'local_only'".to_string()); } McpMode::RemoteOnly if config.effective_mcp_servers().is_empty() => { config.mcp.mode = McpMode::RemotePreferred; config.mcp.allow_fallback = true; changes.push( "downgraded remote-only configuration to remote_preferred because no servers are defined" .to_string(), ); } McpMode::RemotePreferred if !config.mcp.allow_fallback && config.effective_mcp_servers().is_empty() => { config.mcp.allow_fallback = true; changes.push( "enabled [mcp].allow_fallback because no remote servers are configured".to_string(), ); } _ => {} } config.validate()?; config::save_config(&config)?; if changes.is_empty() { println!( "Configuration already up to date: {}", config_path.display() ); } else { println!("Updated {}:", config_path.display()); for change in changes { println!(" - {change}"); } } if !warnings.is_empty() { println!("Warnings:"); for warning in warnings { println!(" - {warning}"); } } Ok(()) } fn ensure_numeric_extra_with_change( extra: &mut std::collections::HashMap, key: &str, default_value: u64, ) -> bool { match extra.get_mut(key) { Some(existing) => { if existing.as_u64().is_some() { false } else { *existing = JsonValue::Number(JsonNumber::from(default_value)); true } } None => { extra.insert( key.to_string(), JsonValue::Number(JsonNumber::from(default_value)), ); true } } } fn ensure_string_extra_with_change( extra: &mut std::collections::HashMap, key: &str, default_value: &str, ) -> bool { match extra.get_mut(key) { Some(existing) => match existing.as_str() { Some(value) if !value.trim().is_empty() => false, _ => { *existing = JsonValue::String(default_value.to_string()); true } }, None => { extra.insert( key.to_string(), JsonValue::String(default_value.to_string()), ); true } } } #[tokio::main(flavor = "multi_thread")] async fn main() -> Result<()> { // Parse command-line arguments let Args { code, command, no_auto_compress, } = Args::parse(); if let Some(command) = command { return run_command(command).await; } let initial_mode = if code { Mode::Code } else { Mode::Chat }; bootstrap::launch( initial_mode, bootstrap::LaunchOptions { disable_auto_compress: no_auto_compress, }, ) .await }