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)]
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
55impl Default for Config {
56 fn default() -> Self {
57 Self {
58 default_provider: Some("zai".to_string()),
61 default_model: Some("zai/glm-5".to_string()),
62 providers: HashMap::new(),
63 agents: HashMap::new(),
64 permissions: PermissionConfig::default(),
65 a2a: A2aConfig::default(),
66 ui: UiConfig::default(),
67 session: SessionConfig::default(),
68 telemetry: TelemetryConfig::default(),
69 }
70 }
71}
72
73#[derive(Clone, Serialize, Deserialize, Default)]
74pub struct ProviderConfig {
75 pub api_key: Option<String>,
77
78 pub base_url: Option<String>,
80
81 #[serde(default)]
83 pub headers: HashMap<String, String>,
84
85 pub organization: Option<String>,
87}
88
89impl std::fmt::Debug for ProviderConfig {
90 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
91 f.debug_struct("ProviderConfig")
92 .field("api_key", &self.api_key.as_ref().map(|_| "<REDACTED>"))
93 .field("api_key_len", &self.api_key.as_ref().map(|k| k.len()))
94 .field("base_url", &self.base_url)
95 .field("organization", &self.organization)
96 .field("headers_count", &self.headers.len())
97 .finish()
98 }
99}
100
101#[derive(Debug, Clone, Serialize, Deserialize)]
102pub struct AgentConfig {
103 pub name: String,
105
106 #[serde(default)]
108 pub description: Option<String>,
109
110 #[serde(default)]
112 pub model: Option<String>,
113
114 #[serde(default)]
116 pub prompt: Option<String>,
117
118 #[serde(default)]
120 pub temperature: Option<f32>,
121
122 #[serde(default)]
124 pub top_p: Option<f32>,
125
126 #[serde(default)]
128 pub permissions: HashMap<String, PermissionAction>,
129
130 #[serde(default)]
132 pub disabled: bool,
133}
134
135#[derive(Debug, Clone, Serialize, Deserialize, Default)]
136pub struct PermissionConfig {
137 #[serde(default)]
139 pub rules: HashMap<String, PermissionAction>,
140
141 #[serde(default)]
143 pub tools: HashMap<String, PermissionAction>,
144
145 #[serde(default)]
147 pub paths: HashMap<String, PermissionAction>,
148}
149
150#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
151#[serde(rename_all = "lowercase")]
152pub enum PermissionAction {
153 Allow,
154 Deny,
155 Ask,
156}
157
158impl Default for PermissionAction {
159 fn default() -> Self {
160 Self::Ask
161 }
162}
163
164#[derive(Debug, Clone, Serialize, Deserialize, Default)]
165pub struct A2aConfig {
166 pub server_url: Option<String>,
168
169 pub worker_name: Option<String>,
171
172 #[serde(default)]
174 pub auto_approve: AutoApprovePolicy,
175
176 #[serde(default)]
178 pub codebases: Vec<PathBuf>,
179}
180
181#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)]
182#[serde(rename_all = "lowercase")]
183pub enum AutoApprovePolicy {
184 All,
185 #[default]
186 Safe,
187 None,
188}
189
190#[derive(Debug, Clone, Serialize, Deserialize)]
191pub struct UiConfig {
192 #[serde(default = "default_theme")]
194 pub theme: String,
195
196 #[serde(default = "default_true")]
198 pub line_numbers: bool,
199
200 #[serde(default = "default_true")]
202 pub mouse: bool,
203
204 #[serde(default)]
206 pub custom_theme: Option<crate::tui::theme::Theme>,
207
208 #[serde(default = "default_false")]
210 pub hot_reload: bool,
211}
212
213impl Default for UiConfig {
214 fn default() -> Self {
215 Self {
216 theme: default_theme(),
217 line_numbers: true,
218 mouse: true,
219 custom_theme: None,
220 hot_reload: false,
221 }
222 }
223}
224
225fn default_theme() -> String {
226 "marketing".to_string()
227}
228
229fn default_true() -> bool {
230 true
231}
232
233fn default_false() -> bool {
234 false
235}
236
237#[derive(Debug, Clone, Serialize, Deserialize)]
238pub struct SessionConfig {
239 #[serde(default = "default_true")]
241 pub auto_compact: bool,
242
243 #[serde(default = "default_max_tokens")]
245 pub max_tokens: usize,
246
247 #[serde(default = "default_true")]
249 pub persist: bool,
250}
251
252impl Default for SessionConfig {
253 fn default() -> Self {
254 Self {
255 auto_compact: true,
256 max_tokens: default_max_tokens(),
257 persist: true,
258 }
259 }
260}
261
262fn default_max_tokens() -> usize {
263 100_000
264}
265
266#[derive(Debug, Clone, Serialize, Deserialize, Default)]
267pub struct TelemetryConfig {
268 #[serde(default, skip_serializing_if = "Option::is_none")]
270 pub crash_reporting: Option<bool>,
271
272 #[serde(default, skip_serializing_if = "Option::is_none")]
274 pub crash_reporting_prompted: Option<bool>,
275
276 #[serde(default, skip_serializing_if = "Option::is_none")]
279 pub crash_report_endpoint: Option<String>,
280}
281
282impl TelemetryConfig {
283 pub fn crash_reporting_enabled(&self) -> bool {
284 self.crash_reporting.unwrap_or(false)
285 }
286
287 pub fn crash_reporting_prompted(&self) -> bool {
288 self.crash_reporting_prompted.unwrap_or(false)
289 }
290
291 pub fn crash_report_endpoint(&self) -> String {
292 self.crash_report_endpoint
293 .clone()
294 .unwrap_or_else(default_crash_report_endpoint)
295 }
296}
297
298fn default_crash_report_endpoint() -> String {
299 "https://api.codetether.run/v1/crash-reports".to_string()
300}
301
302impl Config {
303 pub async fn load() -> Result<Self> {
305 let mut config = Self::default();
306
307 if let Some(global_path) = Self::global_config_path() {
309 if global_path.exists() {
310 let content = fs::read_to_string(&global_path).await?;
311 let global: Config = toml::from_str(&content)?;
312 config = config.merge(global);
313 }
314 }
315
316 for name in ["codetether.toml", ".codetether/config.toml"] {
318 let path = PathBuf::from(name);
319 if path.exists() {
320 let content = fs::read_to_string(&path).await?;
321 let project: Config = toml::from_str(&content)?;
322 config = config.merge(project);
323 }
324 }
325
326 config.apply_env();
328 config.normalize_legacy_defaults();
329
330 Ok(config)
331 }
332
333 pub fn global_config_path() -> Option<PathBuf> {
335 ProjectDirs::from("ai", "codetether", "codetether-agent")
336 .map(|dirs| dirs.config_dir().join("config.toml"))
337 }
338
339 pub fn data_dir() -> Option<PathBuf> {
341 ProjectDirs::from("ai", "codetether", "codetether-agent")
342 .map(|dirs| dirs.data_dir().to_path_buf())
343 }
344
345 pub async fn init_default() -> Result<()> {
347 if let Some(path) = Self::global_config_path() {
348 if let Some(parent) = path.parent() {
349 fs::create_dir_all(parent).await?;
350 }
351 let default = Self::default();
352 let content = toml::to_string_pretty(&default)?;
353 fs::write(&path, content).await?;
354 tracing::info!("Created config at {:?}", path);
355 }
356 Ok(())
357 }
358
359 pub async fn set(key: &str, value: &str) -> Result<()> {
361 let mut config = Self::load().await?;
362
363 match key {
365 "default_provider" => config.default_provider = Some(value.to_string()),
366 "default_model" => config.default_model = Some(value.to_string()),
367 "a2a.server_url" => config.a2a.server_url = Some(value.to_string()),
368 "a2a.worker_name" => config.a2a.worker_name = Some(value.to_string()),
369 "ui.theme" => config.ui.theme = value.to_string(),
370 "telemetry.crash_reporting" => {
371 config.telemetry.crash_reporting = Some(parse_bool(value)?)
372 }
373 "telemetry.crash_reporting_prompted" => {
374 config.telemetry.crash_reporting_prompted = Some(parse_bool(value)?)
375 }
376 "telemetry.crash_report_endpoint" => {
377 config.telemetry.crash_report_endpoint = Some(value.to_string())
378 }
379 _ => anyhow::bail!("Unknown config key: {}", key),
380 }
381
382 if let Some(path) = Self::global_config_path() {
384 let content = toml::to_string_pretty(&config)?;
385 fs::write(&path, content).await?;
386 }
387
388 Ok(())
389 }
390
391 fn merge(mut self, other: Self) -> Self {
393 if other.default_provider.is_some() {
394 self.default_provider = other.default_provider;
395 }
396 if other.default_model.is_some() {
397 self.default_model = other.default_model;
398 }
399 self.providers.extend(other.providers);
400 self.agents.extend(other.agents);
401 self.permissions.rules.extend(other.permissions.rules);
402 self.permissions.tools.extend(other.permissions.tools);
403 self.permissions.paths.extend(other.permissions.paths);
404 if other.a2a.server_url.is_some() {
405 self.a2a = other.a2a;
406 }
407 if other.telemetry.crash_reporting.is_some() {
408 self.telemetry.crash_reporting = other.telemetry.crash_reporting;
409 }
410 if other.telemetry.crash_reporting_prompted.is_some() {
411 self.telemetry.crash_reporting_prompted = other.telemetry.crash_reporting_prompted;
412 }
413 if other.telemetry.crash_report_endpoint.is_some() {
414 self.telemetry.crash_report_endpoint = other.telemetry.crash_report_endpoint;
415 }
416 self
417 }
418
419 pub fn load_theme(&self) -> crate::tui::theme::Theme {
421 if let Some(custom) = &self.ui.custom_theme {
423 return custom.clone();
424 }
425
426 match self.ui.theme.as_str() {
428 "marketing" | "default" => crate::tui::theme::Theme::marketing(),
429 "dark" => crate::tui::theme::Theme::dark(),
430 "light" => crate::tui::theme::Theme::light(),
431 "solarized-dark" => crate::tui::theme::Theme::solarized_dark(),
432 "solarized-light" => crate::tui::theme::Theme::solarized_light(),
433 _ => {
434 tracing::warn!(theme = %self.ui.theme, "Unknown theme name, falling back to marketing");
436 crate::tui::theme::Theme::marketing()
437 }
438 }
439 }
440
441 fn apply_env(&mut self) {
443 if let Ok(val) = std::env::var("CODETETHER_DEFAULT_MODEL") {
444 self.default_model = Some(val);
445 }
446 if let Ok(val) = std::env::var("CODETETHER_DEFAULT_PROVIDER") {
447 self.default_provider = Some(val);
448 }
449 if let Ok(val) = std::env::var("OPENAI_API_KEY") {
450 self.providers
451 .entry("openai".to_string())
452 .or_default()
453 .api_key = Some(val);
454 }
455 if let Ok(val) = std::env::var("ANTHROPIC_API_KEY") {
456 self.providers
457 .entry("anthropic".to_string())
458 .or_default()
459 .api_key = Some(val);
460 }
461 if let Ok(val) = std::env::var("GOOGLE_API_KEY") {
462 self.providers
463 .entry("google".to_string())
464 .or_default()
465 .api_key = Some(val);
466 }
467 if let Ok(val) = std::env::var("CODETETHER_A2A_SERVER") {
468 self.a2a.server_url = Some(val);
469 }
470 if let Ok(val) = std::env::var("CODETETHER_CRASH_REPORTING") {
471 match parse_bool(&val) {
472 Ok(enabled) => self.telemetry.crash_reporting = Some(enabled),
473 Err(_) => tracing::warn!(
474 value = %val,
475 "Invalid CODETETHER_CRASH_REPORTING value; expected true/false"
476 ),
477 }
478 }
479 if let Ok(val) = std::env::var("CODETETHER_CRASH_REPORT_ENDPOINT") {
480 self.telemetry.crash_report_endpoint = Some(val);
481 }
482 }
483
484 fn normalize_legacy_defaults(&mut self) {
490 if let Some(provider) = self.default_provider.as_deref()
491 && provider.trim().eq_ignore_ascii_case("zhipuai")
492 {
493 self.default_provider = Some("zai".to_string());
494 }
495
496 if let Some(model) = self.default_model.as_deref() {
497 let model_trimmed = model.trim();
498
499 if model_trimmed.eq_ignore_ascii_case("zhipuai/glm-5") {
500 self.default_model = Some("zai/glm-5".to_string());
501 return;
502 }
503
504 let is_legacy_kimi_default = model_trimmed.eq_ignore_ascii_case("moonshotai/kimi-k2.5")
505 || model_trimmed.eq_ignore_ascii_case("kimi-k2.5");
506
507 if is_legacy_kimi_default {
508 tracing::info!(
509 from = %model_trimmed,
510 to = "zai/glm-5",
511 "Migrating legacy default model to current Z.AI GLM-5 default"
512 );
513 self.default_model = Some("zai/glm-5".to_string());
514
515 let should_update_provider = self.default_provider.as_deref().is_none_or(|p| {
516 let p = p.trim();
517 p.eq_ignore_ascii_case("moonshotai") || p.eq_ignore_ascii_case("zhipuai")
518 });
519 if should_update_provider {
520 self.default_provider = Some("zai".to_string());
521 }
522 }
523 }
524 }
525}
526
527fn parse_bool(value: &str) -> Result<bool> {
528 let normalized = value.trim().to_ascii_lowercase();
529 match normalized.as_str() {
530 "1" | "true" | "yes" | "on" => Ok(true),
531 "0" | "false" | "no" | "off" => Ok(false),
532 _ => anyhow::bail!("Invalid boolean value: {}", value),
533 }
534}
535
536#[cfg(test)]
537mod tests {
538 use super::Config;
539
540 #[test]
541 fn migrates_legacy_kimi_default_to_zai_glm5() {
542 let mut cfg = Config {
543 default_provider: Some("moonshotai".to_string()),
544 default_model: Some("moonshotai/kimi-k2.5".to_string()),
545 ..Default::default()
546 };
547
548 cfg.normalize_legacy_defaults();
549
550 assert_eq!(cfg.default_provider.as_deref(), Some("zai"));
551 assert_eq!(cfg.default_model.as_deref(), Some("zai/glm-5"));
552 }
553
554 #[test]
555 fn preserves_explicit_non_legacy_default_model() {
556 let mut cfg = Config {
557 default_provider: Some("openai".to_string()),
558 default_model: Some("openai/gpt-4o".to_string()),
559 ..Default::default()
560 };
561
562 cfg.normalize_legacy_defaults();
563
564 assert_eq!(cfg.default_provider.as_deref(), Some("openai"));
565 assert_eq!(cfg.default_model.as_deref(), Some("openai/gpt-4o"));
566 }
567
568 #[test]
569 fn normalizes_zhipuai_aliases_to_zai() {
570 let mut cfg = Config {
571 default_provider: Some("zhipuai".to_string()),
572 default_model: Some("zhipuai/glm-5".to_string()),
573 ..Default::default()
574 };
575
576 cfg.normalize_legacy_defaults();
577
578 assert_eq!(cfg.default_provider.as_deref(), Some("zai"));
579 assert_eq!(cfg.default_model.as_deref(), Some("zai/glm-5"));
580 }
581}