382 lines
14 KiB
Rust
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());
|
|
}
|
|
}
|