1use anyhow::Result;
9use directories::ProjectDirs;
10use serde::{Deserialize, Serialize};
11use std::collections::HashMap;
12use std::path::PathBuf;
13use tokio::fs;
14
15use crate::tui::theme::Theme;
16
17#[derive(Debug, Clone, Serialize, Deserialize, Default)]
19pub struct Config {
20 #[serde(default)]
22 pub default_provider: Option<String>,
23
24 #[serde(default)]
26 pub default_model: Option<String>,
27
28 #[serde(default)]
30 pub providers: HashMap<String, ProviderConfig>,
31
32 #[serde(default)]
34 pub agents: HashMap<String, AgentConfig>,
35
36 #[serde(default)]
38 pub permissions: PermissionConfig,
39
40 #[serde(default)]
42 pub a2a: A2aConfig,
43
44 #[serde(default)]
46 pub ui: UiConfig,
47
48 #[serde(default)]
50 pub session: SessionConfig,
51}
52
53#[derive(Clone, Serialize, Deserialize, Default)]
54pub struct ProviderConfig {
55 pub api_key: Option<String>,
57
58 pub base_url: Option<String>,
60
61 #[serde(default)]
63 pub headers: HashMap<String, String>,
64
65 pub organization: Option<String>,
67}
68
69impl std::fmt::Debug for ProviderConfig {
70 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
71 f.debug_struct("ProviderConfig")
72 .field("api_key", &self.api_key.as_ref().map(|_| "<REDACTED>"))
73 .field("api_key_len", &self.api_key.as_ref().map(|k| k.len()))
74 .field("base_url", &self.base_url)
75 .field("organization", &self.organization)
76 .field("headers_count", &self.headers.len())
77 .finish()
78 }
79}
80
81#[derive(Debug, Clone, Serialize, Deserialize)]
82pub struct AgentConfig {
83 pub name: String,
85
86 #[serde(default)]
88 pub description: Option<String>,
89
90 #[serde(default)]
92 pub model: Option<String>,
93
94 #[serde(default)]
96 pub prompt: Option<String>,
97
98 #[serde(default)]
100 pub temperature: Option<f32>,
101
102 #[serde(default)]
104 pub top_p: Option<f32>,
105
106 #[serde(default)]
108 pub permissions: HashMap<String, PermissionAction>,
109
110 #[serde(default)]
112 pub disabled: bool,
113}
114
115#[derive(Debug, Clone, Serialize, Deserialize, Default)]
116pub struct PermissionConfig {
117 #[serde(default)]
119 pub rules: HashMap<String, PermissionAction>,
120
121 #[serde(default)]
123 pub tools: HashMap<String, PermissionAction>,
124
125 #[serde(default)]
127 pub paths: HashMap<String, PermissionAction>,
128}
129
130#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
131#[serde(rename_all = "lowercase")]
132pub enum PermissionAction {
133 Allow,
134 Deny,
135 Ask,
136}
137
138impl Default for PermissionAction {
139 fn default() -> Self {
140 Self::Ask
141 }
142}
143
144#[derive(Debug, Clone, Serialize, Deserialize, Default)]
145pub struct A2aConfig {
146 pub server_url: Option<String>,
148
149 pub worker_name: Option<String>,
151
152 #[serde(default)]
154 pub auto_approve: AutoApprovePolicy,
155
156 #[serde(default)]
158 pub codebases: Vec<PathBuf>,
159}
160
161#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)]
162#[serde(rename_all = "lowercase")]
163pub enum AutoApprovePolicy {
164 All,
165 #[default]
166 Safe,
167 None,
168}
169
170#[derive(Debug, Clone, Serialize, Deserialize)]
171pub struct UiConfig {
172 #[serde(default = "default_theme")]
174 pub theme: String,
175
176 #[serde(default = "default_true")]
178 pub line_numbers: bool,
179
180 #[serde(default = "default_true")]
182 pub mouse: bool,
183
184 #[serde(default)]
186 pub custom_theme: Option<crate::tui::theme::Theme>,
187
188 #[serde(default = "default_false")]
190 pub hot_reload: bool,
191}
192
193impl Default for UiConfig {
194 fn default() -> Self {
195 Self {
196 theme: default_theme(),
197 line_numbers: true,
198 mouse: true,
199 custom_theme: None,
200 hot_reload: false,
201 }
202 }
203}
204
205fn default_theme() -> String {
206 "default".to_string()
207}
208
209fn default_true() -> bool {
210 true
211}
212
213fn default_false() -> bool {
214 false
215}
216
217#[derive(Debug, Clone, Serialize, Deserialize)]
218pub struct SessionConfig {
219 #[serde(default = "default_true")]
221 pub auto_compact: bool,
222
223 #[serde(default = "default_max_tokens")]
225 pub max_tokens: usize,
226
227 #[serde(default = "default_true")]
229 pub persist: bool,
230}
231
232impl Default for SessionConfig {
233 fn default() -> Self {
234 Self {
235 auto_compact: true,
236 max_tokens: default_max_tokens(),
237 persist: true,
238 }
239 }
240}
241
242fn default_max_tokens() -> usize {
243 100_000
244}
245
246impl Config {
247 pub async fn load() -> Result<Self> {
249 let mut config = Self::default();
250
251 if let Some(global_path) = Self::global_config_path() {
253 if global_path.exists() {
254 let content = fs::read_to_string(&global_path).await?;
255 let global: Config = toml::from_str(&content)?;
256 config = config.merge(global);
257 }
258 }
259
260 for name in ["codetether.toml", ".codetether/config.toml"] {
262 let path = PathBuf::from(name);
263 if path.exists() {
264 let content = fs::read_to_string(&path).await?;
265 let project: Config = toml::from_str(&content)?;
266 config = config.merge(project);
267 }
268 }
269
270 config.apply_env();
272
273 Ok(config)
274 }
275
276 pub fn global_config_path() -> Option<PathBuf> {
278 ProjectDirs::from("ai", "codetether", "codetether-agent")
279 .map(|dirs| dirs.config_dir().join("config.toml"))
280 }
281
282 pub fn data_dir() -> Option<PathBuf> {
284 ProjectDirs::from("ai", "codetether", "codetether-agent")
285 .map(|dirs| dirs.data_dir().to_path_buf())
286 }
287
288 pub async fn init_default() -> Result<()> {
290 if let Some(path) = Self::global_config_path() {
291 if let Some(parent) = path.parent() {
292 fs::create_dir_all(parent).await?;
293 }
294 let default = Self::default();
295 let content = toml::to_string_pretty(&default)?;
296 fs::write(&path, content).await?;
297 tracing::info!("Created config at {:?}", path);
298 }
299 Ok(())
300 }
301
302 pub async fn set(key: &str, value: &str) -> Result<()> {
304 let mut config = Self::load().await?;
305
306 match key {
308 "default_provider" => config.default_provider = Some(value.to_string()),
309 "default_model" => config.default_model = Some(value.to_string()),
310 "a2a.server_url" => config.a2a.server_url = Some(value.to_string()),
311 "a2a.worker_name" => config.a2a.worker_name = Some(value.to_string()),
312 "ui.theme" => config.ui.theme = value.to_string(),
313 _ => anyhow::bail!("Unknown config key: {}", key),
314 }
315
316 if let Some(path) = Self::global_config_path() {
318 let content = toml::to_string_pretty(&config)?;
319 fs::write(&path, content).await?;
320 }
321
322 Ok(())
323 }
324
325 fn merge(mut self, other: Self) -> Self {
327 if other.default_provider.is_some() {
328 self.default_provider = other.default_provider;
329 }
330 if other.default_model.is_some() {
331 self.default_model = other.default_model;
332 }
333 self.providers.extend(other.providers);
334 self.agents.extend(other.agents);
335 self.permissions.rules.extend(other.permissions.rules);
336 self.permissions.tools.extend(other.permissions.tools);
337 self.permissions.paths.extend(other.permissions.paths);
338 if other.a2a.server_url.is_some() {
339 self.a2a = other.a2a;
340 }
341 self
342 }
343
344 pub fn load_theme(&self) -> crate::tui::theme::Theme {
346 if let Some(custom) = &self.ui.custom_theme {
348 return custom.clone();
349 }
350
351 match self.ui.theme.as_str() {
353 "dark" | "default" => crate::tui::theme::Theme::dark(),
354 "light" => crate::tui::theme::Theme::light(),
355 "solarized-dark" => crate::tui::theme::Theme::solarized_dark(),
356 "solarized-light" => crate::tui::theme::Theme::solarized_light(),
357 _ => {
358 tracing::warn!(theme = %self.ui.theme, "Unknown theme name, falling back to dark");
360 crate::tui::theme::Theme::dark()
361 }
362 }
363 }
364
365 fn apply_env(&mut self) {
367 if let Ok(val) = std::env::var("CODETETHER_DEFAULT_MODEL") {
368 self.default_model = Some(val);
369 }
370 if let Ok(val) = std::env::var("CODETETHER_DEFAULT_PROVIDER") {
371 self.default_provider = Some(val);
372 }
373 if let Ok(val) = std::env::var("OPENAI_API_KEY") {
374 self.providers
375 .entry("openai".to_string())
376 .or_default()
377 .api_key = Some(val);
378 }
379 if let Ok(val) = std::env::var("ANTHROPIC_API_KEY") {
380 self.providers
381 .entry("anthropic".to_string())
382 .or_default()
383 .api_key = Some(val);
384 }
385 if let Ok(val) = std::env::var("GOOGLE_API_KEY") {
386 self.providers
387 .entry("google".to_string())
388 .or_default()
389 .api_key = Some(val);
390 }
391 if let Ok(val) = std::env::var("CODETETHER_A2A_SERVER") {
392 self.a2a.server_url = Some(val);
393 }
394 }
395}