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