//! Configuration: YAML schema, loading with includes, merging with the //! embedded default catalog, validation, and path expansion. use crate::cli::Cli; use anyhow::{anyhow, Context, Result}; use serde::{Deserialize, Serialize}; use std::collections::{BTreeMap, HashSet}; use std::path::{Path, PathBuf}; /// Default catalog embedded in the binary at compile time. pub const DEFAULT_CONFIG: &str = include_str!("../config.yaml"); /// Name of the user configuration file inside the config directory. pub const CONFIG_FILE_NAME: &str = "config.yaml"; /// Name of the local (current directory) configuration file. pub const LOCAL_CONFIG_NAME: &str = "agent-manager.yaml"; // --------------------------------------------------------------------------- // Schema // --------------------------------------------------------------------------- #[derive(Debug, Clone, Serialize, Deserialize, Default)] #[serde(deny_unknown_fields)] pub struct Config { #[serde(default = "default_version")] pub version: String, #[serde(default)] pub settings: Settings, #[serde(default)] pub aliases: BTreeMap, #[serde(default)] pub groups: BTreeMap>, #[serde(default)] pub include: Vec, #[serde(default)] pub agents: Vec, } fn default_version() -> String { "1.0".to_string() } #[derive(Debug, Clone, Serialize, Deserialize, Default)] #[serde(deny_unknown_fields)] pub struct Settings { /// Installation root for all agents. #[serde(default)] pub install_dir: Option, /// Directory for logs. #[serde(default)] pub log_dir: Option, /// Shell used to execute install scripts and to run system commands /// in the interactive prompt (defaults to the user's default shell). #[serde(default)] pub default_shell: Option, /// Try to install missing dependencies automatically. #[serde(default)] pub auto_install_deps: Option, /// Ask for confirmation before running install scripts. #[serde(default)] pub confirm_before_run: Option, /// Grace period (seconds) between SIGTERM and SIGKILL; 0 = default (5). #[serde(default)] pub stop_timeout_secs: u64, /// GitHub repository (owner/repo) used by the self-update command. #[serde(default)] pub self_update_repo: Option, } impl Settings { pub fn auto_install_deps(&self) -> bool { self.auto_install_deps.unwrap_or(true) } pub fn confirm_before_run(&self) -> bool { self.confirm_before_run.unwrap_or(true) } pub fn stop_timeout(&self) -> u64 { if self.stop_timeout_secs == 0 { 5 } else { self.stop_timeout_secs } } } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct AgentDef { /// Unique identifier (slug). pub name: String, #[serde(default)] pub display_name: Option, #[serde(default)] pub description: Option, #[serde(default)] pub category: Option, #[serde(default)] pub website: Option, #[serde(default)] pub install: Option, #[serde(default)] pub dependencies: Vec, /// Command used to start the agent (e.g. "claude", "python -m nanobot"). #[serde(default)] pub run: Option, /// Default arguments appended to the run command. #[serde(default)] pub args: Vec, /// Default environment variables for the agent. #[serde(default)] pub env: BTreeMap, /// Known version (otherwise detected via --version). #[serde(default)] pub version: Option, #[serde(default)] pub tags: Vec, /// When false, the agent is listed but cannot be installed locally. #[serde(default = "default_true")] pub installable: bool, /// Explanation shown when installable is false. #[serde(default)] pub note: Option, /// Hidden from list/search output (still addressable by name). #[serde(default)] pub hidden: bool, /// Restrict the agent to platforms: linux, macos, windows (empty = all). #[serde(default)] pub platforms: Vec, } fn default_true() -> bool { true } impl AgentDef { pub fn title(&self) -> &str { self.display_name.as_deref().unwrap_or(&self.name) } /// First word of the run command (the executable name). pub fn first_token(&self) -> Option { self.run .as_deref() .and_then(|r| shell_words::split(r).ok()) .and_then(|mut v| { if v.is_empty() { None } else { Some(v.remove(0)) } }) } /// The run command split into tokens (empty when unset). pub fn run_tokens(&self) -> Vec { self.run .as_deref() .and_then(|r| shell_words::split(r).ok()) .unwrap_or_default() } pub fn supports_current_platform(&self) -> bool { if self.platforms.is_empty() { return true; } let platform = current_platform(); self.platforms.iter().any(|p| p == platform) } } pub fn current_platform() -> &'static str { if cfg!(windows) { "windows" } else if cfg!(target_os = "macos") { "macos" } else { "linux" } } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum InstallType { Npm, Pip, Uv, Cargo, Go, Bun, Curl, Binary, Git, } impl InstallType { pub fn as_str(&self) -> &'static str { match self { InstallType::Npm => "npm", InstallType::Pip => "pip", InstallType::Uv => "uv", InstallType::Cargo => "cargo", InstallType::Go => "go", InstallType::Bun => "bun", InstallType::Curl => "curl", InstallType::Binary => "binary", InstallType::Git => "git", } } } impl std::fmt::Display for InstallType { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(self.as_str()) } } /// A single install method: npm, pip, uv, cargo, go, bun, curl, binary or git. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct MethodSpec { #[serde(rename = "type")] pub kind: InstallType, /// Package name (npm, pip, cargo, go, bun). #[serde(default)] pub package: Option, /// Direct download URL (binary method). #[serde(default)] pub url: Option, /// Install script URL (curl method). #[serde(default)] pub script: Option, /// GitHub repository "owner/repo" (binary method) or full clone URL (git). #[serde(default)] pub repo: Option, /// Substrings that must all appear in the release asset name. #[serde(default)] pub asset_match: Vec, /// Substrings that must not appear in the release asset name. #[serde(default)] pub asset_exclude: Vec, /// Expected checksum of the downloaded file, "sha256:". #[serde(default)] pub checksum: Option, /// Expected binary name (used to locate the executable). #[serde(default)] pub binary: Option, /// Build command(s) for the git method (string or list). #[serde(default)] pub build: Option, /// Path of the built binary inside the repository (git method). #[serde(default)] pub binary_path: Option, /// Extra arguments for the installer command. #[serde(default)] pub args: Vec, /// Extra environment for the installer command. #[serde(default)] pub env: BTreeMap, /// Free-form note shown in info and method listings. #[serde(default)] pub note: Option, } /// A string or a list of strings (for build commands and similar fields). #[derive(Debug, Clone, Serialize)] pub enum OneOrMany { One(String), Many(Vec), } impl OneOrMany { pub fn items(&self) -> Vec { match self { OneOrMany::One(s) => vec![s.clone()], OneOrMany::Many(v) => v.clone(), } } } impl<'de> Deserialize<'de> for OneOrMany { fn deserialize(deserializer: D) -> Result where D: serde::Deserializer<'de>, { struct Visitor; impl<'de> serde::de::Visitor<'de> for Visitor { type Value = OneOrMany; fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { f.write_str("a string or a list of strings") } fn visit_str(self, v: &str) -> Result { Ok(OneOrMany::One(v.to_string())) } fn visit_string(self, v: String) -> Result { Ok(OneOrMany::One(v)) } fn visit_seq(self, mut seq: A) -> Result where A: serde::de::SeqAccess<'de>, { let mut out = Vec::new(); while let Some(v) = seq.next_element::()? { out.push(v); } Ok(OneOrMany::Many(out)) } } deserializer.deserialize_any(Visitor) } } /// Install specification: one primary method, optionally several alternatives. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct InstallSpec { #[serde(flatten)] pub primary: MethodSpec, /// Alternative methods; the first one is the default. #[serde(default)] pub methods: Vec, /// Commands executed after a successful install (PATH includes the bin dir). #[serde(default)] pub post_install: Vec, } impl InstallSpec { /// All available methods: the alternatives when present, else the primary. pub fn all_methods(&self) -> Vec<&MethodSpec> { if self.methods.is_empty() { vec![&self.primary] } else { self.methods.iter().collect() } } } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct Dependency { pub name: String, /// Command used to probe the tool (defaults from the name). #[serde(default)] pub check: Option, /// Minimum required version (lenient comparison). #[serde(default)] pub min_version: Option, /// Human-readable hint on how to install the tool. #[serde(default)] pub install_hint: Option, /// Command that installs the tool (used with auto_install_deps). #[serde(default)] pub install_cmd: Vec, } // --------------------------------------------------------------------------- // Loading and merging // --------------------------------------------------------------------------- pub struct LoadedConfig { pub config: Config, /// The main user configuration file that was found (if any). pub origin: Option, } /// Load the effective configuration: embedded defaults, then the user file /// (found by priority order), then its includes. pub fn load(cli: &Cli) -> Result { let mut base: Config = serde_yaml::from_str(DEFAULT_CONFIG) .context("embedded default catalog is invalid — this is a bug in agent-manager")?; let mut origin: Option = None; let mut candidates: Vec = Vec::new(); if let Some(p) = std::env::var_os("AGENT_MANAGER_CONFIG") { candidates.push(PathBuf::from(p)); } if let Some(p) = &cli.config { candidates.push(p.clone()); } candidates.push(PathBuf::from(LOCAL_CONFIG_NAME)); if let Some(dir) = user_config_dir() { candidates.push(dir.join(CONFIG_FILE_NAME)); } let chosen = candidates.into_iter().find(|p| p.is_file()); if let Some(path) = chosen { let overlay = load_file_with_includes(&path, &mut HashSet::new())?; merge(&mut base, overlay); origin = Some(path); } let problems = validate(&base); if !problems.is_empty() { return Err(anyhow!( "configuration problems: - {}", problems.join(" - ") )); } Ok(LoadedConfig { config: base, origin }) } /// Parse a YAML or JSON config file. pub fn parse_file(path: &Path) -> Result { let text = std::fs::read_to_string(path) .with_context(|| format!("cannot read config file {}", path.display()))?; let is_json = path.extension().map(|e| e == "json").unwrap_or(false); if is_json { serde_json::from_str(&text) .map_err(|e| anyhow!("invalid JSON in {}: {e}", path.display())) } else { serde_yaml::from_str(&text) .map_err(|e| anyhow!("invalid YAML in {}: {e}", path.display())) } } fn load_file_with_includes(path: &Path, visited: &mut HashSet) -> Result { let canon = path.canonicalize().unwrap_or_else(|_| path.to_path_buf()); if !visited.insert(canon.clone()) { return Err(anyhow!("config include cycle detected at {}", path.display())); } let mut cfg = parse_file(path)?; let includes = cfg.include.clone(); cfg.include.clear(); let base_dir = path.parent().unwrap_or_else(|| Path::new(".")); let mut merged = Config::default(); merged.version = cfg.version.clone(); for inc in &includes { let inc_path = expand_path(inc); let abs = if inc_path.is_absolute() { inc_path } else { base_dir.join(inc_path) }; let sub = load_file_with_includes(&abs, visited)?; merge(&mut merged, sub); } merge(&mut merged, cfg); Ok(merged) } /// Merge an overlay config into a base config: user definitions replace /// default agents with the same name; maps are merged key by key. pub fn merge(base: &mut Config, overlay: Config) { let s = &mut base.settings; let o = overlay.settings; if o.install_dir.is_some() { s.install_dir = o.install_dir; } if o.log_dir.is_some() { s.log_dir = o.log_dir; } if o.default_shell.is_some() { s.default_shell = o.default_shell; } if o.auto_install_deps.is_some() { s.auto_install_deps = o.auto_install_deps; } if o.confirm_before_run.is_some() { s.confirm_before_run = o.confirm_before_run; } if o.stop_timeout_secs != 0 { s.stop_timeout_secs = o.stop_timeout_secs; } if o.self_update_repo.is_some() { s.self_update_repo = o.self_update_repo; } for (k, v) in overlay.aliases { base.aliases.insert(k, v); } for (k, v) in overlay.groups { base.groups.insert(k, v); } for agent in overlay.agents { if let Some(existing) = base.agents.iter_mut().find(|a| a.name == agent.name) { *existing = agent; } else { base.agents.push(agent); } } } /// Validate a merged config. Returns a list of human-readable problems. pub fn validate(config: &Config) -> Vec { let mut problems = Vec::new(); if config.version != "1" && config.version != "1.0" { problems.push(format!( "unsupported config version '{}' (expected '1.0')", config.version )); } let mut seen = HashSet::new(); for agent in &config.agents { if !seen.insert(agent.name.clone()) { problems.push(format!("duplicate agent name '{}'", agent.name)); } if agent.name.is_empty() || !agent .name .chars() .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_') { problems.push(format!( "agent name '{}' must be a lowercase slug ([a-z0-9_-])", agent.name )); } if agent.installable { if agent.run.as_deref().unwrap_or("").trim().is_empty() { problems.push(format!("agent '{}': 'run' is required when installable", agent.name)); } if agent.install.is_none() { problems.push(format!( "agent '{}': 'install' block is required when installable", agent.name )); } } for dep in &agent.dependencies { if dep.name.trim().is_empty() { problems.push(format!("agent '{}': dependency without a name", agent.name)); } } } problems } // --------------------------------------------------------------------------- // Path helpers // --------------------------------------------------------------------------- /// Expand "~" and environment variables ("$HOME", "${VAR}") in a path. pub fn expand_path(input: &str) -> PathBuf { let mut s = input.to_string(); if s == "~" { s = home_dir() .map(|h| h.to_string_lossy().to_string()) .unwrap_or_else(|| "~".to_string()); } else if let Some(rest) = s.strip_prefix("~/").or_else(|| s.strip_prefix("~\\")) { if let Some(h) = home_dir() { s = format!("{}{}{}", h.to_string_lossy(), std::path::MAIN_SEPARATOR, rest); } } PathBuf::from(expand_vars(&s)) } /// Expand "$VAR" and "${VAR}" occurrences. pub fn expand_vars(input: &str) -> String { let chars: Vec = input.chars().collect(); let mut out = String::new(); let mut i = 0; while i < chars.len() { if chars[i] == '$' && i + 1 < chars.len() { if chars[i + 1] == '{' { if let Some(end) = chars[i + 2..].iter().position(|&c| c == '}') { let name: String = chars[i + 2..i + 2 + end].iter().collect(); if let Ok(v) = std::env::var(&name) { out.push_str(&v); i += end + 3; continue; } } } else { let mut j = i + 1; while j < chars.len() && (chars[j].is_ascii_alphanumeric() || chars[j] == '_') { j += 1; } if j > i + 1 { let name: String = chars[i + 1..j].iter().collect(); if let Ok(v) = std::env::var(&name) { out.push_str(&v); i = j; continue; } } } } out.push(chars[i]); i += 1; } out } /// The user's home directory. pub fn home_dir() -> Option { if let Some(h) = std::env::var_os("HOME") { return Some(PathBuf::from(h)); } if let Some(h) = std::env::var_os("USERPROFILE") { return Some(PathBuf::from(h)); } None } /// Directory holding the user configuration file. pub fn user_config_dir() -> Option { if let Some(d) = std::env::var_os("AGENT_MANAGER_CONFIG_DIR") { return Some(PathBuf::from(d)); } let base = if cfg!(windows) { std::env::var_os("APPDATA").map(PathBuf::from) } else { std::env::var_os("XDG_CONFIG_HOME") .map(PathBuf::from) .or_else(|| home_dir().map(|h| h.join(".config"))) }; base.map(|b| b.join("agent-manager")) } #[cfg(test)] mod tests { use super::*; #[test] fn parses_embedded_default() { let cfg: Config = serde_yaml::from_str(DEFAULT_CONFIG).unwrap(); assert!(cfg.agents.len() >= 60, "catalog too small: {}", cfg.agents.len()); assert!(cfg.agents.iter().any(|a| a.name == "claude-code")); assert!(validate(&cfg).is_empty(), "embedded config invalid: {:?}", validate(&cfg)); } #[test] fn expands_paths() { std::env::set_var("AM_TEST_EXPAND_VAR", "expanded-value"); let home = home_dir().unwrap_or_else(|| PathBuf::from("/home/test")); let p = expand_path("~/a/$AM_TEST_EXPAND_VAR/b"); let s = p.to_string_lossy().to_string(); assert!(!s.contains('~')); assert!(!s.contains("$AM_TEST_EXPAND_VAR")); assert!(s.contains("expanded-value")); assert!(s.contains("a")); assert!(s.contains(&home.to_string_lossy().to_string())); } #[test] fn merges_user_over_default() { let mut base: Config = serde_yaml::from_str(DEFAULT_CONFIG).unwrap(); let overlay: Config = serde_yaml::from_str( r#" version: "1.0" agents: - name: claude-code display_name: "Mon Claude" install: type: npm package: "@anthropic-ai/claude-code" run: claude - name: my-agent display_name: "My Agent" install: type: binary url: "https://example.com/bin" run: my-agent "#, ) .unwrap(); merge(&mut base, overlay); let cc = base.agents.iter().find(|a| a.name == "claude-code").unwrap(); assert_eq!(cc.display_name.as_deref(), Some("Mon Claude")); assert!(base.agents.iter().any(|a| a.name == "my-agent")); assert!(validate(&base).is_empty()); } #[test] fn validate_rejects_bad_agents() { let cfg: Config = serde_yaml::from_str( r#" version: "1.0" agents: - name: broken installable: true run: "" install: type: npm package: x - name: broken install: type: npm package: y run: broken "#, ) .unwrap(); let problems = validate(&cfg); assert!(problems.iter().any(|p| p.contains("duplicate"))); assert!(problems.iter().any(|p| p.contains("'run' is required"))); } #[test] fn one_or_many_parses_both() { let a: OneOrMany = serde_yaml::from_str("\"make build\"").unwrap(); assert_eq!(a.items(), vec!["make build".to_string()]); let b: OneOrMany = serde_yaml::from_str("- cargo - build").unwrap(); assert_eq!(b.items(), vec!["cargo".to_string(), "build".to_string()]); } #[test] fn includes_resolve_relative() { let dir = tempfile::tempdir().unwrap(); let sub = dir.path().join("sub.yaml"); std::fs::write( &sub, r#" version: "1.0" agents: - name: from-include display_name: "Included" install: { type: npm, package: pkg } run: from-include "#, ) .unwrap(); let main = dir.path().join("main.yaml"); std::fs::write( &main, r#" version: "1.0" include: [sub.yaml] agents: - name: from-main display_name: "Main" install: { type: npm, package: pkg2 } run: from-main "#, ) .unwrap(); let cfg = load_file_with_includes(&main, &mut HashSet::new()).unwrap(); assert!(cfg.agents.iter().any(|a| a.name == "from-include")); assert!(cfg.agents.iter().any(|a| a.name == "from-main")); } }