1use anyhow::Result;
9use directories::ProjectDirs;
10use serde::{Deserialize, Serialize};
11use std::collections::HashMap;
12use std::path::PathBuf;
13use tokio::fs;
14
15#[derive(Debug, Clone, Serialize, Deserialize, Default)]
17pub struct Config {
18 #[serde(default)]
20 pub default_provider: Option<String>,
21
22 #[serde(default)]
24 pub default_model: Option<String>,
25
26 #[serde(default)]
28 pub providers: HashMap<String, ProviderConfig>,
29
30 #[serde(default)]
32 pub agents: HashMap<String, AgentConfig>,
33
34 #[serde(default)]
36 pub permissions: PermissionConfig,
37
38 #[serde(default)]
40 pub a2a: A2aConfig,
41
42 #[serde(default)]
44 pub ui: UiConfig,
45
46 #[serde(default)]
48 pub session: SessionConfig,
49
50 #[serde(default)]
52 pub telemetry: TelemetryConfig,
53}
54
55#[derive(Clone, Serialize, Deserialize, Default)]
56pub struct ProviderConfig {
57 pub api_key: Option<String>,
59
60 pub base_url: Option<String>,
62
63 #[serde(default)]
65 pub headers: HashMap<String, String>,
66
67 pub organization: Option<String>,
69}
70
71impl std::fmt::Debug for ProviderConfig {
72 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
73 f.debug_struct("ProviderConfig")
74 .field("api_key", &self.api_key.as_ref().map(|_| "<REDACTED>"))
75 .field("api_key_len", &self.api_key.as_ref().map(|k| k.len()))
76 .field("base_url", &self.base_url)
77 .field("organization", &self.organization)
78 .field("headers_count", &self.headers.len())
79 .finish()
80 }
81}
82
83#[derive(Debug, Clone, Serialize, Deserialize)]
84pub struct AgentConfig {
85 pub name: String,
87
88 #[serde(default)]
90 pub description: Option<String>,
91
92 #[serde(default)]
94 pub model: Option<String>,
95
96 #[serde(default)]
98 pub prompt: Option<String>,
99
100 #[serde(default)]
102 pub temperature: Option<f32>,
103
104 #[serde(default)]
106 pub top_p: Option<f32>,
107
108 #[serde(default)]
110 pub permissions: HashMap<String, PermissionAction>,
111
112 #[serde(default)]
114 pub disabled: bool,
115}
116
117#[derive(Debug, Clone, Serialize, Deserialize, Default)]
118pub struct PermissionConfig {
119 #[serde(default)]
121 pub rules: HashMap<String, PermissionAction>,
122
123 #[serde(default)]
125 pub tools: HashMap<String, PermissionAction>,
126
127 #[serde(default)]
129 pub paths: HashMap<String, PermissionAction>,
130}
131
132#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
133#[serde(rename_all = "lowercase")]
134pub enum PermissionAction {
135 Allow,
136 Deny,
137 Ask,
138}
139
140impl Default for PermissionAction {
141 fn default() -> Self {
142 Self::Ask
143 }
144}
145
146#[derive(Debug, Clone, Serialize, Deserialize, Default)]
147pub struct A2aConfig {
148 pub server_url: Option<String>,
150
151 pub worker_name: Option<String>,
153
154 #[serde(default)]
156 pub auto_approve: AutoApprovePolicy,
157
158 #[serde(default)]
160 pub codebases: Vec<PathBuf>,
161}
162
163#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)]
164#[serde(rename_all = "lowercase")]
165pub enum AutoApprovePolicy {
166 All,
167 #[default]
168 Safe,
169 None,
170}
171
172#[derive(Debug, Clone, Serialize, Deserialize)]
173pub struct UiConfig {
174 #[serde(default = "default_theme")]
176 pub theme: String,
177
178 #[serde(default = "default_true")]
180 pub line_numbers: bool,
181
182 #[serde(default = "default_true")]
184 pub mouse: bool,
185
186 #[serde(default)]
188 pub custom_theme: Option<crate::tui::theme::Theme>,
189
190 #[serde(default = "default_false")]
192 pub hot_reload: bool,
193}
194
195impl Default for UiConfig {
196 fn default() -> Self {
197 Self {
198 theme: default_theme(),
199 line_numbers: true,
200 mouse: true,
201 custom_theme: None,
202 hot_reload: false,
203 }
204 }
205}
206
207fn default_theme() -> String {
208 "marketing".to_string()
209}
210
211fn default_true() -> bool {
212 true
213}
214
215fn default_false() -> bool {
216 false
217}
218
219#[derive(Debug, Clone, Serialize, Deserialize)]
220pub struct SessionConfig {
221 #[serde(default = "default_true")]
223 pub auto_compact: bool,
224
225 #[serde(default = "default_max_tokens")]
227 pub max_tokens: usize,
228
229 #[serde(default = "default_true")]
231 pub persist: bool,
232}
233
234impl Default for SessionConfig {
235 fn default() -> Self {
236 Self {
237 auto_compact: true,
238 max_tokens: default_max_tokens(),
239 persist: true,
240 }
241 }
242}
243
244fn default_max_tokens() -> usize {
245 100_000
246}
247
248#[derive(Debug, Clone, Serialize, Deserialize, Default)]
249pub struct TelemetryConfig {
250 #[serde(default, skip_serializing_if = "Option::is_none")]
252 pub crash_reporting: Option<bool>,
253
254 #[serde(default, skip_serializing_if = "Option::is_none")]
256 pub crash_reporting_prompted: Option<bool>,
257
258 #[serde(default, skip_serializing_if = "Option::is_none")]
261 pub crash_report_endpoint: Option<String>,
262}
263
264impl TelemetryConfig {
265 pub fn crash_reporting_enabled(&self) -> bool {
266 self.crash_reporting.unwrap_or(false)
267 }
268
269 pub fn crash_reporting_prompted(&self) -> bool {
270 self.crash_reporting_prompted.unwrap_or(false)
271 }
272
273 pub fn crash_report_endpoint(&self) -> String {
274 self.crash_report_endpoint
275 .clone()
276 .unwrap_or_else(default_crash_report_endpoint)
277 }
278}
279
280fn default_crash_report_endpoint() -> String {
281 "https://telemetry.codetether.ai/v1/crash-reports".to_string()
282}
283
284impl Config {
285 pub async fn load() -> Result<Self> {
287 let mut config = Self::default();
288
289 if let Some(global_path) = Self::global_config_path() {
291 if global_path.exists() {
292 let content = fs::read_to_string(&global_path).await?;
293 let global: Config = toml::from_str(&content)?;
294 config = config.merge(global);
295 }
296 }
297
298 for name in ["codetether.toml", ".codetether/config.toml"] {
300 let path = PathBuf::from(name);
301 if path.exists() {
302 let content = fs::read_to_string(&path).await?;
303 let project: Config = toml::from_str(&content)?;
304 config = config.merge(project);
305 }
306 }
307
308 config.apply_env();
310
311 Ok(config)
312 }
313
314 pub fn global_config_path() -> Option<PathBuf> {
316 ProjectDirs::from("ai", "codetether", "codetether-agent")
317 .map(|dirs| dirs.config_dir().join("config.toml"))
318 }
319
320 pub fn data_dir() -> Option<PathBuf> {
322 ProjectDirs::from("ai", "codetether", "codetether-agent")
323 .map(|dirs| dirs.data_dir().to_path_buf())
324 }
325
326 pub async fn init_default() -> Result<()> {
328 if let Some(path) = Self::global_config_path() {
329 if let Some(parent) = path.parent() {
330 fs::create_dir_all(parent).await?;
331 }
332 let default = Self::default();
333 let content = toml::to_string_pretty(&default)?;
334 fs::write(&path, content).await?;
335 tracing::info!("Created config at {:?}", path);
336 }
337 Ok(())
338 }
339
340 pub async fn set(key: &str, value: &str) -> Result<()> {
342 let mut config = Self::load().await?;
343
344 match key {
346 "default_provider" => config.default_provider = Some(value.to_string()),
347 "default_model" => config.default_model = Some(value.to_string()),
348 "a2a.server_url" => config.a2a.server_url = Some(value.to_string()),
349 "a2a.worker_name" => config.a2a.worker_name = Some(value.to_string()),
350 "ui.theme" => config.ui.theme = value.to_string(),
351 "telemetry.crash_reporting" => {
352 config.telemetry.crash_reporting = Some(parse_bool(value)?)
353 }
354 "telemetry.crash_reporting_prompted" => {
355 config.telemetry.crash_reporting_prompted = Some(parse_bool(value)?)
356 }
357 "telemetry.crash_report_endpoint" => {
358 config.telemetry.crash_report_endpoint = Some(value.to_string())
359 }
360 _ => anyhow::bail!("Unknown config key: {}", key),
361 }
362
363 if let Some(path) = Self::global_config_path() {
365 let content = toml::to_string_pretty(&config)?;
366 fs::write(&path, content).await?;
367 }
368
369 Ok(())
370 }
371
372 fn merge(mut self, other: Self) -> Self {
374 if other.default_provider.is_some() {
375 self.default_provider = other.default_provider;
376 }
377 if other.default_model.is_some() {
378 self.default_model = other.default_model;
379 }
380 self.providers.extend(other.providers);
381 self.agents.extend(other.agents);
382 self.permissions.rules.extend(other.permissions.rules);
383 self.permissions.tools.extend(other.permissions.tools);
384 self.permissions.paths.extend(other.permissions.paths);
385 if other.a2a.server_url.is_some() {
386 self.a2a = other.a2a;
387 }
388 if other.telemetry.crash_reporting.is_some() {
389 self.telemetry.crash_reporting = other.telemetry.crash_reporting;
390 }
391 if other.telemetry.crash_reporting_prompted.is_some() {
392 self.telemetry.crash_reporting_prompted = other.telemetry.crash_reporting_prompted;
393 }
394 if other.telemetry.crash_report_endpoint.is_some() {
395 self.telemetry.crash_report_endpoint = other.telemetry.crash_report_endpoint;
396 }
397 self
398 }
399
400 pub fn load_theme(&self) -> crate::tui::theme::Theme {
402 if let Some(custom) = &self.ui.custom_theme {
404 return custom.clone();
405 }
406
407 match self.ui.theme.as_str() {
409 "marketing" | "default" => crate::tui::theme::Theme::marketing(),
410 "dark" => crate::tui::theme::Theme::dark(),
411 "light" => crate::tui::theme::Theme::light(),
412 "solarized-dark" => crate::tui::theme::Theme::solarized_dark(),
413 "solarized-light" => crate::tui::theme::Theme::solarized_light(),
414 _ => {
415 tracing::warn!(theme = %self.ui.theme, "Unknown theme name, falling back to marketing");
417 crate::tui::theme::Theme::marketing()
418 }
419 }
420 }
421
422 fn apply_env(&mut self) {
424 if let Ok(val) = std::env::var("CODETETHER_DEFAULT_MODEL") {
425 self.default_model = Some(val);
426 }
427 if let Ok(val) = std::env::var("CODETETHER_DEFAULT_PROVIDER") {
428 self.default_provider = Some(val);
429 }
430 if let Ok(val) = std::env::var("OPENAI_API_KEY") {
431 self.providers
432 .entry("openai".to_string())
433 .or_default()
434 .api_key = Some(val);
435 }
436 if let Ok(val) = std::env::var("ANTHROPIC_API_KEY") {
437 self.providers
438 .entry("anthropic".to_string())
439 .or_default()
440 .api_key = Some(val);
441 }
442 if let Ok(val) = std::env::var("GOOGLE_API_KEY") {
443 self.providers
444 .entry("google".to_string())
445 .or_default()
446 .api_key = Some(val);
447 }
448 if let Ok(val) = std::env::var("CODETETHER_A2A_SERVER") {
449 self.a2a.server_url = Some(val);
450 }
451 if let Ok(val) = std::env::var("CODETETHER_CRASH_REPORTING") {
452 match parse_bool(&val) {
453 Ok(enabled) => self.telemetry.crash_reporting = Some(enabled),
454 Err(_) => tracing::warn!(
455 value = %val,
456 "Invalid CODETETHER_CRASH_REPORTING value; expected true/false"
457 ),
458 }
459 }
460 if let Ok(val) = std::env::var("CODETETHER_CRASH_REPORT_ENDPOINT") {
461 self.telemetry.crash_report_endpoint = Some(val);
462 }
463 }
464}
465
466fn parse_bool(value: &str) -> Result<bool> {
467 let normalized = value.trim().to_ascii_lowercase();
468 match normalized.as_str() {
469 "1" | "true" | "yes" | "on" => Ok(true),
470 "0" | "false" | "no" | "off" => Ok(false),
471 _ => anyhow::bail!("Invalid boolean value: {}", value),
472 }
473}