use anyhow::Result;
use directories::ProjectDirs;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::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,
}
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(),
}
}
}
#[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")]
pub enum PermissionAction {
Allow,
Deny,
Ask,
}
impl Default for PermissionAction {
fn default() -> Self {
Self::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()
}
impl Config {
pub async fn load() -> Result<Self> {
let mut config = Self::default();
if let Some(global_path) = Self::global_config_path() {
if global_path.exists() {
let content = fs::read_to_string(&global_path).await?;
let global: Config = toml::from_str(&content)?;
config = config.merge(global);
}
}
for name in ["codetether.toml", ".codetether/config.toml"] {
let path = PathBuf::from(name);
if path.exists() {
let content = fs::read_to_string(&path).await?;
let project: Config = toml::from_str(&content)?;
config = config.merge(project);
}
}
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> {
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
}
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),
}
}
#[cfg(test)]
mod tests {
use super::Config;
#[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"));
}
}