741 lines
23 KiB
Rust
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"));
|
|
}
|
|
}
|