use std::borrow::Borrow;
use std::collections::BTreeMap;
use std::fmt;
use std::fs;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Deserializer, Serialize, de::Error as _};
const CONTEXT_PLACEHOLDER: &str = "{context}";
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("invalid {kind} name `{value}`; use letters, digits, '.', '_', or '-'")]
pub struct InvalidName {
kind: &'static str,
value: String,
}
fn valid_name(value: &str) -> bool {
!value.is_empty()
&& value
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
}
macro_rules! name_type {
($name:ident, $kind:literal, $docs:literal) => {
#[doc = $docs]
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
#[serde(transparent)]
pub struct $name(String);
impl $name {
#[doc = concat!("Create a validated ", $kind, " name.")]
pub fn new(value: impl Into<String>) -> Result<Self, InvalidName> {
let value = value.into();
if valid_name(&value) {
Ok(Self(value))
} else {
Err(InvalidName { kind: $kind, value })
}
}
#[doc = concat!("Return the validated ", $kind, " name.")]
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl<'de> Deserialize<'de> for $name {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let value = String::deserialize(deserializer)?;
Self::new(value).map_err(D::Error::custom)
}
}
impl Borrow<str> for $name {
fn borrow(&self) -> &str {
self.as_str()
}
}
impl fmt::Display for $name {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
};
}
name_type!(ContextName, "context", "A validated context name.");
name_type!(DomainName, "domain", "A validated domain name.");
name_type!(HarnessName, "harness", "A validated harness name.");
name_type!(ModelName, "model", "A validated model alias.");
name_type!(SkillName, "skill", "A validated agent skill name.");
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ModelFamily(prompter::FamilyName);
impl ModelFamily {
#[must_use]
pub const fn as_prompter_family(&self) -> &prompter::FamilyName {
&self.0
}
}
impl<'de> Deserialize<'de> for ModelFamily {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let value = String::deserialize(deserializer)?;
prompter::FamilyName::new(value)
.map(Self)
.map_err(D::Error::custom)
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Config {
pub defaults: DefaultsConfig,
pub harness: BTreeMap<HarnessName, HarnessConfig>,
pub model: BTreeMap<ModelName, ModelConfig>,
pub domain: BTreeMap<DomainName, DomainConfig>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct DefaultsConfig {
pub domain: DomainName,
pub contexts: Vec<ContextName>,
pub context_fallback: ContextName,
pub context_by_hostname: BTreeMap<String, ContextName>,
pub shim_path: String,
pub sandbox_wrapper: String,
pub prompter_bundle: String,
pub skills_bundle: String,
pub prompt_cache: String,
pub prompt_cache_ttl_seconds: u64,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct HarnessConfig {
pub bin: String,
pub family: ModelFamily,
pub default_args: Vec<String>,
pub injection: InjectionConfig,
pub config_dir: Option<ConfigDirectory>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)]
pub enum InjectionConfig {
ArgText {
args: Vec<String>,
},
ArgFile {
args: Vec<String>,
},
EnvFile {
environment: String,
},
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ConfigDirectory {
pub environment: String,
pub path: String,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ModelConfig {
pub harness: HarnessName,
pub family: Option<ModelFamily>,
pub env: BTreeMap<String, String>,
pub env_from_secrets: BTreeMap<String, String>,
pub harness_args: Vec<String>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct DomainConfig {
pub profiles: Vec<String>,
pub skills: Vec<SkillName>,
pub env: BTreeMap<String, String>,
pub default_model: Option<ModelName>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConfigIssue {
pub key: String,
pub message: String,
}
impl fmt::Display for ConfigIssue {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(formatter, "{}: {}", self.key, self.message)
}
}
#[derive(Debug, Clone)]
pub struct LoadedConfig {
pub base_path: PathBuf,
pub overlay_path: Option<PathBuf>,
pub config: Config,
}
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
#[error(
"clanker config not found at {path}; run `just install-clanker` in agent-toolkit or set CLANKER_CONFIG"
)]
Missing {
path: PathBuf,
},
#[error("failed to read clanker config {path}: {source}")]
Read {
path: PathBuf,
source: std::io::Error,
},
#[error("failed to parse clanker config {path}: {source}")]
Parse {
path: PathBuf,
source: toml::de::Error,
},
#[error("invalid clanker config schema from {sources}: {source}")]
Schema {
sources: String,
source: toml::de::Error,
},
#[error("invalid clanker config from {sources}:\n{issues}")]
Invalid {
sources: String,
issues: String,
},
}
impl Config {
#[must_use]
pub fn issues(&self) -> Vec<ConfigIssue> {
let mut issues = Vec::new();
if self.harness.is_empty() {
issues.push(ConfigIssue {
key: "harness".to_string(),
message: "must contain at least one harness".to_string(),
});
}
check_nonempty(&mut issues, "defaults.shim_path", &self.defaults.shim_path);
check_nonempty(
&mut issues,
"defaults.sandbox_wrapper",
&self.defaults.sandbox_wrapper,
);
check_nonempty(
&mut issues,
"defaults.prompter_bundle",
&self.defaults.prompter_bundle,
);
check_nonempty(
&mut issues,
"defaults.skills_bundle",
&self.defaults.skills_bundle,
);
check_nonempty(
&mut issues,
"defaults.prompt_cache",
&self.defaults.prompt_cache,
);
if !self.domain.contains_key(&self.defaults.domain) {
issues.push(ConfigIssue {
key: "defaults.domain".to_string(),
message: format!("unknown domain `{}`", self.defaults.domain),
});
}
if self.defaults.contexts.is_empty() {
issues.push(ConfigIssue {
key: "defaults.contexts".to_string(),
message: "must contain at least one context".to_string(),
});
}
if !self
.defaults
.contexts
.contains(&self.defaults.context_fallback)
{
issues.push(ConfigIssue {
key: "defaults.context_fallback".to_string(),
message: format!(
"context `{}` is not in defaults.contexts",
self.defaults.context_fallback
),
});
}
for (hostname, context) in &self.defaults.context_by_hostname {
if hostname.trim().is_empty() {
issues.push(ConfigIssue {
key: "defaults.context_by_hostname".to_string(),
message: "hostname keys must not be empty".to_string(),
});
}
if !self.defaults.contexts.contains(context) {
issues.push(ConfigIssue {
key: format!("defaults.context_by_hostname.{hostname}"),
message: format!("context `{context}` is not in defaults.contexts"),
});
}
}
for (name, harness) in &self.harness {
check_nonempty(&mut issues, &format!("harness.{name}.bin"), &harness.bin);
validate_injection(name, &harness.injection, &mut issues);
if let Some(config_dir) = &harness.config_dir {
let prefix = format!("harness.{name}.config_dir");
check_environment_name(
&mut issues,
&format!("{prefix}.environment"),
&config_dir.environment,
);
require_placeholder(
&mut issues,
&format!("{prefix}.path"),
&config_dir.path,
CONTEXT_PLACEHOLDER,
);
}
}
for (name, model) in &self.model {
if !self.harness.contains_key(&model.harness) {
issues.push(ConfigIssue {
key: format!("model.{name}.harness"),
message: format!("unknown harness `{}`", model.harness),
});
}
for environment in model.env.keys() {
check_environment_name(
&mut issues,
&format!("model.{name}.env.{environment}"),
environment,
);
}
for (target, source) in &model.env_from_secrets {
check_environment_name(
&mut issues,
&format!("model.{name}.env_from_secrets.{target}"),
target,
);
check_environment_name(
&mut issues,
&format!("model.{name}.env_from_secrets.{target}"),
source,
);
if model.env.contains_key(target) {
issues.push(ConfigIssue {
key: format!("model.{name}.env_from_secrets.{target}"),
message: "duplicates a literal model.env target".to_string(),
});
}
}
}
for (name, domain) in &self.domain {
if domain.profiles.is_empty() {
issues.push(ConfigIssue {
key: format!("domain.{name}.profiles"),
message: "must contain at least one profile".to_string(),
});
}
for (index, profile) in domain.profiles.iter().enumerate() {
check_nonempty(
&mut issues,
&format!("domain.{name}.profiles[{index}]"),
profile,
);
}
if let Some(model) = &domain.default_model {
if !self.model.contains_key(model) {
issues.push(ConfigIssue {
key: format!("domain.{name}.default_model"),
message: format!("unknown model `{model}`"),
});
}
}
for environment in domain.env.keys() {
check_environment_name(
&mut issues,
&format!("domain.{name}.env.{environment}"),
environment,
);
}
}
issues
}
#[must_use]
pub fn filesystem_issues(&self, home: &Path) -> Vec<ConfigIssue> {
let mut issues = Vec::new();
let bundle = match expand_configured_path(&self.defaults.skills_bundle, home) {
Ok(bundle) => bundle,
Err(message) => {
issues.push(ConfigIssue {
key: "defaults.skills_bundle".to_string(),
message,
});
return issues;
}
};
if !bundle.is_dir() {
issues.push(ConfigIssue {
key: "defaults.skills_bundle".to_string(),
message: format!("{} is not a directory", bundle.display()),
});
}
for (domain_name, domain) in &self.domain {
for skill in &domain.skills {
let skill_path = bundle.join(skill.as_str()).join("SKILL.md");
if !skill_path.is_file() {
issues.push(ConfigIssue {
key: format!("domain.{domain_name}.skills.{}", skill.as_str()),
message: format!("{} does not exist", skill_path.display()),
});
}
}
}
issues
}
}
fn expand_configured_path(value: &str, home: &Path) -> Result<PathBuf, String> {
if value == "~" {
return Ok(home.to_path_buf());
}
if let Some(relative) = value.strip_prefix("~/") {
return Ok(home.join(relative));
}
if value.starts_with('~') {
return Err(format!(
"unsupported configured path `{value}`; use `~` or `~/...`"
));
}
Ok(PathBuf::from(value))
}
fn validate_injection(
name: &HarnessName,
injection: &InjectionConfig,
issues: &mut Vec<ConfigIssue>,
) {
match injection {
InjectionConfig::ArgText { args } => require_argument_placeholder(
issues,
&format!("harness.{name}.injection.args"),
args,
"{text}",
),
InjectionConfig::ArgFile { args } => require_argument_placeholder(
issues,
&format!("harness.{name}.injection.args"),
args,
"{file}",
),
InjectionConfig::EnvFile { environment } => check_environment_name(
issues,
&format!("harness.{name}.injection.environment"),
environment,
),
}
}
fn require_argument_placeholder(
issues: &mut Vec<ConfigIssue>,
key: &str,
args: &[String],
placeholder: &str,
) {
if !args.iter().any(|argument| argument.contains(placeholder)) {
issues.push(ConfigIssue {
key: key.to_string(),
message: format!("must contain `{placeholder}`"),
});
}
}
fn require_placeholder(issues: &mut Vec<ConfigIssue>, key: &str, value: &str, placeholder: &str) {
if !value.contains(placeholder) {
issues.push(ConfigIssue {
key: key.to_string(),
message: format!("must contain `{placeholder}`"),
});
}
}
fn check_nonempty(issues: &mut Vec<ConfigIssue>, key: &str, value: &str) {
if value.trim().is_empty() {
issues.push(ConfigIssue {
key: key.to_string(),
message: "must not be empty".to_string(),
});
}
}
fn check_environment_name(issues: &mut Vec<ConfigIssue>, key: &str, value: &str) {
let mut bytes = value.bytes();
let starts_valid = bytes
.next()
.is_some_and(|byte| byte.is_ascii_alphabetic() || byte == b'_');
if !starts_valid || !bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_') {
issues.push(ConfigIssue {
key: key.to_string(),
message: format!("invalid environment variable name `{value}`"),
});
}
}
#[must_use]
pub fn local_overlay_path(base_path: &Path) -> PathBuf {
base_path.with_file_name("local.toml")
}
pub fn load_config(base_path: &Path) -> Result<LoadedConfig, ConfigError> {
let overlay_path = local_overlay_path(base_path);
load_config_with_overlay(base_path, Some(&overlay_path))
}
pub fn load_config_with_overlay(
base_path: &Path,
overlay_path: Option<&Path>,
) -> Result<LoadedConfig, ConfigError> {
let mut merged = read_toml(base_path, true)?.ok_or_else(|| ConfigError::Missing {
path: base_path.to_path_buf(),
})?;
let applied_overlay = if let Some(path) = overlay_path {
read_toml(path, false)?.map(|overlay| {
deep_merge(&mut merged, overlay);
path.to_path_buf()
})
} else {
None
};
let sources = config_sources(base_path, applied_overlay.as_deref());
let config: Config = merged.try_into().map_err(|source| ConfigError::Schema {
sources: sources.clone(),
source,
})?;
let issues = config.issues();
if !issues.is_empty() {
return Err(ConfigError::Invalid {
sources,
issues: issues
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n"),
});
}
Ok(LoadedConfig {
base_path: base_path.to_path_buf(),
overlay_path: applied_overlay,
config,
})
}
fn config_sources(base_path: &Path, overlay_path: Option<&Path>) -> String {
overlay_path.map_or_else(
|| base_path.display().to_string(),
|overlay_path| {
format!(
"{} merged with {}",
base_path.display(),
overlay_path.display()
)
},
)
}
fn read_toml(path: &Path, required: bool) -> Result<Option<toml::Value>, ConfigError> {
let text = match fs::read_to_string(path) {
Ok(text) => text,
Err(source) if source.kind() == std::io::ErrorKind::NotFound && !required => {
return Ok(None);
}
Err(source) if source.kind() == std::io::ErrorKind::NotFound => {
return Err(ConfigError::Missing {
path: path.to_path_buf(),
});
}
Err(source) => {
return Err(ConfigError::Read {
path: path.to_path_buf(),
source,
});
}
};
toml::from_str(&text)
.map(Some)
.map_err(|source| ConfigError::Parse {
path: path.to_path_buf(),
source,
})
}
fn deep_merge(base: &mut toml::Value, overlay: toml::Value) {
match (base, overlay) {
(toml::Value::Table(base_table), toml::Value::Table(overlay_table)) => {
for (key, overlay_value) in overlay_table {
if let Some(base_value) = base_table.get_mut(&key) {
deep_merge(base_value, overlay_value);
} else {
base_table.insert(key, overlay_value);
}
}
}
(base_value, overlay_value) => *base_value = overlay_value,
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
const COMPLETE_CONFIG: &str = r#"
[defaults]
domain = "eng"
contexts = ["personal", "work"]
context_fallback = "personal"
context_by_hostname = {}
shim_path = "~/.local/clankers/bin"
sandbox_wrapper = "~/.config/sandbox-exec/run-sandboxed.sh"
prompter_bundle = "~/.local/prompter"
skills_bundle = "~/.local/clanker/skills"
prompt_cache = "~/.cache/clanker/prompts"
prompt_cache_ttl_seconds = 86400
[harness.claude]
bin = "claude"
family = "claude"
default_args = []
[harness.claude.injection]
kind = "arg-text"
args = ["--append-system-prompt", "{text}"]
[model]
[domain.eng]
profiles = ["core.base", "domain.eng"]
skills = []
env = {}
"#;
#[test]
fn deep_merge_replaces_scalars_and_preserves_siblings() {
let mut base: toml::Value = toml::from_str(
r#"
[defaults]
domain = "eng"
context_fallback = "personal"
"#,
)
.unwrap();
let overlay: toml::Value = toml::from_str(
r#"
[defaults]
context_fallback = "work"
"#,
)
.unwrap();
deep_merge(&mut base, overlay);
assert_eq!(base["defaults"]["domain"].as_str(), Some("eng"));
assert_eq!(base["defaults"]["context_fallback"].as_str(), Some("work"));
}
#[test]
fn configured_names_reject_path_components() {
assert!(HarnessName::new("claude").is_ok());
assert!(HarnessName::new("../claude").is_err());
assert!(ContextName::new("").is_err());
}
#[test]
fn warnings_table_is_an_unknown_field_schema_error() {
let temp = TempDir::new().unwrap();
let path = temp.path().join("config.toml");
fs::write(
&path,
format!("{COMPLETE_CONFIG}\n[warnings]\nunknown_profile = \"{{invocation}}\"\n"),
)
.unwrap();
let error = load_config_with_overlay(&path, None).unwrap_err();
assert!(error.to_string().contains("unknown field `warnings`"));
}
#[test]
fn omitted_contexts_registry_is_a_schema_error() {
let temp = TempDir::new().unwrap();
let path = temp.path().join("config.toml");
fs::write(
&path,
COMPLETE_CONFIG.replace("contexts = [\"personal\", \"work\"]\n", ""),
)
.unwrap();
let error = load_config_with_overlay(&path, None).unwrap_err();
assert!(error.to_string().contains("missing field `contexts`"));
}
#[test]
fn unregistered_fallback_and_hostname_contexts_are_invalid() {
let temp = TempDir::new().unwrap();
let path = temp.path().join("config.toml");
fs::write(
&path,
COMPLETE_CONFIG
.replace(
"contexts = [\"personal\", \"work\"]",
"contexts = [\"other\"]",
)
.replace(
"context_by_hostname = {}",
"context_by_hostname = { workstation = \"work\" }",
),
)
.unwrap();
let error = load_config_with_overlay(&path, None).unwrap_err();
let message = error.to_string();
assert!(message.contains("defaults.context_fallback"));
assert!(message.contains("defaults.context_by_hostname.workstation"));
assert!(message.contains("is not in defaults.contexts"));
}
#[test]
fn omitted_required_collection_is_a_schema_error() {
let temp = TempDir::new().unwrap();
let path = temp.path().join("config.toml");
fs::write(
&path,
COMPLETE_CONFIG.replace("context_by_hostname = {}\n", ""),
)
.unwrap();
let error = load_config_with_overlay(&path, None).unwrap_err();
let message = error.to_string();
assert!(message.contains(path.to_str().unwrap()));
assert!(message.contains("missing field `context_by_hostname`"));
}
#[test]
fn omitted_harness_default_args_is_a_schema_error() {
let temp = TempDir::new().unwrap();
let path = temp.path().join("config.toml");
fs::write(&path, COMPLETE_CONFIG.replace("default_args = []\n", "")).unwrap();
let error = load_config_with_overlay(&path, None).unwrap_err();
let message = error.to_string();
assert!(message.contains(path.to_str().unwrap()));
assert!(message.contains("missing field `default_args`"));
}
#[test]
fn omitted_skills_bundle_is_a_schema_error() {
let temp = TempDir::new().unwrap();
let path = temp.path().join("config.toml");
fs::write(
&path,
COMPLETE_CONFIG.replace("skills_bundle = \"~/.local/clanker/skills\"\n", ""),
)
.unwrap();
let error = load_config_with_overlay(&path, None).unwrap_err();
let message = error.to_string();
assert!(message.contains(path.to_str().unwrap()));
assert!(message.contains("missing field `skills_bundle`"));
}
#[test]
fn omitted_domain_skills_is_a_schema_error() {
let temp = TempDir::new().unwrap();
let path = temp.path().join("config.toml");
fs::write(&path, COMPLETE_CONFIG.replace("skills = []\n", "")).unwrap();
let error = load_config_with_overlay(&path, None).unwrap_err();
let message = error.to_string();
assert!(message.contains(path.to_str().unwrap()));
assert!(message.contains("missing field `skills`"));
}
#[test]
fn filesystem_issues_report_missing_bundle_and_skills() {
let temp = TempDir::new().unwrap();
let mut config = toml::from_str::<Config>(
&COMPLETE_CONFIG
.replace(
"skills_bundle = \"~/.local/clanker/skills\"",
"skills_bundle = \"~/missing-skills\"",
)
.replace("skills = []", "skills = [\"current-session\"]"),
)
.unwrap();
let missing_bundle = config.filesystem_issues(temp.path());
assert!(
missing_bundle
.iter()
.any(|issue| issue.key == "defaults.skills_bundle")
);
assert!(missing_bundle.iter().any(|issue| {
issue.key == "domain.eng.skills.current-session"
&& issue.message.contains("current-session/SKILL.md")
}));
let bundle = temp.path().join("skills");
fs::create_dir_all(bundle.join("current-session")).unwrap();
fs::write(bundle.join("current-session/SKILL.md"), "skill").unwrap();
config.defaults.skills_bundle = bundle.display().to_string();
assert!(config.filesystem_issues(temp.path()).is_empty());
}
#[test]
fn unknown_base_parameter_is_a_hard_schema_error() {
let temp = TempDir::new().unwrap();
let path = temp.path().join("config.toml");
fs::write(
&path,
COMPLETE_CONFIG.replace(
"prompter_bundle = \"~/.local/prompter\"",
"prompter_bundle = \"~/.local/prompter\"\nunknown_parameter = true",
),
)
.unwrap();
let error = load_config_with_overlay(&path, None).unwrap_err();
let message = error.to_string();
assert!(message.contains(path.to_str().unwrap()));
assert!(message.contains("unknown field `unknown_parameter`"));
}
#[test]
fn unknown_overlay_parameter_is_a_hard_schema_error() {
let temp = TempDir::new().unwrap();
let base_path = temp.path().join("config.toml");
let overlay_path = temp.path().join("local.toml");
fs::write(&base_path, COMPLETE_CONFIG).unwrap();
fs::write(
&overlay_path,
"[harness.claude]\nunknown_parameter = true\n",
)
.unwrap();
let error = load_config_with_overlay(&base_path, Some(&overlay_path)).unwrap_err();
let message = error.to_string();
assert!(message.contains(base_path.to_str().unwrap()));
assert!(message.contains(overlay_path.to_str().unwrap()));
assert!(message.contains("unknown field `unknown_parameter`"));
}
}