ripl-tui 0.3.3

ripl — a living, breathing TUI framework for AI chat in the shell.
Documentation
use serde::Deserialize;
use std::fs;
use std::path::PathBuf;
use std::process::Command;

#[derive(Debug, Clone, Deserialize, serde::Serialize)]
pub struct Config {
    pub provider: Option<ProviderConfig>,
    pub scaffold: Option<ScaffoldConfig>,
    pub theme: Option<ThemeConfig>,
    pub speech: Option<SpeechConfig>,
}

#[derive(Debug, Clone, Deserialize, serde::Serialize)]
pub struct ProviderConfig {
    pub name: Option<String>,
    pub model: Option<String>,
    pub api_key: Option<String>,
}

#[derive(Debug, Clone, Deserialize, serde::Serialize)]
pub struct ScaffoldConfig {
    pub bootstrap: Option<bool>,
    pub history_max_turns: Option<u32>,
}

#[derive(Debug, Clone, Deserialize, serde::Serialize)]
pub struct ThemeConfig {
    pub root_hue: Option<u16>,
}

#[derive(Debug, Clone, Deserialize, serde::Serialize)]
pub struct SpeechConfig {
    pub tts: Option<String>,
    pub stt: Option<String>,
    pub push_to_talk: Option<bool>,
    pub fish_api_key: Option<String>,
    pub fish_voice_id: Option<String>,
}

impl Config {
    pub fn load() -> Self {
        let path = config_path();
        if let Ok(raw) = fs::read_to_string(path) {
            toml::from_str(&raw).unwrap_or_else(|_| Config::default())
        } else {
            Config::default()
        }
    }
}

impl Default for Config {
    fn default() -> Self {
        Config {
            provider: None,
            scaffold: None,
            theme: None,
            speech: None,
        }
    }
}

pub fn config_path() -> PathBuf {
    let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
    PathBuf::from(home).join(".ripl").join("config.toml")
}

pub fn resolve_provider_key(cfg: &Config) -> Option<String> {
    if let Some(provider) = &cfg.provider {
        if let Some(key) = &provider.api_key {
            if !key.is_empty() {
                return Some(key.clone());
            }
        }
    }
    if let Ok(key) = std::env::var("ANTHROPIC_API_KEY") {
        return Some(key);
    }
    if let Ok(key) = std::env::var("OPENAI_API_KEY") {
        return Some(key);
    }
    if let Ok(key) = std::env::var("OPENROUTER_API_KEY") {
        return Some(key);
    }
    None
}

pub fn resolve_provider_name(cfg: &Config) -> Option<String> {
    if let Some(provider) = &cfg.provider {
        if let Some(name) = &provider.name {
            if !name.is_empty() {
                return Some(name.clone());
            }
        }
    }
    if std::env::var("ANTHROPIC_API_KEY").is_ok() {
        return Some("anthropic".to_string());
    }
    if std::env::var("OPENAI_API_KEY").is_ok() {
        return Some("openai".to_string());
    }
    if std::env::var("OPENROUTER_API_KEY").is_ok() {
        return Some("openrouter".to_string());
    }
    None
}

pub fn scaffold_bootstrap_enabled(cfg: &Config) -> bool {
    cfg.scaffold
        .as_ref()
        .and_then(|s| s.bootstrap)
        .unwrap_or(true)
}

pub fn resolve_tts_mode(cfg: &Config) -> String {
    if let Some(speech) = &cfg.speech {
        if let Some(tts) = &speech.tts {
            if !tts.is_empty() {
                return tts.clone();
            }
        }
    }
    if std::env::var("FISH_AUDIO_API_KEY").is_ok() || std::env::var("FISH_API_KEY").is_ok() {
        return "fish".to_string();
    }
    if cfg!(target_os = "macos") { "say".to_string() } else { "espeak".to_string() }
}

pub fn resolve_stt_mode(cfg: &Config) -> String {
    if let Some(speech) = &cfg.speech {
        if let Some(stt) = &speech.stt {
            if !stt.is_empty() {
                return stt.clone();
            }
        }
    }
    if std::env::var("FISH_AUDIO_API_KEY").is_ok() || std::env::var("FISH_API_KEY").is_ok() {
        return "fish".to_string();
    }
    "whisper".to_string()
}

pub fn resolve_fish_voice_id(cfg: &Config) -> Option<String> {
    if let Some(speech) = &cfg.speech {
        if let Some(id) = &speech.fish_voice_id {
            if !id.is_empty() {
                return Some(id.clone());
            }
        }
    }
    std::env::var("FISH_AUDIO_VOICE_ID")
        .or_else(|_| std::env::var("FISH_VOICE_ID"))
        .ok()
}

pub fn open_config_file() -> Result<(), std::io::Error> {
    let path = config_path();
    if let Some(dir) = path.parent() {
        fs::create_dir_all(dir)?;
    }
    if !path.exists() {
        fs::write(&path, default_config_template())?;
    }
    if cfg!(target_os = "macos") {
        let _ = Command::new("open").arg(&path).status();
    } else if cfg!(target_os = "linux") {
        let _ = Command::new("xdg-open").arg(&path).status();
    } else {
        println!("{}", path.display());
    }
    Ok(())
}

pub fn pair_provider(provider: &str) -> Result<(), std::io::Error> {
    let url = match provider {
        "openai" => "https://platform.openai.com/api-keys",
        "anthropic" => "https://console.anthropic.com/settings/keys",
        "openrouter" => "https://openrouter.ai/keys",
        _ => {
            println!("Usage: ripl pair <openai|anthropic|openrouter>");
            return Ok(());
        }
    };
    if cfg!(target_os = "macos") {
        let _ = Command::new("open").arg(url).status();
    } else if cfg!(target_os = "linux") {
        let _ = Command::new("xdg-open").arg(url).status();
    } else {
        println!("{}", url);
    }
    println!("Paste API key for {provider}:");
    let mut key = String::new();
    std::io::stdin().read_line(&mut key)?;
    let key = key.trim();
    if key.is_empty() {
        return Ok(());
    }
    let mut cfg = Config::load();
    cfg.provider = Some(ProviderConfig {
        name: Some(provider.to_string()),
        model: cfg.provider.as_ref().and_then(|p| p.model.clone()),
        api_key: Some(key.to_string()),
    });
    let path = config_path();
    if let Some(dir) = path.parent() {
        fs::create_dir_all(dir)?;
    }
    let raw = toml::to_string_pretty(&cfg).unwrap_or_else(|_| default_config_template());
    fs::write(path, raw)?;
    Ok(())
}


fn default_config_template() -> String {
    r#"[provider]
# name: anthropic | openai | openrouter | ollama
name = "openai"
model = "gpt-4o-mini"
# api_key = "..."  # or set ANTHROPIC_API_KEY / OPENAI_API_KEY / OPENROUTER_API_KEY

[scaffold]
bootstrap = true
history_max_turns = 10

[theme]
root_hue = 217   # 0–360, or set RIPL_ROOT_HUE

[speech]
# tts: fish | say (macOS) | espeak (Linux) | off
tts = "say"
# stt: fish | whisper | off
stt = "whisper"
push_to_talk = true
# fish_api_key = "..."   # or set FISH_AUDIO_API_KEY
# fish_voice_id = "..."  # or set FISH_AUDIO_VOICE_ID
"#
    .to_string()
}