1use anyhow::Result;
9use directories::ProjectDirs;
10use serde::{Deserialize, Serialize};
11use std::collections::HashMap;
12use std::path::{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 #[serde(default)]
56 pub lsp: LspSettings,
57
58 #[serde(default)]
64 pub rlm: crate::rlm::RlmConfig,
65}
66
67impl Default for Config {
68 fn default() -> Self {
69 Self {
70 default_provider: Some("zai".to_string()),
73 default_model: Some("zai/glm-5".to_string()),
74 providers: HashMap::new(),
75 agents: HashMap::new(),
76 permissions: PermissionConfig::default(),
77 a2a: A2aConfig::default(),
78 ui: UiConfig::default(),
79 session: SessionConfig::default(),
80 telemetry: TelemetryConfig::default(),
81 lsp: LspSettings::default(),
82 rlm: crate::rlm::RlmConfig::default(),
83 }
84 }
85}
86
87#[derive(Clone, Serialize, Deserialize, Default)]
88pub struct ProviderConfig {
89 pub api_key: Option<String>,
91
92 pub base_url: Option<String>,
94
95 #[serde(default)]
97 pub headers: HashMap<String, String>,
98
99 pub organization: Option<String>,
101}
102
103impl std::fmt::Debug for ProviderConfig {
104 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
105 f.debug_struct("ProviderConfig")
106 .field("api_key", &self.api_key.as_ref().map(|_| "<REDACTED>"))
107 .field("api_key_len", &self.api_key.as_ref().map(|k| k.len()))
108 .field("base_url", &self.base_url)
109 .field("organization", &self.organization)
110 .field("headers_count", &self.headers.len())
111 .finish()
112 }
113}
114
115#[derive(Debug, Clone, Serialize, Deserialize)]
116pub struct AgentConfig {
117 pub name: String,
119
120 #[serde(default)]
122 pub description: Option<String>,
123
124 #[serde(default)]
126 pub model: Option<String>,
127
128 #[serde(default)]
130 pub prompt: Option<String>,
131
132 #[serde(default)]
134 pub temperature: Option<f32>,
135
136 #[serde(default)]
138 pub top_p: Option<f32>,
139
140 #[serde(default)]
142 pub permissions: HashMap<String, PermissionAction>,
143
144 #[serde(default)]
146 pub disabled: bool,
147}
148
149#[derive(Debug, Clone, Serialize, Deserialize, Default)]
150pub struct PermissionConfig {
151 #[serde(default)]
153 pub rules: HashMap<String, PermissionAction>,
154
155 #[serde(default)]
157 pub tools: HashMap<String, PermissionAction>,
158
159 #[serde(default)]
161 pub paths: HashMap<String, PermissionAction>,
162}
163
164#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
165#[serde(rename_all = "lowercase")]
166#[derive(Default)]
167pub enum PermissionAction {
168 Allow,
169 Deny,
170 #[default]
171 Ask,
172}
173
174#[derive(Debug, Clone, Serialize, Deserialize, Default)]
175pub struct A2aConfig {
176 pub server_url: Option<String>,
178
179 pub worker_name: Option<String>,
181
182 #[serde(default)]
184 pub auto_approve: AutoApprovePolicy,
185
186 #[serde(default)]
188 pub workspaces: Vec<PathBuf>,
189}
190
191#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)]
192#[serde(rename_all = "lowercase")]
193pub enum AutoApprovePolicy {
194 All,
195 #[default]
196 Safe,
197 None,
198}
199
200#[derive(Debug, Clone, Serialize, Deserialize)]
201pub struct UiConfig {
202 #[serde(default = "default_theme")]
204 pub theme: String,
205
206 #[serde(default = "default_true")]
208 pub line_numbers: bool,
209
210 #[serde(default = "default_true")]
212 pub mouse: bool,
213
214 #[serde(default)]
216 pub custom_theme: Option<crate::tui::theme::Theme>,
217
218 #[serde(default = "default_false")]
220 pub hot_reload: bool,
221}
222
223impl Default for UiConfig {
224 fn default() -> Self {
225 Self {
226 theme: default_theme(),
227 line_numbers: true,
228 mouse: true,
229 custom_theme: None,
230 hot_reload: false,
231 }
232 }
233}
234
235fn default_theme() -> String {
236 "marketing".to_string()
237}
238
239fn default_true() -> bool {
240 true
241}
242
243fn default_false() -> bool {
244 false
245}
246
247#[derive(Debug, Clone, Serialize, Deserialize)]
248pub struct SessionConfig {
249 #[serde(default = "default_true")]
251 pub auto_compact: bool,
252
253 #[serde(default = "default_max_tokens")]
255 pub max_tokens: usize,
256
257 #[serde(default = "default_true")]
259 pub persist: bool,
260}
261
262impl Default for SessionConfig {
263 fn default() -> Self {
264 Self {
265 auto_compact: true,
266 max_tokens: default_max_tokens(),
267 persist: true,
268 }
269 }
270}
271
272fn default_max_tokens() -> usize {
273 100_000
274}
275
276#[derive(Debug, Clone, Serialize, Deserialize, Default)]
277pub struct TelemetryConfig {
278 #[serde(default, skip_serializing_if = "Option::is_none")]
280 pub crash_reporting: Option<bool>,
281
282 #[serde(default, skip_serializing_if = "Option::is_none")]
284 pub crash_reporting_prompted: Option<bool>,
285
286 #[serde(default, skip_serializing_if = "Option::is_none")]
289 pub crash_report_endpoint: Option<String>,
290}
291
292impl TelemetryConfig {
293 pub fn crash_reporting_enabled(&self) -> bool {
294 self.crash_reporting.unwrap_or(false)
295 }
296
297 pub fn crash_reporting_prompted(&self) -> bool {
298 self.crash_reporting_prompted.unwrap_or(false)
299 }
300
301 pub fn crash_report_endpoint(&self) -> String {
302 self.crash_report_endpoint
303 .clone()
304 .unwrap_or_else(default_crash_report_endpoint)
305 }
306}
307
308fn default_crash_report_endpoint() -> String {
309 "https://api.codetether.run/v1/crash-reports".to_string()
310}
311
312#[derive(Debug, Clone, Serialize, Deserialize, Default)]
314pub struct LspSettings {
315 #[serde(default)]
318 pub servers: HashMap<String, LspServerEntry>,
319
320 #[serde(default)]
326 pub linters: HashMap<String, LspLinterEntry>,
327
328 #[serde(default)]
330 pub disable_builtin_linters: bool,
331}
332
333#[derive(Debug, Clone, Serialize, Deserialize)]
335pub struct LspServerEntry {
336 pub command: String,
338 #[serde(default)]
340 pub args: Vec<String>,
341 #[serde(default)]
343 pub file_extensions: Vec<String>,
344 #[serde(default)]
346 pub initialization_options: Option<serde_json::Value>,
347 #[serde(default = "default_lsp_timeout")]
349 pub timeout_ms: u64,
350}
351
352#[derive(Debug, Clone, Serialize, Deserialize)]
354pub struct LspLinterEntry {
355 pub command: Option<String>,
358 #[serde(default)]
360 pub args: Vec<String>,
361 #[serde(default)]
364 pub file_extensions: Vec<String>,
365 #[serde(default)]
367 pub initialization_options: Option<serde_json::Value>,
368 #[serde(default = "default_true")]
370 pub enabled: bool,
371}
372
373impl Default for LspLinterEntry {
374 fn default() -> Self {
375 Self {
376 command: None,
377 args: Vec::new(),
378 file_extensions: Vec::new(),
379 initialization_options: None,
380 enabled: true,
381 }
382 }
383}
384
385fn default_lsp_timeout() -> u64 {
386 30_000
387}
388
389impl Config {
390 pub async fn load() -> Result<Self> {
392 let mut config = Self::default();
393
394 if let Some(global_path) = Self::global_config_path()
396 && global_path.exists()
397 {
398 let content = fs::read_to_string(&global_path).await?;
399 let global: Config = toml::from_str(&content)?;
400 config = config.merge(global);
401 }
402
403 for name in ["codetether.toml", ".codetether/config.toml"] {
405 let path = PathBuf::from(name);
406 if path.exists() {
407 let content = fs::read_to_string(&path).await?;
408 let project: Config = toml::from_str(&content)?;
409 config = config.merge(project);
410 }
411 }
412
413 config.apply_env();
415 config.normalize_legacy_defaults();
416
417 Ok(config)
418 }
419
420 pub fn global_config_path() -> Option<PathBuf> {
422 ProjectDirs::from("ai", "codetether", "codetether-agent")
423 .map(|dirs| dirs.config_dir().join("config.toml"))
424 }
425
426 pub fn data_dir() -> Option<PathBuf> {
428 if let Ok(explicit) = std::env::var("CODETETHER_DATA_DIR") {
429 let explicit = explicit.trim();
430 if !explicit.is_empty() {
431 return Some(PathBuf::from(explicit));
432 }
433 }
434
435 workspace_data_dir().or_else(|| {
436 ProjectDirs::from("ai", "codetether", "codetether-agent")
437 .map(|dirs| dirs.data_dir().to_path_buf())
438 })
439 }
440
441 pub async fn init_default() -> Result<()> {
443 if let Some(path) = Self::global_config_path() {
444 if let Some(parent) = path.parent() {
445 fs::create_dir_all(parent).await?;
446 }
447 let default = Self::default();
448 let content = toml::to_string_pretty(&default)?;
449 fs::write(&path, content).await?;
450 tracing::info!("Created config at {:?}", path);
451 }
452 Ok(())
453 }
454
455 pub async fn set(key: &str, value: &str) -> Result<()> {
457 let mut config = Self::load().await?;
458
459 match key {
461 "default_provider" => config.default_provider = Some(value.to_string()),
462 "default_model" => config.default_model = Some(value.to_string()),
463 "a2a.server_url" => config.a2a.server_url = Some(value.to_string()),
464 "a2a.worker_name" => config.a2a.worker_name = Some(value.to_string()),
465 "ui.theme" => config.ui.theme = value.to_string(),
466 "telemetry.crash_reporting" => {
467 config.telemetry.crash_reporting = Some(parse_bool(value)?)
468 }
469 "telemetry.crash_reporting_prompted" => {
470 config.telemetry.crash_reporting_prompted = Some(parse_bool(value)?)
471 }
472 "telemetry.crash_report_endpoint" => {
473 config.telemetry.crash_report_endpoint = Some(value.to_string())
474 }
475 _ => anyhow::bail!("Unknown config key: {}", key),
476 }
477
478 if let Some(path) = Self::global_config_path() {
480 let content = toml::to_string_pretty(&config)?;
481 fs::write(&path, content).await?;
482 }
483
484 Ok(())
485 }
486
487 fn merge(mut self, other: Self) -> Self {
489 if other.default_provider.is_some() {
490 self.default_provider = other.default_provider;
491 }
492 if other.default_model.is_some() {
493 self.default_model = other.default_model;
494 }
495 self.providers.extend(other.providers);
496 self.agents.extend(other.agents);
497 self.permissions.rules.extend(other.permissions.rules);
498 self.permissions.tools.extend(other.permissions.tools);
499 self.permissions.paths.extend(other.permissions.paths);
500 if other.a2a.server_url.is_some() {
501 self.a2a = other.a2a;
502 }
503 if other.telemetry.crash_reporting.is_some() {
504 self.telemetry.crash_reporting = other.telemetry.crash_reporting;
505 }
506 if other.telemetry.crash_reporting_prompted.is_some() {
507 self.telemetry.crash_reporting_prompted = other.telemetry.crash_reporting_prompted;
508 }
509 if other.telemetry.crash_report_endpoint.is_some() {
510 self.telemetry.crash_report_endpoint = other.telemetry.crash_report_endpoint;
511 }
512 self.lsp.servers.extend(other.lsp.servers);
513 self.lsp.linters.extend(other.lsp.linters);
514 if other.lsp.disable_builtin_linters {
515 self.lsp.disable_builtin_linters = true;
516 }
517 self
518 }
519
520 pub fn load_theme(&self) -> crate::tui::theme::Theme {
522 if let Some(custom) = &self.ui.custom_theme {
524 return custom.clone();
525 }
526
527 match self.ui.theme.as_str() {
529 "marketing" | "default" => crate::tui::theme::Theme::marketing(),
530 "dark" => crate::tui::theme::Theme::dark(),
531 "light" => crate::tui::theme::Theme::light(),
532 "solarized-dark" => crate::tui::theme::Theme::solarized_dark(),
533 "solarized-light" => crate::tui::theme::Theme::solarized_light(),
534 _ => {
535 tracing::warn!(theme = %self.ui.theme, "Unknown theme name, falling back to marketing");
537 crate::tui::theme::Theme::marketing()
538 }
539 }
540 }
541
542 fn apply_env(&mut self) {
544 if let Ok(val) = std::env::var("CODETETHER_DEFAULT_MODEL") {
545 self.default_model = Some(val);
546 }
547 if let Ok(val) = std::env::var("CODETETHER_DEFAULT_PROVIDER") {
548 self.default_provider = Some(val);
549 }
550 if let Ok(val) = std::env::var("OPENAI_API_KEY") {
551 self.providers
552 .entry("openai".to_string())
553 .or_default()
554 .api_key = Some(val);
555 }
556 if let Ok(val) = std::env::var("ANTHROPIC_API_KEY") {
557 self.providers
558 .entry("anthropic".to_string())
559 .or_default()
560 .api_key = Some(val);
561 }
562 if let Ok(val) = std::env::var("GOOGLE_API_KEY") {
563 self.providers
564 .entry("google".to_string())
565 .or_default()
566 .api_key = Some(val);
567 }
568 if let Ok(val) = std::env::var("CODETETHER_A2A_SERVER") {
569 self.a2a.server_url = Some(val);
570 }
571 if let Ok(val) = std::env::var("CODETETHER_CRASH_REPORTING") {
572 match parse_bool(&val) {
573 Ok(enabled) => self.telemetry.crash_reporting = Some(enabled),
574 Err(_) => tracing::warn!(
575 value = %val,
576 "Invalid CODETETHER_CRASH_REPORTING value; expected true/false"
577 ),
578 }
579 }
580 if let Ok(val) = std::env::var("CODETETHER_CRASH_REPORT_ENDPOINT") {
581 self.telemetry.crash_report_endpoint = Some(val);
582 }
583 }
584
585 fn normalize_legacy_defaults(&mut self) {
591 if let Some(provider) = self.default_provider.as_deref()
592 && provider.trim().eq_ignore_ascii_case("zhipuai")
593 {
594 self.default_provider = Some("zai".to_string());
595 }
596
597 if let Some(model) = self.default_model.as_deref() {
598 let model_trimmed = model.trim();
599
600 if model_trimmed.eq_ignore_ascii_case("zhipuai/glm-5") {
601 self.default_model = Some("zai/glm-5".to_string());
602 return;
603 }
604
605 let is_legacy_kimi_default = model_trimmed.eq_ignore_ascii_case("moonshotai/kimi-k2.5")
606 || model_trimmed.eq_ignore_ascii_case("kimi-k2.5");
607
608 if is_legacy_kimi_default {
609 tracing::info!(
610 from = %model_trimmed,
611 to = "zai/glm-5",
612 "Migrating legacy default model to current Z.AI GLM-5 default"
613 );
614 self.default_model = Some("zai/glm-5".to_string());
615
616 let should_update_provider = self.default_provider.as_deref().is_none_or(|p| {
617 let p = p.trim();
618 p.eq_ignore_ascii_case("moonshotai") || p.eq_ignore_ascii_case("zhipuai")
619 });
620 if should_update_provider {
621 self.default_provider = Some("zai".to_string());
622 }
623 }
624 }
625 }
626}
627
628fn parse_bool(value: &str) -> Result<bool> {
629 let normalized = value.trim().to_ascii_lowercase();
630 match normalized.as_str() {
631 "1" | "true" | "yes" | "on" => Ok(true),
632 "0" | "false" | "no" | "off" => Ok(false),
633 _ => anyhow::bail!("Invalid boolean value: {}", value),
634 }
635}
636
637fn workspace_data_dir() -> Option<PathBuf> {
638 let cwd = std::env::current_dir().ok()?;
639 Some(workspace_data_dir_from(&cwd))
640}
641
642fn workspace_data_dir_from(start: &Path) -> PathBuf {
643 detect_workspace_root(start)
644 .unwrap_or_else(|| start.to_path_buf())
645 .join(".codetether-agent")
646}
647
648fn detect_workspace_root(start: &Path) -> Option<PathBuf> {
649 start
650 .ancestors()
651 .find(|path| path.join(".git").exists())
652 .map(Path::to_path_buf)
653}
654
655#[cfg(test)]
656mod tests {
657 use super::Config;
658 use super::{detect_workspace_root, workspace_data_dir_from};
659 use tempfile::tempdir;
660
661 #[test]
662 fn migrates_legacy_kimi_default_to_zai_glm5() {
663 let mut cfg = Config {
664 default_provider: Some("moonshotai".to_string()),
665 default_model: Some("moonshotai/kimi-k2.5".to_string()),
666 ..Default::default()
667 };
668
669 cfg.normalize_legacy_defaults();
670
671 assert_eq!(cfg.default_provider.as_deref(), Some("zai"));
672 assert_eq!(cfg.default_model.as_deref(), Some("zai/glm-5"));
673 }
674
675 #[test]
676 fn preserves_explicit_non_legacy_default_model() {
677 let mut cfg = Config {
678 default_provider: Some("openai".to_string()),
679 default_model: Some("openai/gpt-4o".to_string()),
680 ..Default::default()
681 };
682
683 cfg.normalize_legacy_defaults();
684
685 assert_eq!(cfg.default_provider.as_deref(), Some("openai"));
686 assert_eq!(cfg.default_model.as_deref(), Some("openai/gpt-4o"));
687 }
688
689 #[test]
690 fn normalizes_zhipuai_aliases_to_zai() {
691 let mut cfg = Config {
692 default_provider: Some("zhipuai".to_string()),
693 default_model: Some("zhipuai/glm-5".to_string()),
694 ..Default::default()
695 };
696
697 cfg.normalize_legacy_defaults();
698
699 assert_eq!(cfg.default_provider.as_deref(), Some("zai"));
700 assert_eq!(cfg.default_model.as_deref(), Some("zai/glm-5"));
701 }
702
703 #[test]
704 fn detects_workspace_root_using_git_marker() {
705 let temp = tempdir().expect("tempdir");
706 let repo_root = temp.path().join("repo");
707 std::fs::create_dir_all(repo_root.join(".git")).expect("create .git");
708 let nested = repo_root.join("src").join("nested");
709 std::fs::create_dir_all(&nested).expect("create nested");
710
711 let detected = detect_workspace_root(&nested);
712 assert_eq!(detected.as_deref(), Some(repo_root.as_path()));
713 }
714
715 #[test]
716 fn workspace_data_dir_defaults_to_workspace_root() {
717 let temp = tempdir().expect("tempdir");
718 let repo_root = temp.path().join("repo");
719 std::fs::create_dir_all(repo_root.join(".git")).expect("create .git");
720 let nested = repo_root.join("api").join("src");
721 std::fs::create_dir_all(&nested).expect("create nested");
722
723 let data_dir = workspace_data_dir_from(&nested);
724 assert_eq!(data_dir, repo_root.join(".codetether-agent"));
725 }
726
727 #[test]
728 fn workspace_data_dir_falls_back_to_start_when_not_git_repo() {
729 let temp = tempdir().expect("tempdir");
730 let workspace = temp.path().join("workspace");
731 std::fs::create_dir_all(&workspace).expect("create workspace");
732
733 let data_dir = workspace_data_dir_from(&workspace);
734 assert_eq!(data_dir, workspace.join(".codetether-agent"));
735 }
736}