Skip to main content

ripl/
config.rs

1use serde::Deserialize;
2use std::fs;
3use std::path::PathBuf;
4use std::process::Command;
5
6#[derive(Debug, Clone, Deserialize, serde::Serialize)]
7pub struct Config {
8    pub provider: Option<ProviderConfig>,
9    pub scaffold: Option<ScaffoldConfig>,
10    pub theme: Option<ThemeConfig>,
11    pub speech: Option<SpeechConfig>,
12}
13
14#[derive(Debug, Clone, Deserialize, serde::Serialize)]
15pub struct ProviderConfig {
16    pub name: Option<String>,
17    pub model: Option<String>,
18    pub api_key: Option<String>,
19}
20
21#[derive(Debug, Clone, Deserialize, serde::Serialize)]
22pub struct ScaffoldConfig {
23    pub bootstrap: Option<bool>,
24    pub history_max_turns: Option<u32>,
25}
26
27#[derive(Debug, Clone, Deserialize, serde::Serialize)]
28pub struct ThemeConfig {
29    pub root_hue: Option<u16>,
30}
31
32#[derive(Debug, Clone, Deserialize, serde::Serialize)]
33pub struct SpeechConfig {
34    pub tts: Option<String>,
35    pub stt: Option<String>,
36    pub push_to_talk: Option<bool>,
37    pub fish_api_key: Option<String>,
38    pub fish_voice_id: Option<String>,
39}
40
41impl Config {
42    pub fn load() -> Self {
43        let path = config_path();
44        if let Ok(raw) = fs::read_to_string(path) {
45            toml::from_str(&raw).unwrap_or_else(|_| Config::default())
46        } else {
47            Config::default()
48        }
49    }
50}
51
52impl Default for Config {
53    fn default() -> Self {
54        Config {
55            provider: None,
56            scaffold: None,
57            theme: None,
58            speech: None,
59        }
60    }
61}
62
63pub fn config_path() -> PathBuf {
64    let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
65    PathBuf::from(home).join(".ripl").join("config.toml")
66}
67
68pub fn resolve_provider_key(cfg: &Config) -> Option<String> {
69    if let Some(provider) = &cfg.provider {
70        if let Some(key) = &provider.api_key {
71            if !key.is_empty() {
72                return Some(key.clone());
73            }
74        }
75    }
76    if let Ok(key) = std::env::var("ANTHROPIC_API_KEY") {
77        return Some(key);
78    }
79    if let Ok(key) = std::env::var("OPENAI_API_KEY") {
80        return Some(key);
81    }
82    if let Ok(key) = std::env::var("OPENROUTER_API_KEY") {
83        return Some(key);
84    }
85    None
86}
87
88pub fn resolve_provider_name(cfg: &Config) -> Option<String> {
89    if let Some(provider) = &cfg.provider {
90        if let Some(name) = &provider.name {
91            if !name.is_empty() {
92                return Some(name.clone());
93            }
94        }
95    }
96    if std::env::var("ANTHROPIC_API_KEY").is_ok() {
97        return Some("anthropic".to_string());
98    }
99    if std::env::var("OPENAI_API_KEY").is_ok() {
100        return Some("openai".to_string());
101    }
102    if std::env::var("OPENROUTER_API_KEY").is_ok() {
103        return Some("openrouter".to_string());
104    }
105    None
106}
107
108pub fn scaffold_bootstrap_enabled(cfg: &Config) -> bool {
109    cfg.scaffold
110        .as_ref()
111        .and_then(|s| s.bootstrap)
112        .unwrap_or(true)
113}
114
115pub fn resolve_tts_mode(cfg: &Config) -> String {
116    if let Some(speech) = &cfg.speech {
117        if let Some(tts) = &speech.tts {
118            if !tts.is_empty() {
119                return tts.clone();
120            }
121        }
122    }
123    if std::env::var("FISH_AUDIO_API_KEY").is_ok() || std::env::var("FISH_API_KEY").is_ok() {
124        return "fish".to_string();
125    }
126    if cfg!(target_os = "macos") { "say".to_string() } else { "espeak".to_string() }
127}
128
129pub fn resolve_stt_mode(cfg: &Config) -> String {
130    if let Some(speech) = &cfg.speech {
131        if let Some(stt) = &speech.stt {
132            if !stt.is_empty() {
133                return stt.clone();
134            }
135        }
136    }
137    if std::env::var("FISH_AUDIO_API_KEY").is_ok() || std::env::var("FISH_API_KEY").is_ok() {
138        return "fish".to_string();
139    }
140    "whisper".to_string()
141}
142
143pub fn resolve_fish_voice_id(cfg: &Config) -> Option<String> {
144    if let Some(speech) = &cfg.speech {
145        if let Some(id) = &speech.fish_voice_id {
146            if !id.is_empty() {
147                return Some(id.clone());
148            }
149        }
150    }
151    std::env::var("FISH_AUDIO_VOICE_ID")
152        .or_else(|_| std::env::var("FISH_VOICE_ID"))
153        .ok()
154}
155
156pub fn open_config_file() -> Result<(), std::io::Error> {
157    let path = config_path();
158    if let Some(dir) = path.parent() {
159        fs::create_dir_all(dir)?;
160    }
161    if !path.exists() {
162        fs::write(&path, default_config_template())?;
163    }
164    if cfg!(target_os = "macos") {
165        let _ = Command::new("open").arg(&path).status();
166    } else if cfg!(target_os = "linux") {
167        let _ = Command::new("xdg-open").arg(&path).status();
168    } else {
169        println!("{}", path.display());
170    }
171    Ok(())
172}
173
174pub fn pair_provider(provider: &str) -> Result<(), std::io::Error> {
175    let url = match provider {
176        "openai" => "https://platform.openai.com/api-keys",
177        "anthropic" => "https://console.anthropic.com/settings/keys",
178        "openrouter" => "https://openrouter.ai/keys",
179        _ => {
180            println!("Usage: ripl pair <openai|anthropic|openrouter>");
181            return Ok(());
182        }
183    };
184    if cfg!(target_os = "macos") {
185        let _ = Command::new("open").arg(url).status();
186    } else if cfg!(target_os = "linux") {
187        let _ = Command::new("xdg-open").arg(url).status();
188    } else {
189        println!("{}", url);
190    }
191    println!("Paste API key for {provider}:");
192    let mut key = String::new();
193    std::io::stdin().read_line(&mut key)?;
194    let key = key.trim();
195    if key.is_empty() {
196        return Ok(());
197    }
198    let mut cfg = Config::load();
199    cfg.provider = Some(ProviderConfig {
200        name: Some(provider.to_string()),
201        model: cfg.provider.as_ref().and_then(|p| p.model.clone()),
202        api_key: Some(key.to_string()),
203    });
204    let path = config_path();
205    if let Some(dir) = path.parent() {
206        fs::create_dir_all(dir)?;
207    }
208    let raw = toml::to_string_pretty(&cfg).unwrap_or_else(|_| default_config_template());
209    fs::write(path, raw)?;
210    Ok(())
211}
212
213
214fn default_config_template() -> String {
215    r#"[provider]
216# name: anthropic | openai | openrouter | ollama
217name = "openai"
218model = "gpt-4o-mini"
219# api_key = "..."  # or set ANTHROPIC_API_KEY / OPENAI_API_KEY / OPENROUTER_API_KEY
220
221[scaffold]
222bootstrap = true
223history_max_turns = 10
224
225[theme]
226root_hue = 217   # 0–360, or set RIPL_ROOT_HUE
227
228[speech]
229# tts: fish | say (macOS) | espeak (Linux) | off
230tts = "say"
231# stt: fish | whisper | off
232stt = "whisper"
233push_to_talk = true
234# fish_api_key = "..."   # or set FISH_AUDIO_API_KEY
235# fish_voice_id = "..."  # or set FISH_AUDIO_VOICE_ID
236"#
237    .to_string()
238}