Skip to main content

codetether_agent/config/
mod.rs

1//! Configuration system
2//!
3//! Handles loading configuration from multiple sources:
4//! - Global config (~/.config/codetether/config.toml)
5//! - Project config (./codetether.toml or .codetether/config.toml)
6//! - Environment variables (CODETETHER_*)
7
8use 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/// Main configuration structure
18#[derive(Debug, Clone, Serialize, Deserialize, Default)]
19pub struct Config {
20    /// Default provider to use
21    #[serde(default)]
22    pub default_provider: Option<String>,
23
24    /// Default model to use (provider/model format)
25    #[serde(default)]
26    pub default_model: Option<String>,
27
28    /// Provider-specific configurations
29    #[serde(default)]
30    pub providers: HashMap<String, ProviderConfig>,
31
32    /// Agent configurations
33    #[serde(default)]
34    pub agents: HashMap<String, AgentConfig>,
35
36    /// Permission rules
37    #[serde(default)]
38    pub permissions: PermissionConfig,
39
40    /// A2A worker settings
41    #[serde(default)]
42    pub a2a: A2aConfig,
43
44    /// UI/TUI settings
45    #[serde(default)]
46    pub ui: UiConfig,
47
48    /// Session settings
49    #[serde(default)]
50    pub session: SessionConfig,
51}
52
53#[derive(Clone, Serialize, Deserialize, Default)]
54pub struct ProviderConfig {
55    /// API key (can also be set via env var)
56    pub api_key: Option<String>,
57
58    /// Base URL override
59    pub base_url: Option<String>,
60
61    /// Custom headers
62    #[serde(default)]
63    pub headers: HashMap<String, String>,
64
65    /// Organization ID (for OpenAI)
66    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    /// Agent name
84    pub name: String,
85
86    /// Description
87    #[serde(default)]
88    pub description: Option<String>,
89
90    /// Model override for this agent
91    #[serde(default)]
92    pub model: Option<String>,
93
94    /// System prompt override
95    #[serde(default)]
96    pub prompt: Option<String>,
97
98    /// Temperature setting
99    #[serde(default)]
100    pub temperature: Option<f32>,
101
102    /// Top-p setting
103    #[serde(default)]
104    pub top_p: Option<f32>,
105
106    /// Custom permissions for this agent
107    #[serde(default)]
108    pub permissions: HashMap<String, PermissionAction>,
109
110    /// Whether this agent is disabled
111    #[serde(default)]
112    pub disabled: bool,
113}
114
115#[derive(Debug, Clone, Serialize, Deserialize, Default)]
116pub struct PermissionConfig {
117    /// Default permission rules
118    #[serde(default)]
119    pub rules: HashMap<String, PermissionAction>,
120
121    /// Tool-specific permissions
122    #[serde(default)]
123    pub tools: HashMap<String, PermissionAction>,
124
125    /// Path-specific permissions
126    #[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    /// Default A2A server URL
147    pub server_url: Option<String>,
148
149    /// Worker name
150    pub worker_name: Option<String>,
151
152    /// Auto-approve policy
153    #[serde(default)]
154    pub auto_approve: AutoApprovePolicy,
155
156    /// Codebases to register
157    #[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    /// Theme name ("default", "dark", "light", "solarized-dark", "solarized-light", or custom)
173    #[serde(default = "default_theme")]
174    pub theme: String,
175
176    /// Show line numbers in code
177    #[serde(default = "default_true")]
178    pub line_numbers: bool,
179
180    /// Enable mouse support
181    #[serde(default = "default_true")]
182    pub mouse: bool,
183
184    /// Custom theme configuration (overrides preset themes)
185    #[serde(default)]
186    pub custom_theme: Option<crate::tui::theme::Theme>,
187
188    /// Enable theme hot-reloading (apply changes without restart)
189    #[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    /// Auto-compact sessions when they get too long
220    #[serde(default = "default_true")]
221    pub auto_compact: bool,
222
223    /// Maximum context tokens before compaction
224    #[serde(default = "default_max_tokens")]
225    pub max_tokens: usize,
226
227    /// Enable session persistence
228    #[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    /// Load configuration from all sources (global, project, env)
248    pub async fn load() -> Result<Self> {
249        let mut config = Self::default();
250
251        // Load global config
252        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        // Load project config
261        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        // Apply environment overrides
271        config.apply_env();
272
273        Ok(config)
274    }
275
276    /// Get the global config directory path
277    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    /// Get the data directory path
283    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    /// Initialize default configuration file
289    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    /// Set a configuration value
303    pub async fn set(key: &str, value: &str) -> Result<()> {
304        let mut config = Self::load().await?;
305
306        // Parse key path and set value
307        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        // Save to global config
317        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    /// Merge two configs (other takes precedence)
326    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    /// Load theme based on configuration
345    pub fn load_theme(&self) -> crate::tui::theme::Theme {
346        // Use custom theme if provided
347        if let Some(custom) = &self.ui.custom_theme {
348            return custom.clone();
349        }
350
351        // Use preset theme
352        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                // Log warning and fallback to dark theme
359                tracing::warn!(theme = %self.ui.theme, "Unknown theme name, falling back to dark");
360                crate::tui::theme::Theme::dark()
361            }
362        }
363    }
364
365    /// Apply environment variable overrides
366    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}