use anyhow::Result;
use directories::ProjectDirs;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use tokio::fs;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
#[serde(default)]
pub default_provider: Option<String>,
#[serde(default)]
pub default_model: Option<String>,
#[serde(default)]
pub providers: HashMap<String, ProviderConfig>,
#[serde(default)]
pub agents: HashMap<String, AgentConfig>,
#[serde(default)]
pub permissions: PermissionConfig,
#[serde(default)]
pub a2a: A2aConfig,
#[serde(default)]
pub ui: UiConfig,
#[serde(default)]
pub session: SessionConfig,
#[serde(default)]
pub telemetry: TelemetryConfig,
#[serde(default)]
pub lsp: LspSettings,
#[serde(default)]
pub rlm: crate::rlm::RlmConfig,
}
impl Default for Config {
fn default() -> Self {
Self {
default_provider: Some("zai".to_string()),
default_model: Some("zai/glm-5".to_string()),
providers: HashMap::new(),
agents: HashMap::new(),
permissions: PermissionConfig::default(),
a2a: A2aConfig::default(),
ui: UiConfig::default(),
session: SessionConfig::default(),
telemetry: TelemetryConfig::default(),
lsp: LspSettings::default(),
rlm: crate::rlm::RlmConfig::default(),
}
}
}
#[derive(Clone, Serialize, Deserialize, Default)]
pub struct ProviderConfig {
pub api_key: Option<String>,
pub base_url: Option<String>,
#[serde(default)]
pub headers: HashMap<String, String>,
pub organization: Option<String>,
}
impl std::fmt::Debug for ProviderConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ProviderConfig")
.field("api_key", &self.api_key.as_ref().map(|_| "<REDACTED>"))
.field("api_key_len", &self.api_key.as_ref().map(|k| k.len()))
.field("base_url", &self.base_url)
.field("organization", &self.organization)
.field("headers_count", &self.headers.len())
.finish()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentConfig {
pub name: String,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub model: Option<String>,
#[serde(default)]
pub prompt: Option<String>,
#[serde(default)]
pub temperature: Option<f32>,
#[serde(default)]
pub top_p: Option<f32>,
#[serde(default)]
pub permissions: HashMap<String, PermissionAction>,
#[serde(default)]
pub disabled: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct PermissionConfig {
#[serde(default)]
pub rules: HashMap<String, PermissionAction>,
#[serde(default)]
pub tools: HashMap<String, PermissionAction>,
#[serde(default)]
pub paths: HashMap<String, PermissionAction>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
#[derive(Default)]
pub enum PermissionAction {
Allow,
Deny,
#[default]
Ask,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct A2aConfig {
pub server_url: Option<String>,
pub worker_name: Option<String>,
#[serde(default)]
pub auto_approve: AutoApprovePolicy,
#[serde(default)]
pub workspaces: Vec<PathBuf>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum AutoApprovePolicy {
All,
#[default]
Safe,
None,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UiConfig {
#[serde(default = "default_theme")]
pub theme: String,
#[serde(default = "default_true")]
pub line_numbers: bool,
#[serde(default = "default_true")]
pub mouse: bool,
#[serde(default)]
pub custom_theme: Option<crate::tui::theme::Theme>,
#[serde(default = "default_false")]
pub hot_reload: bool,
}
impl Default for UiConfig {
fn default() -> Self {
Self {
theme: default_theme(),
line_numbers: true,
mouse: true,
custom_theme: None,
hot_reload: false,
}
}
}
fn default_theme() -> String {
"marketing".to_string()
}
fn default_true() -> bool {
true
}
fn default_false() -> bool {
false
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionConfig {
#[serde(default = "default_true")]
pub auto_compact: bool,
#[serde(default = "default_max_tokens")]
pub max_tokens: usize,
#[serde(default = "default_true")]
pub persist: bool,
}
impl Default for SessionConfig {
fn default() -> Self {
Self {
auto_compact: true,
max_tokens: default_max_tokens(),
persist: true,
}
}
}
fn default_max_tokens() -> usize {
100_000
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct TelemetryConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub crash_reporting: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub crash_reporting_prompted: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub crash_report_endpoint: Option<String>,
}
impl TelemetryConfig {
pub fn crash_reporting_enabled(&self) -> bool {
self.crash_reporting.unwrap_or(false)
}
pub fn crash_reporting_prompted(&self) -> bool {
self.crash_reporting_prompted.unwrap_or(false)
}
pub fn crash_report_endpoint(&self) -> String {
self.crash_report_endpoint
.clone()
.unwrap_or_else(default_crash_report_endpoint)
}
}
fn default_crash_report_endpoint() -> String {
"https://api.codetether.run/v1/crash-reports".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct LspSettings {
#[serde(default)]
pub servers: HashMap<String, LspServerEntry>,
#[serde(default)]
pub linters: HashMap<String, LspLinterEntry>,
#[serde(default)]
pub disable_builtin_linters: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LspServerEntry {
pub command: String,
#[serde(default)]
pub args: Vec<String>,
#[serde(default)]
pub file_extensions: Vec<String>,
#[serde(default)]
pub initialization_options: Option<serde_json::Value>,
#[serde(default = "default_lsp_timeout")]
pub timeout_ms: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LspLinterEntry {
pub command: Option<String>,
#[serde(default)]
pub args: Vec<String>,
#[serde(default)]
pub file_extensions: Vec<String>,
#[serde(default)]
pub initialization_options: Option<serde_json::Value>,
#[serde(default = "default_true")]
pub enabled: bool,
}
impl Default for LspLinterEntry {
fn default() -> Self {
Self {
command: None,
args: Vec::new(),
file_extensions: Vec::new(),
initialization_options: None,
enabled: true,
}
}
}
fn default_lsp_timeout() -> u64 {
30_000
}
impl Config {
pub async fn load() -> Result<Self> {
let global_path = Self::global_config_path();
let project_paths = [
PathBuf::from("codetether.toml"),
PathBuf::from(".codetether/config.toml"),
];
async fn read_opt(p: PathBuf) -> (PathBuf, Option<String>) {
match fs::read_to_string(&p).await {
Ok(s) => (p, Some(s)),
Err(_) => (p, None),
}
}
let global_future = async {
match global_path {
Some(p) => Some(read_opt(p).await),
None => None,
}
};
let project_futures = futures::future::join_all(project_paths.into_iter().map(read_opt));
let (global_result, project_results) = tokio::join!(global_future, project_futures);
let mut config = Self::default();
if let Some((path, Some(content))) = global_result {
match toml::from_str::<Config>(&content) {
Ok(global) => config = config.merge(global),
Err(err) => {
return Err(err).map_err(|e| {
anyhow::anyhow!("failed to parse {}: {}", path.display(), e)
});
}
}
}
for (path, maybe) in project_results {
let Some(content) = maybe else { continue };
match toml::from_str::<Config>(&content) {
Ok(project) => config = config.merge(project),
Err(err) => {
return Err(err).map_err(|e| {
anyhow::anyhow!("failed to parse {}: {}", path.display(), e)
});
}
}
}
config.apply_env();
config.normalize_legacy_defaults();
Ok(config)
}
pub fn global_config_path() -> Option<PathBuf> {
ProjectDirs::from("ai", "codetether", "codetether-agent")
.map(|dirs| dirs.config_dir().join("config.toml"))
}
pub fn data_dir() -> Option<PathBuf> {
if let Ok(explicit) = std::env::var("CODETETHER_DATA_DIR") {
let explicit = explicit.trim();
if !explicit.is_empty() {
return Some(PathBuf::from(explicit));
}
}
workspace_data_dir().or_else(|| {
ProjectDirs::from("ai", "codetether", "codetether-agent")
.map(|dirs| dirs.data_dir().to_path_buf())
})
}
pub async fn init_default() -> Result<()> {
if let Some(path) = Self::global_config_path() {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).await?;
}
let default = Self::default();
let content = toml::to_string_pretty(&default)?;
fs::write(&path, content).await?;
tracing::info!("Created config at {:?}", path);
}
Ok(())
}
pub async fn set(key: &str, value: &str) -> Result<()> {
let mut config = Self::load().await?;
match key {
"default_provider" => config.default_provider = Some(value.to_string()),
"default_model" => config.default_model = Some(value.to_string()),
"a2a.server_url" => config.a2a.server_url = Some(value.to_string()),
"a2a.worker_name" => config.a2a.worker_name = Some(value.to_string()),
"ui.theme" => config.ui.theme = value.to_string(),
"telemetry.crash_reporting" => {
config.telemetry.crash_reporting = Some(parse_bool(value)?)
}
"telemetry.crash_reporting_prompted" => {
config.telemetry.crash_reporting_prompted = Some(parse_bool(value)?)
}
"telemetry.crash_report_endpoint" => {
config.telemetry.crash_report_endpoint = Some(value.to_string())
}
_ => anyhow::bail!("Unknown config key: {}", key),
}
if let Some(path) = Self::global_config_path() {
let content = toml::to_string_pretty(&config)?;
fs::write(&path, content).await?;
}
Ok(())
}
fn merge(mut self, other: Self) -> Self {
if other.default_provider.is_some() {
self.default_provider = other.default_provider;
}
if other.default_model.is_some() {
self.default_model = other.default_model;
}
self.providers.extend(other.providers);
self.agents.extend(other.agents);
self.permissions.rules.extend(other.permissions.rules);
self.permissions.tools.extend(other.permissions.tools);
self.permissions.paths.extend(other.permissions.paths);
if other.a2a.server_url.is_some() {
self.a2a = other.a2a;
}
if other.telemetry.crash_reporting.is_some() {
self.telemetry.crash_reporting = other.telemetry.crash_reporting;
}
if other.telemetry.crash_reporting_prompted.is_some() {
self.telemetry.crash_reporting_prompted = other.telemetry.crash_reporting_prompted;
}
if other.telemetry.crash_report_endpoint.is_some() {
self.telemetry.crash_report_endpoint = other.telemetry.crash_report_endpoint;
}
self.lsp.servers.extend(other.lsp.servers);
self.lsp.linters.extend(other.lsp.linters);
if other.lsp.disable_builtin_linters {
self.lsp.disable_builtin_linters = true;
}
self
}
pub fn load_theme(&self) -> crate::tui::theme::Theme {
if let Some(custom) = &self.ui.custom_theme {
return custom.clone();
}
match self.ui.theme.as_str() {
"marketing" | "default" => crate::tui::theme::Theme::marketing(),
"dark" => crate::tui::theme::Theme::dark(),
"light" => crate::tui::theme::Theme::light(),
"solarized-dark" => crate::tui::theme::Theme::solarized_dark(),
"solarized-light" => crate::tui::theme::Theme::solarized_light(),
_ => {
tracing::warn!(theme = %self.ui.theme, "Unknown theme name, falling back to marketing");
crate::tui::theme::Theme::marketing()
}
}
}
fn apply_env(&mut self) {
if let Ok(val) = std::env::var("CODETETHER_DEFAULT_MODEL") {
self.default_model = Some(val);
}
if let Ok(val) = std::env::var("CODETETHER_DEFAULT_PROVIDER") {
self.default_provider = Some(val);
}
if let Ok(val) = std::env::var("OPENAI_API_KEY") {
self.providers
.entry("openai".to_string())
.or_default()
.api_key = Some(val);
}
if let Ok(val) = std::env::var("ANTHROPIC_API_KEY") {
self.providers
.entry("anthropic".to_string())
.or_default()
.api_key = Some(val);
}
if let Ok(val) = std::env::var("GOOGLE_API_KEY") {
self.providers
.entry("google".to_string())
.or_default()
.api_key = Some(val);
}
if let Ok(val) = std::env::var("CODETETHER_A2A_SERVER") {
self.a2a.server_url = Some(val);
}
if let Ok(val) = std::env::var("CODETETHER_CRASH_REPORTING") {
match parse_bool(&val) {
Ok(enabled) => self.telemetry.crash_reporting = Some(enabled),
Err(_) => tracing::warn!(
value = %val,
"Invalid CODETETHER_CRASH_REPORTING value; expected true/false"
),
}
}
if let Ok(val) = std::env::var("CODETETHER_CRASH_REPORT_ENDPOINT") {
self.telemetry.crash_report_endpoint = Some(val);
}
}
fn normalize_legacy_defaults(&mut self) {
if let Some(provider) = self.default_provider.as_deref()
&& provider.trim().eq_ignore_ascii_case("zhipuai")
{
self.default_provider = Some("zai".to_string());
}
if let Some(model) = self.default_model.as_deref() {
let model_trimmed = model.trim();
if model_trimmed.eq_ignore_ascii_case("zhipuai/glm-5") {
self.default_model = Some("zai/glm-5".to_string());
return;
}
let is_legacy_kimi_default = model_trimmed.eq_ignore_ascii_case("moonshotai/kimi-k2.5")
|| model_trimmed.eq_ignore_ascii_case("kimi-k2.5");
if is_legacy_kimi_default {
tracing::info!(
from = %model_trimmed,
to = "zai/glm-5",
"Migrating legacy default model to current Z.AI GLM-5 default"
);
self.default_model = Some("zai/glm-5".to_string());
let should_update_provider = self.default_provider.as_deref().is_none_or(|p| {
let p = p.trim();
p.eq_ignore_ascii_case("moonshotai") || p.eq_ignore_ascii_case("zhipuai")
});
if should_update_provider {
self.default_provider = Some("zai".to_string());
}
}
}
}
}
fn parse_bool(value: &str) -> Result<bool> {
let normalized = value.trim().to_ascii_lowercase();
match normalized.as_str() {
"1" | "true" | "yes" | "on" => Ok(true),
"0" | "false" | "no" | "off" => Ok(false),
_ => anyhow::bail!("Invalid boolean value: {}", value),
}
}
fn workspace_data_dir() -> Option<PathBuf> {
let cwd = std::env::current_dir().ok()?;
Some(workspace_data_dir_from(&cwd))
}
fn workspace_data_dir_from(start: &Path) -> PathBuf {
detect_workspace_root(start)
.unwrap_or_else(|| start.to_path_buf())
.join(".codetether-agent")
}
fn detect_workspace_root(start: &Path) -> Option<PathBuf> {
start
.ancestors()
.find(|path| path.join(".git").exists())
.map(Path::to_path_buf)
}
#[cfg(test)]
mod tests {
use super::Config;
use super::{detect_workspace_root, workspace_data_dir_from};
use tempfile::tempdir;
#[test]
fn migrates_legacy_kimi_default_to_zai_glm5() {
let mut cfg = Config {
default_provider: Some("moonshotai".to_string()),
default_model: Some("moonshotai/kimi-k2.5".to_string()),
..Default::default()
};
cfg.normalize_legacy_defaults();
assert_eq!(cfg.default_provider.as_deref(), Some("zai"));
assert_eq!(cfg.default_model.as_deref(), Some("zai/glm-5"));
}
#[test]
fn preserves_explicit_non_legacy_default_model() {
let mut cfg = Config {
default_provider: Some("openai".to_string()),
default_model: Some("openai/gpt-4o".to_string()),
..Default::default()
};
cfg.normalize_legacy_defaults();
assert_eq!(cfg.default_provider.as_deref(), Some("openai"));
assert_eq!(cfg.default_model.as_deref(), Some("openai/gpt-4o"));
}
#[test]
fn normalizes_zhipuai_aliases_to_zai() {
let mut cfg = Config {
default_provider: Some("zhipuai".to_string()),
default_model: Some("zhipuai/glm-5".to_string()),
..Default::default()
};
cfg.normalize_legacy_defaults();
assert_eq!(cfg.default_provider.as_deref(), Some("zai"));
assert_eq!(cfg.default_model.as_deref(), Some("zai/glm-5"));
}
#[test]
fn detects_workspace_root_using_git_marker() {
let temp = tempdir().expect("tempdir");
let repo_root = temp.path().join("repo");
std::fs::create_dir_all(repo_root.join(".git")).expect("create .git");
let nested = repo_root.join("src").join("nested");
std::fs::create_dir_all(&nested).expect("create nested");
let detected = detect_workspace_root(&nested);
assert_eq!(detected.as_deref(), Some(repo_root.as_path()));
}
#[test]
fn workspace_data_dir_defaults_to_workspace_root() {
let temp = tempdir().expect("tempdir");
let repo_root = temp.path().join("repo");
std::fs::create_dir_all(repo_root.join(".git")).expect("create .git");
let nested = repo_root.join("api").join("src");
std::fs::create_dir_all(&nested).expect("create nested");
let data_dir = workspace_data_dir_from(&nested);
assert_eq!(data_dir, repo_root.join(".codetether-agent"));
}
#[test]
fn workspace_data_dir_falls_back_to_start_when_not_git_repo() {
let temp = tempdir().expect("tempdir");
let workspace = temp.path().join("workspace");
std::fs::create_dir_all(&workspace).expect("create workspace");
let data_dir = workspace_data_dir_from(&workspace);
assert_eq!(data_dir, workspace.join(".codetether-agent"));
}
}