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, Default)]
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
51#[derive(Clone, Serialize, Deserialize, Default)]
52pub struct ProviderConfig {
53 pub api_key: Option<String>,
55
56 pub base_url: Option<String>,
58
59 #[serde(default)]
61 pub headers: HashMap<String, String>,
62
63 pub organization: Option<String>,
65}
66
67impl std::fmt::Debug for ProviderConfig {
68 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
69 f.debug_struct("ProviderConfig")
70 .field("api_key", &self.api_key.as_ref().map(|_| "<REDACTED>"))
71 .field("api_key_len", &self.api_key.as_ref().map(|k| k.len()))
72 .field("base_url", &self.base_url)
73 .field("organization", &self.organization)
74 .field("headers_count", &self.headers.len())
75 .finish()
76 }
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct AgentConfig {
81 pub name: String,
83
84 #[serde(default)]
86 pub description: Option<String>,
87
88 #[serde(default)]
90 pub model: Option<String>,
91
92 #[serde(default)]
94 pub prompt: Option<String>,
95
96 #[serde(default)]
98 pub temperature: Option<f32>,
99
100 #[serde(default)]
102 pub top_p: Option<f32>,
103
104 #[serde(default)]
106 pub permissions: HashMap<String, PermissionAction>,
107
108 #[serde(default)]
110 pub disabled: bool,
111}
112
113#[derive(Debug, Clone, Serialize, Deserialize, Default)]
114pub struct PermissionConfig {
115 #[serde(default)]
117 pub rules: HashMap<String, PermissionAction>,
118
119 #[serde(default)]
121 pub tools: HashMap<String, PermissionAction>,
122
123 #[serde(default)]
125 pub paths: HashMap<String, PermissionAction>,
126}
127
128#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
129#[serde(rename_all = "lowercase")]
130pub enum PermissionAction {
131 Allow,
132 Deny,
133 Ask,
134}
135
136impl Default for PermissionAction {
137 fn default() -> Self {
138 Self::Ask
139 }
140}
141
142#[derive(Debug, Clone, Serialize, Deserialize, Default)]
143pub struct A2aConfig {
144 pub server_url: Option<String>,
146
147 pub worker_name: Option<String>,
149
150 #[serde(default)]
152 pub auto_approve: AutoApprovePolicy,
153
154 #[serde(default)]
156 pub codebases: Vec<PathBuf>,
157}
158
159#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)]
160#[serde(rename_all = "lowercase")]
161pub enum AutoApprovePolicy {
162 All,
163 #[default]
164 Safe,
165 None,
166}
167
168#[derive(Debug, Clone, Serialize, Deserialize)]
169pub struct UiConfig {
170 #[serde(default = "default_theme")]
172 pub theme: String,
173
174 #[serde(default = "default_true")]
176 pub line_numbers: bool,
177
178 #[serde(default = "default_true")]
180 pub mouse: bool,
181
182 #[serde(default)]
184 pub custom_theme: Option<crate::tui::theme::Theme>,
185
186 #[serde(default = "default_false")]
188 pub hot_reload: bool,
189}
190
191impl Default for UiConfig {
192 fn default() -> Self {
193 Self {
194 theme: default_theme(),
195 line_numbers: true,
196 mouse: true,
197 custom_theme: None,
198 hot_reload: false,
199 }
200 }
201}
202
203fn default_theme() -> String {
204 "default".to_string()
205}
206
207fn default_true() -> bool {
208 true
209}
210
211fn default_false() -> bool {
212 false
213}
214
215#[derive(Debug, Clone, Serialize, Deserialize)]
216pub struct SessionConfig {
217 #[serde(default = "default_true")]
219 pub auto_compact: bool,
220
221 #[serde(default = "default_max_tokens")]
223 pub max_tokens: usize,
224
225 #[serde(default = "default_true")]
227 pub persist: bool,
228}
229
230impl Default for SessionConfig {
231 fn default() -> Self {
232 Self {
233 auto_compact: true,
234 max_tokens: default_max_tokens(),
235 persist: true,
236 }
237 }
238}
239
240fn default_max_tokens() -> usize {
241 100_000
242}
243
244impl Config {
245 pub async fn load() -> Result<Self> {
247 let mut config = Self::default();
248
249 if let Some(global_path) = Self::global_config_path() {
251 if global_path.exists() {
252 let content = fs::read_to_string(&global_path).await?;
253 let global: Config = toml::from_str(&content)?;
254 config = config.merge(global);
255 }
256 }
257
258 for name in ["codetether.toml", ".codetether/config.toml"] {
260 let path = PathBuf::from(name);
261 if path.exists() {
262 let content = fs::read_to_string(&path).await?;
263 let project: Config = toml::from_str(&content)?;
264 config = config.merge(project);
265 }
266 }
267
268 config.apply_env();
270
271 Ok(config)
272 }
273
274 pub fn global_config_path() -> Option<PathBuf> {
276 ProjectDirs::from("ai", "codetether", "codetether-agent")
277 .map(|dirs| dirs.config_dir().join("config.toml"))
278 }
279
280 pub fn data_dir() -> Option<PathBuf> {
282 ProjectDirs::from("ai", "codetether", "codetether-agent")
283 .map(|dirs| dirs.data_dir().to_path_buf())
284 }
285
286 pub async fn init_default() -> Result<()> {
288 if let Some(path) = Self::global_config_path() {
289 if let Some(parent) = path.parent() {
290 fs::create_dir_all(parent).await?;
291 }
292 let default = Self::default();
293 let content = toml::to_string_pretty(&default)?;
294 fs::write(&path, content).await?;
295 tracing::info!("Created config at {:?}", path);
296 }
297 Ok(())
298 }
299
300 pub async fn set(key: &str, value: &str) -> Result<()> {
302 let mut config = Self::load().await?;
303
304 match key {
306 "default_provider" => config.default_provider = Some(value.to_string()),
307 "default_model" => config.default_model = Some(value.to_string()),
308 "a2a.server_url" => config.a2a.server_url = Some(value.to_string()),
309 "a2a.worker_name" => config.a2a.worker_name = Some(value.to_string()),
310 "ui.theme" => config.ui.theme = value.to_string(),
311 _ => anyhow::bail!("Unknown config key: {}", key),
312 }
313
314 if let Some(path) = Self::global_config_path() {
316 let content = toml::to_string_pretty(&config)?;
317 fs::write(&path, content).await?;
318 }
319
320 Ok(())
321 }
322
323 fn merge(mut self, other: Self) -> Self {
325 if other.default_provider.is_some() {
326 self.default_provider = other.default_provider;
327 }
328 if other.default_model.is_some() {
329 self.default_model = other.default_model;
330 }
331 self.providers.extend(other.providers);
332 self.agents.extend(other.agents);
333 self.permissions.rules.extend(other.permissions.rules);
334 self.permissions.tools.extend(other.permissions.tools);
335 self.permissions.paths.extend(other.permissions.paths);
336 if other.a2a.server_url.is_some() {
337 self.a2a = other.a2a;
338 }
339 self
340 }
341
342 pub fn load_theme(&self) -> crate::tui::theme::Theme {
344 if let Some(custom) = &self.ui.custom_theme {
346 return custom.clone();
347 }
348
349 match self.ui.theme.as_str() {
351 "dark" | "default" => crate::tui::theme::Theme::dark(),
352 "light" => crate::tui::theme::Theme::light(),
353 "solarized-dark" => crate::tui::theme::Theme::solarized_dark(),
354 "solarized-light" => crate::tui::theme::Theme::solarized_light(),
355 _ => {
356 tracing::warn!(theme = %self.ui.theme, "Unknown theme name, falling back to dark");
358 crate::tui::theme::Theme::dark()
359 }
360 }
361 }
362
363 fn apply_env(&mut self) {
365 if let Ok(val) = std::env::var("CODETETHER_DEFAULT_MODEL") {
366 self.default_model = Some(val);
367 }
368 if let Ok(val) = std::env::var("CODETETHER_DEFAULT_PROVIDER") {
369 self.default_provider = Some(val);
370 }
371 if let Ok(val) = std::env::var("OPENAI_API_KEY") {
372 self.providers
373 .entry("openai".to_string())
374 .or_default()
375 .api_key = Some(val);
376 }
377 if let Ok(val) = std::env::var("ANTHROPIC_API_KEY") {
378 self.providers
379 .entry("anthropic".to_string())
380 .or_default()
381 .api_key = Some(val);
382 }
383 if let Ok(val) = std::env::var("GOOGLE_API_KEY") {
384 self.providers
385 .entry("google".to_string())
386 .or_default()
387 .api_key = Some(val);
388 }
389 if let Ok(val) = std::env::var("CODETETHER_A2A_SERVER") {
390 self.a2a.server_url = Some(val);
391 }
392 }
393}