blob: e0e84e2423343afb15b7c2e15cd00b60a3e0190e [file]
//! Profiles: a name for the flags a launch is given, kept in `profiles.toml` under the
//! root, so `amem claude` or `amem work nightly` is the whole command.
//!
//! ```toml
//! [nightly]
//! harness = "codex"
//! auth = "gateway"
//! model = "qwen3.8-27b"
//! subagents = "board"
//! workdir = "/home/me/src/project"
//! ```
//!
//! A profile becomes the flags it names, put in front of the command line before it is
//! parsed, and only for the flags the command line does not give itself: what is typed
//! wins over what is written down. Every harness is a profile of its own name with
//! nothing else set, so `amem claude` works with no file at all.
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::ffi::OsString;
use std::path::Path;
/// The file, relative to the root.
pub const FILE: &str = "profiles.toml";
/// One profile: each field is the launch flag of the same name.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Profile {
/// `--harness`.
pub harness: String,
/// `--auth`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auth: Option<String>,
/// `--provider`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
/// `--model`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
/// `--server-auth`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub server_auth: Option<String>,
/// `--server-provider`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub server_provider: Option<String>,
/// `--subagents`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub subagents: Option<String>,
/// `--backend`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub backend: Option<String>,
/// `--workdir`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workdir: Option<String>,
}
impl Profile {
/// The flags, in order.
fn flags(&self) -> Vec<(&'static str, &str)> {
let mut v = vec![("--harness", self.harness.as_str())];
for (flag, value) in [
("--auth", &self.auth),
("--provider", &self.provider),
("--model", &self.model),
("--server-auth", &self.server_auth),
("--server-provider", &self.server_provider),
("--subagents", &self.subagents),
("--backend", &self.backend),
("--workdir", &self.workdir),
] {
if let Some(v2) = value {
v.push((flag, v2.as_str()));
}
}
v
}
}
/// The harnesses, each a profile of its own name.
pub const HARNESSES: &[&str] = &[
"claude",
"codex",
"gemini",
"antigravity",
"qwen",
"grok",
"muse",
"pi",
"copilot",
"opencode",
];
/// Whether `name` may name a profile: what a person types, and nothing a path or a flag
/// could be made of.
#[must_use]
pub fn valid_name(name: &str) -> bool {
(1..=32).contains(&name.len())
&& name.bytes().next().is_some_and(|b| b.is_ascii_lowercase())
&& name
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-' || b == b'_')
}
/// Every harness as a profile of its own name.
#[must_use]
pub fn builtin() -> BTreeMap<String, Profile> {
HARNESSES
.iter()
.map(|h| {
(
(*h).to_string(),
Profile {
harness: (*h).to_string(),
..Profile::default()
},
)
})
.collect()
}
/// The profiles under `root`: the file's, and the harnesses' own where the file does not
/// name them. A profile may not take a command's name, which would always win over it.
///
/// # Errors
///
/// When the file is there and does not parse, or names a profile badly.
pub fn load(root: &Path, commands: &[String]) -> Result<BTreeMap<String, Profile>, String> {
let mut all = builtin();
let path = root.join(FILE);
let text = match std::fs::read_to_string(&path) {
Ok(t) => t,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(all),
Err(e) => return Err(format!("{}: {e}", path.display())),
};
let named: BTreeMap<String, Profile> =
toml::from_str(&text).map_err(|e| format!("{}: {e}", path.display()))?;
for (name, p) in named {
if commands.contains(&name) {
return Err(format!(
"{}: `{name}` is a command, so `amem {name}` would never reach the profile; \
give it another name",
path.display()
));
}
if !valid_name(&name) {
return Err(format!(
"{}: `{name}` is not a profile name: lowercase letters, digits, - and _, \
starting with a letter",
path.display()
));
}
all.insert(name, p);
}
Ok(all)
}
/// `amem profiles`: a line each, or the whole as JSON.
///
/// # Errors
///
/// When the JSON cannot be written.
pub fn show(
all: &BTreeMap<String, Profile>,
as_json: bool,
) -> Result<(), Box<dyn std::error::Error>> {
if as_json {
println!("{}", serde_json::to_string_pretty(all)?);
return Ok(());
}
for (name, p) in all {
let rest: Vec<String> = p
.flags()
.into_iter()
.skip(1)
.map(|(flag, value)| format!("{flag} {value}"))
.collect();
println!("{name:<16} {:<12} {}", p.harness, rest.join(" "));
}
Ok(())
}
/// Whether a flag of a command takes the next word as its value; `--flag=value` and a
/// flag that takes none consume no word.
pub type TakesValue<'a> = &'a dyn Fn(&str) -> bool;
/// Whether `args` gives `flag`, as `--flag value` or `--flag=value`, among the command's
/// own flags: up to `--`, or the first word that is not a flag, after which everything
/// is the harness's.
fn gives(args: &[OsString], flag: &str, takes_value: TakesValue<'_>) -> bool {
let eq = format!("{flag}=");
let mut i = 0;
while let Some(a) = args.get(i).and_then(|a| a.to_str()) {
if a == "--" || !a.starts_with('-') {
return false;
}
if a == flag || a.starts_with(&eq) {
return true;
}
i += if !a.contains('=') && takes_value(a) {
2
} else {
1
};
}
false
}
/// Put `profile`'s flags in front of `rest`, skipping those `rest` gives itself.
fn with_profile(
profile: &Profile,
rest: &[OsString],
takes_value: TakesValue<'_>,
) -> Vec<OsString> {
let mut out = Vec::new();
for (flag, value) in profile.flags() {
if !gives(rest, flag, takes_value) {
out.push(OsString::from(flag));
out.push(OsString::from(value));
}
}
out.extend(rest.iter().cloned());
out
}
/// Take `--profile <name>` (or `--profile=<name>`) out of `args`, before any `--`.
fn take_profile_flag(args: &mut Vec<OsString>) -> Option<String> {
let end = args.iter().position(|a| a == "--").unwrap_or(args.len());
for i in 0..end {
if args[i] == "--profile" && i + 1 < end {
let name = args.remove(i + 1).to_string_lossy().into_owned();
args.remove(i);
return Some(name);
}
if let Some(name) = args[i].to_str().and_then(|s| s.strip_prefix("--profile=")) {
let name = name.to_string();
args.remove(i);
return Some(name);
}
}
None
}
fn find<'a>(profiles: &'a BTreeMap<String, Profile>, name: &str) -> Result<&'a Profile, String> {
profiles.get(name).ok_or_else(|| {
format!(
"no command or profile named `{name}`; profiles: {}",
profiles.keys().cloned().collect::<Vec<_>>().join(", ")
)
})
}
/// Where the command word is: after the program and any global `--root`.
fn command_at(argv: &[OsString]) -> usize {
let mut i = 1;
while i < argv.len() {
if argv[i] == "--root" {
i += 2;
} else if argv[i].to_str().is_some_and(|s| s.starts_with("--root=")) {
i += 1;
} else {
break;
}
}
i
}
/// The command line with any profile it names spelled out as flags: `amem <name> ...`
/// is `amem launch <flags> ...`, and `launch` and `work` take `--profile <name>`, `work`
/// its name as a word too. `commands` are the words that are commands, not profiles.
///
/// # Errors
///
/// When a profile is named that is not one, or the file does not parse.
pub fn expand(
argv: Vec<OsString>,
commands: &[String],
takes_value: &dyn Fn(&str, &str) -> bool,
profiles: impl FnOnce() -> Result<BTreeMap<String, Profile>, String>,
) -> Result<Vec<OsString>, String> {
let at = command_at(&argv);
let Some(word) = argv.get(at).and_then(|w| w.to_str()).map(str::to_string) else {
return Ok(argv);
};
if word.starts_with('-') {
return Ok(argv);
}
let (head, rest) = argv.split_at(at + 1);
let mut rest = rest.to_vec();
if !commands.contains(&word) {
// a word that is no command is a profile, launched
let all = profiles()?;
let p = find(&all, &word)?;
let mut out = head[..at].to_vec();
out.push("launch".into());
out.extend(with_profile(p, &rest, &|f| takes_value("launch", f)));
return Ok(out);
}
if word != "launch" && word != "work" {
return Ok(argv);
}
let named = take_profile_flag(&mut rest).or_else(|| {
// `work nightly`: work takes no word of its own, so a first word is a profile
(word == "work"
&& rest
.first()
.is_some_and(|a| !a.to_string_lossy().starts_with('-')))
.then(|| rest.remove(0).to_string_lossy().into_owned())
});
let Some(name) = named else {
return Ok(argv);
};
let all = profiles()?;
let p = find(&all, &name)?;
let mut out = head.to_vec();
out.extend(with_profile(p, &rest, &|f| takes_value(&word, f)));
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
fn os(v: &[&str]) -> Vec<OsString> {
v.iter().map(OsString::from).collect()
}
fn strs(v: &[OsString]) -> Vec<String> {
v.iter().map(|a| a.to_string_lossy().into_owned()).collect()
}
/// As clap has it for launch and work: these take no value.
fn takes(_: &str, flag: &str) -> bool {
!matches!(
flag,
"--unconfined" | "--loop" | "--no-handoff" | "--mine" | "-h" | "--help"
)
}
fn commands() -> Vec<String> {
["launch", "work", "web", "doctor", "init", "profiles"]
.iter()
.map(|s| (*s).to_string())
.collect()
}
fn sample() -> BTreeMap<String, Profile> {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join(FILE),
"[nightly]\nharness = \"codex\"\nauth = \"gateway\"\nmodel = \"m1\"\nsubagents = \"board\"\n",
)
.unwrap();
load(dir.path(), &commands()).unwrap()
}
#[test]
fn a_harness_is_a_profile_and_the_file_adds_more() {
let all = sample();
assert_eq!(all["claude"].harness, "claude");
assert_eq!(all["claude"].auth, None);
assert_eq!(all["nightly"].model.as_deref(), Some("m1"));
assert!(all.contains_key("pi"));
}
#[test]
fn a_profile_name_is_launched_and_what_is_typed_wins() {
let out = expand(
os(&["amem", "nightly", "--model", "m2", "--", "--resume"]),
&commands(),
&takes,
|| Ok(sample()),
)
.unwrap();
assert_eq!(
strs(&out),
[
"amem",
"launch",
"--harness",
"codex",
"--auth",
"gateway",
"--subagents",
"board",
"--model",
"m2",
"--",
"--resume"
]
);
let out = expand(
os(&["amem", "--root", "/r", "claude"]),
&commands(),
&takes,
|| Ok(sample()),
)
.unwrap();
assert_eq!(
strs(&out),
["amem", "--root", "/r", "launch", "--harness", "claude"]
);
}
#[test]
fn the_typed_flag_is_found_as_written_and_the_harness_words_are_not_flags() {
let run =
|argv: &[&str]| strs(&expand(os(argv), &commands(), &takes, || Ok(sample())).unwrap());
// `--model=m2` is the flag too
assert!(!run(&["amem", "nightly", "--model=m2"]).contains(&"m1".to_string()));
// after `--`, or the first word that is not a flag, the words are the harness's
for argv in [
&["amem", "nightly", "--", "--model", "x"][..],
&["amem", "nightly", "--unconfined", "fix", "--model", "x"],
] {
let out = run(argv);
assert!(out.windows(2).any(|w| w == ["--model", "m1"]), "{out:?}");
}
// a flag that takes a value consumes it: `--prompt --model` is a prompt
let out = run(&["amem", "nightly", "--prompt", "--model"]);
assert!(out.windows(2).any(|w| w == ["--model", "m1"]), "{out:?}");
}
#[test]
fn launch_and_work_take_a_profile_and_work_takes_it_as_a_word() {
let out = expand(
os(&["amem", "work", "nightly", "--loop"]),
&commands(),
&takes,
|| Ok(sample()),
)
.unwrap();
assert_eq!(
strs(&out),
[
"amem",
"work",
"--harness",
"codex",
"--auth",
"gateway",
"--model",
"m1",
"--subagents",
"board",
"--loop"
]
);
let out = expand(
os(&["amem", "launch", "--profile=nightly", "--auth", "key"]),
&commands(),
&takes,
|| Ok(sample()),
)
.unwrap();
assert_eq!(
strs(&out),
[
"amem",
"launch",
"--harness",
"codex",
"--model",
"m1",
"--subagents",
"board",
"--auth",
"key"
]
);
}
#[test]
fn commands_and_flags_pass_through_untouched_and_the_file_is_not_read() {
for argv in [
&["amem", "doctor"][..],
&["amem"],
&["amem", "--no-open"],
&["amem", "launch", "--harness", "claude"],
] {
let out = expand(os(argv), &commands(), &takes, || Err("read".into())).unwrap();
assert_eq!(strs(&out), argv);
}
}
#[test]
fn an_unknown_name_says_what_there_is() {
let e = expand(os(&["amem", "nope"]), &commands(), &takes, || Ok(sample())).unwrap_err();
assert!(
e.contains("no command or profile named `nope`") && e.contains("nightly"),
"{e}"
);
}
#[test]
fn a_bad_file_or_name_is_an_error() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join(FILE), "[Bad]\nharness = \"codex\"\n").unwrap();
assert!(load(dir.path(), &commands())
.unwrap_err()
.contains("not a profile name"));
std::fs::write(
dir.path().join(FILE),
"[ok]\nharness = \"codex\"\ncolour = \"red\"\n",
)
.unwrap();
assert!(load(dir.path(), &commands())
.unwrap_err()
.contains("colour"));
assert!(
valid_name("night-1") && !valid_name("../x") && !valid_name("1x") && !valid_name("")
);
}
}