Files
agent-manager/src/commands/config_cmd.rs
T

382 lines
14 KiB
Rust

//! 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<i32> {
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<i32> {
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 <name>' command so a switched theme
/// survives a restart.
pub fn persist_setting(app: &App, key: &str, value: &str) -> Result<()> {
let path = active_config_path(app)?;
write_dotted_key(&path, key, value)?;
reload_and_validate(&path)
}
/// Path of the configuration file that is actually in effect: the loaded
/// origin when one exists, otherwise the user config file (created on
/// demand). Mutating commands write here so the change survives a reload.
pub(crate) fn active_config_path(app: &App) -> Result<PathBuf> {
match &app.config_origin {
Some(p) => Ok(p.clone()),
None => user_config_path(app),
}
}
/// 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.
pub(crate) 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::<bool>() {
return serde_yaml::Value::Bool(b);
}
if let Ok(i) = value.parse::<i64>() {
return serde_yaml::Value::Number(i.into());
}
serde_yaml::Value::String(value.to_string())
}
fn user_config_path_if_any(app: &App) -> Option<PathBuf> {
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).
pub(crate) fn user_config_path(app: &App) -> Result<PathBuf> {
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<i32> {
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<String> {
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::<serde_yaml::Value>(&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());
}
}