485 lines
17 KiB
Rust
485 lines
17 KiB
Rust
#![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<OwlenCommand>,
|
|
}
|
|
|
|
#[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<String, JsonValue>,
|
|
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<String, JsonValue>,
|
|
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
|
|
}
|