1use anyhow::Result;
9use directories::ProjectDirs;
10use serde::{Deserialize, Serialize};
11use std::collections::HashMap;
12use std::path::{Path, PathBuf};
13use tokio::fs;
14
15pub mod guardrails;
16
17use guardrails::CostGuardrails;
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct Config {
22 #[serde(default)]
24 pub default_provider: Option<String>,
25
26 #[serde(default)]
28 pub default_model: Option<String>,
29
30 #[serde(default)]
32 pub providers: HashMap<String, ProviderConfig>,
33
34 #[serde(default)]
36 pub agents: HashMap<String, AgentConfig>,
37
38 #[serde(default)]
40 pub permissions: PermissionConfig,
41
42 #[serde(default)]
44 pub a2a: A2aConfig,
45
46 #[serde(default)]
48 pub ui: UiConfig,
49
50 #[serde(default)]
52 pub session: SessionConfig,
53
54 #[serde(default)]
56 pub telemetry: TelemetryConfig,
57
58 #[serde(default)]
60 pub guardrails: CostGuardrails,
61
62 #[serde(default)]
64 pub lsp: LspSettings,
65
66 #[serde(default)]
72 pub rlm: crate::rlm::RlmConfig,
73}
74
75impl Default for Config {
76 fn default() -> Self {
77 Self {
78 default_provider: Some("zai".to_string()),
81 default_model: Some("zai/glm-5".to_string()),
82 providers: HashMap::new(),
83 agents: HashMap::new(),
84 permissions: PermissionConfig::default(),
85 a2a: A2aConfig::default(),
86 ui: UiConfig::default(),
87 session: SessionConfig::default(),
88 telemetry: TelemetryConfig::default(),
89 guardrails: CostGuardrails::default(),
90 lsp: LspSettings::default(),
91 rlm: crate::rlm::RlmConfig::default(),
92 }
93 }
94}
95
96#[derive(Clone, Serialize, Deserialize, Default)]
97pub struct ProviderConfig {
98 pub api_key: Option<String>,
100
101 pub base_url: Option<String>,
103
104 #[serde(default)]
106 pub headers: HashMap<String, String>,
107
108 pub organization: Option<String>,
110}
111
112impl std::fmt::Debug for ProviderConfig {
113 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
114 f.debug_struct("ProviderConfig")
115 .field("api_key", &self.api_key.as_ref().map(|_| "<REDACTED>"))
116 .field("api_key_len", &self.api_key.as_ref().map(|k| k.len()))
117 .field("base_url", &self.base_url)
118 .field("organization", &self.organization)
119 .field("headers_count", &self.headers.len())
120 .finish()
121 }
122}
123
124#[derive(Debug, Clone, Serialize, Deserialize)]
125pub struct AgentConfig {
126 pub name: String,
128
129 #[serde(default)]
131 pub description: Option<String>,
132
133 #[serde(default)]
135 pub model: Option<String>,
136
137 #[serde(default)]
139 pub prompt: Option<String>,
140
141 #[serde(default)]
143 pub temperature: Option<f32>,
144
145 #[serde(default)]
147 pub top_p: Option<f32>,
148
149 #[serde(default)]
151 pub permissions: HashMap<String, PermissionAction>,
152
153 #[serde(default)]
155 pub disabled: bool,
156}
157
158#[derive(Debug, Clone, Serialize, Deserialize, Default)]
159pub struct PermissionConfig {
160 #[serde(default)]
162 pub rules: HashMap<String, PermissionAction>,
163
164 #[serde(default)]
166 pub tools: HashMap<String, PermissionAction>,
167
168 #[serde(default)]
170 pub paths: HashMap<String, PermissionAction>,
171}
172
173#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
174#[serde(rename_all = "lowercase")]
175#[derive(Default)]
176pub enum PermissionAction {
177 Allow,
178 Deny,
179 #[default]
180 Ask,
181}
182
183#[derive(Debug, Clone, Serialize, Deserialize, Default)]
184pub struct A2aConfig {
185 pub server_url: Option<String>,
187
188 pub worker_name: Option<String>,
190
191 #[serde(default)]
193 pub auto_approve: AutoApprovePolicy,
194
195 #[serde(default)]
197 pub workspaces: Vec<PathBuf>,
198}
199
200#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)]
201#[serde(rename_all = "lowercase")]
202pub enum AutoApprovePolicy {
203 All,
204 #[default]
205 Safe,
206 None,
207}
208
209#[derive(Debug, Clone, Serialize, Deserialize)]
210pub struct UiConfig {
211 #[serde(default = "default_theme")]
213 pub theme: String,
214
215 #[serde(default = "default_true")]
217 pub line_numbers: bool,
218
219 #[serde(default = "default_true")]
221 pub mouse: bool,
222
223 #[serde(default)]
225 pub custom_theme: Option<crate::tui::theme::Theme>,
226
227 #[serde(default = "default_false")]
229 pub hot_reload: bool,
230}
231
232impl Default for UiConfig {
233 fn default() -> Self {
234 Self {
235 theme: default_theme(),
236 line_numbers: true,
237 mouse: true,
238 custom_theme: None,
239 hot_reload: false,
240 }
241 }
242}
243
244fn default_theme() -> String {
245 "marketing".to_string()
246}
247
248fn default_true() -> bool {
249 true
250}
251
252fn default_false() -> bool {
253 false
254}
255
256#[derive(Debug, Clone, Serialize, Deserialize)]
257pub struct SessionConfig {
258 #[serde(default = "default_true")]
260 pub auto_compact: bool,
261
262 #[serde(default = "default_max_tokens")]
264 pub max_tokens: usize,
265
266 #[serde(default = "default_true")]
268 pub persist: bool,
269}
270
271impl Default for SessionConfig {
272 fn default() -> Self {
273 Self {
274 auto_compact: true,
275 max_tokens: default_max_tokens(),
276 persist: true,
277 }
278 }
279}
280
281fn default_max_tokens() -> usize {
282 100_000
283}
284
285#[derive(Debug, Clone, Serialize, Deserialize, Default)]
286pub struct TelemetryConfig {
287 #[serde(default, skip_serializing_if = "Option::is_none")]
289 pub crash_reporting: Option<bool>,
290
291 #[serde(default, skip_serializing_if = "Option::is_none")]
293 pub crash_reporting_prompted: Option<bool>,
294
295 #[serde(default, skip_serializing_if = "Option::is_none")]
298 pub crash_report_endpoint: Option<String>,
299}
300
301impl TelemetryConfig {
302 pub fn crash_reporting_enabled(&self) -> bool {
303 self.crash_reporting.unwrap_or(false)
304 }
305
306 pub fn crash_reporting_prompted(&self) -> bool {
307 self.crash_reporting_prompted.unwrap_or(false)
308 }
309
310 pub fn crash_report_endpoint(&self) -> String {
311 self.crash_report_endpoint
312 .clone()
313 .unwrap_or_else(default_crash_report_endpoint)
314 }
315}
316
317fn default_crash_report_endpoint() -> String {
318 "https://api.codetether.run/v1/crash-reports".to_string()
319}
320
321#[derive(Debug, Clone, Serialize, Deserialize, Default)]
323pub struct LspSettings {
324 #[serde(default)]
327 pub servers: HashMap<String, LspServerEntry>,
328
329 #[serde(default)]
335 pub linters: HashMap<String, LspLinterEntry>,
336
337 #[serde(default)]
339 pub disable_builtin_linters: bool,
340}
341
342#[derive(Debug, Clone, Serialize, Deserialize)]
344pub struct LspServerEntry {
345 pub command: String,
347 #[serde(default)]
349 pub args: Vec<String>,
350 #[serde(default)]
352 pub file_extensions: Vec<String>,
353 #[serde(default)]
355 pub initialization_options: Option<serde_json::Value>,
356 #[serde(default = "default_lsp_timeout")]
358 pub timeout_ms: u64,
359}
360
361#[derive(Debug, Clone, Serialize, Deserialize)]
363pub struct LspLinterEntry {
364 pub command: Option<String>,
367 #[serde(default)]
369 pub args: Vec<String>,
370 #[serde(default)]
373 pub file_extensions: Vec<String>,
374 #[serde(default)]
376 pub initialization_options: Option<serde_json::Value>,
377 #[serde(default = "default_true")]
379 pub enabled: bool,
380}
381
382impl Default for LspLinterEntry {
383 fn default() -> Self {
384 Self {
385 command: None,
386 args: Vec::new(),
387 file_extensions: Vec::new(),
388 initialization_options: None,
389 enabled: true,
390 }
391 }
392}
393
394fn default_lsp_timeout() -> u64 {
395 30_000
396}
397
398impl Config {
399 pub async fn load() -> Result<Self> {
406 let global_path = Self::global_config_path();
408 let project_paths = [
409 PathBuf::from("codetether.toml"),
410 PathBuf::from(".codetether/config.toml"),
411 ];
412
413 async fn read_opt(p: PathBuf) -> (PathBuf, Option<String>) {
416 match fs::read_to_string(&p).await {
417 Ok(s) => (p, Some(s)),
418 Err(_) => (p, None),
419 }
420 }
421
422 let global_future = async {
423 match global_path {
424 Some(p) => Some(read_opt(p).await),
425 None => None,
426 }
427 };
428 let project_futures = futures::future::join_all(project_paths.into_iter().map(read_opt));
429
430 let (global_result, project_results) = tokio::join!(global_future, project_futures);
431
432 let mut config = Self::default();
433 if let Some((path, Some(content))) = global_result {
434 match toml::from_str::<Config>(&content) {
435 Ok(global) => config = config.merge(global),
436 Err(err) => {
437 return Err(err)
438 .map_err(|e| anyhow::anyhow!("failed to parse {}: {}", path.display(), e));
439 }
440 }
441 }
442 for (path, maybe) in project_results {
443 let Some(content) = maybe else { continue };
444 match toml::from_str::<Config>(&content) {
445 Ok(project) => config = config.merge(project),
446 Err(err) => {
447 return Err(err)
448 .map_err(|e| anyhow::anyhow!("failed to parse {}: {}", path.display(), e));
449 }
450 }
451 }
452
453 config.apply_env();
455 config.normalize_legacy_defaults();
456
457 Ok(config)
458 }
459
460 pub fn global_config_path() -> Option<PathBuf> {
462 ProjectDirs::from("ai", "codetether", "codetether-agent")
463 .map(|dirs| dirs.config_dir().join("config.toml"))
464 }
465
466 pub fn data_dir() -> Option<PathBuf> {
468 if let Ok(explicit) = std::env::var("CODETETHER_DATA_DIR") {
469 let explicit = explicit.trim();
470 if !explicit.is_empty() {
471 return Some(PathBuf::from(explicit));
472 }
473 }
474
475 workspace_data_dir().or_else(|| {
476 ProjectDirs::from("ai", "codetether", "codetether-agent")
477 .map(|dirs| dirs.data_dir().to_path_buf())
478 })
479 }
480
481 pub async fn init_default() -> Result<()> {
483 if let Some(path) = Self::global_config_path() {
484 if let Some(parent) = path.parent() {
485 fs::create_dir_all(parent).await?;
486 }
487 let default = Self::default();
488 let content = toml::to_string_pretty(&default)?;
489 fs::write(&path, content).await?;
490 tracing::info!("Created config at {:?}", path);
491 }
492 Ok(())
493 }
494
495 pub async fn set(key: &str, value: &str) -> Result<()> {
497 let mut config = Self::load().await?;
498
499 match key {
501 "default_provider" => config.default_provider = Some(value.to_string()),
502 "default_model" => config.default_model = Some(value.to_string()),
503 "a2a.server_url" => config.a2a.server_url = Some(value.to_string()),
504 "a2a.worker_name" => config.a2a.worker_name = Some(value.to_string()),
505 "ui.theme" => config.ui.theme = value.to_string(),
506 "telemetry.crash_reporting" => {
507 config.telemetry.crash_reporting = Some(parse_bool(value)?)
508 }
509 "telemetry.crash_reporting_prompted" => {
510 config.telemetry.crash_reporting_prompted = Some(parse_bool(value)?)
511 }
512 "telemetry.crash_report_endpoint" => {
513 config.telemetry.crash_report_endpoint = Some(value.to_string())
514 }
515 _ => anyhow::bail!("Unknown config key: {}", key),
516 }
517
518 if let Some(path) = Self::global_config_path() {
520 let content = toml::to_string_pretty(&config)?;
521 fs::write(&path, content).await?;
522 }
523
524 Ok(())
525 }
526
527 fn merge(mut self, other: Self) -> Self {
529 if other.default_provider.is_some() {
530 self.default_provider = other.default_provider;
531 }
532 if other.default_model.is_some() {
533 self.default_model = other.default_model;
534 }
535 self.providers.extend(other.providers);
536 self.agents.extend(other.agents);
537 self.permissions.rules.extend(other.permissions.rules);
538 self.permissions.tools.extend(other.permissions.tools);
539 self.permissions.paths.extend(other.permissions.paths);
540 if other.a2a.server_url.is_some() {
541 self.a2a = other.a2a;
542 }
543 if other.telemetry.crash_reporting.is_some() {
544 self.telemetry.crash_reporting = other.telemetry.crash_reporting;
545 }
546 if other.telemetry.crash_reporting_prompted.is_some() {
547 self.telemetry.crash_reporting_prompted = other.telemetry.crash_reporting_prompted;
548 }
549 if other.telemetry.crash_report_endpoint.is_some() {
550 self.telemetry.crash_report_endpoint = other.telemetry.crash_report_endpoint;
551 }
552 self.lsp.servers.extend(other.lsp.servers);
553 self.lsp.linters.extend(other.lsp.linters);
554 if other.lsp.disable_builtin_linters {
555 self.lsp.disable_builtin_linters = true;
556 }
557 self
558 }
559
560 pub fn load_theme(&self) -> crate::tui::theme::Theme {
562 if let Some(custom) = &self.ui.custom_theme {
564 return custom.clone();
565 }
566
567 match self.ui.theme.as_str() {
569 "marketing" | "default" => crate::tui::theme::Theme::marketing(),
570 "dark" => crate::tui::theme::Theme::dark(),
571 "light" => crate::tui::theme::Theme::light(),
572 "solarized-dark" => crate::tui::theme::Theme::solarized_dark(),
573 "solarized-light" => crate::tui::theme::Theme::solarized_light(),
574 _ => {
575 tracing::warn!(theme = %self.ui.theme, "Unknown theme name, falling back to marketing");
577 crate::tui::theme::Theme::marketing()
578 }
579 }
580 }
581
582 fn apply_env(&mut self) {
584 if let Ok(val) = std::env::var("CODETETHER_DEFAULT_MODEL") {
585 self.default_model = Some(val);
586 }
587 if let Ok(val) = std::env::var("CODETETHER_DEFAULT_PROVIDER") {
588 self.default_provider = Some(val);
589 }
590 if let Ok(val) = std::env::var("OPENAI_API_KEY") {
591 self.providers
592 .entry("openai".to_string())
593 .or_default()
594 .api_key = Some(val);
595 }
596 if let Ok(val) = std::env::var("ANTHROPIC_API_KEY") {
597 self.providers
598 .entry("anthropic".to_string())
599 .or_default()
600 .api_key = Some(val);
601 }
602 if let Ok(val) = std::env::var("GOOGLE_API_KEY") {
603 self.providers
604 .entry("google".to_string())
605 .or_default()
606 .api_key = Some(val);
607 }
608 if let Ok(val) = std::env::var("CODETETHER_A2A_SERVER") {
609 self.a2a.server_url = Some(val);
610 }
611 if let Ok(val) = std::env::var("CODETETHER_CRASH_REPORTING") {
612 match parse_bool(&val) {
613 Ok(enabled) => self.telemetry.crash_reporting = Some(enabled),
614 Err(_) => tracing::warn!(
615 value = %val,
616 "Invalid CODETETHER_CRASH_REPORTING value; expected true/false"
617 ),
618 }
619 }
620 if let Ok(val) = std::env::var("CODETETHER_CRASH_REPORT_ENDPOINT") {
621 self.telemetry.crash_report_endpoint = Some(val);
622 }
623 }
624
625 fn normalize_legacy_defaults(&mut self) {
631 if let Some(provider) = self.default_provider.as_deref()
632 && provider.trim().eq_ignore_ascii_case("zhipuai")
633 {
634 self.default_provider = Some("zai".to_string());
635 }
636
637 if let Some(model) = self.default_model.as_deref() {
638 let model_trimmed = model.trim();
639
640 if model_trimmed.eq_ignore_ascii_case("zhipuai/glm-5") {
641 self.default_model = Some("zai/glm-5".to_string());
642 return;
643 }
644
645 let is_legacy_kimi_default = model_trimmed.eq_ignore_ascii_case("moonshotai/kimi-k2.5")
646 || model_trimmed.eq_ignore_ascii_case("kimi-k2.5");
647
648 if is_legacy_kimi_default {
649 tracing::info!(
650 from = %model_trimmed,
651 to = "zai/glm-5",
652 "Migrating legacy default model to current Z.AI GLM-5 default"
653 );
654 self.default_model = Some("zai/glm-5".to_string());
655
656 let should_update_provider = self.default_provider.as_deref().is_none_or(|p| {
657 let p = p.trim();
658 p.eq_ignore_ascii_case("moonshotai") || p.eq_ignore_ascii_case("zhipuai")
659 });
660 if should_update_provider {
661 self.default_provider = Some("zai".to_string());
662 }
663 }
664 }
665 }
666}
667
668fn parse_bool(value: &str) -> Result<bool> {
669 let normalized = value.trim().to_ascii_lowercase();
670 match normalized.as_str() {
671 "1" | "true" | "yes" | "on" => Ok(true),
672 "0" | "false" | "no" | "off" => Ok(false),
673 _ => anyhow::bail!("Invalid boolean value: {}", value),
674 }
675}
676
677fn workspace_data_dir() -> Option<PathBuf> {
678 let cwd = std::env::current_dir().ok()?;
679 Some(workspace_data_dir_from(&cwd))
680}
681
682fn workspace_data_dir_from(start: &Path) -> PathBuf {
683 detect_workspace_root(start)
684 .unwrap_or_else(|| start.to_path_buf())
685 .join(".codetether-agent")
686}
687
688fn detect_workspace_root(start: &Path) -> Option<PathBuf> {
689 start
690 .ancestors()
691 .find(|path| path.join(".git").exists())
692 .map(Path::to_path_buf)
693}
694
695#[cfg(test)]
696mod tests {
697 use super::Config;
698 use super::{detect_workspace_root, workspace_data_dir_from};
699 use tempfile::tempdir;
700
701 #[test]
702 fn migrates_legacy_kimi_default_to_zai_glm5() {
703 let mut cfg = Config {
704 default_provider: Some("moonshotai".to_string()),
705 default_model: Some("moonshotai/kimi-k2.5".to_string()),
706 ..Default::default()
707 };
708
709 cfg.normalize_legacy_defaults();
710
711 assert_eq!(cfg.default_provider.as_deref(), Some("zai"));
712 assert_eq!(cfg.default_model.as_deref(), Some("zai/glm-5"));
713 }
714
715 #[test]
716 fn preserves_explicit_non_legacy_default_model() {
717 let mut cfg = Config {
718 default_provider: Some("openai".to_string()),
719 default_model: Some("openai/gpt-4o".to_string()),
720 ..Default::default()
721 };
722
723 cfg.normalize_legacy_defaults();
724
725 assert_eq!(cfg.default_provider.as_deref(), Some("openai"));
726 assert_eq!(cfg.default_model.as_deref(), Some("openai/gpt-4o"));
727 }
728
729 #[test]
730 fn normalizes_zhipuai_aliases_to_zai() {
731 let mut cfg = Config {
732 default_provider: Some("zhipuai".to_string()),
733 default_model: Some("zhipuai/glm-5".to_string()),
734 ..Default::default()
735 };
736
737 cfg.normalize_legacy_defaults();
738
739 assert_eq!(cfg.default_provider.as_deref(), Some("zai"));
740 assert_eq!(cfg.default_model.as_deref(), Some("zai/glm-5"));
741 }
742
743 #[test]
744 fn detects_workspace_root_using_git_marker() {
745 let temp = tempdir().expect("tempdir");
746 let repo_root = temp.path().join("repo");
747 std::fs::create_dir_all(repo_root.join(".git")).expect("create .git");
748 let nested = repo_root.join("src").join("nested");
749 std::fs::create_dir_all(&nested).expect("create nested");
750
751 let detected = detect_workspace_root(&nested);
752 assert_eq!(detected.as_deref(), Some(repo_root.as_path()));
753 }
754
755 #[test]
756 fn workspace_data_dir_defaults_to_workspace_root() {
757 let temp = tempdir().expect("tempdir");
758 let repo_root = temp.path().join("repo");
759 std::fs::create_dir_all(repo_root.join(".git")).expect("create .git");
760 let nested = repo_root.join("api").join("src");
761 std::fs::create_dir_all(&nested).expect("create nested");
762
763 let data_dir = workspace_data_dir_from(&nested);
764 assert_eq!(data_dir, repo_root.join(".codetether-agent"));
765 }
766
767 #[test]
768 fn workspace_data_dir_falls_back_to_start_when_not_git_repo() {
769 let temp = tempdir().expect("tempdir");
770 let workspace = temp.path().join("workspace");
771 std::fs::create_dir_all(&workspace).expect("create workspace");
772
773 let data_dir = workspace_data_dir_from(&workspace);
774 assert_eq!(data_dir, workspace.join(".codetether-agent"));
775 }
776}