From 25628d1d58bd7511deaafcb4b21debb1db58aa0e Mon Sep 17 00:00:00 2001 From: vikingowl Date: Fri, 24 Oct 2025 13:51:15 +0200 Subject: [PATCH] feat(config): align defaults with provider sections AC:\n- Config defaults include provider TTL/context extras and normalize cloud quotas/endpoints when missing.\n- owlen config init scaffolds the latest schema; config doctor updates legacy env names and issues warnings.\n- Documentation covers init/doctor usage and runtime env precedence.\n\nTests:\n- cargo test -p owlen-cli\n- cargo test -p owlen-core default_config_sets_provider_extras\n- cargo test -p owlen-core ensure_defaults_backfills_missing_provider_metadata --- README.md | 2 +- crates/owlen-cli/src/main.rs | 250 +++++++++++++++++++++++++- crates/owlen-core/src/config.rs | 306 +++++++++++++++++++++++++++++++- docs/configuration.md | 2 +- docs/troubleshooting.md | 1 + 5 files changed, 551 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index ec47680..32ced6a 100644 --- a/README.md +++ b/README.md @@ -155,7 +155,7 @@ OWLEN stores its configuration in the standard platform-specific config director | macOS | `~/Library/Application Support/owlen/config.toml` | | Windows | `%APPDATA%\owlen\config.toml` | -Use `owlen config path` to print the exact location on your machine and `owlen config doctor` to migrate a legacy config automatically. +Use `owlen config init` to scaffold a fresh configuration (pass `--force` to overwrite an existing file), `owlen config path` to print the resolved location, and `owlen config doctor` to migrate legacy layouts automatically. You can also add custom themes alongside the config directory (e.g., `~/.config/owlen/themes/`). See the [themes/README.md](themes/README.md) for more details on theming. diff --git a/crates/owlen-cli/src/main.rs b/crates/owlen-cli/src/main.rs index 0d9f4e6..72a4b9b 100644 --- a/crates/owlen-cli/src/main.rs +++ b/crates/owlen-cli/src/main.rs @@ -6,17 +6,24 @@ mod bootstrap; mod commands; mod mcp; -use anyhow::Result; +use anyhow::{Result, anyhow}; use clap::{Parser, Subcommand}; use commands::{ cloud::{CloudCommand, run_cloud_command}, 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::config::McpMode; +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)] @@ -56,6 +63,12 @@ enum ConfigCommand { 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<()> { @@ -85,15 +98,35 @@ fn run_config_command(command: ConfigCommand) -> Result<()> { 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()); @@ -111,13 +144,164 @@ 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(); + 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) { @@ -213,9 +397,63 @@ fn run_config_doctor() -> Result<()> { } } + 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 diff --git a/crates/owlen-core/src/config.rs b/crates/owlen-core/src/config.rs index a5f521e..3fcf246 100644 --- a/crates/owlen-core/src/config.rs +++ b/crates/owlen-core/src/config.rs @@ -36,6 +36,10 @@ pub const LEGACY_OWLEN_OLLAMA_CLOUD_API_KEY_ENV: &str = "OWLEN_OLLAMA_CLOUD_API_ pub const DEFAULT_OLLAMA_CLOUD_HOURLY_QUOTA: u64 = 50_000; /// Default weekly soft quota for Ollama Cloud usage visualization (tokens). pub const DEFAULT_OLLAMA_CLOUD_WEEKLY_QUOTA: u64 = 250_000; +/// Default TTL (seconds) for cached model listings per provider. +pub const DEFAULT_PROVIDER_LIST_TTL_SECS: u64 = 60; +/// Default context window (tokens) assumed when provider metadata is absent. +pub const DEFAULT_PROVIDER_CONTEXT_WINDOW_TOKENS: u32 = 8_192; /// Default base URL for local Ollama daemons. pub const OLLAMA_LOCAL_BASE_URL: &str = "http://localhost:11434"; /// Default OpenAI API base URL. @@ -392,6 +396,13 @@ impl Config { self.providers.entry(name).or_insert(default_cfg); } + if let Some(local) = self.providers.get_mut("ollama_local") { + normalize_local_provider_config(local); + } + if let Some(cloud) = self.providers.get_mut("ollama_cloud") { + normalize_cloud_provider_config(cloud); + } + if self.schema_version.is_empty() { self.schema_version = Self::default_schema_version(); } @@ -868,13 +879,25 @@ fn default_provider_configs() -> HashMap { } fn default_ollama_local_config() -> ProviderConfig { + let mut extra = HashMap::new(); + extra.insert( + "list_ttl_secs".to_string(), + serde_json::Value::Number(serde_json::Number::from(DEFAULT_PROVIDER_LIST_TTL_SECS)), + ); + extra.insert( + "default_context_window".to_string(), + serde_json::Value::Number(serde_json::Number::from(u64::from( + DEFAULT_PROVIDER_CONTEXT_WINDOW_TOKENS, + ))), + ); + ProviderConfig { enabled: true, provider_type: canonical_provider_type("ollama_local"), base_url: Some(OLLAMA_LOCAL_BASE_URL.to_string()), api_key: None, api_key_env: None, - extra: HashMap::new(), + extra, } } @@ -892,6 +915,16 @@ fn default_ollama_cloud_config() -> ProviderConfig { "weekly_quota_tokens".to_string(), serde_json::Value::Number(serde_json::Number::from(DEFAULT_OLLAMA_CLOUD_WEEKLY_QUOTA)), ); + extra.insert( + "list_ttl_secs".to_string(), + serde_json::Value::Number(serde_json::Number::from(DEFAULT_PROVIDER_LIST_TTL_SECS)), + ); + extra.insert( + "default_context_window".to_string(), + serde_json::Value::Number(serde_json::Number::from(u64::from( + DEFAULT_PROVIDER_CONTEXT_WINDOW_TOKENS, + ))), + ); ProviderConfig { enabled: false, @@ -935,6 +968,119 @@ fn default_provider_config_for(name: &str) -> Option { } } +fn ensure_numeric_extra( + extra: &mut HashMap, + key: &str, + default_value: u64, +) { + let needs_update = match extra.get(key) { + Some(existing) => existing.as_u64().is_none(), + None => true, + }; + + if needs_update { + extra.insert( + key.to_string(), + serde_json::Value::Number(serde_json::Number::from(default_value)), + ); + } +} + +fn ensure_string_extra( + extra: &mut HashMap, + key: &str, + default_value: &str, +) { + let needs_update = match extra.get(key) { + Some(existing) => existing + .as_str() + .map(|value| value.trim().is_empty()) + .unwrap_or(true), + None => true, + }; + + if needs_update { + extra.insert( + key.to_string(), + serde_json::Value::String(default_value.to_string()), + ); + } +} + +fn normalize_local_provider_config(provider: &mut ProviderConfig) { + if provider.provider_type.trim().is_empty() || provider.provider_type != "ollama" { + provider.provider_type = "ollama".to_string(); + } + + ensure_numeric_extra( + &mut provider.extra, + "list_ttl_secs", + DEFAULT_PROVIDER_LIST_TTL_SECS, + ); + ensure_numeric_extra( + &mut provider.extra, + "default_context_window", + u64::from(DEFAULT_PROVIDER_CONTEXT_WINDOW_TOKENS), + ); +} + +fn normalize_cloud_provider_config(provider: &mut ProviderConfig) { + if provider.provider_type.trim().is_empty() + || !provider.provider_type.eq_ignore_ascii_case("ollama_cloud") + { + provider.provider_type = "ollama_cloud".to_string(); + } + + match provider + .base_url + .as_ref() + .map(|value| value.trim_end_matches('/')) + { + None => { + provider.base_url = Some(OLLAMA_CLOUD_BASE_URL.to_string()); + } + Some(current) if current.eq_ignore_ascii_case(LEGACY_OLLAMA_CLOUD_BASE_URL) => { + provider.base_url = Some(OLLAMA_CLOUD_BASE_URL.to_string()); + } + _ => {} + } + + if provider + .api_key_env + .as_ref() + .map(|value| value.trim().is_empty()) + .unwrap_or(true) + { + provider.api_key_env = Some(OLLAMA_API_KEY_ENV.to_string()); + } + + ensure_string_extra( + &mut provider.extra, + OLLAMA_CLOUD_ENDPOINT_KEY, + OLLAMA_CLOUD_BASE_URL, + ); + ensure_numeric_extra( + &mut provider.extra, + "hourly_quota_tokens", + DEFAULT_OLLAMA_CLOUD_HOURLY_QUOTA, + ); + ensure_numeric_extra( + &mut provider.extra, + "weekly_quota_tokens", + DEFAULT_OLLAMA_CLOUD_WEEKLY_QUOTA, + ); + ensure_numeric_extra( + &mut provider.extra, + "list_ttl_secs", + DEFAULT_PROVIDER_LIST_TTL_SECS, + ); + ensure_numeric_extra( + &mut provider.extra, + "default_context_window", + u64::from(DEFAULT_PROVIDER_CONTEXT_WINDOW_TOKENS), + ); +} + fn normalize_provider_key(name: &str) -> String { let normalized = name.trim().to_ascii_lowercase(); match normalized.as_str() { @@ -2012,6 +2158,162 @@ mod tests { } } + #[test] + fn default_config_sets_provider_extras() { + let config = Config::default(); + let local = config + .providers + .get("ollama_local") + .expect("local provider"); + assert_eq!( + local + .extra + .get("list_ttl_secs") + .and_then(|value| value.as_u64()), + Some(DEFAULT_PROVIDER_LIST_TTL_SECS) + ); + assert_eq!( + local + .extra + .get("default_context_window") + .and_then(|value| value.as_u64()), + Some(u64::from(DEFAULT_PROVIDER_CONTEXT_WINDOW_TOKENS)) + ); + + let cloud = config + .providers + .get("ollama_cloud") + .expect("cloud provider"); + assert_eq!( + cloud + .extra + .get("list_ttl_secs") + .and_then(|value| value.as_u64()), + Some(DEFAULT_PROVIDER_LIST_TTL_SECS) + ); + assert_eq!( + cloud + .extra + .get("default_context_window") + .and_then(|value| value.as_u64()), + Some(u64::from(DEFAULT_PROVIDER_CONTEXT_WINDOW_TOKENS)) + ); + assert_eq!( + cloud + .extra + .get("hourly_quota_tokens") + .and_then(|value| value.as_u64()), + Some(DEFAULT_OLLAMA_CLOUD_HOURLY_QUOTA) + ); + assert_eq!( + cloud + .extra + .get("weekly_quota_tokens") + .and_then(|value| value.as_u64()), + Some(DEFAULT_OLLAMA_CLOUD_WEEKLY_QUOTA) + ); + assert_eq!( + cloud + .extra + .get(OLLAMA_CLOUD_ENDPOINT_KEY) + .and_then(|value| value.as_str()), + Some(OLLAMA_CLOUD_BASE_URL) + ); + } + + #[test] + fn ensure_defaults_backfills_missing_provider_metadata() { + let mut config = Config::default(); + + if let Some(local) = config.providers.get_mut("ollama_local") { + local.extra.remove("list_ttl_secs"); + local.extra.insert( + "default_context_window".into(), + serde_json::Value::String("".into()), + ); + local.provider_type.clear(); + } + + if let Some(cloud) = config.providers.get_mut("ollama_cloud") { + cloud.extra.remove("list_ttl_secs"); + cloud.extra.insert( + "default_context_window".into(), + serde_json::Value::String("invalid".into()), + ); + cloud.extra.remove("hourly_quota_tokens"); + cloud.extra.remove("weekly_quota_tokens"); + cloud.extra.remove(OLLAMA_CLOUD_ENDPOINT_KEY); + cloud.api_key_env = None; + cloud.provider_type.clear(); + cloud.base_url = Some(LEGACY_OLLAMA_CLOUD_BASE_URL.to_string()); + } + + config.ensure_defaults(); + + let local = config + .providers + .get("ollama_local") + .expect("local provider"); + assert_eq!(local.provider_type, "ollama"); + assert_eq!( + local + .extra + .get("list_ttl_secs") + .and_then(|value| value.as_u64()), + Some(DEFAULT_PROVIDER_LIST_TTL_SECS) + ); + assert_eq!( + local + .extra + .get("default_context_window") + .and_then(|value| value.as_u64()), + Some(u64::from(DEFAULT_PROVIDER_CONTEXT_WINDOW_TOKENS)) + ); + + let cloud = config + .providers + .get("ollama_cloud") + .expect("cloud provider"); + assert_eq!(cloud.provider_type, "ollama_cloud"); + assert_eq!(cloud.base_url.as_deref(), Some(OLLAMA_CLOUD_BASE_URL)); + assert_eq!(cloud.api_key_env.as_deref(), Some(OLLAMA_API_KEY_ENV)); + assert_eq!( + cloud + .extra + .get("list_ttl_secs") + .and_then(|value| value.as_u64()), + Some(DEFAULT_PROVIDER_LIST_TTL_SECS) + ); + assert_eq!( + cloud + .extra + .get("default_context_window") + .and_then(|value| value.as_u64()), + Some(u64::from(DEFAULT_PROVIDER_CONTEXT_WINDOW_TOKENS)) + ); + assert_eq!( + cloud + .extra + .get("hourly_quota_tokens") + .and_then(|value| value.as_u64()), + Some(DEFAULT_OLLAMA_CLOUD_HOURLY_QUOTA) + ); + assert_eq!( + cloud + .extra + .get("weekly_quota_tokens") + .and_then(|value| value.as_u64()), + Some(DEFAULT_OLLAMA_CLOUD_WEEKLY_QUOTA) + ); + assert_eq!( + cloud + .extra + .get(OLLAMA_CLOUD_ENDPOINT_KEY) + .and_then(|value| value.as_str()), + Some(OLLAMA_CLOUD_BASE_URL) + ); + } + #[test] fn test_storage_platform_specific_paths() { let config = Config::default(); @@ -2110,7 +2412,7 @@ mod tests { .expect("migrated config"); assert!(cloud.enabled); assert_eq!(cloud.provider_type, "ollama_cloud"); - assert_eq!(cloud.base_url.as_deref(), Some("https://api.ollama.com")); + assert_eq!(cloud.base_url.as_deref(), Some(OLLAMA_CLOUD_BASE_URL)); assert_eq!(cloud.api_key.as_deref(), Some("secret")); } diff --git a/docs/configuration.md b/docs/configuration.md index 94c0235..1888484 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -12,7 +12,7 @@ Owlen resolves the configuration path using the platform-specific config directo | macOS | `~/Library/Application Support/owlen/config.toml` | | Windows | `%APPDATA%\owlen\config.toml` | -Run `owlen config path` to print the exact location on your machine. A default configuration file is created on the first run if one doesn't exist, and `owlen config doctor` can migrate/repair legacy files automatically. +Use `owlen config init` to scaffold the latest default configuration (pass `--force` to overwrite an existing file), `owlen config path` to print the resolved location, and `owlen config doctor` to migrate or repair legacy files automatically. ## Configuration Precedence diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 4d55f9c..4487e50 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -46,6 +46,7 @@ Owlen is built with `ratatui`, which supports most modern terminals. However, if If Owlen is not behaving as you expect, there might be an issue with your configuration file. - **Location:** Run `owlen config path` to print the exact location (Linux, macOS, or Windows). Owlen now follows platform defaults instead of hard-coding `~/.config`. +- **Init:** Run `owlen config init` to recreate the latest default file (`--force` will overwrite an existing configuration). - **Syntax:** The configuration file is in TOML format. Make sure the syntax is correct. - **Values:** Check that the values for your models, providers, and other settings are correct. - **Automation:** Run `owlen config doctor` to migrate legacy settings (`mode = "legacy"`, missing providers) and validate the file before launching the TUI.