diff --git a/crates/owlen-cli/Cargo.toml b/crates/owlen-cli/Cargo.toml index ca3f449..b18c813 100644 --- a/crates/owlen-cli/Cargo.toml +++ b/crates/owlen-cli/Cargo.toml @@ -24,6 +24,7 @@ required-features = ["chat-client"] [dependencies] owlen-core = { path = "../owlen-core" } +owlen-providers = { path = "../owlen-providers" } # Optional TUI dependency, enabled by the "chat-client" feature. owlen-tui = { path = "../owlen-tui", optional = true } log = { workspace = true } diff --git a/crates/owlen-cli/src/cloud.rs b/crates/owlen-cli/src/commands/cloud.rs similarity index 100% rename from crates/owlen-cli/src/cloud.rs rename to crates/owlen-cli/src/commands/cloud.rs diff --git a/crates/owlen-cli/src/commands/mod.rs b/crates/owlen-cli/src/commands/mod.rs new file mode 100644 index 0000000..c82bff4 --- /dev/null +++ b/crates/owlen-cli/src/commands/mod.rs @@ -0,0 +1,4 @@ +//! Command implementations for the `owlen` CLI. + +pub mod cloud; +pub mod providers; diff --git a/crates/owlen-cli/src/commands/providers.rs b/crates/owlen-cli/src/commands/providers.rs new file mode 100644 index 0000000..5fda892 --- /dev/null +++ b/crates/owlen-cli/src/commands/providers.rs @@ -0,0 +1,652 @@ +use std::collections::HashMap; +use std::sync::Arc; + +use anyhow::{Result, anyhow}; +use clap::{Args, Subcommand}; +use owlen_core::ProviderConfig; +use owlen_core::config::{self as core_config, Config}; +use owlen_core::provider::{ + AnnotatedModelInfo, ModelProvider, ProviderManager, ProviderStatus, ProviderType, +}; +use owlen_core::storage::StorageManager; +use owlen_providers::ollama::{OllamaCloudProvider, OllamaLocalProvider}; +use owlen_tui::config as tui_config; + +use super::cloud; + +/// CLI subcommands for provider management. +#[derive(Debug, Subcommand)] +pub enum ProvidersCommand { + /// List configured providers and their metadata. + List, + /// Run health checks against providers. + Status { + /// Optional provider identifier to check. + #[arg(value_name = "PROVIDER")] + provider: Option, + }, + /// Enable a provider in the configuration. + Enable { + /// Provider identifier to enable. + provider: String, + }, + /// Disable a provider in the configuration. + Disable { + /// Provider identifier to disable. + provider: String, + }, +} + +/// Arguments for the `owlen models` command. +#[derive(Debug, Default, Args)] +pub struct ModelsArgs { + /// Restrict output to a specific provider. + #[arg(long)] + pub provider: Option, +} + +pub async fn run_providers_command(command: ProvidersCommand) -> Result<()> { + match command { + ProvidersCommand::List => list_providers(), + ProvidersCommand::Status { provider } => status_providers(provider.as_deref()).await, + ProvidersCommand::Enable { provider } => toggle_provider(&provider, true), + ProvidersCommand::Disable { provider } => toggle_provider(&provider, false), + } +} + +pub async fn run_models_command(args: ModelsArgs) -> Result<()> { + list_models(args.provider.as_deref()).await +} + +fn list_providers() -> Result<()> { + let config = tui_config::try_load_config().unwrap_or_default(); + let default_provider = canonical_provider_id(&config.general.default_provider); + + let mut rows = Vec::new(); + for (id, cfg) in &config.providers { + let type_label = describe_provider_type(id, cfg); + let auth_label = describe_auth(cfg, requires_auth(id, cfg)); + let enabled = if cfg.enabled { "yes" } else { "no" }; + let default = if id == &default_provider { "*" } else { "" }; + let base = cfg + .base_url + .as_ref() + .map(|value| value.trim().to_string()) + .unwrap_or_else(|| "-".to_string()); + + rows.push(ProviderListRow { + id: id.to_string(), + type_label, + enabled: enabled.to_string(), + default: default.to_string(), + auth: auth_label, + base_url: base, + }); + } + + rows.sort_by(|a, b| a.id.cmp(&b.id)); + + let id_width = rows + .iter() + .map(|row| row.id.len()) + .max() + .unwrap_or(8) + .max("Provider".len()); + let enabled_width = rows + .iter() + .map(|row| row.enabled.len()) + .max() + .unwrap_or(7) + .max("Enabled".len()); + let default_width = rows + .iter() + .map(|row| row.default.len()) + .max() + .unwrap_or(7) + .max("Default".len()); + let type_width = rows + .iter() + .map(|row| row.type_label.len()) + .max() + .unwrap_or(4) + .max("Type".len()); + let auth_width = rows + .iter() + .map(|row| row.auth.len()) + .max() + .unwrap_or(4) + .max("Auth".len()); + + println!( + "{:) -> Result<()> { + let mut config = tui_config::try_load_config().unwrap_or_default(); + let filter = filter.map(canonical_provider_id); + verify_provider_filter(&config, filter.as_deref())?; + + let storage = Arc::new(StorageManager::new().await?); + cloud::load_runtime_credentials(&mut config, storage.clone()).await?; + + let manager = ProviderManager::new(&config); + let records = register_enabled_providers(&manager, &config, filter.as_deref()).await?; + let health = manager.refresh_health().await; + + let mut rows = Vec::new(); + for record in records { + let status = health.get(&record.id).copied(); + rows.push(ProviderStatusRow::from_record(record, status)); + } + + rows.sort_by(|a, b| a.id.cmp(&b.id)); + print_status_rows(&rows); + Ok(()) +} + +async fn list_models(filter: Option<&str>) -> Result<()> { + let mut config = tui_config::try_load_config().unwrap_or_default(); + let filter = filter.map(canonical_provider_id); + verify_provider_filter(&config, filter.as_deref())?; + + let storage = Arc::new(StorageManager::new().await?); + cloud::load_runtime_credentials(&mut config, storage.clone()).await?; + + let manager = ProviderManager::new(&config); + let records = register_enabled_providers(&manager, &config, filter.as_deref()).await?; + let models = manager + .list_all_models() + .await + .map_err(|err| anyhow!(err))?; + let statuses = manager.provider_statuses().await; + + print_models(records, models, statuses); + Ok(()) +} + +fn verify_provider_filter(config: &Config, filter: Option<&str>) -> Result<()> { + if let Some(filter) = filter { + if !config.providers.contains_key(filter) { + return Err(anyhow!( + "Provider '{}' is not defined in configuration.", + filter + )); + } + } + Ok(()) +} + +fn toggle_provider(provider: &str, enable: bool) -> Result<()> { + let mut config = tui_config::try_load_config().unwrap_or_default(); + let canonical = canonical_provider_id(provider); + if canonical.is_empty() { + return Err(anyhow!("Provider name cannot be empty.")); + } + + let previous_default = config.general.default_provider.clone(); + let previous_fallback_enabled = config.providers.get("ollama_local").map(|cfg| cfg.enabled); + + let previous_enabled; + { + let entry = core_config::ensure_provider_config_mut(&mut config, &canonical); + previous_enabled = entry.enabled; + if previous_enabled == enable { + println!( + "Provider '{}' is already {}.", + canonical, + if enable { "enabled" } else { "disabled" } + ); + return Ok(()); + } + entry.enabled = enable; + } + + if !enable && config.general.default_provider == canonical { + if let Some(candidate) = choose_fallback_provider(&config, &canonical) { + config.general.default_provider = candidate.clone(); + println!( + "Default provider set to '{}' because '{}' was disabled.", + candidate, canonical + ); + } else { + let entry = core_config::ensure_provider_config_mut(&mut config, "ollama_local"); + entry.enabled = true; + config.general.default_provider = "ollama_local".to_string(); + println!( + "Enabled 'ollama_local' and made it default because no other providers are active." + ); + } + } + + if let Err(err) = config.validate() { + { + let entry = core_config::ensure_provider_config_mut(&mut config, &canonical); + entry.enabled = previous_enabled; + } + config.general.default_provider = previous_default; + if let Some(enabled) = previous_fallback_enabled { + if let Some(entry) = config.providers.get_mut("ollama_local") { + entry.enabled = enabled; + } + } + return Err(anyhow!(err)); + } + + tui_config::save_config(&config).map_err(|err| anyhow!(err))?; + + println!( + "{} provider '{}'.", + if enable { "Enabled" } else { "Disabled" }, + canonical + ); + Ok(()) +} + +fn choose_fallback_provider(config: &Config, exclude: &str) -> Option { + if exclude != "ollama_local" { + if let Some(cfg) = config.providers.get("ollama_local") { + if cfg.enabled { + return Some("ollama_local".to_string()); + } + } + } + + let mut candidates: Vec = config + .providers + .iter() + .filter(|(id, cfg)| cfg.enabled && id.as_str() != exclude) + .map(|(id, _)| id.clone()) + .collect(); + candidates.sort(); + candidates.into_iter().next() +} + +async fn register_enabled_providers( + manager: &ProviderManager, + config: &Config, + filter: Option<&str>, +) -> Result> { + let default_provider = canonical_provider_id(&config.general.default_provider); + let mut records = Vec::new(); + + for (id, cfg) in &config.providers { + if let Some(filter) = filter { + if id != filter { + continue; + } + } + + let mut record = ProviderRecord::from_config(id, cfg, id == &default_provider); + if !cfg.enabled { + records.push(record); + continue; + } + + match instantiate_provider(id, cfg) { + Ok(provider) => { + let metadata = provider.metadata().clone(); + record.provider_type_label = provider_type_label(metadata.provider_type); + record.requires_auth = metadata.requires_auth; + record.metadata = Some(metadata); + manager.register_provider(provider).await; + } + Err(err) => { + record.registration_error = Some(err.to_string()); + } + } + + records.push(record); + } + + records.sort_by(|a, b| a.id.cmp(&b.id)); + Ok(records) +} + +fn instantiate_provider(id: &str, cfg: &ProviderConfig) -> Result> { + let kind = cfg.provider_type.trim().to_ascii_lowercase(); + if kind == "ollama" || id == "ollama_local" { + let provider = OllamaLocalProvider::new(cfg.base_url.clone(), None, None) + .map_err(|err| anyhow!(err))?; + Ok(Arc::new(provider)) + } else if kind == "ollama_cloud" || id == "ollama_cloud" { + let provider = OllamaCloudProvider::new(cfg.base_url.clone(), cfg.api_key.clone(), None) + .map_err(|err| anyhow!(err))?; + Ok(Arc::new(provider)) + } else { + Err(anyhow!( + "Provider '{}' uses unsupported type '{}'.", + id, + if kind.is_empty() { + "unknown" + } else { + kind.as_str() + } + )) + } +} + +fn describe_provider_type(id: &str, cfg: &ProviderConfig) -> String { + if cfg.provider_type.trim().eq_ignore_ascii_case("ollama") || id.ends_with("_local") { + "Local".to_string() + } else if cfg + .provider_type + .trim() + .eq_ignore_ascii_case("ollama_cloud") + || id.contains("cloud") + { + "Cloud".to_string() + } else { + "Custom".to_string() + } +} + +fn requires_auth(id: &str, cfg: &ProviderConfig) -> bool { + cfg.api_key.is_some() + || cfg.api_key_env.is_some() + || matches!(id, "ollama_cloud" | "openai" | "anthropic") +} + +fn describe_auth(cfg: &ProviderConfig, required: bool) -> String { + if let Some(env) = cfg + .api_key_env + .as_ref() + .map(|value| value.trim()) + .filter(|value| !value.is_empty()) + { + format!("env:{env}") + } else if cfg + .api_key + .as_ref() + .map(|value| !value.trim().is_empty()) + .unwrap_or(false) + { + "config".to_string() + } else if required { + "required".to_string() + } else { + "-".to_string() + } +} + +fn canonical_provider_id(raw: &str) -> String { + let trimmed = raw.trim().to_ascii_lowercase(); + if trimmed.is_empty() { + return trimmed; + } + + match trimmed.as_str() { + "ollama" | "ollama-local" => "ollama_local".to_string(), + "ollama_cloud" | "ollama-cloud" => "ollama_cloud".to_string(), + other => other.replace('-', "_"), + } +} + +fn provider_type_label(provider_type: ProviderType) -> String { + match provider_type { + ProviderType::Local => "Local".to_string(), + ProviderType::Cloud => "Cloud".to_string(), + } +} + +fn provider_status_strings(status: ProviderStatus) -> (&'static str, &'static str) { + match status { + ProviderStatus::Available => ("OK", "available"), + ProviderStatus::Unavailable => ("ERR", "unavailable"), + ProviderStatus::RequiresSetup => ("SETUP", "requires setup"), + } +} + +fn print_status_rows(rows: &[ProviderStatusRow]) { + let id_width = rows + .iter() + .map(|row| row.id.len()) + .max() + .unwrap_or(8) + .max("Provider".len()); + let type_width = rows + .iter() + .map(|row| row.provider_type.len()) + .max() + .unwrap_or(4) + .max("Type".len()); + let status_width = rows + .iter() + .map(|row| row.indicator.len() + 1 + row.status_label.len()) + .max() + .unwrap_or(6) + .max("State".len()); + + println!( + "{:, + models: Vec, + statuses: HashMap, +) { + let mut grouped: HashMap> = HashMap::new(); + for info in models { + grouped + .entry(info.provider_id.clone()) + .or_default() + .push(info); + } + + for record in records { + let status = statuses.get(&record.id).copied().or_else(|| { + if record.metadata.is_some() && record.registration_error.is_none() && record.enabled { + Some(ProviderStatus::Unavailable) + } else { + None + } + }); + + let (indicator, label, status_value) = if !record.enabled { + ("-", "disabled", None) + } else if record.registration_error.is_some() { + ("ERR", "error", None) + } else if let Some(status) = status { + let (indicator, label) = provider_status_strings(status); + (indicator, label, Some(status)) + } else { + ("?", "unknown", None) + }; + + let title = if record.default_provider { + format!("{} (default)", record.id) + } else { + record.id.clone() + }; + println!( + "{} {} [{}] {}", + indicator, title, record.provider_type_label, label + ); + + if let Some(err) = &record.registration_error { + println!(" error: {}", err); + println!(); + continue; + } + + if !record.enabled { + println!(" provider disabled"); + println!(); + continue; + } + + if let Some(entries) = grouped.get(&record.id) { + let mut entries = entries.clone(); + entries.sort_by(|a, b| a.model.name.cmp(&b.model.name)); + if entries.is_empty() { + println!(" (no models reported)"); + } else { + for entry in entries { + let mut line = format!(" - {}", entry.model.name); + if let Some(description) = &entry.model.description { + if !description.trim().is_empty() { + line.push_str(&format!(" — {}", description.trim())); + } + } + println!("{}", line); + } + } + } else { + println!(" (no models reported)"); + } + + if let Some(ProviderStatus::RequiresSetup) = status_value { + if record.requires_auth { + println!(" configure provider credentials or API key"); + } + } + println!(); + } +} + +struct ProviderListRow { + id: String, + type_label: String, + enabled: String, + default: String, + auth: String, + base_url: String, +} + +struct ProviderRecord { + id: String, + enabled: bool, + default_provider: bool, + provider_type_label: String, + requires_auth: bool, + registration_error: Option, + metadata: Option, +} + +impl ProviderRecord { + fn from_config(id: &str, cfg: &ProviderConfig, default_provider: bool) -> Self { + Self { + id: id.to_string(), + enabled: cfg.enabled, + default_provider, + provider_type_label: describe_provider_type(id, cfg), + requires_auth: requires_auth(id, cfg), + registration_error: None, + metadata: None, + } + } +} + +struct ProviderStatusRow { + id: String, + provider_type: String, + default_provider: bool, + indicator: String, + status_label: String, + detail: Option, +} + +impl ProviderStatusRow { + fn from_record(record: ProviderRecord, status: Option) -> Self { + if !record.enabled { + return Self { + id: record.id, + provider_type: record.provider_type_label, + default_provider: record.default_provider, + indicator: "-".to_string(), + status_label: "disabled".to_string(), + detail: None, + }; + } + + if let Some(err) = record.registration_error { + return Self { + id: record.id, + provider_type: record.provider_type_label, + default_provider: record.default_provider, + indicator: "ERR".to_string(), + status_label: "error".to_string(), + detail: Some(err), + }; + } + + if let Some(status) = status { + let (indicator, label) = provider_status_strings(status); + return Self { + id: record.id, + provider_type: record.provider_type_label, + default_provider: record.default_provider, + indicator: indicator.to_string(), + status_label: label.to_string(), + detail: if matches!(status, ProviderStatus::RequiresSetup) && record.requires_auth { + Some("credentials required".to_string()) + } else { + None + }, + }; + } + + Self { + id: record.id, + provider_type: record.provider_type_label, + default_provider: record.default_provider, + indicator: "?".to_string(), + status_label: "unknown".to_string(), + detail: None, + } + } +} diff --git a/crates/owlen-cli/src/main.rs b/crates/owlen-cli/src/main.rs index 3a5202a..0391985 100644 --- a/crates/owlen-cli/src/main.rs +++ b/crates/owlen-cli/src/main.rs @@ -2,13 +2,16 @@ //! OWLEN CLI - Chat TUI client -mod cloud; +mod commands; mod mcp; use anyhow::{Result, anyhow}; use async_trait::async_trait; use clap::{Parser, Subcommand}; -use cloud::{CloudCommand, load_runtime_credentials, set_env_var}; +use commands::{ + cloud::{CloudCommand, load_runtime_credentials, run_cloud_command, set_env_var}, + providers::{ModelsArgs, ProvidersCommand, run_models_command, run_providers_command}, +}; use mcp::{McpCommand, run_mcp_command}; use owlen_core::config as core_config; use owlen_core::{ @@ -58,6 +61,11 @@ enum OwlenCommand { /// 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), @@ -136,7 +144,9 @@ fn build_local_provider(cfg: &Config) -> anyhow::Result> { async fn run_command(command: OwlenCommand) -> Result<()> { match command { OwlenCommand::Config(config_cmd) => run_config_command(config_cmd), - OwlenCommand::Cloud(cloud_cmd) => cloud::run_cloud_command(cloud_cmd).await, + 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::Upgrade => { println!( @@ -184,6 +194,68 @@ fn run_config_doctor() -> Result<()> { } } + if let Some(entry) = config.providers.get_mut("ollama_local") { + if entry.provider_type.trim().is_empty() || entry.provider_type != "ollama" { + entry.provider_type = "ollama".to_string(); + changes.push("normalised providers.ollama_local.provider_type to 'ollama'".to_string()); + } + } + + 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;