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
183impl Default for UiConfig {
184 fn default() -> Self {
185 Self {
186 theme: default_theme(),
187 line_numbers: true,
188 mouse: true,
189 }
190 }
191}
192
193fn default_theme() -> String {
194 "default".to_string()
195}
196
197fn default_true() -> bool {
198 true
199}
200
201#[derive(Debug, Clone, Serialize, Deserialize)]
202pub struct SessionConfig {
203 #[serde(default = "default_true")]
205 pub auto_compact: bool,
206
207 #[serde(default = "default_max_tokens")]
209 pub max_tokens: usize,
210
211 #[serde(default = "default_true")]
213 pub persist: bool,
214}
215
216impl Default for SessionConfig {
217 fn default() -> Self {
218 Self {
219 auto_compact: true,
220 max_tokens: default_max_tokens(),
221 persist: true,
222 }
223 }
224}
225
226fn default_max_tokens() -> usize {
227 100_000
228}
229
230impl Config {
231 pub async fn load() -> Result<Self> {
233 let mut config = Self::default();
234
235 if let Some(global_path) = Self::global_config_path() {
237 if global_path.exists() {
238 let content = fs::read_to_string(&global_path).await?;
239 let global: Config = toml::from_str(&content)?;
240 config = config.merge(global);
241 }
242 }
243
244 for name in ["codetether.toml", ".codetether/config.toml"] {
246 let path = PathBuf::from(name);
247 if path.exists() {
248 let content = fs::read_to_string(&path).await?;
249 let project: Config = toml::from_str(&content)?;
250 config = config.merge(project);
251 }
252 }
253
254 config.apply_env();
256
257 Ok(config)
258 }
259
260 pub fn global_config_path() -> Option<PathBuf> {
262 ProjectDirs::from("ai", "codetether", "codetether-agent")
263 .map(|dirs| dirs.config_dir().join("config.toml"))
264 }
265
266 pub fn data_dir() -> Option<PathBuf> {
268 ProjectDirs::from("ai", "codetether", "codetether-agent")
269 .map(|dirs| dirs.data_dir().to_path_buf())
270 }
271
272 pub async fn init_default() -> Result<()> {
274 if let Some(path) = Self::global_config_path() {
275 if let Some(parent) = path.parent() {
276 fs::create_dir_all(parent).await?;
277 }
278 let default = Self::default();
279 let content = toml::to_string_pretty(&default)?;
280 fs::write(&path, content).await?;
281 tracing::info!("Created config at {:?}", path);
282 }
283 Ok(())
284 }
285
286 pub async fn set(key: &str, value: &str) -> Result<()> {
288 let mut config = Self::load().await?;
289
290 match key {
292 "default_provider" => config.default_provider = Some(value.to_string()),
293 "default_model" => config.default_model = Some(value.to_string()),
294 "a2a.server_url" => config.a2a.server_url = Some(value.to_string()),
295 "a2a.worker_name" => config.a2a.worker_name = Some(value.to_string()),
296 "ui.theme" => config.ui.theme = value.to_string(),
297 _ => anyhow::bail!("Unknown config key: {}", key),
298 }
299
300 if let Some(path) = Self::global_config_path() {
302 let content = toml::to_string_pretty(&config)?;
303 fs::write(&path, content).await?;
304 }
305
306 Ok(())
307 }
308
309 fn merge(mut self, other: Self) -> Self {
311 if other.default_provider.is_some() {
312 self.default_provider = other.default_provider;
313 }
314 if other.default_model.is_some() {
315 self.default_model = other.default_model;
316 }
317 self.providers.extend(other.providers);
318 self.agents.extend(other.agents);
319 self.permissions.rules.extend(other.permissions.rules);
320 self.permissions.tools.extend(other.permissions.tools);
321 self.permissions.paths.extend(other.permissions.paths);
322 if other.a2a.server_url.is_some() {
323 self.a2a = other.a2a;
324 }
325 self
326 }
327
328 fn apply_env(&mut self) {
330 if let Ok(val) = std::env::var("CODETETHER_DEFAULT_MODEL") {
331 self.default_model = Some(val);
332 }
333 if let Ok(val) = std::env::var("CODETETHER_DEFAULT_PROVIDER") {
334 self.default_provider = Some(val);
335 }
336 if let Ok(val) = std::env::var("OPENAI_API_KEY") {
337 self.providers
338 .entry("openai".to_string())
339 .or_default()
340 .api_key = Some(val);
341 }
342 if let Ok(val) = std::env::var("ANTHROPIC_API_KEY") {
343 self.providers
344 .entry("anthropic".to_string())
345 .or_default()
346 .api_key = Some(val);
347 }
348 if let Ok(val) = std::env::var("GOOGLE_API_KEY") {
349 self.providers
350 .entry("google".to_string())
351 .or_default()
352 .api_key = Some(val);
353 }
354 if let Ok(val) = std::env::var("CODETETHER_A2A_SERVER") {
355 self.a2a.server_url = Some(val);
356 }
357 }
358}