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,
}
}
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()
}
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");
}
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");
}
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());
}
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");
}
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());
}
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}"))
}
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))?;
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 tests {
use super::*;
use crate::paths::Paths;
#[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 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());
}
}