//! config subcommands: show, path, edit, validate, add. use super::*; use crate::cli::ConfigCmd; use crate::config; use crate::output::print_json; use anyhow::{anyhow, Context, Result}; use clap::Parser; use std::path::{Path, PathBuf}; use std::process::Command; const USER_CONFIG_TEMPLATE: &str = "# User configuration for agent-manager. # This file overrides/completes the embedded default catalog. # Run 'am config show' to see the effective configuration. version: \"1.0\" agents: [] "; pub fn run(app: &App, cmd: &ConfigCmd) -> Result { match cmd { ConfigCmd::Show => { if app.json() { print_json(&app.config); } else { println!("{}", serde_yaml::to_string(&app.config)?); } Ok(0) } ConfigCmd::Path => { if app.json() { print_json(&serde_json::json!({ "origin": app.config_origin.as_ref().map(|p| p.display().to_string()), "user_dir": app.paths.config_dir.as_ref().map(|p| p.display().to_string()), "user_file": user_config_path_if_any(app).map(|p| p.display().to_string()), "local_file": PathBuf::from(config::LOCAL_CONFIG_NAME).display().to_string(), })); } else { match &app.config_origin { Some(p) => println!("active config: {}", p.display()), None => println!("no user config found — using the embedded default catalog"), } if let Some(d) = &app.paths.config_dir { println!("user config directory: {}", d.display()); println!( "user config file: {}", d.join(config::CONFIG_FILE_NAME).display() ); } println!( "local config file: {} (current directory)", PathBuf::from(config::LOCAL_CONFIG_NAME).display() ); } Ok(0) } ConfigCmd::Validate => { let problems = config::validate(&app.config); if app.json() { print_json(&serde_json::json!({ "valid": problems.is_empty(), "problems": problems, })); return Ok(if problems.is_empty() { 0 } else { 1 }); } if problems.is_empty() { app.log .success(&format!("configuration is valid ({} agents)", app.catalog.len())); Ok(0) } else { app.log.error(&format!( "configuration problems: - {}", problems.join(" - ") )); Ok(1) } } ConfigCmd::Edit => { let path = user_config_path(app)?; let editor = std::env::var("VISUAL") .or_else(|_| std::env::var("EDITOR")) .unwrap_or_else(|_| { if cfg!(windows) { "notepad".to_string() } else { "vi".to_string() } }); let status = Command::new(&editor) .arg(&path) .status() .with_context(|| format!("cannot launch editor '{editor}'"))?; Ok(status.code().unwrap_or(1)) } ConfigCmd::Add { file } => add_file(app, file), ConfigCmd::Set { key, value } => set_value(app, key, value), } } /// config set: set one dotted key (e.g. settings.default_shell) in the user /// configuration file, then validate the result. pub fn set_value(app: &App, key: &str, value: &str) -> Result { let path = user_config_path(app)?; write_dotted_key(&path, key, value)?; reload_and_validate(&path)?; app.log.success(&format!("{key} = {value}")); Ok(0) } /// Persist one setting into the configuration file that is actually in /// effect (the loaded origin), creating the user config file when none /// exists. Used by the REPL 'theme ' command so a switched theme /// survives a restart. pub fn persist_setting(app: &App, key: &str, value: &str) -> Result<()> { let path = match &app.config_origin { Some(p) => p.clone(), None => user_config_path(app)?, }; write_dotted_key(&path, key, value)?; reload_and_validate(&path) } /// Set one dotted key (e.g. settings.default_shell) in a YAML config file. fn write_dotted_key(path: &Path, key: &str, value: &str) -> Result<()> { let text = std::fs::read_to_string(path) .with_context(|| format!("cannot read {}", path.display()))?; let mut root: serde_yaml::Value = serde_yaml::from_str(&text) .with_context(|| format!("{} is not valid YAML", path.display()))?; let parts: Vec<&str> = key.split('.').collect(); if parts.is_empty() || parts.iter().any(|p| p.is_empty()) { return Err(anyhow!("invalid key '{key}' — expected a dotted path like settings.default_shell")); } let parsed = parse_scalar(value); let mut node = root .as_mapping_mut() .ok_or_else(|| anyhow!("{} must contain a YAML mapping", path.display()))?; for part in &parts[..parts.len() - 1] { let entry = node .entry(serde_yaml::Value::String(part.to_string())) .or_insert_with(|| serde_yaml::Value::Mapping(Default::default())); node = entry .as_mapping_mut() .ok_or_else(|| anyhow!("'{part}' is not a mapping — cannot descend into it"))?; } node.insert( serde_yaml::Value::String(parts.last().unwrap().to_string()), parsed, ); std::fs::write(path, serde_yaml::to_string(&root)?) .with_context(|| format!("cannot write {}", path.display()))?; Ok(()) } /// Reload a written config file to make sure the change stays valid. fn reload_and_validate(path: &Path) -> Result<()> { let cli = crate::cli::Cli::parse_from(["am", "--config", path.to_str().unwrap_or_default()]); config::load(&cli).map_err(|e| { anyhow!("the new value makes the configuration invalid: {e:#}") })?; Ok(()) } /// Parse a scalar: booleans and integers keep their type, everything else /// stays a string. fn parse_scalar(value: &str) -> serde_yaml::Value { if let Ok(b) = value.parse::() { return serde_yaml::Value::Bool(b); } if let Ok(i) = value.parse::() { return serde_yaml::Value::Number(i.into()); } serde_yaml::Value::String(value.to_string()) } fn user_config_path_if_any(app: &App) -> Option { app.paths .config_dir .as_ref() .map(|d| d.join(config::CONFIG_FILE_NAME)) } /// Ensure the user config file exists (creating a template when needed). fn user_config_path(app: &App) -> Result { let dir = app .paths .config_dir .clone() .ok_or_else(|| anyhow!("cannot determine the user config directory (no HOME set)"))?; std::fs::create_dir_all(&dir) .with_context(|| format!("cannot create {}", dir.display()))?; let path = dir.join(config::CONFIG_FILE_NAME); if !path.exists() { std::fs::write(&path, USER_CONFIG_TEMPLATE) .with_context(|| format!("cannot create {}", path.display()))?; } Ok(path) } /// config add: copy an agent definition file into the config dir and /// register it via an include entry in the user config file. fn add_file(app: &App, file: &Path) -> Result { config::parse_file(file)?; let dir = app .paths .config_dir .clone() .ok_or_else(|| anyhow!("cannot determine the user config directory (no HOME set)"))?; std::fs::create_dir_all(&dir)?; let base = file .file_name() .and_then(|n| n.to_str()) .unwrap_or("agents.yaml") .to_string(); let dest = dir.join(&base); std::fs::copy(file, &dest) .with_context(|| format!("cannot copy {} to {}", file.display(), dest.display()))?; register_include(app, &base)?; app.log.success(&format!( "added {} and registered it in {}", dest.display(), user_config_path(app)?.display() )); Ok(0) } /// Read the `include:` entries of the user config file (they are consumed /// at load time, so the resolved config no longer lists them). pub fn user_includes(app: &App) -> Vec { let Some(cfg_path) = user_config_path_if_any(app) else { return Vec::new(); }; let Ok(text) = std::fs::read_to_string(&cfg_path) else { return Vec::new(); }; let Ok(value) = serde_yaml::from_str::(&text) else { return Vec::new(); }; value .get("include") .and_then(|i| i.as_sequence()) .map(|seq| { seq.iter() .filter_map(|v| v.as_str().map(|s| s.to_string())) .collect() }) .unwrap_or_default() } /// Add one entry to the `include:` list of the user config file (creating /// the file with a template when it does not exist). Used by 'config add' /// (local files) and 'catalog add/update' (remote URLs, issues #60 #67). pub fn register_include(app: &App, entry: &str) -> Result<()> { let cfg_path = user_config_path(app)?; let text = std::fs::read_to_string(&cfg_path)?; let mut value: serde_yaml::Value = serde_yaml::from_str(&text) .unwrap_or_else(|_| serde_yaml::Value::Null); match &mut value { serde_yaml::Value::Mapping(map) => { let key = serde_yaml::Value::String("include".to_string()); match map.get_mut(&key) { Some(serde_yaml::Value::Sequence(seq)) => { let entry = serde_yaml::Value::String(entry.to_string()); if !seq.contains(&entry) { seq.push(entry); } } _ => { map.insert( key, serde_yaml::Value::Sequence(vec![serde_yaml::Value::String( entry.to_string(), )]), ); } } std::fs::write(&cfg_path, serde_yaml::to_string(&value)?) .with_context(|| format!("cannot write {}", cfg_path.display()))?; } _ => { std::fs::write( &cfg_path, format!("version: \"1.0\"\ninclude:\n - {entry}\n"), )?; } } Ok(()) } #[cfg(test)] mod tests { use super::*; use clap::Parser; /// App whose active config is a file inside a leaked TempDir (the same /// pattern as profile_cmd's tests: the guard would delete the directory /// when the helper returns). fn app_with_config(text: &str) -> (App, PathBuf) { let guard = tempfile::tempdir().unwrap(); let dir = guard.path().to_path_buf(); std::mem::forget(guard); let cfg = dir.join("config.yaml"); std::fs::write(&cfg, text).unwrap(); let cli = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]); let mut app = App::from_cli(cli).unwrap(); let mut p = app.paths.clone(); p.install_dir = dir.join("agents"); p.bin_dir = dir.join("agents").join("bin"); p.log_dir = dir.join("logs"); p.state_file = dir.join("state.json"); p.probe_cache_file = dir.join("probe-cache.json"); p.config_dir = Some(dir.join("config")); app.paths = p; (app, cfg) } #[test] fn persist_setting_writes_into_the_active_config() { let (app, cfg) = app_with_config("version: \"1.0\" settings: theme: default agents: [] "); persist_setting(&app, "settings.theme", "ocean").unwrap(); let text = std::fs::read_to_string(&cfg).unwrap(); let value: serde_yaml::Value = serde_yaml::from_str(&text).unwrap(); assert_eq!(value["settings"]["theme"], "ocean"); } #[test] fn persisted_theme_is_used_on_next_start() { let (app, cfg) = app_with_config("version: \"1.0\" settings: theme: default agents: [] "); persist_setting(&app, "settings.theme", "ocean").unwrap(); // A fresh start reads settings.theme from the config file. let cli = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]); let reloaded = App::from_cli(cli).unwrap(); assert_eq!(reloaded.theme().name, "ocean"); } #[test] fn persist_setting_creates_the_user_config_when_none_exists() { let (mut app, _cfg) = app_with_config("version: \"1.0\" agents: [] "); // Simulate a run without any user/local config: no origin. app.config_origin = None; persist_setting(&app, "settings.theme", "mono").unwrap(); let user_file = app.paths.config_dir.as_ref().unwrap().join(config::CONFIG_FILE_NAME); assert!(user_file.is_file(), "user config was not created"); let text = std::fs::read_to_string(&user_file).unwrap(); let value: serde_yaml::Value = serde_yaml::from_str(&text).unwrap(); assert_eq!(value["settings"]["theme"], "mono"); } #[test] fn write_dotted_key_descends_into_nested_mappings() { let guard = tempfile::tempdir().unwrap(); let path = guard.path().join("c.yaml"); std::fs::write(&path, "version: \"1.0\" agents: [] ").unwrap(); write_dotted_key(&path, "settings.default_shell", "pwsh").unwrap(); let value: serde_yaml::Value = serde_yaml::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); assert_eq!(value["settings"]["default_shell"], "pwsh"); assert!(value["agents"].is_sequence()); } }