use crate::paths::Paths;
use anyhow::{bail, Context, Result};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct SlotRecord {
pub name: String,
pub id: String,
pub config_dir: PathBuf,
#[serde(default)]
pub adopted: bool,
#[serde(default = "default_tool")]
pub tool: String,
}
fn default_tool() -> String {
"claude-code".to_string()
}
pub struct Slots {
file: PathBuf,
slots_dir: PathBuf,
all: Vec<SlotRecord>,
records: Vec<SlotRecord>,
tool: String,
}
pub fn home_var(tool: &str) -> Option<&'static str> {
match tool {
"claude-code" => Some("CLAUDE_CONFIG_DIR"),
"codex" => Some("CODEX_HOME"),
_ => None,
}
}
pub fn name_reads_as_a_tool_home(name: &str) -> bool {
let n = name.trim().to_ascii_lowercase();
matches!(
n.as_str(),
"claude" | "claude-code" | "codex" | "gemini" | "antigravity" | ".claude" | ".codex"
)
}
pub fn slots_sharing_an_account(named: &[(String, Option<String>)]) -> Vec<Vec<String>> {
let mut groups: Vec<(String, Vec<String>)> = Vec::new();
for (name, uuid) in named {
let Some(u) = uuid else { continue };
match groups.iter_mut().find(|(k, _)| k == u) {
Some((_, names)) => names.push(name.clone()),
None => groups.push((u.clone(), vec![name.clone()])),
}
}
groups
.into_iter()
.map(|(_, names)| names)
.filter(|names| names.len() > 1)
.collect()
}
pub fn suggest_non_colliding(name: &str, taken: &[String]) -> String {
let base = format!("{}-account", name.trim().to_ascii_lowercase());
if !taken.iter().any(|t| t == &base) {
return base;
}
(2..)
.map(|i| format!("{base}{i}"))
.find(|c| !taken.iter().any(|t| t == c))
.unwrap_or(base)
}
fn new_id(name: &str) -> String {
use sha2::{Digest, Sha256};
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let mut h = Sha256::new();
h.update(name.as_bytes());
h.update(nanos.to_le_bytes());
h.finalize()
.iter()
.take(8)
.map(|b| format!("{b:02x}"))
.collect()
}
fn reject_tool_home_name(name: &str) -> Result<()> {
if name_reads_as_a_tool_home(name) {
bail!(
"'{name}' reads as the tool's own home directory, not as an account - \
a slot by that name points wherever it was made, which is somewhere else \
entirely. Pick something that names the ACCOUNT (its owner, its purpose): \
e.g. '{}'",
suggest_non_colliding(name, &[])
);
}
Ok(())
}
impl Slots {
pub fn open(paths: &Paths) -> Result<Slots> {
Self::open_for(paths, "claude-code")
}
pub fn open_for(paths: &Paths, tool: &str) -> Result<Slots> {
let store = paths.store_dir();
let file = store.join("slots.json");
let all: Vec<SlotRecord> = if file.exists() {
let bytes = std::fs::read(&file).context("read slots.json")?;
serde_json::from_slice(&bytes).context("slots.json is corrupt")?
} else {
Vec::new()
};
let records = all.iter().filter(|r| r.tool == tool).cloned().collect();
Ok(Slots {
file,
slots_dir: store.join("slots"),
all,
records,
tool: tool.to_string(),
})
}
pub fn get(&self, name: &str) -> Option<SlotRecord> {
self.records.iter().find(|r| r.name == name).cloned()
}
pub fn list(&self) -> Vec<SlotRecord> {
self.records.clone()
}
pub fn create(&mut self, name: &str) -> Result<SlotRecord> {
let name = name.trim();
if name.is_empty() {
bail!("a slot name is required");
}
if self.records.iter().any(|r| r.name == name) {
bail!("a slot named '{name}' already exists");
}
reject_tool_home_name(name)?;
let id = new_id(name);
let config_dir = self.slots_dir.join(&id);
std::fs::create_dir_all(&config_dir).context("create slot dir")?;
let rec = SlotRecord {
name: name.to_string(),
id,
config_dir,
adopted: false,
tool: self.tool.clone(),
};
self.records.push(rec.clone());
self.persist()?;
Ok(rec)
}
pub fn rename(&mut self, old: &str, new: &str) -> Result<bool> {
let new = new.trim();
if new.is_empty() {
bail!("a slot name is required");
}
if self.records.iter().any(|r| r.name == new) {
bail!("an account named '{new}' already exists");
}
reject_tool_home_name(new)?;
let Some(r) = self.records.iter_mut().find(|r| r.name == old) else {
return Ok(false);
};
r.name = new.to_string();
self.persist()?;
Ok(true)
}
pub fn remove(&mut self, name: &str) -> Result<bool> {
let Some(i) = self.records.iter().position(|r| r.name == name) else {
return Ok(false);
};
let gone = self.records.remove(i);
if self.default_dir().as_deref() == Some(gone.config_dir.as_path()) {
let _ = std::fs::remove_file(self.pointer_file());
}
let served = std::fs::read_to_string(self.serving_file()).unwrap_or_default();
if served.trim() == gone.config_dir.to_string_lossy() {
let _ = std::fs::remove_file(self.serving_file());
}
self.persist()?;
Ok(true)
}
fn persist(&mut self) -> Result<()> {
if let Some(parent) = self.file.parent() {
std::fs::create_dir_all(parent).context("create store dir")?;
}
let mut out: Vec<SlotRecord> = self
.all
.iter()
.filter(|r| r.tool != self.tool)
.cloned()
.collect();
out.extend(self.records.iter().cloned());
let bytes = serde_json::to_vec_pretty(&out)?;
std::fs::write(&self.file, bytes).context("write slots.json")?;
self.all = out;
Ok(())
}
pub fn adopt(&mut self, name: &str, config_dir: &std::path::Path) -> Result<SlotRecord> {
let name = name.trim();
if name.is_empty() {
bail!("a slot name is required");
}
if self.records.iter().any(|r| r.name == name) {
bail!("a slot named '{name}' already exists");
}
reject_tool_home_name(name)?;
if !config_dir.is_absolute() {
bail!("config dir must be an absolute path");
}
if !config_dir.is_dir() {
bail!("config dir does not exist: {}", config_dir.display());
}
self.prune_serving();
let rec = SlotRecord {
name: name.to_string(),
id: new_id(name),
config_dir: config_dir.to_path_buf(),
adopted: true,
tool: self.tool.clone(),
};
self.records.push(rec.clone());
self.persist()?;
Ok(rec)
}
fn pointer_file(&self) -> PathBuf {
let store = self
.file
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_default();
let short = match self.tool.as_str() {
"claude-code" => "claude",
other => other,
};
store.join(format!("active-{short}"))
}
fn serving_file(&self) -> PathBuf {
let mut p = self.pointer_file();
let name = p
.file_name()
.map(|n| n.to_string_lossy().replace("active-", "serving-"))
.unwrap_or_else(|| "serving-claude".into());
p.set_file_name(name);
p
}
pub fn set_serving(&self, name: &str) -> Result<()> {
let rec = self
.get(name)
.with_context(|| format!("no account named '{name}'"))?;
let p = self.serving_file();
if let Some(parent) = p.parent() {
std::fs::create_dir_all(parent).context("create store dir")?;
}
std::fs::write(&p, rec.config_dir.to_string_lossy().as_bytes())
.with_context(|| format!("write {} serving pointer", self.tool))
}
pub fn prune_serving(&self) {
let Ok(s) = std::fs::read_to_string(self.serving_file()) else {
return;
};
let dir = PathBuf::from(s.trim());
if dir.as_os_str().is_empty() {
return;
}
if !self.list().iter().any(|r| r.config_dir == dir) {
let _ = std::fs::remove_file(self.serving_file());
}
}
pub fn clear_serving(&self) -> Result<()> {
match std::fs::remove_file(self.serving_file()) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e).context("clear serving pointer"),
}
}
pub fn serving_dir(&self) -> Option<PathBuf> {
let s = std::fs::read_to_string(self.serving_file()).ok()?;
let dir = PathBuf::from(s.trim());
if dir.as_os_str().is_empty() {
return None;
}
self.list()
.into_iter()
.any(|r| r.config_dir == dir)
.then_some(dir)
}
pub fn payer(&self) -> Option<String> {
let dir = self.serving_dir().or_else(|| self.default_dir())?;
self.list()
.into_iter()
.find(|r| r.config_dir == dir)
.map(|r| r.name)
}
pub fn set_default(&self, name: &str) -> Result<()> {
let rec = self
.get(name)
.with_context(|| format!("no slot named '{name}'"))?;
let p = self.pointer_file();
if let Some(parent) = p.parent() {
std::fs::create_dir_all(parent).context("create store dir")?;
}
std::fs::write(&p, rec.config_dir.to_string_lossy().as_bytes())
.with_context(|| format!("write {} pointer", self.tool))?;
self.clear_serving()?;
Ok(())
}
pub fn default_dir(&self) -> Option<PathBuf> {
let s = std::fs::read_to_string(self.pointer_file()).ok()?;
let s = s.trim();
(!s.is_empty()).then(|| PathBuf::from(s))
}
}
pub const SHARED_CONFIG_FILES: &[&str] = &["settings.json", "CLAUDE.md"];
pub const SHARED_CONFIG_FILES_CODEX: &[&str] = &["config.toml", "AGENTS.md"];
pub fn shared_files(tool: &str) -> &'static [&'static str] {
match tool {
"codex" => SHARED_CONFIG_FILES_CODEX,
_ => SHARED_CONFIG_FILES,
}
}
pub fn link_shared_config(
slot: &std::path::Path,
source: &std::path::Path,
tool: &str,
) -> Vec<String> {
let mut linked = Vec::new();
for name in shared_files(tool) {
let src = source.join(name);
let dst = slot.join(name);
if src.exists() && !dst.exists() {
#[cfg(unix)]
if std::os::unix::fs::symlink(&src, &dst).is_ok() {
linked.push((*name).to_string());
}
}
}
linked
}
#[cfg(test)]
mod sharing_tests {
use super::*;
fn n(v: &[(&str, Option<&str>)]) -> Vec<(String, Option<String>)> {
v.iter()
.map(|(a, b)| (a.to_string(), b.map(str::to_string)))
.collect()
}
#[test]
fn two_directories_holding_one_login_are_reported_together() {
let got = slots_sharing_an_account(&n(&[
("bsgong", Some("8dd1a9aa")),
("rnd", Some("202743db")),
("bsgong-slot", Some("8dd1a9aa")),
]));
assert_eq!(
got,
vec![vec!["bsgong".to_string(), "bsgong-slot".to_string()]]
);
}
#[test]
fn unreadable_identities_are_never_grouped() {
assert!(slots_sharing_an_account(&n(&[("a", None), ("b", None)])).is_empty());
}
#[test]
fn distinct_accounts_say_nothing() {
assert!(slots_sharing_an_account(&n(&[("a", Some("u1")), ("b", Some("u2"))])).is_empty());
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::paths::Paths;
#[test]
fn a_serving_pointer_to_a_removed_account_names_nobody() {
let root = tempfile::tempdir().unwrap();
let paths = Paths::rooted(root.path());
{
let mut s = Slots::open_for(&paths, "codex").unwrap();
s.create("company").unwrap();
s.set_serving("company").unwrap();
}
assert!(
Slots::open_for(&paths, "codex")
.unwrap()
.serving_dir()
.is_some(),
"the pointer answers while the account is there"
);
{
let mut s = Slots::open_for(&paths, "codex").unwrap();
s.remove("company").unwrap();
}
assert_eq!(
Slots::open_for(&paths, "codex").unwrap().serving_dir(),
None,
"and stops the moment the account it named is gone"
);
}
#[test]
fn removing_the_serving_account_takes_the_pointer_too() {
let root = tempfile::tempdir().unwrap();
let paths = Paths::rooted(root.path());
let dir = {
let mut s = Slots::open_for(&paths, "codex").unwrap();
let rec = s.create("company").unwrap();
s.set_serving("company").unwrap();
s.remove("company").unwrap();
rec.config_dir
};
let mut s = Slots::open_for(&paths, "codex").unwrap();
s.adopt("company", &dir).unwrap();
assert_eq!(
Slots::open_for(&paths, "codex").unwrap().serving_dir(),
None,
"adopting the same directory back does not make it serve again"
);
}
#[test]
fn the_payer_is_the_server_or_the_default_behind_it() {
let root = tempfile::tempdir().unwrap();
let paths = Paths::rooted(root.path());
{
let mut s = Slots::open_for(&paths, "codex").unwrap();
s.create("main").unwrap();
s.create("company").unwrap();
s.set_default("main").unwrap();
}
let open = || Slots::open_for(&paths, "codex").unwrap();
assert_eq!(
open().payer().as_deref(),
Some("main"),
"with nobody serving, the default pays"
);
open().set_serving("company").unwrap();
assert_eq!(
open().payer().as_deref(),
Some("company"),
"and the account directing turns takes over"
);
open().remove("company").unwrap();
assert_eq!(
open().payer().as_deref(),
Some("main"),
"and it hands back when that account is gone"
);
}
#[test]
fn adopting_a_directory_a_dead_pointer_names_does_not_resume_paying() {
let root = tempfile::tempdir().unwrap();
let paths = Paths::rooted(root.path());
let orphan = root.path().join("codex-company");
std::fs::create_dir_all(&orphan).unwrap();
let s = Slots::open_for(&paths, "codex").unwrap();
std::fs::create_dir_all(paths.store_dir()).unwrap();
std::fs::write(s.serving_file(), orphan.to_string_lossy().as_bytes()).unwrap();
drop(s);
let mut s = Slots::open_for(&paths, "codex").unwrap();
s.adopt("company", &orphan).unwrap();
assert_eq!(
Slots::open_for(&paths, "codex").unwrap().serving_dir(),
None,
"the directory is registered again, but nobody asked it to pay"
);
}
#[test]
fn create_persists_and_reloads() {
let root = tempfile::tempdir().unwrap();
let paths = Paths::rooted(root.path());
let rec = {
let mut s = Slots::open(&paths).unwrap();
s.create("work").unwrap()
};
assert!(rec.config_dir.is_absolute());
assert!(rec.config_dir.starts_with(paths.store_dir().join("slots")));
assert!(rec.config_dir.is_dir(), "slot dir was created");
assert!(!rec.adopted);
let s2 = Slots::open(&paths).unwrap();
assert_eq!(s2.get("work").unwrap().id, rec.id);
assert_eq!(s2.list().len(), 1);
}
#[test]
fn slots_are_scoped_to_their_tool() {
let root = tempfile::tempdir().unwrap();
let paths = Paths::rooted(root.path());
{
let mut c = Slots::open_for(&paths, "claude-code").unwrap();
c.create("work").unwrap();
}
{
let mut x = Slots::open_for(&paths, "codex").unwrap();
x.create("work").unwrap();
}
let c = Slots::open_for(&paths, "claude-code").unwrap();
let x = Slots::open_for(&paths, "codex").unwrap();
assert_eq!(c.list().len(), 1, "claude sees only its own");
assert_eq!(x.list().len(), 1, "codex sees only its own");
assert_ne!(
c.get("work").unwrap().config_dir,
x.get("work").unwrap().config_dir,
"two tools never share a directory"
);
c.set_default("work").unwrap();
assert_eq!(c.default_dir(), Some(c.get("work").unwrap().config_dir));
assert_eq!(x.default_dir(), None, "codex has no default yet");
x.set_default("work").unwrap();
assert_eq!(c.default_dir(), Some(c.get("work").unwrap().config_dir));
assert_eq!(x.default_dir(), Some(x.get("work").unwrap().config_dir));
}
#[test]
fn a_slot_recorded_without_a_tool_is_claudes() {
let root = tempfile::tempdir().unwrap();
let paths = Paths::rooted(root.path());
std::fs::create_dir_all(paths.store_dir()).unwrap();
std::fs::write(
paths.store_dir().join("slots.json"),
br#"[{"name":"old","id":"abc","config_dir":"/tmp/old-slot","adopted":true}]"#,
)
.unwrap();
let c = Slots::open_for(&paths, "claude-code").unwrap();
assert_eq!(c.list().len(), 1, "the pre-upgrade slot still lists");
assert_eq!(c.get("old").unwrap().tool, "claude-code");
let x = Slots::open_for(&paths, "codex").unwrap();
assert!(x.list().is_empty(), "it was never a codex slot");
c.set_default("old").unwrap();
assert!(paths.store_dir().join("active-claude").exists());
}
#[test]
fn where_you_start_and_who_serves_are_separate_answers() {
let root = tempfile::tempdir().unwrap();
let paths = Paths::rooted(root.path());
let mut s = Slots::open(&paths).unwrap();
let home = s.create("home").unwrap();
let payer = s.create("payer").unwrap();
s.set_default("home").unwrap();
assert_eq!(s.default_dir(), Some(home.config_dir.clone()));
assert_eq!(s.serving_dir(), None);
s.set_serving("payer").unwrap();
assert_eq!(s.serving_dir(), Some(payer.config_dir.clone()));
assert_eq!(
s.default_dir(),
Some(home.config_dir.clone()),
"the conversation store is untouched - this is the whole point"
);
s.set_default("payer").unwrap();
assert_eq!(s.serving_dir(), None, "a fresh start pays for itself");
}
#[test]
fn a_name_that_reads_as_a_tools_home_is_refused() {
let root = tempfile::tempdir().unwrap();
let paths = Paths::rooted(root.path());
let mut s = Slots::open(&paths).unwrap();
for bad in ["claude", "Claude", "codex", "claude-code", ".claude"] {
let e = s.create(bad).expect_err("refused");
let msg = e.to_string();
assert!(msg.contains("reads as the tool's own home"), "{msg}");
assert!(msg.contains("-account"), "it names a usable one: {msg}");
}
let dir = root.path().join("some-home");
std::fs::create_dir_all(&dir).unwrap();
assert!(s.adopt("codex", &dir).is_err());
assert!(s.create("claude-personal").is_ok());
assert!(s.create("work").is_ok());
assert!(s.rename("work", "codex").is_err());
s.records.push(SlotRecord {
name: "claude".into(),
id: "legacy".into(),
config_dir: root.path().join("legacy"),
adopted: true,
tool: "claude-code".into(),
});
assert!(
s.rename("claude", "youdie006").unwrap(),
"the way out works"
);
}
#[test]
fn a_suggested_name_steps_around_what_is_taken() {
assert_eq!(suggest_non_colliding("claude", &[]), "claude-account");
assert_eq!(
suggest_non_colliding("claude", &["claude-account".into()]),
"claude-account2"
);
assert_eq!(
suggest_non_colliding("Codex", &["codex-account".into(), "codex-account2".into()]),
"codex-account3"
);
}
#[test]
fn duplicate_and_empty_names_are_rejected() {
let root = tempfile::tempdir().unwrap();
let paths = Paths::rooted(root.path());
let mut s = Slots::open(&paths).unwrap();
s.create("work").unwrap();
assert!(s.create("work").is_err(), "duplicate name rejected");
assert!(s.create(" ").is_err(), "empty name rejected");
}
#[test]
fn id_is_stable_and_name_independent() {
let root = tempfile::tempdir().unwrap();
let paths = Paths::rooted(root.path());
let mut s = Slots::open(&paths).unwrap();
let a = s.create("alpha").unwrap();
let b = s.create("beta").unwrap();
assert_ne!(a.id, b.id);
assert_ne!(a.id, "alpha", "id is opaque, not the display name");
}
#[test]
fn set_default_points_at_the_slot_dir() {
let root = tempfile::tempdir().unwrap();
let paths = Paths::rooted(root.path());
let mut s = Slots::open(&paths).unwrap();
let rec = s.create("work").unwrap();
assert_eq!(s.default_dir(), None, "no default until set");
s.set_default("work").unwrap();
assert_eq!(s.default_dir(), Some(rec.config_dir.clone()));
assert_eq!(
Slots::open(&paths).unwrap().default_dir(),
Some(rec.config_dir)
);
assert!(s.set_default("missing").is_err(), "unknown name rejected");
}
#[test]
fn rename_keeps_the_directory_so_the_login_survives() {
let root = tempfile::tempdir().unwrap();
let paths = Paths::rooted(root.path());
let mut s = Slots::open(&paths).unwrap();
let before = s.create("company2").unwrap();
assert!(s.rename("company2", "rnd").unwrap());
let after = s.get("rnd").expect("renamed");
assert_eq!(
after.config_dir, before.config_dir,
"the directory is untouched - the Keychain item is keyed on it"
);
assert_eq!(after.id, before.id, "and so is the id");
assert!(s.get("company2").is_none());
s.create("other").unwrap();
assert!(s.rename("rnd", "other").is_err(), "duplicate refused");
assert!(
!s.rename(" ", "x").unwrap_or(false),
"a blank name is not a rename"
);
assert!(!s.rename("ghost", "x").unwrap(), "no such slot");
}
#[test]
fn remove_unregisters_but_never_deletes_the_directory() {
let root = tempfile::tempdir().unwrap();
let paths = Paths::rooted(root.path());
let mut s = Slots::open(&paths).unwrap();
let rec = s.create("work").unwrap();
s.set_default("work").unwrap();
assert!(s.remove("work").unwrap());
assert!(s.get("work").is_none(), "the mapping is gone");
assert!(
rec.config_dir.is_dir(),
"the directory - and the login in it - is left alone"
);
assert_eq!(
s.default_dir(),
None,
"a pointer at the removed slot is cleared, not left dangling"
);
assert!(!s.remove("work").unwrap());
assert!(Slots::open(&paths).unwrap().get("work").is_none());
}
#[test]
fn adopt_registers_an_existing_dir_without_moving_it() {
let root = tempfile::tempdir().unwrap();
let paths = Paths::rooted(root.path());
let existing = root.path().join("dot-claude-company");
std::fs::create_dir_all(&existing).unwrap();
let mut s = Slots::open(&paths).unwrap();
let rec = s.adopt("company", &existing).unwrap();
assert_eq!(rec.config_dir, existing, "config dir is the existing path");
assert!(rec.adopted);
assert!(existing.is_dir(), "the existing dir is left in place");
assert!(s.adopt("nope", &root.path().join("absent")).is_err());
}
}