use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Debug, Clone, Default, PartialEq, Deserialize, Serialize)]
pub struct Config {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub index_dir: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub signing_key: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub allowed_signers: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cache_ttl_secs: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub author_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub record_names: Option<bool>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub repos: Vec<RepoEntry>,
}
pub const KEYS: [&str; 7] = [
"index_dir",
"signing_key",
"allowed_signers",
"cache_ttl_secs",
"author_name",
"record_names",
"repos",
];
pub use crate::pipeline::RepoEntry;
pub fn expanded(repos: &[RepoEntry]) -> Vec<RepoEntry> {
repos
.iter()
.map(|r| RepoEntry {
path: expand_tilde(&r.path),
remote: r.remote.clone(),
})
.collect()
}
const TABLE: &str = "preferences";
pub fn store_path() -> PathBuf {
config_dir().join("settings")
}
pub fn stored() -> Result<crate::docstore::Stored<Config>> {
let uri = store_path().to_string_lossy().into_owned();
crate::rt::block_on(crate::docstore::load(&uri, TABLE))
}
fn save(cfg: &Config, expected: Option<u64>) -> Result<u64> {
let uri = store_path().to_string_lossy().into_owned();
let me = local_identity();
let by = me.name.unwrap_or(me.actor);
crate::rt::block_on(crate::docstore::save(&uri, TABLE, cfg, expected, &by))
}
pub fn history() -> Result<Vec<crate::docstore::Version>> {
let uri = store_path().to_string_lossy().into_owned();
crate::rt::block_on(crate::docstore::history(&uri, TABLE))
}
pub fn set_repos(list: &[RepoEntry]) -> Result<()> {
let s = stored()?;
let mut cfg = s.doc;
cfg.repos = list.to_vec();
save(&cfg, s.version).map(drop)
}
impl Config {
pub fn load() -> Self {
match stored() {
Ok(s) => s.doc,
Err(e) => {
crate::ui::warn(&format!("your preferences could not be read: {e:#}"));
Self::default()
}
}
}
pub fn index_dir(&self, flag: Option<&str>) -> String {
if let Some(f) = flag {
return expand_tilde(f);
}
if let Some(e) = non_empty_env("COMINDLY_INDEX_DIR") {
return expand_tilde(&e);
}
if let Some(d) = &self.index_dir {
return expand_tilde(d);
}
default_index_dir()
}
pub fn graph_dir(&self, flag: Option<&str>) -> String {
format!("{}/_graph", self.index_dir(flag).trim_end_matches('/'))
}
pub fn fresh(&self) -> std::time::Duration {
std::time::Duration::from_secs(self.cache_ttl_secs.unwrap_or(crate::cache::DEFAULT_TTL))
}
}
pub fn config_path() -> PathBuf {
config_dir().join("config.toml")
}
pub fn config_dir() -> PathBuf {
config_home().join("comindly")
}
pub enum Setting {
Text(String),
Flag(bool),
Number(i64),
Remove,
}
pub fn update(changes: &[(&str, Setting)]) -> Result<()> {
let s = stored()?;
let mut doc = serde_json::to_value(&s.doc)?;
let map = doc.as_object_mut().context("preferences")?;
for (key, value) in changes {
anyhow::ensure!(
KEYS.contains(key) && *key != "repos",
"unknown preference `{key}` (one of {})",
KEYS[..6].join(", ")
);
match value {
Setting::Text(v) => drop(map.insert(key.to_string(), v.as_str().into())),
Setting::Flag(v) => drop(map.insert(key.to_string(), (*v).into())),
Setting::Number(v) => drop(map.insert(key.to_string(), (*v).into())),
Setting::Remove => drop(map.remove(*key)),
}
}
let next: Config = serde_json::from_value(doc).context("preferences")?;
if next != s.doc {
save(&next, s.version)?;
}
Ok(())
}
pub fn ui_token() -> Result<String> {
let path = config_dir().join("ui-token");
if let Ok(t) = std::fs::read_to_string(&path) {
if !t.trim().is_empty() {
return Ok(t.trim().to_string());
}
}
std::fs::create_dir_all(path.parent().context("config dir")?)?;
let token = crate::web::new_token();
std::fs::write(&path, &token).with_context(|| format!("write {}", path.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?;
}
Ok(token)
}
pub fn cache_dir() -> PathBuf {
home_path("~/.cache/comindly")
}
pub fn data_dir() -> String {
data_home().join("comindly").to_string_lossy().into_owned()
}
pub fn default_index_dir() -> String {
data_dir()
}
fn non_empty_env(key: &str) -> Option<String> {
env(key).filter(|v| !v.is_empty())
}
pub fn env(key: &str) -> Option<String> {
std::env::var(key).ok()
}
fn home() -> PathBuf {
std::env::var_os("HOME")
.map(PathBuf::from)
.unwrap_or_default()
}
fn config_home() -> PathBuf {
std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.filter(|p| !p.as_os_str().is_empty())
.unwrap_or_else(|| home().join(".config"))
}
fn data_home() -> PathBuf {
std::env::var_os("XDG_DATA_HOME")
.map(PathBuf::from)
.filter(|p| !p.as_os_str().is_empty())
.unwrap_or_else(|| home().join(".local").join("share"))
}
pub fn expand_tilde(s: &str) -> String {
if let Some(rest) = s.strip_prefix("~/") {
return home().join(rest).to_string_lossy().into_owned();
}
if s == "~" {
return home().to_string_lossy().into_owned();
}
s.to_string()
}
pub async fn index_settings(root: &str) -> crate::context::Settings {
let team = crate::team::load(root)
.await
.map(|s| s.settings)
.unwrap_or_default();
crate::context::Settings::resolve(&team, crate::config::env)
}
pub fn llm_client(
settings: &crate::context::Settings,
team: Option<&str>,
server: Option<&crate::server::Server>,
) -> Option<crate::llm::LlmClient> {
let key = crate::credentials::llm_key(team, server).0;
crate::llm::LlmClient::new(
settings.llm_base_url.as_deref(),
&settings.llm_model,
key.as_deref(),
)
.ok()
.map(|c| c.with_models(settings.task_models.clone()))
}
fn home_path(p: &str) -> PathBuf {
PathBuf::from(expand_tilde(p))
}
pub fn local_signing_key() -> Option<PathBuf> {
if let Some(k) = env("COMINDLY_SIGNING_KEY").or_else(|| Config::load().signing_key) {
return Some(home_path(&k));
}
let cfg = git2::Config::open_default().ok()?;
if cfg.get_string("gpg.format").ok()? != "ssh" {
return None;
}
let k = cfg.get_string("user.signingkey").ok()?;
(!k.starts_with("key::")).then(|| home_path(&k))
}
pub fn local_allowed_signers() -> Option<PathBuf> {
let p = env("COMINDLY_ALLOWED_SIGNERS")
.or_else(|| Config::load().allowed_signers)
.or_else(|| {
git2::Config::open_default()
.ok()?
.get_string("gpg.ssh.allowedSignersFile")
.ok()
})?;
Some(home_path(&p)).filter(|p| p.is_file())
}
pub fn env_token(host: &str) -> Option<String> {
let host = host.to_ascii_lowercase();
let gitlab = host == "gitlab.com"
|| crate::server::env_secret("COMINDLY_GITLAB_HOSTS").is_some_and(|hosts| {
hosts
.split([',', ' '])
.any(|h| h.trim().eq_ignore_ascii_case(&host))
});
let vars: &[&str] = if host == "github.com" {
&["GH_TOKEN", "GITHUB_TOKEN"]
} else if gitlab {
&["GITLAB_TOKEN"]
} else {
&[]
};
vars.iter().find_map(|v| crate::server::env_secret(v))
}
pub fn git_credentials(
server: Option<std::sync::Arc<crate::server::Server>>,
) -> std::sync::Arc<dyn crate::worktree::Credentials> {
std::sync::Arc::new(move |host: &str| match &server {
Some(s) => s.git_token(host),
None => env_token(host),
})
}
pub fn local_verifier() -> crate::signing::Verifier {
crate::signing::Verifier::new(local_allowed_signers(), Some(cache_dir().join("verified")))
}
pub fn local_identity() -> crate::ledger::Identity {
let actor = env("COMINDLY_ACTOR").unwrap_or_else(|| {
let who = std::env::var("USER").unwrap_or_default();
let host = std::env::var("HOSTNAME")
.or_else(|_| std::env::var("HOST"))
.unwrap_or_default();
let h = blake3::hash(format!("{who}@{host}").as_bytes()).to_hex();
format!("actor-{}", &h[..10])
});
let name = match env("COMINDLY_AUTHOR_NAME") {
Some(n) => Some(n),
None => {
let cfg = Config::load();
if cfg.record_names == Some(false) {
None
} else {
cfg.author_name.or_else(|| {
git2::Config::open_default()
.ok()?
.get_string("user.name")
.ok()
})
}
}
};
crate::ledger::Identity {
actor,
name: name.map(|n| n.trim().to_string()).filter(|n| !n.is_empty()),
}
}
#[derive(Debug, Default, Deserialize)]
struct Legacy {
llm_model: Option<String>,
embed_model: Option<String>,
llm_base_url: Option<String>,
max_enrich: Option<usize>,
max_flows: Option<usize>,
format: Option<String>,
embed: Option<bool>,
enrich: Option<bool>,
flows: Option<bool>,
guide: Option<bool>,
scip: Option<String>,
auto_index: Option<bool>,
}
const LEGACY: [&str; 12] = [
"llm_model",
"embed_model",
"llm_base_url",
"max_enrich",
"max_flows",
"format",
"embed",
"enrich",
"flows",
"guide",
"scip",
"auto_index",
];
pub fn adopt_legacy(text: &str, team: &mut crate::team::TeamSettings) -> bool {
let Ok(old) = toml::from_str::<Legacy>(text) else {
return false;
};
let mut changed = false;
macro_rules! take {
($($f:ident),*) => {$(
if team.$f.is_none() && old.$f.is_some() {
team.$f = old.$f.clone();
changed = true;
}
)*};
}
take!(
llm_model,
embed_model,
llm_base_url,
max_enrich,
max_flows,
format,
embed,
enrich,
flows,
guide,
scip,
auto_index
);
changed
}
const OBSOLETE: [&str; 2] = ["aws_profile", "sso_auto_login"];
#[derive(Debug, Default)]
pub struct Imported {
pub changed: bool,
pub kept: Vec<String>,
pub moved: bool,
}
pub fn import(text: &str, overwrite: bool) -> Result<Imported> {
let table: toml::Table = toml::from_str(text).context("not a TOML file")?;
let unknown: Vec<&str> = table
.keys()
.map(String::as_str)
.filter(|k| !KEYS.contains(k) && !LEGACY.contains(k) && !OBSOLETE.contains(k))
.collect();
anyhow::ensure!(
unknown.is_empty(),
"unknown setting(s): {} (preferences are {})",
unknown.join(", "),
KEYS.join(", ")
);
let file: Config = toml::from_str(text)?;
let s = stored()?;
let mut out = Imported::default();
let next = if overwrite {
file
} else {
let mut next = s.doc.clone();
macro_rules! fill {
($($f:ident),*) => {$(
match (&next.$f, &file.$f) {
(None, Some(_)) => next.$f = file.$f.clone(),
(Some(a), Some(b)) if a != b => out.kept.push(stringify!($f).to_string()),
_ => {}
}
)*};
}
fill!(
index_dir,
signing_key,
allowed_signers,
cache_ttl_secs,
author_name,
record_names
);
if next.repos.is_empty() {
next.repos = file.repos.clone();
} else if !file.repos.is_empty() && file.repos != next.repos {
out.kept.push("repos".into());
}
next
};
if next != s.doc {
save(&next, s.version)?;
out.changed = true;
}
let root = next.index_dir(None);
if LEGACY.iter().any(|k| table.contains_key(*k)) && !crate::cache::is_remote(&root) {
let me = local_identity();
let by = me.name.unwrap_or(me.actor);
out.moved = crate::rt::block_on(async {
let stored = crate::team::load(&root).await?;
let mut settings = stored.settings;
let moved = adopt_legacy(text, &mut settings);
if moved {
crate::team::save_as(&root, &settings, stored.version, &by).await?;
}
anyhow::Ok(moved)
})?;
}
Ok(out)
}
pub fn export(cfg: &Config) -> Result<String> {
Ok(format!(
"# comindly preferences: `comindly config import <file> --override` applies this file.\n\n{}",
toml::to_string_pretty(cfg)?
))
}
pub fn adopt_file() {
let path = config_path();
let Ok(text) = std::fs::read_to_string(&path) else {
return;
};
match stored() {
Ok(s) if s.version.is_some() => {
crate::ui::warn(&format!(
"{} is not read; apply it with `comindly config import {}`",
path.display(),
path.display()
));
return;
}
Err(_) => return,
Ok(_) => {}
}
let done = import(&text, true).and_then(|_| {
Ok(std::fs::rename(
&path,
path.with_extension("toml.imported"),
)?)
});
match done {
Ok(()) => crate::ui::note(&format!(
"imported {} into your preferences (`comindly config`); the file is kept as {}",
path.display(),
path.with_extension("toml.imported").display()
)),
Err(e) => crate::ui::warn(&format!("could not import {}: {e:#}", path.display())),
}
}
pub async fn save_team(
root: &str,
settings: &crate::team::TeamSettings,
expected: Option<u64>,
) -> Result<u64> {
let me = local_identity();
crate::team::save_as(
root,
settings,
expected,
me.name.as_deref().unwrap_or(&me.actor),
)
.await
}