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}