use std::path::PathBuf;
use serde::{Deserialize, Serialize};
const DEFAULT_MODEL: &str = "claude-sonnet-4-5-20250929";
const DEFAULT_SYSTEM_PROMPT: &str = "You are Aonyx Agent — the agent with a real memory palace. Be concise. Cite sources when you recall facts. Confirm scope before destructive actions.";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
pub provider: String,
pub model: String,
#[serde(default)]
pub anthropic_api_key: Option<String>,
#[serde(default)]
pub openai_api_key: Option<String>,
#[serde(default)]
pub openrouter_api_key: Option<String>,
#[serde(default)]
pub openai_base_url: Option<String>,
#[serde(default)]
pub lm_studio_base_url: Option<String>,
#[serde(default)]
pub ollama_base_url: Option<String>,
#[serde(default)]
pub claude_code_binary: Option<String>,
#[serde(default)]
pub claude_code_extra_args: Vec<String>,
#[serde(default)]
pub openrouter_referer: Option<String>,
#[serde(default)]
pub openrouter_title: Option<String>,
#[serde(default)]
pub system_prompt: Option<String>,
#[serde(default = "default_max_iterations")]
pub max_iterations: usize,
#[serde(default)]
pub theme: Option<String>,
#[serde(default)]
pub show_thinking: bool,
#[serde(default)]
pub desktop_notifications: bool,
#[serde(default)]
pub auto_compact: bool,
#[serde(default = "default_compact_threshold")]
pub auto_compact_threshold: u64,
#[serde(default)]
pub mcp_servers: Vec<McpServerConfig>,
#[serde(default)]
pub custom_theme: Option<CustomTheme>,
#[serde(default)]
pub tool_approvals: Vec<String>,
#[serde(default)]
pub telegram_allowed_chats: Vec<i64>,
#[serde(default)]
pub discord_allowed_channels: Vec<i64>,
#[serde(default = "default_skill_autogen")]
pub skill_autogen: bool,
#[serde(default = "default_skill_autogen_threshold")]
pub skill_autogen_threshold: usize,
#[serde(default)]
pub sandbox_backend: Option<String>,
#[serde(default)]
pub sandbox_image: Option<String>,
#[serde(default)]
pub sandbox_url: Option<String>,
#[serde(default)]
pub tools_allow: Vec<String>,
#[serde(default)]
pub tools_deny: Vec<String>,
#[serde(default)]
pub auto_retrieve: bool,
#[serde(default = "default_auto_retrieve_top_k")]
pub auto_retrieve_top_k: usize,
#[serde(default = "default_auto_retrieve_min_len")]
pub auto_retrieve_min_len: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CustomTheme {
pub header_fg: [u8; 3],
pub composer_border: [u8; 3],
pub suggestion_border: [u8; 3],
pub status_bg: [u8; 3],
pub status_fg: [u8; 3],
pub user_prefix: [u8; 3],
pub assistant_prefix: [u8; 3],
pub thinking: [u8; 3],
pub dim: [u8; 3],
pub status_busy_bg: [u8; 3],
}
impl CustomTheme {
pub fn to_rgb_fields(&self) -> [(u8, u8, u8); 10] {
let t = |a: [u8; 3]| (a[0], a[1], a[2]);
[
t(self.header_fg),
t(self.composer_border),
t(self.suggestion_border),
t(self.status_bg),
t(self.status_fg),
t(self.user_prefix),
t(self.assistant_prefix),
t(self.thinking),
t(self.dim),
t(self.status_busy_bg),
]
}
pub fn from_rgb_fields(f: &[(u8, u8, u8); 10]) -> Self {
let a = |t: (u8, u8, u8)| [t.0, t.1, t.2];
Self {
header_fg: a(f[0]),
composer_border: a(f[1]),
suggestion_border: a(f[2]),
status_bg: a(f[3]),
status_fg: a(f[4]),
user_prefix: a(f[5]),
assistant_prefix: a(f[6]),
thinking: a(f[7]),
dim: a(f[8]),
status_busy_bg: a(f[9]),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpServerConfig {
pub name: String,
#[serde(default)]
pub command: Option<String>,
#[serde(default)]
pub args: Vec<String>,
#[serde(default)]
pub url: Option<String>,
#[serde(default)]
pub bearer_token: Option<String>,
}
fn default_compact_threshold() -> u64 {
24_000
}
fn default_max_iterations() -> usize {
10
}
fn default_skill_autogen() -> bool {
true
}
fn default_skill_autogen_threshold() -> usize {
3
}
fn default_auto_retrieve_top_k() -> usize {
5
}
fn default_auto_retrieve_min_len() -> usize {
12
}
impl Default for Config {
fn default() -> Self {
Self {
provider: "anthropic".to_string(),
model: DEFAULT_MODEL.to_string(),
anthropic_api_key: None,
openai_api_key: None,
openrouter_api_key: None,
openai_base_url: None,
lm_studio_base_url: None,
ollama_base_url: None,
claude_code_binary: None,
claude_code_extra_args: Vec::new(),
openrouter_referer: None,
openrouter_title: None,
system_prompt: Some(DEFAULT_SYSTEM_PROMPT.to_string()),
max_iterations: default_max_iterations(),
theme: None,
show_thinking: false,
desktop_notifications: false,
auto_compact: false,
auto_compact_threshold: default_compact_threshold(),
mcp_servers: Vec::new(),
custom_theme: None,
tool_approvals: Vec::new(),
telegram_allowed_chats: Vec::new(),
discord_allowed_channels: Vec::new(),
skill_autogen: true,
skill_autogen_threshold: 3,
sandbox_backend: None,
sandbox_image: None,
sandbox_url: None,
tools_allow: Vec::new(),
tools_deny: Vec::new(),
auto_retrieve: false,
auto_retrieve_top_k: default_auto_retrieve_top_k(),
auto_retrieve_min_len: default_auto_retrieve_min_len(),
}
}
}
impl Config {
pub fn config_dir() -> anyhow::Result<PathBuf> {
let home =
dirs::home_dir().ok_or_else(|| anyhow::anyhow!("could not resolve home directory"))?;
Ok(home.join(".aonyx"))
}
pub fn config_path() -> anyhow::Result<PathBuf> {
Ok(Self::config_dir()?.join("config.toml"))
}
fn merge_env(&mut self) {
if self.anthropic_api_key.is_none() {
self.anthropic_api_key = std::env::var("ANTHROPIC_API_KEY").ok();
}
if self.openai_api_key.is_none() {
self.openai_api_key = std::env::var("OPENAI_API_KEY").ok();
}
if self.openrouter_api_key.is_none() {
self.openrouter_api_key = std::env::var("OPENROUTER_API_KEY").ok();
}
}
pub fn load_or_init() -> anyhow::Result<Self> {
let path = Self::config_path()?;
if !path.exists() {
std::fs::create_dir_all(Self::config_dir()?)?;
let default = Self::default();
std::fs::write(&path, toml::to_string_pretty(&default)?)?;
eprintln!("aonyx: created {}", path.display());
let mut config = default;
config.merge_env();
return Ok(config);
}
let raw = std::fs::read_to_string(&path)?;
let mut config: Config = toml::from_str(&raw)?;
config.merge_env();
Ok(config)
}
pub fn load_raw() -> anyhow::Result<Self> {
let path = Self::config_path()?;
if !path.exists() {
return Ok(Self::default());
}
let raw = std::fs::read_to_string(&path)?;
Ok(toml::from_str(&raw)?)
}
pub fn save(&self) -> anyhow::Result<()> {
std::fs::create_dir_all(Self::config_dir()?)?;
std::fs::write(Self::config_path()?, toml::to_string_pretty(self)?)?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_provider_is_anthropic() {
let c = Config::default();
assert_eq!(c.provider, "anthropic");
assert_eq!(c.max_iterations, 10);
}
#[test]
fn default_has_no_persisted_api_keys() {
let c = Config::default();
assert!(c.anthropic_api_key.is_none());
assert!(c.openai_api_key.is_none());
assert!(c.openrouter_api_key.is_none());
}
#[test]
fn toml_round_trip_preserves_fields() {
let original = Config {
provider: "ollama".into(),
model: "llama3.1:8b".into(),
anthropic_api_key: Some("sk-test".into()),
openai_api_key: None,
openrouter_api_key: None,
openai_base_url: None,
lm_studio_base_url: None,
ollama_base_url: Some("http://localhost:9999".into()),
claude_code_binary: None,
claude_code_extra_args: Vec::new(),
openrouter_referer: None,
openrouter_title: None,
system_prompt: Some("be quiet".into()),
max_iterations: 5,
theme: Some("dracula".into()),
show_thinking: true,
desktop_notifications: false,
auto_compact: true,
auto_compact_threshold: 12_000,
mcp_servers: vec![McpServerConfig {
name: "demo".into(),
command: Some("echo".into()),
args: vec!["hi".into()],
url: None,
bearer_token: None,
}],
custom_theme: None,
tool_approvals: Vec::new(),
telegram_allowed_chats: Vec::new(),
discord_allowed_channels: Vec::new(),
skill_autogen: true,
skill_autogen_threshold: 3,
sandbox_backend: None,
sandbox_image: None,
sandbox_url: None,
tools_allow: Vec::new(),
tools_deny: Vec::new(),
auto_retrieve: true,
auto_retrieve_top_k: 5,
auto_retrieve_min_len: 12,
};
let s = toml::to_string(&original).unwrap();
let parsed: Config = toml::from_str(&s).unwrap();
assert_eq!(parsed.provider, original.provider);
assert_eq!(parsed.model, original.model);
assert_eq!(parsed.max_iterations, original.max_iterations);
assert_eq!(parsed.system_prompt.as_deref(), Some("be quiet"));
assert_eq!(
parsed.ollama_base_url.as_deref(),
Some("http://localhost:9999")
);
assert!(parsed.auto_compact);
assert_eq!(parsed.auto_compact_threshold, 12_000);
}
#[test]
fn missing_compact_fields_use_defaults() {
let raw = r#"
provider = "anthropic"
model = "claude-sonnet"
"#;
let parsed: Config = toml::from_str(raw).unwrap();
assert!(!parsed.auto_compact);
assert_eq!(parsed.auto_compact_threshold, 24_000);
}
#[test]
fn missing_optional_fields_use_defaults() {
let raw = r#"
provider = "anthropic"
model = "claude-sonnet"
"#;
let parsed: Config = toml::from_str(raw).unwrap();
assert_eq!(parsed.max_iterations, 10);
assert!(parsed.system_prompt.is_none());
}
}