Files
agent-manager/src/config.rs
T
bruno 313645eea2
release / release (push) Successful in 13m12s
release / macos (push) Canceled after 0s
feat: am ai — langage naturel vers action shell securisee via AIChat (issues #96 #97)
- Commande am ai (alias CLI: am shell) : conversationnel par defaut
  (aichat -f <ctx> "<prompt>"), mode --exec avec generation aichat --dry-run
- Securite (issue #97) : classification safe/risky + certainite affichee,
  politique dry-run par defaut (settings.shell_ai.default_safety:
  dry-run | confirm | auto), confirmation y/N pour les commandes risky,
  --yes pour executer, --dry-run force la simulation
- Flags : --exec/-e, --files/-f (defaut: dossier courant), --provider,
  --model (registre providers -> env aichat + modele, token via keyring)
- Execution via le shell utilisateur (default_shell > SHELL > COMSPEC)
- Journalisation : EventKind::ShellAi (mode, risk, certainty, executed)
- Module src/shell_ai.rs (classification, politique, generation, execution)
- Câblage complet : cli, dispatch, help, REPL (parseur+completer+banner),
  tip, man pages (am-ai.1), i18n EN, config.yaml (bloc shell_ai)
- Tests : 7 unitaires (classification, certainty, extraction, decision)
  + 4 integration (shim aichat.cmd : dry-run par defaut, --yes, mode
  conversationnel, mode confirm) — 339 tests verts
- v1.0.5 ; ROADMAP axe 13.3 coche (6 lignes), epic #93 clôturee

closes #93
closes #96
closes #97
2026-08-20 12:07:38 -04:00

1378 lines
48 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>,
/// Per-project profiles: root, default agent, env and hooks.
#[serde(default)]
pub projects: BTreeMap<String, ProjectProfile>,
/// Environment profiles: a reusable set of agent + env + args
/// (issue #40) - 'am start <agent> --profile dev'.
#[serde(default)]
pub profiles: BTreeMap<String, Profile>,
}
/// 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<String>,
/// Environment variables injected when launching with this profile.
#[serde(default)]
pub env: BTreeMap<String, String>,
/// Extra arguments appended to the run command.
#[serde(default)]
pub args: Vec<String>,
}
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,
/// Repository (owner/repo) used by the self-update command.
#[serde(default)]
pub self_update_repo: Option<String>,
/// 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<String>,
/// Color theme for the output (see 'am help theme' for the list; the
/// --theme flag overrides it).
#[serde(default)]
pub theme: Option<String>,
/// Lifecycle hooks: on_install / on_start / on_stop / on_update command
/// lines (global; project profiles can add their own).
#[serde(default)]
pub hooks: BTreeMap<String, Vec<String>>,
/// Update policy: how aggressively 'am update' should act (issue #65).
#[serde(default)]
pub update_policy: Option<UpdatePolicy>,
/// A model is a prune candidate after this many days without use
/// (issue #73, default 30).
#[serde(default)]
pub models_prune_days: Option<u64>,
/// 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<String>,
/// 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<u64>,
/// How many pre-update backups 'am update --rollback' keeps
/// (issue #64, default 5).
#[serde(default)]
pub backups_keep: Option<u32>,
/// Purge sessions finished more than N days ago (issue #53, default 90).
#[serde(default)]
pub sessions_retention_days: Option<u64>,
/// Token price models used to estimate session costs (issue #49).
#[serde(default)]
pub cost_models: Option<BTreeMap<String, CostModel>>,
/// CPU/memory thresholds for 'am monitor' alerts (issue #50).
#[serde(default)]
pub monitor_thresholds: Option<MonitorThresholds>,
/// Git repository where 'am sync' pushes the state (issue #66).
#[serde(default)]
pub sync_repo: Option<String>,
/// Push automatically when the REPL exits (issue #66, opt-in).
#[serde(default)]
pub sync_on_exit: Option<bool>,
/// 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<TelemetrySettings>,
/// 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<AskSettings>,
/// Community registry (issue #77): trusted catalog sources + the author
/// name recorded by `am registry publish`.
#[serde(default)]
pub registry: Option<RegistrySettings>,
/// Plugin scripts (issue #75): default timeout and enable list.
#[serde(default)]
pub plugins: Option<PluginSettings>,
/// 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<String>,
/// 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/<name>/api_key`
/// (issue #89).
#[serde(default)]
pub providers: Option<BTreeMap<String, Option<ProviderDef>>>,
/// 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<ShellAiSettings>,
}
/// 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<String>,
}
/// 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<String>,
/// Model for the refinement call. Falls back to the provider default.
pub model: Option<String>,
}
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<String>,
/// 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<String>,
}
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<String>,
/// Author name recorded by `am registry publish` (default: "unknown").
pub author: Option<String>,
}
/// 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<String>,
/// Allowed working directories (support ~ and env vars). Empty = no
/// directory restriction.
#[serde(default)]
pub dirs: Vec<String>,
/// 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<String>,
/// Models offered by this provider.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub models: Vec<String>,
}
/// 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<u64>,
/// Only run these plugin names (empty = all the plugins of the dir).
#[serde(default)]
pub enabled: Option<Vec<String>>,
}
/// 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<String, CostModel> {
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<f64>,
/// Alert when a managed process exceeds this RSS in MB (0 = disabled).
#[serde(default)]
pub mem_mb: Option<u64>,
}
/// 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<String>,
/// Environment variables injected when launching the default agent.
#[serde(default)]
pub env: BTreeMap<String, String>,
/// Hooks: on_start / on_stop / on_install / on_update command lines.
#[serde(default)]
pub hooks: BTreeMap<String, Vec<String>>,
/// Default environment profile applied when launching the default
/// agent (issue #40).
#[serde(default)]
pub profile: 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
}
}
/// 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<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>,
/// 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<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>,
/// Pinned version: 'am update' will refuse to install a different one (issue #65).
#[serde(default)]
pub pin_version: Option<String>,
/// Environment variable carrying the model name at launch
/// (issue #71, e.g. ANTHROPIC_MODEL for claude-code).
#[serde(default)]
pub model_env: Option<String>,
/// CLI flag carrying the model name at launch (issue #71, e.g. "--model").
#[serde(default)]
pub model_arg: Option<String>,
/// 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<String>,
/// Preferred model for this agent (issue #90). Falls back to the
/// provider's default_model when unset.
#[serde(default)]
pub model: Option<String>,
/// Post-install provider configuration (issue #91).
#[serde(default)]
pub config: Option<AgentConfig>,
/// 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<SandboxProfile>,
#[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>,
/// Health check waited before the next orchestration step (issue #57).
#[serde(default)]
pub healthcheck: Option<HealthCheck>,
/// Container profile used by 'am run --container' (issue #58).
#[serde(default)]
pub container: Option<ContainerSpec>,
/// 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<String>,
}
/// 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<String>,
/// TCP port to connect to (accepts = healthy).
#[serde(default)]
pub port: Option<u16>,
/// Seconds to wait before declaring failure (default 30).
#[serde(default)]
pub timeout_secs: Option<u64>,
}
/// Container profile (issue #58): image, volumes, network and env applied
/// by 'am run <agent> --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<String>,
/// Network (default: host on Linux, bridge elsewhere).
#[serde(default)]
pub network: Option<String>,
/// Extra environment variables inside the container.
#[serde(default)]
pub env: BTreeMap<String, 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))
}
})
}
/// 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<String> {
self.detect.clone().or_else(|| self.first_token())
}
/// 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>,
/// 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<String, String>,
/// Configuration files to write or softly edit after the install.
pub files: Vec<AgentConfigFile>,
/// Provider of this agent, otherwise settings.default_provider.
pub provider_default: Option<String>,
}
/// 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<String>,
}
#[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 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<PathBuf>) -> Result<Config> {
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<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));
}
}
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<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!(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"));
}
}