agent-manager v0.2.0 : gestionnaire d'agents IA locaux
- CLI Rust (am) : list/install/uninstall/update/start/stop/restart/run/status/search/info/config/doctor/completion/export/import/self-update + shell interactif (Tab, historique) - 9 methodes d'installation (npm, pip, uv, cargo, go, bun, curl, binary, git) - catalogue de 72 agents, dependances, processus, securite (dry-run, checksums) - sondes de version paralleles + cache (am list ~25 ms a chaud) - installateurs one-liner : install.sh, install.ps1
This commit is contained in:
+739
@@ -0,0 +1,739 @@
|
||||
//! 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 (defaults to sh on Unix).
|
||||
#[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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user