use crate::memory;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::{collections::BTreeMap, path::PathBuf};
#[derive(Debug, Serialize, Deserialize)]
pub struct Settings {
#[serde(default = "cover_memory")]
pub cover_memory: u64,
#[serde(default)]
pub features: Features,
#[serde(default)]
pub agents: Vec<Agent>,
}
#[derive(Debug, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct Features {
pub sessions: bool,
pub boards: bool,
pub tables: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Feature {
Sessions,
Boards,
Tables,
}
impl Feature {
pub const ALL: [Self; 3] = [Self::Sessions, Self::Boards, Self::Tables];
fn key(self) -> &'static str {
match self {
Self::Sessions => "sessions",
Self::Boards => "boards",
Self::Tables => "tables",
}
}
pub fn on(self, features: &Features) -> bool {
match self {
Self::Sessions => features.sessions,
Self::Boards => features.boards,
Self::Tables => features.tables,
}
}
pub fn set(self, features: &mut Features, on: bool) {
match self {
Self::Sessions => features.sessions = on,
Self::Boards => features.boards = on,
Self::Tables => features.tables = on,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Agent {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
pub command: String,
#[serde(default)]
pub args: Vec<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub env: BTreeMap<String, String>,
}
fn cover_memory() -> u64 {
memory::DEFAULT_LIMIT / 1_000_000
}
const RUNNERS: [&str; 3] = ["npx", "bunx", "pnpx"];
impl Agent {
pub fn pinned(&self) -> bool {
if !RUNNERS.contains(&self.command.as_str()) {
return true;
}
self.args
.iter()
.filter(|arg| !arg.starts_with('-'))
.all(|spec| {
let name = cacp_agents::package_name(spec);
spec.len() > name.len()
&& spec[name.len() + 1..].starts_with(|c: char| c.is_ascii_digit())
})
}
}
impl Default for Settings {
fn default() -> Self {
let npx = |name: &str, pkg: &str| Agent {
name: name.into(),
id: None,
command: "npx".into(),
args: vec!["-y".into(), pkg.into()],
env: BTreeMap::new(),
};
Self {
cover_memory: cover_memory(),
features: Features::default(),
agents: vec![
npx("claude", "@agentclientprotocol/claude-agent-acp@0.73.0"),
npx("codex", "@agentclientprotocol/codex-acp@1.8.0"),
],
}
}
}
pub fn data_dir() -> Result<PathBuf> {
if let Ok(xdg) = std::env::var("XDG_DATA_HOME")
&& !xdg.is_empty()
{
return Ok(PathBuf::from(xdg).join("cydonia"));
}
Ok(dirs::home_dir()
.context("no home directory on this system")?
.join(".local")
.join("share")
.join("cydonia"))
}
pub fn dir() -> Result<PathBuf> {
if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME")
&& !xdg.is_empty()
{
return Ok(PathBuf::from(xdg).join("cydonia"));
}
Ok(dirs::home_dir()
.context("no home directory on this system")?
.join(".config")
.join("cydonia"))
}
pub fn load() -> Result<Settings> {
let dir = dir()?;
let path = dir.join("settings.toml");
if !path.exists() {
let settings = Settings::default();
std::fs::create_dir_all(&dir)?;
let body = format!(
"# generated by cydonia — edits are kept, deleting regenerates defaults\n\n{}",
toml::to_string_pretty(&settings)?
);
std::fs::write(&path, body)?;
return Ok(settings);
}
let content = std::fs::read_to_string(&path)?;
let mut settings: Settings = toml::from_str(&content)
.with_context(|| format!("invalid settings: {}", path.display()))?;
settings.agents.retain(Agent::pinned);
Ok(settings)
}
pub fn set_feature(feature: Feature, on: bool) -> Result<()> {
let path = dir()?.join("settings.toml");
let body = std::fs::read_to_string(&path).unwrap_or_default();
let mut doc: toml_edit::DocumentMut =
body.parse().context("settings.toml is not valid toml")?;
let features = doc["features"].or_insert(toml_edit::table());
let Some(features) = features.as_table_mut() else {
anyhow::bail!("`features` in settings.toml is not a table");
};
features.set_implicit(false);
features[feature.key()] = toml_edit::value(on);
std::fs::write(&path, doc.to_string())?;
Ok(())
}
pub fn set_cover_memory(mb: u64) -> Result<()> {
let path = dir()?.join("settings.toml");
let body = std::fs::read_to_string(&path).unwrap_or_default();
let mut doc: toml_edit::DocumentMut =
body.parse().context("settings.toml is not valid toml")?;
doc["cover_memory"] = toml_edit::value(mb as i64);
std::fs::write(&path, doc.to_string())?;
Ok(())
}
pub fn put_agent(agent: &Agent, supersedes: Option<&str>) -> Result<()> {
let path = dir()?.join("settings.toml");
let body = std::fs::read_to_string(&path).unwrap_or_default();
let mut doc: toml_edit::DocumentMut =
body.parse().context("settings.toml is not valid toml")?;
let agents = doc["agents"].or_insert(toml_edit::Item::ArrayOfTables(
toml_edit::ArrayOfTables::new(),
));
let Some(agents) = agents.as_array_of_tables_mut() else {
anyhow::bail!("`agents` in settings.toml is not a list of tables");
};
let existing = agents
.iter()
.position(|table| claims(table, agent, supersedes));
let name = existing
.and_then(|ix| agents.get(ix))
.and_then(|table| table.get("name"))
.and_then(|n| n.as_str())
.unwrap_or(&agent.name)
.to_owned();
let decor = existing
.and_then(|ix| agents.get(ix))
.map(|table| table.decor().clone());
let mut entry = toml_edit::Table::new();
entry["name"] = toml_edit::value(name);
if let Some(id) = &agent.id {
entry["id"] = toml_edit::value(id.clone());
}
entry["command"] = toml_edit::value(agent.command.clone());
let mut args = toml_edit::Array::new();
for arg in &agent.args {
args.push(arg.as_str());
}
entry["args"] = toml_edit::value(args);
if !agent.env.is_empty() {
let mut env = toml_edit::InlineTable::new();
for (key, value) in &agent.env {
env.insert(key, value.as_str().into());
}
entry["env"] = toml_edit::value(env);
}
match existing {
Some(ix) => {
if let Some(decor) = decor {
*entry.decor_mut() = decor;
}
*agents.get_mut(ix).expect("position is in range") = entry;
}
None => agents.push(entry),
}
std::fs::write(&path, doc.to_string())?;
Ok(())
}
pub fn remove_agent(id: &str) -> Result<()> {
let path = dir()?.join("settings.toml");
let body = std::fs::read_to_string(&path).unwrap_or_default();
let mut doc: toml_edit::DocumentMut =
body.parse().context("settings.toml is not valid toml")?;
let Some(agents) = doc
.get_mut("agents")
.and_then(|a| a.as_array_of_tables_mut())
else {
return Ok(());
};
let Some(ix) = agents
.iter()
.position(|table| table.get("id").and_then(|i| i.as_str()) == Some(id))
else {
return Ok(());
};
let prefix = agents
.get(ix)
.and_then(|table| table.decor().prefix().cloned());
agents.remove(ix);
if ix == 0
&& let Some(prefix) = prefix
{
match agents.get_mut(0) {
Some(first) => first.decor_mut().set_prefix(prefix),
None => doc.as_table_mut().decor_mut().set_prefix(prefix),
}
}
std::fs::write(&path, doc.to_string())?;
Ok(())
}
fn claims(table: &toml_edit::Table, agent: &Agent, supersedes: Option<&str>) -> bool {
let field = |key| table.get(key).and_then(|v| v.as_str());
if agent.id.is_some() && field("id") == agent.id.as_deref() {
return true;
}
let Some(package) = supersedes else {
return false;
};
table
.get("args")
.and_then(|args| args.as_array())
.is_some_and(|args| {
args.iter()
.filter_map(|v| v.as_str())
.any(|arg| cacp_agents::package_name(arg) == package)
})
}