//! 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, /// Per-project profiles: root, default agent, env and hooks. #[serde(default)] pub projects: BTreeMap, /// Environment profiles: a reusable set of agent + env + args /// (issue #40) - 'am start --profile dev'. #[serde(default)] pub profiles: BTreeMap, } /// An environment profile: a named set of agent, env and args applied at /// launch time (issue #40). #[derive(Debug, Clone, Serialize, Deserialize, Default)] #[serde(deny_unknown_fields)] pub struct Profile { /// Default agent for this profile (used by 'am start --profile dev' /// without an agent name). #[serde(default)] pub agent: Option, /// Environment variables injected when launching with this profile. #[serde(default)] pub env: BTreeMap, /// Extra arguments appended to the run command. #[serde(default)] pub args: 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, /// Repository (owner/repo) used by the self-update command. #[serde(default)] pub self_update_repo: Option, /// API base URL of the release server (default: GitHub api.github.com; /// set to https://git.dracodev.net/api/v1 for a Gitea instance). #[serde(default)] pub self_update_base_url: Option, /// Color theme for the output (see 'am help theme' for the list; the /// --theme flag overrides it). #[serde(default)] pub theme: Option, /// Lifecycle hooks: on_install / on_start / on_stop / on_update command /// lines (global; project profiles can add their own). #[serde(default)] pub hooks: BTreeMap>, /// Update policy: how aggressively 'am update' should act (issue #65). #[serde(default)] pub update_policy: Option, /// A model is a prune candidate after this many days without use /// (issue #73, default 30). #[serde(default)] pub models_prune_days: Option, /// Base URL of the official catalog used by 'am catalog update' /// (issue #60; default: the embedded catalog on Gitea). #[serde(default)] pub catalog_url: Option, /// How long (seconds) a remote catalog stays cached before a refresh /// is attempted (issue #60, default 3600). #[serde(default)] pub catalog_cache_ttl_secs: Option, /// How many pre-update backups 'am update --rollback' keeps /// (issue #64, default 5). #[serde(default)] pub backups_keep: Option, /// Purge sessions finished more than N days ago (issue #53, default 90). #[serde(default)] pub sessions_retention_days: Option, /// Token price models used to estimate session costs (issue #49). #[serde(default)] pub cost_models: Option>, /// CPU/memory thresholds for 'am monitor' alerts (issue #50). #[serde(default)] pub monitor_thresholds: Option, /// Git repository where 'am sync' pushes the state (issue #66). #[serde(default)] pub sync_repo: Option, /// Push automatically when the REPL exits (issue #66, opt-in). #[serde(default)] pub sync_on_exit: Option, /// Anonymous opt-in telemetry (issue #76): aggregated counters only. /// Nothing is collected or sent while `enabled` is false (the default). #[serde(default)] pub telemetry: Option, /// am ask (issue #78): natural language → am command. The local rules /// are always available; the optional LLM refinement uses the provider /// registry. `enabled: false` turns the whole command off. #[serde(default)] pub ask: Option, /// Community registry (issue #77): trusted catalog sources + the author /// name recorded by `am registry publish`. #[serde(default)] pub registry: Option, /// Plugin scripts (issue #75): default timeout and enable list. #[serde(default)] pub plugins: Option, /// Name of the provider used by default (issue #88) — the provider /// whose default model is applied at install/run when none is given. #[serde(default)] pub default_provider: Option, /// LLM provider registry (issue #88): provider name -> base URL, /// available models and default model. API tokens are NOT stored here — /// they live in the OS keyring under `providers//api_key` /// (issue #89). #[serde(default)] pub providers: Option>>, /// am ai security settings (issue #97): default safety policy and /// extra risky command patterns for the Shell AI command. #[serde(default)] pub shell_ai: Option, } /// Anonymous opt-in telemetry (issue #76): aggregated counters only — never /// paths, commands, agent names or identifiers. Disabled by default. #[derive(Debug, Clone, Serialize, Deserialize, Default)] #[serde(default)] pub struct TelemetrySettings { /// Explicit opt-in: nothing is collected or sent while false. pub enabled: bool, /// Batch endpoint (e.g. https://am-telemetry.example/v1/ping). When /// unset, counters stay local even when enabled. pub endpoint: Option, } /// am ask settings (issue #78): the LLM refinement is optional — without a /// provider, the local rule-based translator still works, offline. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default)] pub struct AskSettings { /// Master switch; false disables `am ask` entirely (issue #78). pub enabled: bool, /// Provider of the registry used for the refinement call. Falls back /// to settings.default_provider when unset. None = rules only. pub provider: Option, /// Model for the refinement call. Falls back to the provider default. pub model: Option, } impl Default for AskSettings { fn default() -> Self { AskSettings { enabled: true, provider: None, model: None, } } } /// am ai security settings (issue #97): safety policy and risky patterns /// for the Shell AI command. The default policy is `dry-run` — nothing is /// executed without an explicit confirmation. #[derive(Debug, Clone, Serialize, Deserialize, Default)] #[serde(default)] pub struct ShellAiSettings { /// Default safety policy of `am ai --exec`: /// `dry-run` (show only), `confirm` (ask for risky commands) or /// `auto` (execute everything). Default: dry-run. pub default_safety: Option, /// Extra command substrings classified as risky (in addition to the /// built-in patterns: rm -rf, dd if, mkfs, chmod -R 777, ...). #[serde(default)] pub risky_patterns: Vec, } impl ShellAiSettings { /// The effective default safety mode (unknown values fall back to /// dry-run — the safe choice). pub fn safety_mode(&self) -> crate::shell_ai::SafetyMode { match self.default_safety.as_deref() { Some("auto") => crate::shell_ai::SafetyMode::Auto, Some("confirm") => crate::shell_ai::SafetyMode::Confirm, _ => crate::shell_ai::SafetyMode::DryRun, } } } /// Community registry settings (issue #77). #[derive(Debug, Clone, Serialize, Deserialize, Default)] #[serde(default)] pub struct RegistrySettings { /// Trusted catalog sources: their manifests are validated (checksum) /// and their agents searchable through `am registry search`. #[serde(default)] pub sources: Vec, /// Author name recorded by `am registry publish` (default: "unknown"). pub author: Option, } /// Sandbox profile of an agent (issue #79): restrict which commands it may /// run, which working directories it may use, and whether it may reach the /// network. Enforcement is best-effort per platform — the launcher checks /// the resolved command and the process cwd, and a warning is emitted when /// the enforcement cannot be fully guaranteed. #[derive(Debug, Clone, Serialize, Deserialize, Default)] #[serde(default)] pub struct SandboxProfile { /// Sandbox active for this agent (default false). pub enabled: bool, /// Allowed command names (basenames). Empty = no command restriction. #[serde(default)] pub commands: Vec, /// Allowed working directories (support ~ and env vars). Empty = no /// directory restriction. #[serde(default)] pub dirs: Vec, /// Network access (default true). false = best-effort block. #[serde(default = "default_true")] pub network: bool, } /// One entry of the LLM provider registry (issue #88). #[derive(Debug, Clone, Serialize, Deserialize, Default)] #[serde(deny_unknown_fields)] pub struct ProviderDef { /// Base URL of the provider API (e.g. https://api.openai.com/v1). pub base_url: String, /// Model used by default for this provider. #[serde(default, skip_serializing_if = "Option::is_none")] pub default_model: Option, /// Models offered by this provider. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub models: Vec, } /// Plugin scripts settings (issue #75): how long one plugin may run and /// which plugins of the directory are enabled (empty = every plugin). #[derive(Debug, Clone, Serialize, Deserialize, Default)] #[serde(deny_unknown_fields)] pub struct PluginSettings { /// Default timeout in seconds for every plugin (default 10). #[serde(default)] pub timeout_secs: Option, /// Only run these plugin names (empty = all the plugins of the dir). #[serde(default)] pub enabled: Option>, } /// Token price model: USD per million tokens (issue #49). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CostModel { pub input_per_mtok: f64, pub output_per_mtok: f64, } /// Default price sheet (USD / MTok) used when settings.cost_models is absent. pub fn default_cost_models() -> BTreeMap { let mut m = BTreeMap::new(); m.insert("claude-sonnet-4".into(), CostModel { input_per_mtok: 3.0, output_per_mtok: 15.0 }); m.insert("claude-opus-4".into(), CostModel { input_per_mtok: 15.0, output_per_mtok: 75.0 }); m.insert("claude-haiku-3.5".into(), CostModel { input_per_mtok: 0.8, output_per_mtok: 4.0 }); m.insert("gpt-4o".into(), CostModel { input_per_mtok: 2.5, output_per_mtok: 10.0 }); m.insert("gpt-4o-mini".into(), CostModel { input_per_mtok: 0.15, output_per_mtok: 0.6 }); m.insert("deepseek-chat".into(), CostModel { input_per_mtok: 0.27, output_per_mtok: 1.10 }); m.insert("llama-3.1-8b".into(), CostModel { input_per_mtok: 0.05, output_per_mtok: 0.05 }); m } /// Alert thresholds for 'am monitor' (issue #50). #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct MonitorThresholds { /// Alert when a managed process exceeds this CPU % (0 = disabled). #[serde(default)] pub cpu_pct: Option, /// Alert when a managed process exceeds this RSS in MB (0 = disabled). #[serde(default)] pub mem_mb: Option, } /// Policy controlling how aggressively 'am update' upgrades installed agents. #[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)] #[serde(rename_all = "lowercase")] pub enum UpdatePolicy { /// Never update automatically; 'am update --all' is a no-op. None, /// Default: update only when explicitly requested by name or with --all. #[default] Manual, /// Update only to the next patch/minor that is known safe (future use). Safe, /// Always update to the latest available version. Latest, } impl UpdatePolicy { pub fn as_str(&self) -> &'static str { match self { UpdatePolicy::None => "none", UpdatePolicy::Manual => "manual", UpdatePolicy::Safe => "safe", UpdatePolicy::Latest => "latest", } } } /// A project profile: everything am needs to know about one project. #[derive(Debug, Clone, Serialize, Deserialize, Default)] #[serde(deny_unknown_fields)] pub struct ProjectProfile { /// Root directory of the project (accepts ~ and env variables). pub root: String, /// Agent launched by 'am start' without arguments inside the project. #[serde(default)] pub default_agent: Option, /// Environment variables injected when launching the default agent. #[serde(default)] pub env: BTreeMap, /// Hooks: on_start / on_stop / on_install / on_update command lines. #[serde(default)] pub hooks: BTreeMap>, /// Default environment profile applied when launching the default /// agent (issue #40). #[serde(default)] pub profile: 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 } } /// Look up one provider of the registry (issue #88). pub fn provider(&self, name: &str) -> Option<&ProviderDef> { self.providers .as_ref() .and_then(|m| m.get(name)) .and_then(|p| p.as_ref()) } /// Name of the default provider (issue #88). pub fn default_provider_name(&self) -> Option<&str> { self.default_provider.as_deref() } /// The default provider entry, when both the name and the definition /// exist (issue #88). pub fn default_provider(&self) -> Option<(&String, &ProviderDef)> { let name = self.default_provider.as_ref()?; let def = self.provider(name)?; Some((name, def)) } } #[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, /// Binary name probed on the PATH for external detection. When unset, /// the first token of `run` is used — which is wrong for interpreter /// launchers ("npx pkg", "python -m pkg"): the interpreter itself would /// be matched, reporting the agent as present whenever node/python is /// on the PATH. Set it to the real entry point (e.g. "lazycodex-ai"). #[serde(default)] pub detect: 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, /// Pinned version: 'am update' will refuse to install a different one (issue #65). #[serde(default)] pub pin_version: Option, /// Environment variable carrying the model name at launch /// (issue #71, e.g. ANTHROPIC_MODEL for claude-code). #[serde(default)] pub model_env: Option, /// CLI flag carrying the model name at launch (issue #71, e.g. "--model"). #[serde(default)] pub model_arg: Option, /// Preferred provider of this agent (issue #90, e.g. "anthropic" for /// claude-code). Falls back to settings.default_provider when unset. #[serde(default)] pub provider: Option, /// Preferred model for this agent (issue #90). Falls back to the /// provider's default_model when unset. #[serde(default)] pub model: Option, /// Post-install provider configuration (issue #91). #[serde(default)] pub config: Option, /// Sandbox profile (issue #79): allowed commands, working directories /// and network policy. Disabled by default — the unsandboxed mode /// stays available and documented. #[serde(default)] pub sandbox: 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, /// Health check waited before the next orchestration step (issue #57). #[serde(default)] pub healthcheck: Option, /// Container profile used by 'am run --container' (issue #58). #[serde(default)] pub container: Option, /// Price model key (settings.cost_models) used to estimate session costs /// (issue #49; defaults to the first model of the price sheet). #[serde(default)] pub cost_model: Option, } /// Health check for group orchestration (issue #57): a command that must /// exit 0, or a TCP port that must accept connections, within timeout_secs. #[derive(Debug, Clone, Serialize, Deserialize, Default)] #[serde(deny_unknown_fields)] pub struct HealthCheck { /// Shell command to probe (exit 0 = healthy). #[serde(default)] pub command: Option, /// TCP port to connect to (accepts = healthy). #[serde(default)] pub port: Option, /// Seconds to wait before declaring failure (default 30). #[serde(default)] pub timeout_secs: Option, } /// Container profile (issue #58): image, volumes, network and env applied /// by 'am run --container'. #[derive(Debug, Clone, Serialize, Deserialize, Default)] #[serde(deny_unknown_fields)] pub struct ContainerSpec { /// Container image (required). pub image: String, /// Extra volumes "host:container[:ro]" on top of state_dir/log_dir. #[serde(default)] pub volumes: Vec, /// Network (default: host on Linux, bridge elsewhere). #[serde(default)] pub network: Option, /// Extra environment variables inside the container. #[serde(default)] pub env: BTreeMap, } 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)) } }) } /// Binary probed on the PATH for external detection: the explicit /// `detect` name when set, otherwise the first token of `run`. pub fn detect_token(&self) -> Option { self.detect.clone().or_else(|| self.first_token()) } /// 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, /// The agent accepts a post-install provider configuration (issue #91). /// False for agents without any API (issue #90). #[serde(default = "default_true")] pub configurable: bool, } 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() } } } /// Post-install provider configuration of an agent (issue #91): the env vars /// the agent expects and the config files to write or softly edit. The /// api_key slot is always injected as the `@secret` reference (issue #89) — /// a token is never written in clear. #[derive(Debug, Clone, Serialize, Deserialize, Default)] #[serde(default)] pub struct AgentConfig { /// Expected env vars, keyed by semantic slot (api_key / model / /// base_url / provider) -> the agent's variable name. pub env_map: BTreeMap, /// Configuration files to write or softly edit after the install. pub files: Vec, /// Provider of this agent, otherwise settings.default_provider. pub provider_default: Option, } /// One config file of an agent (issue #91). #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct AgentConfigFile { /// File path (supports ~ and env vars). pub path: String, /// Keys to write among: api_key, model, base_url, provider. #[serde(default)] pub keys: Vec, } #[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 sub = load_include(inc, base_dir, visited)?; merge(&mut merged, sub); } merge(&mut merged, cfg); Ok(merged) } /// Load one include entry: a local path (relative to the including file) or /// a remote catalog URL (http/https/file, cached on disk, issue #67). fn load_include(inc: &str, base_dir: &Path, visited: &mut HashSet) -> Result { let lower = inc.to_lowercase(); if lower.starts_with("http://") || lower.starts_with("https://") { let state_dir = crate::app::state_dir(); let text = crate::catalog_remote::fetch_for_config(&state_dir, inc)?; let sub = crate::catalog_remote::parse_catalog(&text) .with_context(|| format!("remote include {inc} is not a valid catalog"))?; // Remote includes can themselves reference other URLs; local paths // inside them are resolved against the config directory. let mut merged = Config::default(); merged.version = sub.version.clone(); for sub_inc in &sub.include { let nested = load_include(sub_inc, base_dir, visited)?; merge(&mut merged, nested); } let mut own = sub; own.include.clear(); merge(&mut merged, own); return Ok(merged); } if lower.starts_with("file://") { let local = inc.strip_prefix("file://").unwrap_or(inc); // Windows: "file:///C:/x" -> "C:/x" (drop the leading slash). let local = if cfg!(windows) && local.starts_with('/') { local.strip_prefix('/').unwrap_or(local) } else { local }; let abs = PathBuf::from(local); return load_file_with_includes(&abs, visited); } let inc_path = expand_path(inc); let abs = if inc_path.is_absolute() { inc_path } else { base_dir.join(inc_path) }; load_file_with_includes(&abs, visited) } /// 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; } if o.self_update_base_url.is_some() { s.self_update_base_url = o.self_update_base_url; } if o.theme.is_some() { s.theme = o.theme; } if o.update_policy.is_some() { s.update_policy = o.update_policy; } if o.models_prune_days.is_some() { s.models_prune_days = o.models_prune_days; } if o.catalog_url.is_some() { s.catalog_url = o.catalog_url; } if o.catalog_cache_ttl_secs.is_some() { s.catalog_cache_ttl_secs = o.catalog_cache_ttl_secs; } if o.backups_keep.is_some() { s.backups_keep = o.backups_keep; } if o.sessions_retention_days.is_some() { s.sessions_retention_days = o.sessions_retention_days; } if o.cost_models.is_some() { s.cost_models = o.cost_models; } if o.monitor_thresholds.is_some() { s.monitor_thresholds = o.monitor_thresholds; } if o.sync_repo.is_some() { s.sync_repo = o.sync_repo; } if o.sync_on_exit.is_some() { s.sync_on_exit = o.sync_on_exit; } if o.telemetry.is_some() { s.telemetry = o.telemetry; } if o.ask.is_some() { s.ask = o.ask; } if o.registry.is_some() { s.registry = o.registry; } if o.plugins.is_some() { s.plugins = o.plugins; } if o.default_provider.is_some() { s.default_provider = o.default_provider; } if o.shell_ai.is_some() { s.shell_ai = o.shell_ai; } // Providers merge key by key (issue #88): an overlay adds or replaces // one provider without wiping the others. An explicit `null` in the // overlay DELETES the provider (the standard YAML overlay pattern) so a // provider shipped in the default catalog can be removed by the user. if let Some(overlay_providers) = o.providers { match s.providers.as_mut() { Some(map) => { for (k, v) in overlay_providers { match v { Some(def) => { map.insert(k, Some(def)); } None => { map.remove(&k); } } } } None => s.providers = Some(overlay_providers), } } for (k, v) in o.hooks { s.hooks.insert(k, v); } 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); } } for (k, v) in overlay.projects { base.projects.insert(k, v); } for (k, v) in overlay.profiles { base.profiles.insert(k, v); } } /// 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)); } } if let Some(d) = &config.settings.default_provider { if config.settings.provider(d).is_none() { problems.push(format!( "settings.default_provider '{d}' is not defined in settings.providers" )); } } if let Some(providers) = &config.settings.providers { for (name, p) in providers { // A null entry is a deletion marker (issue #90): skip it. let Some(p) = p else { continue; }; if p.base_url.trim().is_empty() { problems.push(format!("provider '{name}': 'base_url' is required")); } if !name .chars() .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_') { problems.push(format!( "provider name '{name}' must be a lowercase slug ([a-z0-9_-])" )); } } } } 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!(cfg.agents.iter().any(|a| a.name == "deepseek-harness")); assert!(validate(&cfg).is_empty(), "embedded config invalid: {:?}", validate(&cfg)); } #[test] fn detect_token_prefers_explicit_binary() { // Interpreter launcher: without `detect`, the interpreter itself // would be probed (false "external" when node/python is on PATH). let a: AgentDef = serde_yaml::from_str( "name: lazycodex\nrun: \"npx lazycodex-ai\"\ndetect: lazycodex-ai\n", ) .unwrap(); assert_eq!(a.detect_token().as_deref(), Some("lazycodex-ai")); // No `detect`: falls back to the first token of run. let b: AgentDef = serde_yaml::from_str("name: nanobot\nrun: \"python -m nanobot\"\n").unwrap(); assert_eq!(b.detect_token().as_deref(), Some("python")); assert_eq!(b.first_token().as_deref(), Some("python")); // Plain binary command is unchanged. let c: AgentDef = serde_yaml::from_str("name: claude\nrun: claude\n").unwrap(); assert_eq!(c.detect_token().as_deref(), Some("claude")); // The embedded catalog must stay valid with the new field. let cfg: Config = serde_yaml::from_str(DEFAULT_CONFIG).unwrap(); let lazy = cfg.agents.iter().find(|a| a.name == "lazycodex").unwrap(); assert_eq!(lazy.detect_token().as_deref(), Some("lazycodex-ai")); let nano = cfg.agents.iter().find(|a| a.name == "nanobot").unwrap(); assert_eq!(nano.detect_token().as_deref(), Some("nanobot")); } #[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 merge_keeps_the_user_theme_setting() { let mut base: Config = serde_yaml::from_str(DEFAULT_CONFIG).unwrap(); let overlay: Config = serde_yaml::from_str( r#" version: "1.0" settings: theme: ocean agents: [] "#, ) .unwrap(); merge(&mut base, overlay); assert_eq!(base.settings.theme.as_deref(), Some("ocean")); // The embedded default catalog defines no theme: an overlay without // one leaves the merged setting untouched. let mut base: Config = serde_yaml::from_str(DEFAULT_CONFIG).unwrap(); let overlay: Config = serde_yaml::from_str("version: \"1.0\" agents: [] ").unwrap(); merge(&mut base, overlay); assert_eq!(base.settings.theme, None); } #[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")); } }