- 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
1378 lines
48 KiB
Rust
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"));
|
|
}
|
|
}
|