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

215 lines
6.7 KiB
Rust

//! profile: environment profiles (issue #40) — a named set of
//! agent + env + args, defined in the config, applied with
//! 'am start <agent> --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<String>,
env: BTreeMap<String, String>,
args: Vec<String>,
}
pub fn run(app: &App, cmd: &ProfileCmd) -> Result<i32> {
match cmd {
ProfileCmd::List => run_list(app),
ProfileCmd::Show { name } => run_show(app, name),
}
}
pub fn run_list(app: &App) -> Result<i32> {
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<ProfileJson> = 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::<Vec<_>>()
.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<i32> {
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::<Vec<_>>().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<String, String>,
) -> Result<(Vec<String>, BTreeMap<String, String>)> {
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<Option<&'a str>> {
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());
}
}