use std::collections::HashMap;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result, bail};
use serde::{Deserialize, Serialize};
use crate::paths;
#[derive(Debug, Clone, Copy)]
pub struct AgentKind {
pub name: &'static str,
pub command: &'static str,
pub home_env: &'static str,
pub default_home: &'static str,
pub login: &'static [&'static str],
pub login_status: &'static [&'static str],
pub resume: &'static [&'static str],
pub session_flag: Option<&'static str>,
pub shared: &'static [&'static str],
pub copied: &'static [&'static str],
pub clear_env: &'static [&'static str],
}
pub const AGENTS: &[AgentKind] = &[
AgentKind {
name: "claude",
command: "claude",
home_env: "CLAUDE_CONFIG_DIR",
default_home: "~/.claude",
login: &["auth", "login"],
login_status: &["auth", "status"],
resume: &["--resume", "{session}"],
session_flag: Some("--session-id"),
shared: &[
"CLAUDE.md",
"settings.json",
"skills",
"agents",
"commands",
"output-styles",
"plugins",
"projects",
"plans",
],
copied: &[],
clear_env: &["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN"],
},
AgentKind {
name: "codex",
command: "codex",
home_env: "CODEX_HOME",
default_home: "~/.codex",
login: &["login"],
login_status: &["login", "status"],
resume: &["resume", "{session}"],
session_flag: None,
shared: &[],
copied: &["config.toml"],
clear_env: &["OPENAI_API_KEY", "CODEX_API_KEY"],
},
];
pub fn agent_kind(name: &str) -> Option<&'static AgentKind> {
AGENTS.iter().find(|a| a.name == name)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Mode {
#[default]
Notify,
Ask,
Auto,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Limits {
#[serde(default = "default_threshold")]
pub threshold: u8,
#[serde(default)]
pub mode: Mode,
}
fn default_threshold() -> u8 {
90
}
impl Default for Limits {
fn default() -> Self {
Limits { threshold: default_threshold(), mode: Mode::Notify }
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Account {
pub name: String,
pub agent: String,
pub home: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub threshold: Option<u8>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub note: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Rule {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workspace: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent: Option<String>,
pub accounts: Vec<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ClaudeOptions {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub statusline: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
struct File {
#[serde(default)]
limits: Limits,
#[serde(default)]
claude: ClaudeOptions,
#[serde(default, rename = "account")]
accounts: Vec<Account>,
#[serde(default, rename = "rule")]
rules: Vec<Rule>,
}
const HEADER: &str = "# kraal agent accounts. One [[account]] per subscription: a name, the agent (claude, codex)\n\
# and its config home. [[rule]] picks accounts by workspace or directory, in order; [limits] sets\n\
# the usage threshold (percent) and what happens when an account reaches it: notify, ask or auto.\n\n";
#[derive(Debug, Clone)]
pub struct Accounts {
pub limits: Limits,
pub claude: ClaudeOptions,
pub accounts: Vec<Account>,
pub rules: Vec<Rule>,
path: PathBuf,
}
impl Accounts {
pub fn path() -> PathBuf {
paths::config_dir().join("accounts.toml")
}
pub fn load() -> Result<Accounts> {
Self::load_from(&Self::path())
}
pub fn load_from(path: &Path) -> Result<Accounts> {
let file: File = match std::fs::read_to_string(path) {
Ok(text) => toml::from_str(&text).with_context(|| format!("parsing {}", path.display()))?,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => File::default(),
Err(e) => return Err(e).with_context(|| format!("reading {}", path.display())),
};
Ok(Accounts {
limits: file.limits,
claude: file.claude,
accounts: file.accounts,
rules: file.rules,
path: path.to_path_buf(),
})
}
pub fn empty() -> Accounts {
Accounts {
limits: Limits::default(),
claude: ClaudeOptions::default(),
accounts: Vec::new(),
rules: Vec::new(),
path: Self::path(),
}
}
fn text(&self) -> Result<String> {
let file = File {
limits: self.limits.clone(),
claude: self.claude.clone(),
accounts: self.accounts.clone(),
rules: self.rules.clone(),
};
Ok(format!("{HEADER}{}", toml::to_string_pretty(&file)?))
}
pub fn save(&self) -> Result<()> {
if let Some(dir) = self.path.parent() {
std::fs::create_dir_all(dir)?;
}
paths::write_atomic(&self.path, self.text()?.as_bytes())
.with_context(|| format!("writing {}", self.path.display()))
}
pub fn update<T>(f: impl FnOnce(&mut Accounts) -> Result<T>) -> Result<T> {
Self::update_at(&Self::path(), f)
}
pub fn update_at<T>(path: &Path, f: impl FnOnce(&mut Accounts) -> Result<T>) -> Result<T> {
paths::locked(path, || {
let mut all = Self::load_from(path)?;
let before = all.text()?;
let out = f(&mut all)?;
if all.text()? != before {
all.save()?;
}
Ok(out)
})?
}
pub fn for_agent(&self, agent: &str) -> Vec<Account> {
let mut out: Vec<Account> = self.accounts.iter().filter(|a| a.agent == agent).cloned().collect();
if let Some(kind) = agent_kind(agent) {
let default = paths::expand_tilde(kind.default_home);
if !out.iter().any(|a| paths::expand_tilde(&a.home) == default || a.name == "default") {
out.insert(
0,
Account {
name: "default".into(),
agent: agent.into(),
home: kind.default_home.into(),
threshold: None,
note: None,
},
);
}
}
out
}
pub fn all(&self) -> Vec<Account> {
AGENTS.iter().flat_map(|k| self.for_agent(k.name)).collect()
}
pub fn get(&self, agent: &str, name: &str) -> Option<Account> {
self.for_agent(agent).into_iter().find(|a| a.name == name)
}
pub fn by_home(&self, agent: &str, home: &Path) -> Option<Account> {
let home = std::fs::canonicalize(home).unwrap_or_else(|_| home.to_path_buf());
self.for_agent(agent).into_iter().find(|a| {
let h = paths::expand_tilde(&a.home);
std::fs::canonicalize(&h).unwrap_or(h) == home
})
}
pub fn threshold(&self, a: &Account) -> u8 {
a.threshold.unwrap_or(self.limits.threshold)
}
pub fn add(&mut self, mut account: Account) -> Result<()> {
let kind = agent_kind(&account.agent)
.with_context(|| format!("unknown agent {:?} (known: {})", account.agent, agent_names()))?;
account.home = absolute_home(&account.home)?;
if !valid_name(&account.name) {
bail!("invalid account name {:?} (letters, digits, - _ . only)", account.name);
}
if account.name == "default" && paths::expand_tilde(&account.home) != paths::expand_tilde(kind.default_home) {
bail!("\"default\" is the account in {}; give this one another name", kind.default_home);
}
if self.for_agent(kind.name).iter().any(|a| a.name == account.name && a.name != "default") {
bail!("{} already has an account named {:?}", kind.name, account.name);
}
self.accounts.retain(|a| !(a.agent == account.agent && a.name == account.name));
self.accounts.push(account);
Ok(())
}
pub fn remove(&mut self, agent: &str, name: &str) -> bool {
let before = self.accounts.len();
self.accounts.retain(|a| !(a.agent == agent && a.name == name));
let shared = self.all().iter().any(|a| a.name == name);
let mine: Vec<String> = self.for_agent(agent).into_iter().map(|a| a.name).collect();
let mut refusals = Vec::new();
for (i, r) in self.rules.iter_mut().enumerate() {
if r.agent.as_deref() == Some(agent) || (r.agent.is_none() && !shared) {
r.accounts.retain(|n| n != name);
} else if r.agent.is_none()
&& r.accounts.iter().any(|n| n == name)
&& !r.accounts.iter().any(|n| mine.contains(n))
{
refusals.push((i, Rule { agent: Some(agent.to_string()), accounts: Vec::new(), ..r.clone() }));
}
}
for (i, rule) in refusals.into_iter().rev() {
self.rules.insert(i, rule);
}
before != self.accounts.len()
}
pub fn candidates(&self, agent: &str, workspace: Option<&str>, cwd: Option<&Path>) -> Vec<Account> {
self.rule_accounts(agent, workspace, cwd).unwrap_or_else(|| self.for_agent(agent))
}
pub fn rule_accounts(&self, agent: &str, workspace: Option<&str>, cwd: Option<&Path>) -> Option<Vec<Account>> {
let all = self.for_agent(agent);
let everyone = self.all();
for rule in &self.rules {
if rule.agent.as_deref().is_some_and(|a| a != agent) {
continue;
}
let ws_ok = rule.workspace.as_deref().is_none_or(|w| Some(w) == workspace);
let path_ok = rule.path.as_deref().is_none_or(|p| {
let p = paths::expand_tilde(p);
cwd.is_some_and(|c| {
c.starts_with(&p) || crate::git::known_project_path(c).is_some_and(|k| k.starts_with(&p))
})
});
if rule.workspace.is_none() && rule.path.is_none() {
continue;
}
if ws_ok && path_ok {
let picked: Vec<Account> =
rule.accounts.iter().filter_map(|n| all.iter().find(|a| &a.name == n).cloned()).collect();
let dangling = !rule.accounts.iter().any(|n| everyone.iter().any(|a| &a.name == n));
if !picked.is_empty() || rule.agent.is_some() || dangling {
return Some(picked);
}
}
}
None
}
pub fn undecided(&self, agent: &str, cwd: Option<&Path>) -> bool {
let Some(c) = cwd else { return false };
if crate::git::known_project_path(c).is_some() {
return false;
}
self.rules.iter().any(|r| {
r.agent.as_deref().is_none_or(|a| a == agent)
&& r.path.as_deref().is_some_and(|p| !c.starts_with(paths::expand_tilde(p)))
})
}
pub fn choose(
&self,
agent: &str,
workspace: Option<&str>,
cwd: Option<&Path>,
usage: &UsageMap,
now: i64,
) -> Option<Account> {
let candidates = self.candidates(agent, workspace, cwd);
if let Some(a) = candidates.iter().find(|a| usage.peak(a, now) < self.threshold(a) as f64) {
return Some(a.clone());
}
candidates.into_iter().min_by_key(|a| usage.get(a).and_then(|u| u.next_reset(now)).unwrap_or(i64::MAX))
}
pub fn alternative(
&self,
current: &Account,
workspace: Option<&str>,
cwd: Option<&Path>,
usage: &UsageMap,
now: i64,
) -> Option<Account> {
self.candidates(¤t.agent, workspace, cwd)
.into_iter()
.find(|a| a.name != current.name && usage.peak(a, now) < self.threshold(a) as f64)
}
}
pub fn agent_names() -> String {
AGENTS.iter().map(|a| a.name).collect::<Vec<_>>().join(", ")
}
fn valid_name(name: &str) -> bool {
!name.is_empty() && name.chars().all(|c| c.is_alphanumeric() || matches!(c, '-' | '_' | '.'))
}
pub fn label(a: &Account) -> String {
format!("{}·{}", a.agent, a.name)
}
pub fn key(agent: &str, name: &str) -> String {
format!("{agent}:{name}")
}
pub fn absolute_home(home: &str) -> Result<String> {
if !paths::expand_tilde(home).is_relative() {
return Ok(home.to_string());
}
Ok(std::path::absolute(home).with_context(|| format!("locating {home}"))?.display().to_string())
}
pub fn default_home_for(agent: &str, name: &str) -> PathBuf {
paths::data_home().join("kraal/accounts").join(format!("{agent}-{name}"))
}
pub fn prepare_home(a: &Account) -> Result<Vec<String>> {
let kind = agent_kind(&a.agent).context("unknown agent")?;
let home = paths::expand_tilde(&a.home);
let default = paths::expand_tilde(kind.default_home);
let mut notes = Vec::new();
if home == default {
return Ok(notes);
}
std::fs::create_dir_all(&home).with_context(|| format!("creating {}", home.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&home, std::fs::Permissions::from_mode(0o700));
}
for entry in kind.shared {
let src = default.join(entry);
let dst = home.join(entry);
if !entry.contains('.') && !src.exists() {
std::fs::create_dir_all(&src).with_context(|| format!("creating {}", src.display()))?;
}
if !src.exists() || dst.symlink_metadata().is_ok() {
continue;
}
#[cfg(unix)]
std::os::unix::fs::symlink(&src, &dst).with_context(|| format!("linking {}", dst.display()))?;
notes.push(format!("shared {entry}"));
}
for entry in kind.copied {
let src = default.join(entry);
let dst = home.join(entry);
if src.is_file() && !dst.exists() {
std::fs::copy(&src, &dst).with_context(|| format!("copying {}", src.display()))?;
notes.push(format!("copied {entry}"));
}
}
Ok(notes)
}
#[derive(Debug, Clone, PartialEq, Default)]
pub struct Launch {
pub argv: Vec<String>,
pub env: Vec<(String, String)>,
pub env_remove: Vec<String>,
}
pub fn launch(a: &Account, args: &[String], resume: Option<&str>, new_session: Option<&str>) -> Result<Launch> {
let kind = agent_kind(&a.agent).context("unknown agent")?;
let mut l = Launch { argv: vec![kind.command.to_string()], ..Default::default() };
let home = paths::expand_tilde(&a.home);
if home != paths::expand_tilde(kind.default_home) {
l.env.push((kind.home_env.to_string(), home.display().to_string()));
} else {
l.env_remove.push(kind.home_env.to_string());
}
l.env.push(("KRAAL_ACCOUNT".into(), key(&a.agent, &a.name)));
l.env_remove.extend(kind.clear_env.iter().map(|s| s.to_string()));
match (resume, new_session, kind.session_flag) {
(Some(id), _, _) => l.argv.extend(kind.resume.iter().map(|s| s.replace("{session}", id))),
(None, Some(id), Some(flag)) => l.argv.extend([flag.to_string(), id.to_string()]),
_ => {}
}
l.argv.extend(args.iter().cloned());
Ok(l)
}
pub fn launch_args(kind: &AgentKind, argv: &[String]) -> Vec<String> {
let rest = argv.get(1..).unwrap_or_default();
let resumes = rest.len() >= kind.resume.len()
&& kind.resume.iter().zip(rest).all(|(t, a)| t.contains("{session}") || *t == a.as_str());
let skip = if resumes {
kind.resume.len()
} else if kind.session_flag.is_some_and(|f| rest.first().is_some_and(|a| a == f)) {
2
} else {
0
};
rest.get(skip..).unwrap_or_default().to_vec()
}
const CLAUDE_VALUE: &[&str] = &[
"--model",
"--fallback-model",
"--permission-mode",
"--permission-prompts",
"--append-system-prompt",
"--system-prompt",
"--system-prompt-snapshot",
"--settings",
"--setting-sources",
"--output-format",
"--input-format",
"--agents",
"--agent",
"--autocompact",
"--environment",
"--max-turns",
"--permission-prompt-tool",
"--session-id",
"--effort",
"--json-schema",
"--max-budget-usd",
"--debug-file",
"--plugin-dir",
"--plugin-url",
"--remote",
"--remote-control-session-name-prefix",
"-n",
"--name",
];
const CLAUDE_FLAGS: &[&str] = &[
"--verbose",
"--dangerously-skip-permissions",
"--allow-dangerously-skip-permissions",
"--ax-screen-reader",
"--bare",
"--bg",
"--background",
"--brief",
"--chrome",
"--no-chrome",
"--desktop",
"--exclude-dynamic-system-prompt-sections",
"--forward-subagent-text",
"--ide",
"--include-hook-events",
"--strict-mcp-config",
"--include-partial-messages",
"--replay-user-messages",
"--restricted",
"--safe-mode",
"--tmux",
"--fork-session",
"--mcp-debug",
"--continue",
"-c",
"--no-session-persistence",
"--disable-slash-commands",
];
const CODEX_FLAGS: &[&str] = &[
"--oss",
"--search",
"--full-auto",
"--approve-for-me",
"--dangerously-bypass-approvals-and-sandbox",
"--dangerously-bypass-hook-trust",
"--yolo",
"--skip-git-repo-check",
"--strict-config",
"--worktree",
"--no-alt-screen",
"--no-daemon",
];
const CLAUDE_VALUES: &[&str] = &[
"--add-dir",
"--mcp-config",
"--allowedTools",
"--allowed-tools",
"--disallowedTools",
"--disallowed-tools",
"--betas",
"--file",
"--tools",
];
const CODEX_VALUE: &[&str] = &[
"-m",
"--model",
"-p",
"--profile",
"-c",
"--config",
"-s",
"--sandbox",
"-a",
"--ask-for-approval",
"-C",
"--cd",
"--add-dir",
"--local-provider",
"--enable",
"--disable",
"--remote",
"--remote-auth-token-env",
];
const CODEX_VALUES: &[&str] = &["-i", "--image"];
const CLAUDE_ONE_SHOT: &[&str] = &["-p", "--print", "-h", "--help", "-v", "--version"];
const CODEX_ONE_SHOT: &[&str] = &["-h", "--help", "-V", "--version"];
const CLAUDE_COMMANDS: &[&str] = &[
"agents",
"attach",
"auth",
"auto-mode",
"doctor",
"gateway",
"import",
"install",
"logs",
"mcp",
"plugin",
"plugins",
"purge",
"respawn",
"rm",
"setup-token",
"stop",
"kill",
"ultrareview",
"update",
"upgrade",
];
const CODEX_COMMANDS: &[&str] = &[
"exec",
"e",
"review",
"login",
"logout",
"mcp",
"mcp-server",
"plugin",
"app-server",
"exec-server",
"remote-control",
"completion",
"update",
"doctor",
"sandbox",
"debug",
"apply",
"a",
"queue",
"archive",
"unarchive",
"delete",
"migrate-rollouts",
"agents",
"proto",
"cloud",
"features",
"help",
];
const CODEX_CONVERSATIONS: &[&str] = &["resume", "fork"];
const CODEX_CHOOSERS: &[&str] = &["--last", "--all"];
pub fn resumable_args(kind: &AgentKind, args: &[String]) -> Option<(Vec<String>, bool)> {
type Table = &'static [&'static str];
let (one_shot, single, several, flags, commands, conversations): (Table, Table, Table, Table, Table, Table) =
match kind.name {
"claude" => (CLAUDE_ONE_SHOT, CLAUDE_VALUE, CLAUDE_VALUES, CLAUDE_FLAGS, CLAUDE_COMMANDS, &[]),
"codex" => (CODEX_ONE_SHOT, CODEX_VALUE, CODEX_VALUES, CODEX_FLAGS, CODEX_COMMANDS, CODEX_CONVERSATIONS),
_ => return None,
};
let options = &args[..args.iter().position(|a| a == "--").unwrap_or(args.len())];
if options.iter().any(|a| one_shot.contains(&a.split('=').next().unwrap_or(a))) {
return None;
}
let mut kept = Vec::new();
let mut exact = true;
let mut positional = false;
let mut conversation: Option<(usize, bool)> = None;
let mut i = 0;
while i < args.len() {
let a = &args[i];
if a == "--" {
exact &= i + 1 == args.len();
break;
}
if a.starts_with('-') && a.len() > 1 {
let (name, attached) = match a.split_once('=') {
Some((n, _)) => (n, true),
None => (a.as_str(), false),
};
i += 1;
if let Some((_, last)) = conversation.as_mut()
&& CODEX_CHOOSERS.contains(&name)
{
*last |= name == "--last";
continue;
}
kept.push(a.clone());
if flags.contains(&name) || (attached && single.contains(&name)) {
continue;
}
if single.contains(&name) {
kept.extend(args.get(i).cloned());
i += 1;
} else if several.contains(&name) {
while let Some(v) = args.get(i).filter(|v| !v.starts_with('-')) {
kept.push(v.clone());
i += 1;
}
} else if args.get(i).is_some_and(|v| !v.starts_with('-')) {
kept.extend(args[i..].iter().cloned());
exact = false;
break;
}
} else {
i += 1;
match conversation.as_mut() {
Some((words, last)) => {
if *words > 0 || *last {
exact = false;
}
*words += 1;
}
None if !positional && conversations.contains(&a.as_str()) => conversation = Some((0, false)),
None if !positional && commands.contains(&a.as_str()) => return None,
None => {
positional = true;
exact = false;
}
}
}
}
Some((kept, exact))
}
pub fn resume_argv(kind: &AgentKind, argv: &[String], session: &str) -> Option<(Vec<String>, bool)> {
let safe = !session.is_empty() && session.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_');
let program = argv.first()?;
if !safe || program.rsplit('/').next() != Some(kind.command) {
return None;
}
let mut rest: Vec<String> = Vec::new();
let args = argv[1..].iter();
match kind.name {
"claude" => {
let mut args = args.peekable();
while let Some(a) = args.next() {
match a.as_str() {
"--session-id" => {
args.next();
}
"--resume" | "-r" => {
args.next_if(|v| !v.starts_with('-'));
}
"--continue" | "-c" => {}
_ if a.starts_with("--resume=") || a.starts_with("--session-id=") => {}
_ => rest.push(a.clone()),
}
}
}
"codex" => rest.extend(args.cloned()),
_ => return None,
}
let (kept, exact) = resumable_args(kind, &rest)?;
let mut out = vec![program.clone()];
out.extend(kind.resume.iter().map(|t| t.replace("{session}", session)));
out.extend(kept);
Some((out, exact))
}
pub fn conversation_exists(a: &Account, session: &str) -> Result<Option<bool>> {
if a.agent != "claude" || !session.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') {
return Ok(None);
}
Ok(Some(transcript(a, session)?.is_some()))
}
fn transcript(a: &Account, session: &str) -> Result<Option<PathBuf>> {
let file = format!("{session}.jsonl");
let projects = paths::expand_tilde(&a.home).join("projects");
let dirs = match std::fs::read_dir(&projects) {
Ok(d) => d,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(e).with_context(|| format!("reading {}", projects.display())),
};
for dir in dirs {
let path = dir.with_context(|| format!("reading {}", projects.display()))?.path().join(&file);
match std::fs::metadata(&path) {
Ok(m) if m.is_file() => return Ok(Some(path)),
Ok(_) => {}
Err(e) if matches!(e.raw_os_error(), Some(libc::ENOENT) | Some(libc::ENOTDIR)) => {}
Err(e) => return Err(e).with_context(|| format!("reading {}", path.display())),
}
}
Ok(None)
}
fn is_prefix(short: &Path, long: &Path) -> std::io::Result<bool> {
use std::io::Read;
let (mut a, mut b) = (std::fs::File::open(short)?, std::fs::File::open(long)?);
if a.metadata()?.len() > b.metadata()?.len() {
return Ok(false);
}
let (mut x, mut y) = (vec![0u8; 64 << 10], vec![0u8; 64 << 10]);
loop {
let n = a.read(&mut x)?;
if n == 0 {
return Ok(true);
}
b.read_exact(&mut y[..n])?;
if x[..n] != y[..n] {
return Ok(false);
}
}
}
enum Carry {
Nothing,
There,
Copy(PathBuf, PathBuf),
}
fn carry_plan(from: &Account, to: &Account, session: &str) -> Result<Carry> {
if from.agent != "claude" || to.agent != "claude" || conversation_exists(to, session)?.is_none() {
return Ok(Carry::Nothing);
}
let (src, dst) = (transcript(from, session)?, transcript(to, session)?);
let Some(src) = src else {
return Ok(if dst.is_some() { Carry::There } else { Carry::Nothing });
};
let Some(dst) = dst else {
let project = src.parent().and_then(|d| d.file_name()).context("an odd transcript path")?;
let dst = paths::expand_tilde(&to.home).join("projects").join(project).join(src.file_name().unwrap());
return Ok(Carry::Copy(src, dst));
};
let same = match (std::fs::canonicalize(&src), std::fs::canonicalize(&dst)) {
(Ok(a), Ok(b)) => a == b,
_ => false,
};
if same || is_prefix(&src, &dst)? {
return Ok(Carry::There);
}
if is_prefix(&dst, &src)? {
return Ok(Carry::Copy(src, dst));
}
bail!(
"{} and {} each went on with this conversation on their own: continuing on one would lose \
what was said on the other",
label(from),
label(to)
)
}
pub fn check_carry(from: &Account, to: &Account, session: &str) -> Result<()> {
carry_plan(from, to, session).map(|_| ())
}
pub fn carry_conversation(from: &Account, to: &Account, session: &str) -> Result<bool> {
let (src, dst) = match carry_plan(from, to, session)? {
Carry::Nothing => return Ok(false),
Carry::There => return Ok(true),
Carry::Copy(src, dst) => (src, dst),
};
let dir = dst.parent().context("an odd transcript path")?;
std::fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?;
let data = std::fs::read(&src).with_context(|| format!("reading {}", src.display()))?;
paths::write_private(&dst, &data).with_context(|| format!("copying the conversation to {}", dst.display()))?;
Ok(true)
}
pub fn login(a: &Account) -> Result<Launch> {
let kind = agent_kind(&a.agent).context("unknown agent")?;
agent_command(a, kind.login)
}
pub fn login_status(a: &Account) -> Result<Launch> {
let kind = agent_kind(&a.agent).context("unknown agent")?;
agent_command(a, kind.login_status)
}
fn agent_command(a: &Account, args: &[&str]) -> Result<Launch> {
let kind = agent_kind(&a.agent).context("unknown agent")?;
let mut l = launch(a, &[], None, None)?;
l.argv = std::iter::once(kind.command.to_string()).chain(args.iter().map(|s| s.to_string())).collect();
Ok(l)
}
pub fn new_uuid() -> String {
let mut b = [0u8; 16];
if let Ok(mut f) = std::fs::File::open("/dev/urandom") {
use std::io::Read;
let _ = f.read_exact(&mut b);
} else {
let t = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_nanos();
b.copy_from_slice(&t.to_le_bytes());
}
b[6] = (b[6] & 0x0f) | 0x40;
b[8] = (b[8] & 0x3f) | 0x80;
let h: String = b.iter().map(|x| format!("{x:02x}")).collect();
format!("{}-{}-{}-{}-{}", &h[0..8], &h[8..12], &h[12..16], &h[16..20], &h[20..32])
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Window {
pub used_percent: f64,
pub resets_at: Option<i64>,
}
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
pub struct Usage {
pub five_hour: Option<Window>,
pub weekly: Option<Window>,
pub observed_at: i64,
pub source: String,
}
impl Usage {
fn windows(&self) -> impl Iterator<Item = &Window> {
self.five_hour.iter().chain(self.weekly.iter())
}
pub fn peak(&self, now: i64) -> f64 {
let live = |w: &Window, period: i64| w.resets_at.unwrap_or(self.observed_at + period) > now;
let five = self.five_hour.iter().filter(|w| live(w, 5 * 3600));
let week = self.weekly.iter().filter(|w| live(w, 7 * 24 * 3600));
five.chain(week).map(|w| w.used_percent).fold(0.0, f64::max)
}
pub fn observe_screen(&mut self, weekly: bool, pct: f64, now: i64) {
let w = if weekly { &mut self.weekly } else { &mut self.five_hour };
let current = w.as_ref().filter(|w| w.resets_at.is_none_or(|r| r > now));
let period = if weekly { 7 * 24 * 3600 } else { 5 * 3600 };
let resets_at = current.and_then(|w| w.resets_at).unwrap_or(now + period);
*w = Some(Window { used_percent: pct, resets_at: Some(resets_at) });
self.observed_at = now;
self.source = "screen".into();
}
pub fn next_reset(&self, now: i64) -> Option<i64> {
self.windows()
.filter(|w| w.resets_at.is_some_and(|r| r > now))
.max_by(|a, b| a.used_percent.total_cmp(&b.used_percent))
.and_then(|w| w.resets_at)
}
}
pub fn usage_path(agent: &str, name: &str) -> PathBuf {
paths::state_dir().join("usage").join(format!("{agent}-{name}.json"))
}
pub fn save_usage(agent: &str, name: &str, u: &Usage) -> Result<()> {
let path = usage_path(agent, name);
if let Some(d) = path.parent() {
paths::ensure_private_dir(d)?;
}
paths::write_private(&path, &serde_json::to_vec(u)?)?;
Ok(())
}
pub fn load_usage(agent: &str, name: &str) -> Option<Usage> {
serde_json::from_slice(&std::fs::read(usage_path(agent, name)).ok()?).ok()
}
#[derive(Debug, Clone, Default)]
pub struct UsageMap(pub HashMap<String, Usage>);
impl UsageMap {
pub fn load(accounts: &[Account]) -> UsageMap {
UsageMap(
accounts
.iter()
.filter_map(|a| load_usage(&a.agent, &a.name).map(|u| (key(&a.agent, &a.name), u)))
.collect(),
)
}
pub fn get(&self, a: &Account) -> Option<&Usage> {
self.0.get(&key(&a.agent, &a.name))
}
pub fn peak(&self, a: &Account, now: i64) -> f64 {
self.get(a).map(|u| u.peak(now)).unwrap_or(0.0)
}
}
pub fn now() -> i64 {
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs() as i64).unwrap_or(0)
}
pub fn usage_from_statusline(v: &serde_json::Value) -> Option<Usage> {
let rl = v.get("rate_limits")?;
let window = |name: &str| -> Option<Window> {
let w = rl.get(name)?;
Some(Window {
used_percent: w.get("used_percentage")?.as_f64()?,
resets_at: w.get("resets_at").and_then(|r| r.as_i64().or_else(|| r.as_f64().map(|f| f as i64))),
})
};
let u = Usage {
five_hour: window("five_hour"),
weekly: window("seven_day"),
observed_at: now(),
source: "statusline".into(),
};
(u.five_hour.is_some() || u.weekly.is_some()).then_some(u)
}
pub fn limit_from_screen(screen: &str) -> Option<(bool, f64)> {
let tail: Vec<&str> = screen.lines().rev().filter(|l| !l.trim().is_empty()).take(15).collect();
for line in tail {
let l = line.to_lowercase();
let weekly = l.contains("weekly limit");
let session = l.contains("session limit") || l.contains("5-hour limit") || l.contains("usage limit");
if !(weekly || session) {
continue;
}
if l.contains("hit your") || l.contains("limit reached") || l.contains("reached your") {
return Some((weekly, 100.0));
}
if let Some(i) = l.find("used ")
&& let Some(p) = l[i + 5..].split('%').next()
&& let Ok(n) = p.trim().parse::<f64>()
{
return Some((weekly, n));
}
}
None
}
pub fn describe_reset(resets_at: Option<i64>, now: i64) -> String {
let Some(r) = resets_at else { return String::new() };
let secs = (r - now).max(0);
if secs >= 86_400 {
format!("resets in {}d{}h", secs / 86_400, (secs % 86_400) / 3600)
} else if secs >= 3600 {
format!("resets in {}h{:02}", secs / 3600, (secs % 3600) / 60)
} else {
format!("resets in {}m", secs / 60)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn agents_started_directly_resume_with_their_arguments() {
let claude = agent_kind("claude").unwrap();
let argv = |s: &str| s.split(' ').map(String::from).collect::<Vec<_>>();
assert_eq!(
resume_argv(claude, &argv("/usr/bin/claude --model opus -c"), "abc").unwrap(),
(argv("/usr/bin/claude --resume abc --model opus"), true)
);
assert_eq!(
resume_argv(claude, &argv("claude --resume old"), "abc").unwrap(),
(argv("claude --resume abc"), true)
);
assert_eq!(
resume_argv(claude, &argv("claude -r --model opus"), "abc").unwrap(),
(argv("claude --resume abc --model opus"), true),
"a picker's -r has no value: the option after it stays"
);
assert!(resume_argv(claude, &argv("claude"), "x; rm -rf /").is_none());
assert!(resume_argv(claude, &argv("vim"), "abc").is_none());
let codex = agent_kind("codex").unwrap();
assert_eq!(resume_argv(codex, &argv("codex -m o3"), "abc").unwrap(), (argv("codex resume abc -m o3"), true));
assert!(resume_argv(codex, &argv("codex exec hi"), "abc").is_none());
assert!(resume_argv(codex, &argv("codex -m o3 exec hi"), "abc").is_none());
assert!(resume_argv(claude, &argv("claude -p deploy"), "abc").is_none());
assert!(resume_argv(claude, &argv("claude --model opus --print deploy"), "abc").is_none());
assert_eq!(
resume_argv(claude, &argv("claude --model opus deploy"), "abc").unwrap(),
(argv("claude --resume abc --model opus"), false)
);
assert_eq!(
resume_argv(claude, &argv("claude --add-dir a b --verbose -- deploy"), "abc").unwrap(),
(argv("claude --resume abc --add-dir a b --verbose"), false)
);
assert_eq!(
resume_argv(codex, &argv("codex -m o3 fix"), "abc").unwrap(),
(argv("codex resume abc -m o3"), false)
);
assert_eq!(
resume_argv(claude, &argv("claude --effort high"), "abc").unwrap(),
(argv("claude --resume abc --effort high"), true)
);
assert_eq!(
resume_argv(codex, &argv("codex --enable web_search"), "abc").unwrap(),
(argv("codex resume abc --enable web_search"), true)
);
assert_eq!(
resume_argv(claude, &argv("claude --verbose deploy"), "abc").unwrap(),
(argv("claude --resume abc --verbose"), false),
"a word after a flag that takes no value is a prompt"
);
assert_eq!(
resume_argv(claude, &argv("claude --new-option x"), "abc").unwrap(),
(argv("claude --resume abc --new-option x"), false)
);
assert_eq!(
resume_argv(claude, &argv("claude --new-option a b --verbose c"), "abc").unwrap(),
(argv("claude --resume abc --new-option a b --verbose c"), false)
);
assert_eq!(
resume_argv(claude, &argv("claude --debug api deploy"), "abc").unwrap(),
(argv("claude --resume abc --debug api deploy"), false)
);
assert!(resume_argv(claude, &argv("claude --debug --verbose"), "abc").unwrap().1);
assert_eq!(
resume_argv(claude, &argv("claude --file file_abc:doc.txt file_def:img.png"), "abc").unwrap(),
(argv("claude --resume abc --file file_abc:doc.txt file_def:img.png"), true)
);
assert_eq!(
resume_argv(claude, &argv("claude --chrome Deploy"), "abc").unwrap(),
(argv("claude --resume abc --chrome"), false)
);
assert!(resume_argv(claude, &argv("claude --help"), "abc").is_none());
assert!(resume_argv(claude, &argv("claude --debug api --print Deploy"), "abc").is_none());
assert!(resume_argv(claude, &argv("claude --brand-new x -p Deploy"), "abc").is_none());
assert!(resume_argv(claude, &argv("claude update"), "abc").is_none());
assert!(resume_argv(claude, &argv("claude mcp list"), "abc").is_none());
assert!(resume_argv(claude, &argv("claude --model opus update"), "abc").is_none());
assert!(resume_argv(claude, &argv("claude --verbose mcp list"), "abc").is_none());
assert_eq!(
resume_argv(claude, &argv("claude --worktree update Deploy"), "abc").unwrap(),
(argv("claude --resume abc --worktree update Deploy"), false)
);
assert_eq!(
resume_argv(claude, &argv("claude -w update Deploy"), "abc").unwrap(),
(argv("claude --resume abc -w update Deploy"), false)
);
assert_eq!(
resume_argv(codex, &argv("codex --brand-new x exec hi"), "abc").unwrap(),
(argv("codex resume abc --brand-new x exec hi"), false)
);
assert_eq!(
resume_argv(claude, &argv("claude --file=file_abc:doc.txt file_def:img.png"), "abc").unwrap(),
(argv("claude --resume abc --file=file_abc:doc.txt file_def:img.png"), true)
);
assert_eq!(
resume_argv(claude, &argv("claude --model=opus Deploy"), "abc").unwrap(),
(argv("claude --resume abc --model=opus"), false)
);
assert_eq!(
resume_argv(claude, &argv("claude --brand-new=a b"), "abc").unwrap(),
(argv("claude --resume abc --brand-new=a b"), false)
);
assert_eq!(
resume_argv(codex, &argv("codex fork parent"), "child").unwrap(),
(argv("codex resume child"), true)
);
assert_eq!(
resume_argv(codex, &argv("codex fork parent Deploy"), "child").unwrap(),
(argv("codex resume child"), false)
);
assert_eq!(
resume_argv(codex, &argv("codex -m o3 fork --last --search Deploy"), "child").unwrap(),
(argv("codex resume child -m o3 --search"), false)
);
assert_eq!(
resume_argv(codex, &argv("codex fork --all -i a.png b.png parent"), "child").unwrap(),
(argv("codex resume child -i a.png b.png parent"), true),
"-i takes every word after it, as Codex's own parser does"
);
assert_eq!(
resume_argv(codex, &argv("codex resume old -m o3"), "abc").unwrap(),
(argv("codex resume abc -m o3"), true)
);
assert_eq!(resume_argv(codex, &argv("codex resume --last"), "abc").unwrap(), (argv("codex resume abc"), true));
assert_eq!(
resumable_args(codex, &argv("fork parent Deploy")).unwrap(),
(Vec::new(), false),
"started from an account, the same"
);
}
#[test]
fn conversations_are_found_by_their_transcript() {
let home = std::env::temp_dir().join(format!("kraal-conv-{}", std::process::id()));
let a = Account {
name: "t".into(),
agent: "claude".into(),
home: home.display().to_string(),
threshold: None,
note: None,
};
assert_eq!(conversation_exists(&a, "abc").unwrap(), Some(false));
std::fs::create_dir_all(home.join("projects/-home-me")).unwrap();
std::fs::write(home.join("projects/-home-me/abc.jsonl"), "").unwrap();
assert_eq!(conversation_exists(&a, "abc").unwrap(), Some(true));
assert_eq!(conversation_exists(&a, "other").unwrap(), Some(false));
use std::os::unix::fs::PermissionsExt;
let projects = home.join("projects");
std::fs::set_permissions(&projects, std::fs::Permissions::from_mode(0o000)).unwrap();
let unreadable = std::fs::read_dir(&projects).is_err(); if unreadable {
assert!(conversation_exists(&a, "abc").is_err());
}
std::fs::set_permissions(&projects, std::fs::Permissions::from_mode(0o755)).unwrap();
let _ = std::fs::remove_dir_all(home);
}
#[test]
fn login_status_commands() {
let a = Accounts::empty();
let argv = |agent: &str| login_status(&a.get(agent, "default").unwrap()).unwrap().argv;
assert_eq!(argv("claude"), vec!["claude", "auth", "status"]);
assert_eq!(argv("codex"), vec!["codex", "login", "status"]);
}
#[test]
fn launch_args_are_what_the_user_gave() {
let claude = agent_kind("claude").unwrap();
let argv = |s: &str| s.split(' ').map(String::from).collect::<Vec<_>>();
assert_eq!(launch_args(claude, &argv("claude --session-id abc --model opus")), argv("--model opus"));
assert_eq!(launch_args(claude, &argv("claude --resume abc --model opus")), argv("--model opus"));
assert!(launch_args(claude, &argv("claude")).is_empty());
let codex = agent_kind("codex").unwrap();
assert_eq!(launch_args(codex, &argv("codex resume abc -m o3")), argv("-m o3"));
assert_eq!(launch_args(codex, &argv("codex -m o3")), argv("-m o3"));
}
fn acct(agent: &str, name: &str, home: &str) -> Account {
Account { name: name.into(), agent: agent.into(), home: home.into(), threshold: None, note: None }
}
fn accounts() -> Accounts {
let mut a = Accounts::empty();
a.accounts = vec![acct("claude", "pro", "/tmp/kraal-test-claude-pro"), acct("claude", "perso", "~/.claude")];
a.rules = vec![Rule { workspace: Some("work".into()), path: None, agent: None, accounts: vec!["pro".into()] }];
a
}
#[test]
fn implicit_default_account() {
let a = Accounts::empty();
let codex = a.for_agent("codex");
assert_eq!(codex.len(), 1);
assert_eq!(codex[0].name, "default");
let a = accounts();
let names: Vec<String> = a.for_agent("claude").into_iter().map(|x| x.name).collect();
assert_eq!(names, vec!["pro", "perso"]);
let mut a = Accounts::empty();
assert!(a.add(acct("codex", "default", "/tmp/kraal-test-other")).is_err());
a.accounts.push(acct("codex", "default", "/tmp/kraal-test-other"));
assert_eq!(a.get("codex", "default").unwrap().home, "/tmp/kraal-test-other", "an older one stays reachable");
}
#[test]
fn account_homes_are_kept_absolute() {
assert_eq!(absolute_home("~/.claude-x").unwrap(), "~/.claude-x");
assert_eq!(absolute_home("/srv/x").unwrap(), "/srv/x");
let rel = absolute_home("agent-home").unwrap();
assert!(Path::new(&rel).is_absolute() && rel.ends_with("agent-home"), "{rel}");
}
#[test]
fn rules_and_thresholds_choose() {
let a = accounts();
let mut usage = UsageMap::default();
let now = 1_000_000;
assert_eq!(a.choose("claude", Some("work"), None, &usage, now).unwrap().name, "pro");
assert_eq!(a.choose("claude", Some("other"), None, &usage, now).unwrap().name, "pro");
usage.0.insert(
"claude:pro".into(),
Usage { five_hour: Some(Window { used_percent: 95.0, resets_at: Some(now + 600) }), ..Default::default() },
);
assert_eq!(a.choose("claude", Some("work"), None, &usage, now).unwrap().name, "pro");
assert_eq!(a.choose("claude", None, None, &usage, now).unwrap().name, "perso");
let pro = a.get("claude", "pro").unwrap();
assert!(a.alternative(&pro, Some("work"), None, &usage, now).is_none());
assert_eq!(a.alternative(&pro, None, None, &usage, now).unwrap().name, "perso");
assert_eq!(usage.peak(&pro, now + 601), 0.0);
}
#[test]
fn a_worktree_anywhere_waits_for_git_before_any_account() {
let dir = std::env::temp_dir().join(format!("kraal-rule-worktree-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let repo = dir.join("company/repo");
std::fs::create_dir_all(&repo).unwrap();
let git = |cwd: &Path, args: &[&str]| {
let ok = std::process::Command::new("git")
.arg("-C")
.arg(cwd)
.args(["-c", "user.name=t", "-c", "user.email=t@t", "-c", "commit.gpgsign=false"])
.args(args)
.output()
.unwrap();
assert!(ok.status.success(), "{args:?}: {}", String::from_utf8_lossy(&ok.stderr));
};
git(&repo, &["init", "-q", "-b", "main"]);
git(&repo, &["commit", "-q", "--allow-empty", "-m", "one"]);
let feature = dir.join("elsewhere/feature");
git(&repo, &["worktree", "add", "-q", "-b", "feature", feature.to_str().unwrap()]);
let repo = std::fs::canonicalize(&repo).unwrap();
let feature = std::fs::canonicalize(&feature).unwrap();
let mut a = accounts();
a.rules = vec![Rule {
workspace: None,
path: Some(repo.display().to_string()),
agent: None,
accounts: vec!["pro".into()],
}];
let usage = UsageMap::default();
assert!(a.undecided("claude", Some(&feature)));
assert!(!a.undecided("claude", Some(&repo.join("src"))), "inside the rule's own path");
crate::git::project_path(&feature);
assert!(!a.undecided("claude", Some(&feature)));
assert_eq!(a.choose("claude", None, Some(&feature), &usage, 0).unwrap().name, "pro");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_conversation_follows_to_an_account_that_keeps_its_own() {
let dir = std::env::temp_dir().join(format!("kraal-carry-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let (from, to) = (dir.join("perso"), dir.join("pro"));
std::fs::create_dir_all(from.join("projects/-work-app")).unwrap();
std::fs::create_dir_all(to.join("projects")).unwrap();
std::fs::write(from.join("projects/-work-app/abc-1.jsonl"), "{}\n").unwrap();
let a = acct("claude", "perso", from.to_str().unwrap());
let b = acct("claude", "pro", to.to_str().unwrap());
assert_eq!(conversation_exists(&b, "abc-1").unwrap(), Some(false));
assert!(carry_conversation(&a, &b, "abc-1").unwrap());
assert_eq!(conversation_exists(&b, "abc-1").unwrap(), Some(true));
assert!(carry_conversation(&a, &b, "abc-1").unwrap(), "again: already there");
assert!(!carry_conversation(&a, &b, "nope").unwrap(), "nothing to carry");
let codex = acct("codex", "pro", to.to_str().unwrap());
assert!(!carry_conversation(&codex, &codex, "abc-1").unwrap());
let (on_from, on_to) = (from.join("projects/-work-app/abc-1.jsonl"), to.join("projects/-work-app/abc-1.jsonl"));
std::fs::write(&on_to, "{}\n{\"more\":1}\n").unwrap();
assert!(carry_conversation(&b, &a, "abc-1").unwrap());
assert_eq!(std::fs::read_to_string(&on_from).unwrap(), "{}\n{\"more\":1}\n");
assert!(carry_conversation(&b, &a, "abc-1").unwrap(), "perso has all of it now");
assert!(carry_conversation(&a, &b, "abc-1").unwrap(), "pro has all of it too");
std::fs::write(&on_from, "{}\n{\"more\":1}\n{\"perso\":1}\n").unwrap();
std::fs::write(&on_to, "{}\n{\"more\":1}\n{\"pro\":1}\n").unwrap();
assert!(check_carry(&a, &b, "abc-1").is_err());
assert!(carry_conversation(&a, &b, "abc-1").is_err());
assert_eq!(std::fs::read_to_string(&on_to).unwrap(), "{}\n{\"more\":1}\n{\"pro\":1}\n", "untouched");
use std::os::unix::fs::PermissionsExt;
std::fs::write(&on_from, "{}\n{\"more\":1}\n").unwrap();
std::fs::write(&on_to, "{}\n").unwrap();
for side in [from.join("projects"), to.join("projects")] {
std::fs::set_permissions(&side, std::fs::Permissions::from_mode(0o000)).unwrap();
if std::fs::read_dir(&side).is_err() {
assert!(check_carry(&a, &b, "abc-1").is_err(), "{}", side.display());
assert!(carry_conversation(&a, &b, "abc-1").is_err(), "{}", side.display());
}
std::fs::set_permissions(&side, std::fs::Permissions::from_mode(0o755)).unwrap();
}
assert_eq!(std::fs::read_to_string(&on_to).unwrap(), "{}\n", "untouched");
let shared = dir.join("shared");
std::fs::create_dir_all(shared.join("projects")).unwrap();
std::os::unix::fs::symlink(from.join("projects"), shared.join("projects/../projects-link")).unwrap();
let c = acct("claude", "shared", shared.to_str().unwrap());
std::fs::remove_dir(shared.join("projects")).unwrap();
std::fs::rename(shared.join("projects-link"), shared.join("projects")).unwrap();
assert!(carry_conversation(&a, &c, "abc-1").unwrap());
assert!(check_carry(&c, &a, "abc-1").is_ok());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn what_the_screen_says_goes_up_or_down_and_ends() {
let mut u = Usage::default();
u.observe_screen(false, 100.0, 1_000);
assert_eq!(u.peak(1_000), 100.0);
u.observe_screen(false, 40.0, 2_000);
assert_eq!(u.peak(2_000), 40.0, "a lower figure seen since counts");
assert_eq!(u.five_hour.as_ref().unwrap().resets_at, Some(1_000 + 5 * 3600), "the same window");
assert_eq!(u.peak(1_000 + 5 * 3600 + 1), 0.0, "the window ends with its period");
}
#[test]
fn removing_an_account_keeps_other_agents_rules_and_narrows() {
let mut a = accounts();
a.accounts.push(acct("codex", "pro", "/tmp/kraal-test-codex-pro"));
a.rules.push(Rule {
workspace: Some("ops".into()),
path: None,
agent: Some("codex".into()),
accounts: vec!["pro".into()],
});
assert!(a.remove("claude", "pro"));
let named: Vec<(Option<String>, Vec<String>)> =
a.rules.iter().map(|r| (r.agent.clone(), r.accounts.clone())).collect();
assert_eq!(
named,
vec![
(Some("claude".into()), vec![]),
(None, vec!["pro".into()]),
(Some("codex".into()), vec!["pro".into()])
]
);
let usage = UsageMap::default();
assert_eq!(a.choose("codex", Some("work"), None, &usage, 0).unwrap().name, "pro");
assert!(a.choose("claude", Some("work"), None, &usage, 0).is_none());
assert_eq!(a.choose("claude", None, None, &usage, 0).unwrap().name, "perso");
assert!(a.remove("codex", "pro"));
assert!(a.choose("codex", Some("ops"), None, &usage, 0).is_none(), "an emptied rule refuses");
}
#[test]
fn path_rules() {
let mut a = accounts();
a.rules.push(Rule {
workspace: None,
path: Some("/srv/client".into()),
agent: Some("claude".into()),
accounts: vec!["perso".into()],
});
let picked = a.candidates("claude", None, Some(Path::new("/srv/client/app")));
assert_eq!(picked[0].name, "perso");
assert_eq!(picked.len(), 1);
}
#[test]
fn launch_lines() {
let a = accounts();
let pro = a.get("claude", "pro").unwrap();
let l = launch(&pro, &[], None, Some("abc")).unwrap();
assert_eq!(l.argv, vec!["claude", "--session-id", "abc"]);
assert!(l.env.contains(&("CLAUDE_CONFIG_DIR".into(), "/tmp/kraal-test-claude-pro".into())));
assert!(l.env_remove.contains(&"ANTHROPIC_API_KEY".to_string()));
let l = launch(&pro, &["--model".into(), "opus".into()], Some("s-1"), None).unwrap();
assert_eq!(l.argv, vec!["claude", "--resume", "s-1", "--model", "opus"]);
let perso = a.get("claude", "perso").unwrap();
let l = launch(&perso, &[], None, None).unwrap();
assert!(l.env_remove.contains(&"CLAUDE_CONFIG_DIR".to_string()), "default home clears the variable");
let codex = Accounts::empty().get("codex", "default").unwrap();
assert_eq!(launch(&codex, &[], Some("t-9"), None).unwrap().argv, vec!["codex", "resume", "t-9"]);
assert_eq!(login(&pro).unwrap().argv, vec!["claude", "auth", "login"]);
}
#[test]
fn statusline_usage() {
let v: serde_json::Value = serde_json::from_str(
r#"{"session_id":"x","rate_limits":{"five_hour":{"used_percentage":42.5,"resets_at":1760000000},"seven_day":{"used_percentage":71}}}"#,
)
.unwrap();
let u = usage_from_statusline(&v).unwrap();
assert_eq!(u.five_hour.as_ref().unwrap().used_percent, 42.5);
assert_eq!(u.weekly.as_ref().unwrap().used_percent, 71.0);
assert!(usage_from_statusline(&serde_json::json!({"model":{}})).is_none());
}
#[test]
fn limits_without_a_reset_time_end() {
let u = Usage {
five_hour: Some(Window { used_percent: 100.0, resets_at: None }),
observed_at: 1_000,
..Default::default()
};
assert_eq!(u.peak(1_000 + 3600), 100.0);
assert_eq!(u.peak(1_000 + 5 * 3600 + 1), 0.0, "five hours later it has reset");
}
#[test]
fn screen_limits() {
assert_eq!(limit_from_screen("…\nYou've hit your session limit · resets 3:45pm\n"), Some((false, 100.0)));
assert_eq!(limit_from_screen("You've used 85% of your weekly limit · resets Mon"), Some((true, 85.0)));
assert_eq!(limit_from_screen("all good"), None);
}
#[test]
fn uuids_are_v4() {
let u = new_uuid();
assert_eq!(u.len(), 36);
assert_eq!(&u[14..15], "4");
assert_ne!(u, new_uuid());
}
#[test]
fn save_and_reload() {
let dir = std::env::temp_dir().join(format!("kraal-accounts-{}", std::process::id()));
let path = dir.join("accounts.toml");
let mut a = Accounts::load_from(&path).unwrap();
a.add(acct("codex", "work", "/tmp/x")).unwrap();
assert!(a.add(acct("codex", "work", "/tmp/y")).is_err());
assert!(a.add(acct("nope", "w", "/tmp/z")).is_err());
a.limits.mode = Mode::Ask;
a.save().unwrap();
let b = Accounts::load_from(&path).unwrap();
assert_eq!(b.limits.mode, Mode::Ask);
assert_eq!(b.accounts.len(), 1);
let _ = std::fs::remove_dir_all(dir);
}
}