215 lines
6.7 KiB
Rust
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());
|
|
}
|
|
}
|