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> {
397 let global_path = Self::global_config_path();
399 let project_paths = [
400 PathBuf::from("codetether.toml"),
401 PathBuf::from(".codetether/config.toml"),
402 ];
403
404 async fn read_opt(p: PathBuf) -> (PathBuf, Option<String>) {
407 match fs::read_to_string(&p).await {
408 Ok(s) => (p, Some(s)),
409 Err(_) => (p, None),
410 }
411 }
412
413 let global_future = async {
414 match global_path {
415 Some(p) => Some(read_opt(p).await),
416 None => None,
417 }
418 };
419 let project_futures = futures::future::join_all(project_paths.into_iter().map(read_opt));
420
421 let (global_result, project_results) = tokio::join!(global_future, project_futures);
422
423 let mut config = Self::default();
424 if let Some((path, Some(content))) = global_result {
425 match toml::from_str::<Config>(&content) {
426 Ok(global) => config = config.merge(global),
427 Err(err) => {
428 return Err(err).map_err(|e| {
429 anyhow::anyhow!("failed to parse {}: {}", path.display(), e)
430 });
431 }
432 }
433 }
434 for (path, maybe) in project_results {
435 let Some(content) = maybe else { continue };
436 match toml::from_str::<Config>(&content) {
437 Ok(project) => config = config.merge(project),
438 Err(err) => {
439 return Err(err).map_err(|e| {
440 anyhow::anyhow!("failed to parse {}: {}", path.display(), e)
441 });
442 }
443 }
444 }
445
446 config.apply_env();
448 config.normalize_legacy_defaults();
449
450 Ok(config)
451 }
452
453 pub fn global_config_path() -> Option<PathBuf> {
455 ProjectDirs::from("ai", "codetether", "codetether-agent")
456 .map(|dirs| dirs.config_dir().join("config.toml"))
457 }
458
459 pub fn data_dir() -> Option<PathBuf> {
461 if let Ok(explicit) = std::env::var("CODETETHER_DATA_DIR") {
462 let explicit = explicit.trim();
463 if !explicit.is_empty() {
464 return Some(PathBuf::from(explicit));
465 }
466 }
467
468 workspace_data_dir().or_else(|| {
469 ProjectDirs::from("ai", "codetether", "codetether-agent")
470 .map(|dirs| dirs.data_dir().to_path_buf())
471 })
472 }
473
474 pub async fn init_default() -> Result<()> {
476 if let Some(path) = Self::global_config_path() {
477 if let Some(parent) = path.parent() {
478 fs::create_dir_all(parent).await?;
479 }
480 let default = Self::default();
481 let content = toml::to_string_pretty(&default)?;
482 fs::write(&path, content).await?;
483 tracing::info!("Created config at {:?}", path);
484 }
485 Ok(())
486 }
487
488 pub async fn set(key: &str, value: &str) -> Result<()> {
490 let mut config = Self::load().await?;
491
492 match key {
494 "default_provider" => config.default_provider = Some(value.to_string()),
495 "default_model" => config.default_model = Some(value.to_string()),
496 "a2a.server_url" => config.a2a.server_url = Some(value.to_string()),
497 "a2a.worker_name" => config.a2a.worker_name = Some(value.to_string()),
498 "ui.theme" => config.ui.theme = value.to_string(),
499 "telemetry.crash_reporting" => {
500 config.telemetry.crash_reporting = Some(parse_bool(value)?)
501 }
502 "telemetry.crash_reporting_prompted" => {
503 config.telemetry.crash_reporting_prompted = Some(parse_bool(value)?)
504 }
505 "telemetry.crash_report_endpoint" => {
506 config.telemetry.crash_report_endpoint = Some(value.to_string())
507 }
508 _ => anyhow::bail!("Unknown config key: {}", key),
509 }
510
511 if let Some(path) = Self::global_config_path() {
513 let content = toml::to_string_pretty(&config)?;
514 fs::write(&path, content).await?;
515 }
516
517 Ok(())
518 }
519
520 fn merge(mut self, other: Self) -> Self {
522 if other.default_provider.is_some() {
523 self.default_provider = other.default_provider;
524 }
525 if other.default_model.is_some() {
526 self.default_model = other.default_model;
527 }
528 self.providers.extend(other.providers);
529 self.agents.extend(other.agents);
530 self.permissions.rules.extend(other.permissions.rules);
531 self.permissions.tools.extend(other.permissions.tools);
532 self.permissions.paths.extend(other.permissions.paths);
533 if other.a2a.server_url.is_some() {
534 self.a2a = other.a2a;
535 }
536 if other.telemetry.crash_reporting.is_some() {
537 self.telemetry.crash_reporting = other.telemetry.crash_reporting;
538 }
539 if other.telemetry.crash_reporting_prompted.is_some() {
540 self.telemetry.crash_reporting_prompted = other.telemetry.crash_reporting_prompted;
541 }
542 if other.telemetry.crash_report_endpoint.is_some() {
543 self.telemetry.crash_report_endpoint = other.telemetry.crash_report_endpoint;
544 }
545 self.lsp.servers.extend(other.lsp.servers);
546 self.lsp.linters.extend(other.lsp.linters);
547 if other.lsp.disable_builtin_linters {
548 self.lsp.disable_builtin_linters = true;
549 }
550 self
551 }
552
553 pub fn load_theme(&self) -> crate::tui::theme::Theme {
555 if let Some(custom) = &self.ui.custom_theme {
557 return custom.clone();
558 }
559
560 match self.ui.theme.as_str() {
562 "marketing" | "default" => crate::tui::theme::Theme::marketing(),
563 "dark" => crate::tui::theme::Theme::dark(),
564 "light" => crate::tui::theme::Theme::light(),
565 "solarized-dark" => crate::tui::theme::Theme::solarized_dark(),
566 "solarized-light" => crate::tui::theme::Theme::solarized_light(),
567 _ => {
568 tracing::warn!(theme = %self.ui.theme, "Unknown theme name, falling back to marketing");
570 crate::tui::theme::Theme::marketing()
571 }
572 }
573 }
574
575 fn apply_env(&mut self) {
577 if let Ok(val) = std::env::var("CODETETHER_DEFAULT_MODEL") {
578 self.default_model = Some(val);
579 }
580 if let Ok(val) = std::env::var("CODETETHER_DEFAULT_PROVIDER") {
581 self.default_provider = Some(val);
582 }
583 if let Ok(val) = std::env::var("OPENAI_API_KEY") {
584 self.providers
585 .entry("openai".to_string())
586 .or_default()
587 .api_key = Some(val);
588 }
589 if let Ok(val) = std::env::var("ANTHROPIC_API_KEY") {
590 self.providers
591 .entry("anthropic".to_string())
592 .or_default()
593 .api_key = Some(val);
594 }
595 if let Ok(val) = std::env::var("GOOGLE_API_KEY") {
596 self.providers
597 .entry("google".to_string())
598 .or_default()
599 .api_key = Some(val);
600 }
601 if let Ok(val) = std::env::var("CODETETHER_A2A_SERVER") {
602 self.a2a.server_url = Some(val);
603 }
604 if let Ok(val) = std::env::var("CODETETHER_CRASH_REPORTING") {
605 match parse_bool(&val) {
606 Ok(enabled) => self.telemetry.crash_reporting = Some(enabled),
607 Err(_) => tracing::warn!(
608 value = %val,
609 "Invalid CODETETHER_CRASH_REPORTING value; expected true/false"
610 ),
611 }
612 }
613 if let Ok(val) = std::env::var("CODETETHER_CRASH_REPORT_ENDPOINT") {
614 self.telemetry.crash_report_endpoint = Some(val);
615 }
616 }
617
618 fn normalize_legacy_defaults(&mut self) {
624 if let Some(provider) = self.default_provider.as_deref()
625 && provider.trim().eq_ignore_ascii_case("zhipuai")
626 {
627 self.default_provider = Some("zai".to_string());
628 }
629
630 if let Some(model) = self.default_model.as_deref() {
631 let model_trimmed = model.trim();
632
633 if model_trimmed.eq_ignore_ascii_case("zhipuai/glm-5") {
634 self.default_model = Some("zai/glm-5".to_string());
635 return;
636 }
637
638 let is_legacy_kimi_default = model_trimmed.eq_ignore_ascii_case("moonshotai/kimi-k2.5")
639 || model_trimmed.eq_ignore_ascii_case("kimi-k2.5");
640
641 if is_legacy_kimi_default {
642 tracing::info!(
643 from = %model_trimmed,
644 to = "zai/glm-5",
645 "Migrating legacy default model to current Z.AI GLM-5 default"
646 );
647 self.default_model = Some("zai/glm-5".to_string());
648
649 let should_update_provider = self.default_provider.as_deref().is_none_or(|p| {
650 let p = p.trim();
651 p.eq_ignore_ascii_case("moonshotai") || p.eq_ignore_ascii_case("zhipuai")
652 });
653 if should_update_provider {
654 self.default_provider = Some("zai".to_string());
655 }
656 }
657 }
658 }
659}
660
661fn parse_bool(value: &str) -> Result<bool> {
662 let normalized = value.trim().to_ascii_lowercase();
663 match normalized.as_str() {
664 "1" | "true" | "yes" | "on" => Ok(true),
665 "0" | "false" | "no" | "off" => Ok(false),
666 _ => anyhow::bail!("Invalid boolean value: {}", value),
667 }
668}
669
670fn workspace_data_dir() -> Option<PathBuf> {
671 let cwd = std::env::current_dir().ok()?;
672 Some(workspace_data_dir_from(&cwd))
673}
674
675fn workspace_data_dir_from(start: &Path) -> PathBuf {
676 detect_workspace_root(start)
677 .unwrap_or_else(|| start.to_path_buf())
678 .join(".codetether-agent")
679}
680
681fn detect_workspace_root(start: &Path) -> Option<PathBuf> {
682 start
683 .ancestors()
684 .find(|path| path.join(".git").exists())
685 .map(Path::to_path_buf)
686}
687
688#[cfg(test)]
689mod tests {
690 use super::Config;
691 use super::{detect_workspace_root, workspace_data_dir_from};
692 use tempfile::tempdir;
693
694 #[test]
695 fn migrates_legacy_kimi_default_to_zai_glm5() {
696 let mut cfg = Config {
697 default_provider: Some("moonshotai".to_string()),
698 default_model: Some("moonshotai/kimi-k2.5".to_string()),
699 ..Default::default()
700 };
701
702 cfg.normalize_legacy_defaults();
703
704 assert_eq!(cfg.default_provider.as_deref(), Some("zai"));
705 assert_eq!(cfg.default_model.as_deref(), Some("zai/glm-5"));
706 }
707
708 #[test]
709 fn preserves_explicit_non_legacy_default_model() {
710 let mut cfg = Config {
711 default_provider: Some("openai".to_string()),
712 default_model: Some("openai/gpt-4o".to_string()),
713 ..Default::default()
714 };
715
716 cfg.normalize_legacy_defaults();
717
718 assert_eq!(cfg.default_provider.as_deref(), Some("openai"));
719 assert_eq!(cfg.default_model.as_deref(), Some("openai/gpt-4o"));
720 }
721
722 #[test]
723 fn normalizes_zhipuai_aliases_to_zai() {
724 let mut cfg = Config {
725 default_provider: Some("zhipuai".to_string()),
726 default_model: Some("zhipuai/glm-5".to_string()),
727 ..Default::default()
728 };
729
730 cfg.normalize_legacy_defaults();
731
732 assert_eq!(cfg.default_provider.as_deref(), Some("zai"));
733 assert_eq!(cfg.default_model.as_deref(), Some("zai/glm-5"));
734 }
735
736 #[test]
737 fn detects_workspace_root_using_git_marker() {
738 let temp = tempdir().expect("tempdir");
739 let repo_root = temp.path().join("repo");
740 std::fs::create_dir_all(repo_root.join(".git")).expect("create .git");
741 let nested = repo_root.join("src").join("nested");
742 std::fs::create_dir_all(&nested).expect("create nested");
743
744 let detected = detect_workspace_root(&nested);
745 assert_eq!(detected.as_deref(), Some(repo_root.as_path()));
746 }
747
748 #[test]
749 fn workspace_data_dir_defaults_to_workspace_root() {
750 let temp = tempdir().expect("tempdir");
751 let repo_root = temp.path().join("repo");
752 std::fs::create_dir_all(repo_root.join(".git")).expect("create .git");
753 let nested = repo_root.join("api").join("src");
754 std::fs::create_dir_all(&nested).expect("create nested");
755
756 let data_dir = workspace_data_dir_from(&nested);
757 assert_eq!(data_dir, repo_root.join(".codetether-agent"));
758 }
759
760 #[test]
761 fn workspace_data_dir_falls_back_to_start_when_not_git_repo() {
762 let temp = tempdir().expect("tempdir");
763 let workspace = temp.path().join("workspace");
764 std::fs::create_dir_all(&workspace).expect("create workspace");
765
766 let data_dir = workspace_data_dir_from(&workspace);
767 assert_eq!(data_dir, workspace.join(".codetether-agent"));
768 }
769}