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
15/// Main configuration structure
16#[derive(Debug, Clone, Serialize, Deserialize, Default)]
17pub struct Config {
18    /// Default provider to use
19    #[serde(default)]
20    pub default_provider: Option<String>,
21
22    /// Default model to use (provider/model format)
23    #[serde(default)]
24    pub default_model: Option<String>,
25
26    /// Provider-specific configurations
27    #[serde(default)]
28    pub providers: HashMap<String, ProviderConfig>,
29
30    /// Agent configurations
31    #[serde(default)]
32    pub agents: HashMap<String, AgentConfig>,
33
34    /// Permission rules
35    #[serde(default)]
36    pub permissions: PermissionConfig,
37
38    /// A2A worker settings
39    #[serde(default)]
40    pub a2a: A2aConfig,
41
42    /// UI/TUI settings
43    #[serde(default)]
44    pub ui: UiConfig,
45
46    /// Session settings
47    #[serde(default)]
48    pub session: SessionConfig,
49}
50
51#[derive(Clone, Serialize, Deserialize, Default)]
52pub struct ProviderConfig {
53    /// API key (can also be set via env var)
54    pub api_key: Option<String>,
55
56    /// Base URL override
57    pub base_url: Option<String>,
58
59    /// Custom headers
60    #[serde(default)]
61    pub headers: HashMap<String, String>,
62
63    /// Organization ID (for OpenAI)
64    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    /// Agent name
82    pub name: String,
83
84    /// Description
85    #[serde(default)]
86    pub description: Option<String>,
87
88    /// Model override for this agent
89    #[serde(default)]
90    pub model: Option<String>,
91
92    /// System prompt override
93    #[serde(default)]
94    pub prompt: Option<String>,
95
96    /// Temperature setting
97    #[serde(default)]
98    pub temperature: Option<f32>,
99
100    /// Top-p setting
101    #[serde(default)]
102    pub top_p: Option<f32>,
103
104    /// Custom permissions for this agent
105    #[serde(default)]
106    pub permissions: HashMap<String, PermissionAction>,
107
108    /// Whether this agent is disabled
109    #[serde(default)]
110    pub disabled: bool,
111}
112
113#[derive(Debug, Clone, Serialize, Deserialize, Default)]
114pub struct PermissionConfig {
115    /// Default permission rules
116    #[serde(default)]
117    pub rules: HashMap<String, PermissionAction>,
118
119    /// Tool-specific permissions
120    #[serde(default)]
121    pub tools: HashMap<String, PermissionAction>,
122
123    /// Path-specific permissions
124    #[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    /// Default A2A server URL
145    pub server_url: Option<String>,
146
147    /// Worker name
148    pub worker_name: Option<String>,
149
150    /// Auto-approve policy
151    #[serde(default)]
152    pub auto_approve: AutoApprovePolicy,
153
154    /// Codebases to register
155    #[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    /// Theme name
171    #[serde(default = "default_theme")]
172    pub theme: String,
173
174    /// Show line numbers in code
175    #[serde(default = "default_true")]
176    pub line_numbers: bool,
177
178    /// Enable mouse support
179    #[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    /// Auto-compact sessions when they get too long
204    #[serde(default = "default_true")]
205    pub auto_compact: bool,
206
207    /// Maximum context tokens before compaction
208    #[serde(default = "default_max_tokens")]
209    pub max_tokens: usize,
210
211    /// Enable session persistence
212    #[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    /// Load configuration from all sources (global, project, env)
232    pub async fn load() -> Result<Self> {
233        let mut config = Self::default();
234
235        // Load global config
236        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        // Load project config
245        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        // Apply environment overrides
255        config.apply_env();
256
257        Ok(config)
258    }
259
260    /// Get the global config directory path
261    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    /// Get the data directory path
267    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    /// Initialize default configuration file
273    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    /// Set a configuration value
287    pub async fn set(key: &str, value: &str) -> Result<()> {
288        let mut config = Self::load().await?;
289        
290        // Parse key path and set value
291        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        // Save to global config
301        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    /// Merge two configs (other takes precedence)
310    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    /// Apply environment variable overrides
329    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}