Files
agent-manager/src/config.rs
T

741 lines
23 KiB
Rust

//! 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<String, String>,
#[serde(default)]
pub groups: BTreeMap<String, Vec<String>>,
#[serde(default)]
pub include: Vec<String>,
#[serde(default)]
pub agents: Vec<AgentDef>,
}
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<String>,
/// Directory for logs.
#[serde(default)]
pub log_dir: Option<String>,
/// 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<String>,
/// Try to install missing dependencies automatically.
#[serde(default)]
pub auto_install_deps: Option<bool>,
/// Ask for confirmation before running install scripts.
#[serde(default)]
pub confirm_before_run: Option<bool>,
/// 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<String>,
}
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<String>,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub category: Option<String>,
#[serde(default)]
pub website: Option<String>,
#[serde(default)]
pub install: Option<InstallSpec>,
#[serde(default)]
pub dependencies: Vec<Dependency>,
/// Command used to start the agent (e.g. "claude", "python -m nanobot").
#[serde(default)]
pub run: Option<String>,
/// Default arguments appended to the run command.
#[serde(default)]
pub args: Vec<String>,
/// Default environment variables for the agent.
#[serde(default)]
pub env: BTreeMap<String, String>,
/// Known version (otherwise detected via --version).
#[serde(default)]
pub version: Option<String>,
#[serde(default)]
pub tags: Vec<String>,
/// 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<String>,
/// 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<String>,
}
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<String> {
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<String> {
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<String>,
/// Direct download URL (binary method).
#[serde(default)]
pub url: Option<String>,
/// Install script URL (curl method).
#[serde(default)]
pub script: Option<String>,
/// GitHub repository "owner/repo" (binary method) or full clone URL (git).
#[serde(default)]
pub repo: Option<String>,
/// Substrings that must all appear in the release asset name.
#[serde(default)]
pub asset_match: Vec<String>,
/// Substrings that must not appear in the release asset name.
#[serde(default)]
pub asset_exclude: Vec<String>,
/// Expected checksum of the downloaded file, "sha256:<hex>".
#[serde(default)]
pub checksum: Option<String>,
/// Expected binary name (used to locate the executable).
#[serde(default)]
pub binary: Option<String>,
/// Build command(s) for the git method (string or list).
#[serde(default)]
pub build: Option<OneOrMany>,
/// Path of the built binary inside the repository (git method).
#[serde(default)]
pub binary_path: Option<String>,
/// Extra arguments for the installer command.
#[serde(default)]
pub args: Vec<String>,
/// Extra environment for the installer command.
#[serde(default)]
pub env: BTreeMap<String, String>,
/// Free-form note shown in info and method listings.
#[serde(default)]
pub note: Option<String>,
}
/// A string or a list of strings (for build commands and similar fields).
#[derive(Debug, Clone, Serialize)]
pub enum OneOrMany {
One(String),
Many(Vec<String>),
}
impl OneOrMany {
pub fn items(&self) -> Vec<String> {
match self {
OneOrMany::One(s) => vec![s.clone()],
OneOrMany::Many(v) => v.clone(),
}
}
}
impl<'de> Deserialize<'de> for OneOrMany {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
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<E>(self, v: &str) -> Result<OneOrMany, E> {
Ok(OneOrMany::One(v.to_string()))
}
fn visit_string<E>(self, v: String) -> Result<OneOrMany, E> {
Ok(OneOrMany::One(v))
}
fn visit_seq<A>(self, mut seq: A) -> Result<OneOrMany, A::Error>
where
A: serde::de::SeqAccess<'de>,
{
let mut out = Vec::new();
while let Some(v) = seq.next_element::<String>()? {
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<MethodSpec>,
/// Commands executed after a successful install (PATH includes the bin dir).
#[serde(default)]
pub post_install: Vec<String>,
}
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<String>,
/// Minimum required version (lenient comparison).
#[serde(default)]
pub min_version: Option<String>,
/// Human-readable hint on how to install the tool.
#[serde(default)]
pub install_hint: Option<String>,
/// Command that installs the tool (used with auto_install_deps).
#[serde(default)]
pub install_cmd: Vec<String>,
}
// ---------------------------------------------------------------------------
// Loading and merging
// ---------------------------------------------------------------------------
pub struct LoadedConfig {
pub config: Config,
/// The main user configuration file that was found (if any).
pub origin: Option<PathBuf>,
}
/// Load the effective configuration: embedded defaults, then the user file
/// (found by priority order), then its includes.
pub fn load(cli: &Cli) -> Result<LoadedConfig> {
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<PathBuf> = None;
let mut candidates: Vec<PathBuf> = 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<Config> {
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<PathBuf>) -> Result<Config> {
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<String> {
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<char> = 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<PathBuf> {
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<PathBuf> {
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"));
}
}