//! profile: environment profiles (issue #40) — a named set of //! agent + env + args, defined in the config, applied with //! 'am start --profile dev'. use super::*; use crate::cli::ProfileCmd; use crate::config::Profile; use crate::output::{boxed_table, print_json}; use serde::Serialize; #[derive(Serialize)] struct ProfileJson { name: String, agent: Option, env: BTreeMap, args: Vec, } pub fn run(app: &App, cmd: &ProfileCmd) -> Result { match cmd { ProfileCmd::List => run_list(app), ProfileCmd::Show { name } => run_show(app, name), } } pub fn run_list(app: &App) -> Result { if app.config.profiles.is_empty() { app.log.info( "no profiles defined — add a 'profiles:' section to your config, e.g.:\n\ profiles:\n\ \x20 dev:\n\ \x20 agent: claude-code\n\ \x20 env: {DEBUG: \"1\"}\n\ \x20 args: [--model, sonnet]", ); return Ok(0); } let rows: Vec = app .config .profiles .iter() .map(|(name, p)| ProfileJson { name: name.clone(), agent: p.agent.clone(), env: p.env.clone(), args: p.args.clone(), }) .collect(); if app.json() { print_json(&rows); return Ok(0); } let mut table_rows = Vec::new(); for r in &rows { table_rows.push(vec![ r.name.clone(), r.agent.clone().unwrap_or_else(|| "—".to_string()), if r.env.is_empty() { "—".to_string() } else { r.env .iter() .map(|(k, v)| format!("{k}={v}")) .collect::>() .join(" ") }, if r.args.is_empty() { "—".to_string() } else { r.args.join(" ") }, ]); } print!( "{}", boxed_table( vec!["PROFILE", "AGENT", "ENV", "ARGS"], table_rows, app.theme(), app.color(), ) ); Ok(0) } pub fn run_show(app: &App, name: &str) -> Result { let profile = resolve(app, name)?; if app.json() { print_json(&ProfileJson { name: name.to_string(), agent: profile.agent.clone(), env: profile.env.clone(), args: profile.args.clone(), }); return Ok(0); } println!("{}", name); if let Some(a) = &profile.agent { println!(" agent {a}"); } if profile.env.is_empty() { println!(" env —"); } else { for (k, v) in &profile.env { println!(" env {k}={v}"); } } if profile.args.is_empty() { println!(" args —"); } else { println!(" args {}", profile.args.join(" ")); } Ok(0) } /// Resolve a profile by name; unknown names fail with the list of profiles. pub fn resolve<'a>(app: &'a App, name: &str) -> Result<&'a Profile> { app.config.profiles.get(name).ok_or_else(|| { let available = if app.config.profiles.is_empty() { "no profile is defined in the config".to_string() } else { format!( "available profiles: {}", app.config.profiles.keys().cloned().collect::>().join(", ") ) }; anyhow!("unknown profile '{name}' — {available}") }) } /// Apply a profile to launch parameters: its args are appended and its env /// entries override the base ones (the profile wins — it is the most /// specific input at launch time). pub fn apply( app: &App, name: Option<&str>, base_args: &[String], base_env: &BTreeMap, ) -> Result<(Vec, BTreeMap)> { let Some(name) = name else { return Ok((base_args.to_vec(), base_env.clone())); }; let profile = resolve(app, name)?; let mut args = base_args.to_vec(); args.extend(profile.args.iter().cloned()); let mut env = base_env.clone(); env.extend(profile.env.clone()); Ok((args, env)) } /// The default agent declared by a profile, if any. pub fn default_agent<'a>(app: &'a App, name: Option<&str>) -> Result> { match name { None => Ok(None), Some(n) => Ok(resolve(app, n)?.agent.as_deref()), } } #[cfg(test)] mod tests { use super::*; use clap::Parser; fn cfg_with_profiles() -> App { // The TempDir guard would delete the directory when this helper // returns; leak it on purpose so the App keeps a live state dir. 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, "version: \"1.0\"\nsettings:\n auto_install_deps: false\n confirm_before_run: false\nprofiles:\n dev:\n agent: claude-code\n env:\n DEBUG: \"1\"\n LOG_LEVEL: debug\n args: [--model, sonnet]\n prod:\n env:\n LOG_LEVEL: info\nagents: []\n", ) .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 } #[test] fn apply_merges_args_and_env() { let app = cfg_with_profiles(); let (args, env) = apply(&app, Some("dev"), &["--base".to_string()], &BTreeMap::from([("KEEP".into(), "1".into())])) .unwrap(); assert_eq!(args, vec!["--base", "--model", "sonnet"]); assert_eq!(env.get("DEBUG").map(String::as_str), Some("1")); assert_eq!(env.get("KEEP").map(String::as_str), Some("1")); } #[test] fn unknown_profile_lists_available() { let app = cfg_with_profiles(); let err = resolve(&app, "staging").unwrap_err().to_string(); assert!(err.contains("unknown profile 'staging'"), "{err}"); assert!(err.contains("dev") && err.contains("prod"), "{err}"); } #[test] fn none_profile_is_noop() { let app = cfg_with_profiles(); let (args, env) = apply(&app, None, &["x".to_string()], &BTreeMap::new()).unwrap(); assert_eq!(args, vec!["x"]); assert!(env.is_empty()); } }