use crate::adapters::{self, Account, AuthTool};
use crate::paths::Paths;
use crate::store::Store;
use anyhow::Result;
use serde_json::Value;
use std::process::Command;
fn pretty_tool_flag(tool: &str) -> &str {
if tool == "claude-code" {
"claude"
} else {
tool
}
}
fn now_nanos() -> u128 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
}
fn command_exists(cmd: &str) -> bool {
let Some(path) = std::env::var_os("PATH") else {
return false;
};
std::env::split_paths(&path).any(|dir| is_executable(&dir.join(cmd)))
}
#[cfg(unix)]
fn is_executable(p: &std::path::Path) -> bool {
use std::os::unix::fs::PermissionsExt;
std::fs::metadata(p).is_ok_and(|m| m.is_file() && m.permissions().mode() & 0o111 != 0)
}
#[cfg(not(unix))]
fn is_executable(p: &std::path::Path) -> bool {
p.is_file()
}
#[derive(Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
pub enum ToolSel {
#[value(alias = "claude-code")]
Claude,
Codex,
Gemini,
Antigravity,
#[value(alias = "both")]
All,
}
impl ToolSel {
fn wants(self, tool: &str) -> bool {
match self {
ToolSel::Claude => tool == "claude-code",
ToolSel::Codex => tool == "codex",
ToolSel::Gemini => tool == "gemini",
ToolSel::Antigravity => tool == "antigravity",
ToolSel::All => true,
}
}
}
fn selected_adapters(sel: Option<ToolSel>) -> Vec<Box<dyn AuthTool>> {
adapters::all()
.into_iter()
.filter(|a| sel.map(|s| s.wants(a.name())).unwrap_or(true))
.collect()
}
fn is_explicit(sel: Option<ToolSel>) -> bool {
matches!(
sel,
Some(ToolSel::Claude)
| Some(ToolSel::Codex)
| Some(ToolSel::Gemini)
| Some(ToolSel::Antigravity)
)
}
fn macos_keychain_note(_paths: &Paths, _tool: &str) -> Option<&'static str> {
None
}
fn snapshot_account_id(snap: &crate::adapters::Snapshot, tool: &str) -> Option<String> {
match tool {
"codex" => {
let v: Value = serde_json::from_slice(snap.part("auth")?.expose()).ok()?;
v["tokens"]["account_id"].as_str().map(|s| s.to_string())
}
"claude-code" => {
let v: Value = serde_json::from_slice(snap.part("oauth_account")?.expose()).ok()?;
v["accountUuid"].as_str().map(|s| s.to_string())
}
"gemini" => {
let v: Value = serde_json::from_slice(snap.part("oauth")?.expose()).ok()?;
crate::adapters::gemini_jwt_claim(v["id_token"].as_str(), "sub")
}
"antigravity" => {
let v: Value = serde_json::from_slice(snap.part("token")?.expose()).ok()?;
let fp = crate::adapters::antigravity_fingerprint(&v);
(!fp.is_empty()).then_some(fp)
}
_ => None,
}
}
fn profile_account_id(store: &Store, name: &str, tool: &str) -> Option<String> {
let snap = store.load(name, tool).ok()??;
snapshot_account_id(&snap, tool)
}
pub(crate) fn matched_profile_name(store: &Store, tool: &str, live_id: &str) -> Option<String> {
if live_id.is_empty() {
return None;
}
store
.list()
.into_iter()
.find(|p| {
p.tools.iter().any(|t| t == tool)
&& profile_account_id(store, &p.name, tool).as_deref() == Some(live_id)
})
.map(|p| p.name)
}
fn matching_profile_names(store: &Store, tool: &str, live_id: &str) -> Vec<String> {
if live_id.is_empty() {
return Vec::new();
}
store
.list()
.into_iter()
.filter(|p| {
p.tools.iter().any(|t| t == tool)
&& profile_account_id(store, &p.name, tool).as_deref() == Some(live_id)
})
.map(|p| p.name)
.collect()
}
fn reject_bad_name(name: &str) -> Option<i32> {
if crate::store::valid_profile_name(name) {
None
} else {
eprintln!(
"swapdex: invalid profile name '{name}' (1-64 bytes, not all spaces; \
no '/', '\\', leading '.', or control chars)"
);
Some(2)
}
}
fn reject_reserved_name(name: &str) -> Option<i32> {
if name == "-" {
eprintln!("swapdex: '-' is reserved (`swapdex use -` toggles to the previous profile)");
Some(2)
} else if name.trim().is_empty() {
eprintln!("swapdex: a profile name cannot be only whitespace");
Some(2)
} else {
None
}
}
pub fn add(paths: &Paths, name: Option<&str>, sel: Option<ToolSel>, update: bool) -> Result<i32> {
crate::atomic::ensure_not_root()?;
let store = Store::open(paths)?;
let asked;
let name: &str = match name {
Some(n) => n,
None => {
use std::io::IsTerminal;
let tty =
std::io::stdin().is_terminal() || std::env::var_os("SWAPDEX_ASSUME_TTY").is_some();
if !tty {
eprintln!(
"swapdex: a profile name is required: swapdex add <name> \
(or run `swapdex setup` for the guided flow)"
);
return Ok(2);
}
let who = adapters::all()
.iter()
.find_map(|a| a.identity(paths).ok().flatten())
.map(|id| id.email.unwrap_or(id.display))
.unwrap_or_else(|| "account".into());
let suggestion = suggest_name(&who);
match ask_name(
&store,
&format!("name for this account [{suggestion}]: "),
&suggestion,
) {
Some(n) => {
asked = n;
&asked
}
None => {
println!("nothing saved.");
return Ok(0);
}
}
}
};
if let Some(c) = reject_bad_name(name).or_else(|| reject_reserved_name(name)) {
return Ok(c);
}
let _lock = match store.lock() {
Ok(g) => g,
Err(crate::store::LockError::Busy) => {
eprintln!(
"swapdex: another swapdex is busy (a switch, or a `swapdex login` waiting \
for a sign-in). Finish or close it, then retry."
);
return Ok(4);
}
Err(crate::store::LockError::Unwritable(e)) => {
eprintln!(
"swapdex: the store is not writable ({e}) - check permissions/mount of \
the store directory"
);
return Ok(4);
}
};
let mut saved = Vec::new();
let mut skipped = Vec::new();
let mut capture_failed: Vec<&str> = Vec::new();
let mut declined: Vec<&str> = Vec::new(); for adapter in selected_adapters(sel) {
let tool = adapter.name();
if !adapter.present(paths) {
if is_explicit(sel) {
eprintln!("swapdex: not logged in to {tool}");
if let Some(note) = macos_keychain_note(paths, tool) {
eprintln!("swapdex: note - {note}");
}
return Ok(3);
}
continue;
}
if update {
let stored_id = profile_account_id(&store, name, tool).filter(|s| !s.is_empty());
let live_id = adapter
.identity(paths)
.ok()
.flatten()
.map(|i| i.account_id)
.filter(|s| !s.is_empty());
if let (Some(stored), Some(live)) = (&stored_id, &live_id) {
if stored != live {
use std::io::IsTerminal;
let tty = std::io::stdin().is_terminal()
|| std::env::var_os("SWAPDEX_ASSUME_TTY").is_some();
let msg = format!(
"profile '{name}' holds a different account for {tool} \
than the one you're logged into"
);
if !tty {
eprintln!("swapdex: {msg}.");
eprintln!(
" keep both: swapdex add <new-name> --tool {} | really \
repoint: swapdex rm {name} && swapdex add {name}",
pretty_tool_flag(tool)
);
return Ok(7);
}
if !yes_no(
&format!("{msg}. Repoint '{name}' to the current login? [y/N]: "),
false,
) {
println!("skipped {tool}.");
declined.push(tool);
continue;
}
}
}
}
if store.load(name, tool)?.is_some() && !update {
if is_explicit(sel) {
eprintln!(
"swapdex: profile '{name}' already has a {tool} login; pass --update to replace"
);
return Ok(6);
}
skipped.push(tool);
continue;
}
let snap = match adapter.capture(paths) {
Ok(s) => s,
Err(e) => {
eprintln!("swapdex: {tool}: could not read the live login ({e:#}) - skipped");
capture_failed.push(tool);
continue;
}
};
store.save(name, &snap)?;
let email = adapter
.identity(paths)
.ok()
.flatten()
.and_then(|id| id.email);
saved.push((tool, email));
}
if saved.is_empty() {
if !declined.is_empty() {
println!(
"nothing saved for {} (you declined the repoint).",
declined.join(", ")
);
return Ok(0);
}
if !skipped.is_empty() {
eprintln!(
"swapdex: profile '{name}' already has {}; pass --update to replace",
skipped.join(", ")
);
return Ok(6);
}
if !capture_failed.is_empty() {
eprintln!(
"swapdex: nothing saved - the live login for {} is present but could not be \
read (see the error above)",
capture_failed.join(", ")
);
return Ok(1);
}
eprintln!("swapdex: not logged in to any selected tool");
return Ok(3);
}
let note = if skipped.is_empty() {
String::new()
} else {
format!(
" ({} already saved; --update to replace)",
skipped.join(", ")
)
};
let saved_disp = saved
.iter()
.map(|(tool, email)| match email {
Some(e) => format!("{tool} = {e}"),
None => tool.to_string(),
})
.collect::<Vec<_>>()
.join(", ");
println!("saved profile '{name}' ({saved_disp}){note}");
if !capture_failed.is_empty() {
eprintln!(
"swapdex: {} tool(s) could not be read and were NOT saved: {}",
capture_failed.len(),
capture_failed.join(", ")
);
}
if name.contains(char::is_whitespace) {
println!(
"note: the name has spaces - quote it in later commands (`swapdex use \"{name}\"`)"
);
}
Ok(if capture_failed.is_empty() { 0 } else { 1 })
}
fn tool_session_running(tool: &str, running: &[String], dry_run: bool) -> bool {
if dry_run {
return false;
}
if std::env::var_os("SWAPDEX_ROOT").is_some() {
return std::env::var("SWAPDEX_TEST_RUNNING")
.map(|v| v.split(',').any(|t| t.trim() == tool))
.unwrap_or(false);
}
crate::proc::tool_running(tool, running)
}
pub fn use_account(
paths: &Paths,
name: &str,
sel: Option<ToolSel>,
dry_run: bool,
force: bool,
) -> Result<i32> {
let tool = slot_tool(sel);
if crate::slots::Slots::open_for(paths, tool)?
.get(name)
.is_some()
{
return use_slot_default(paths, name, tool, dry_run);
}
use_account_inner(paths, name, sel, dry_run, false, None, force)
}
pub fn use_account_open(
paths: &Paths,
name: &str,
sel: Option<ToolSel>,
dir: Option<&std::path::Path>,
force: bool,
) -> Result<i32> {
if !is_explicit(sel) {
eprintln!("swapdex: --open needs --tool <claude|codex|gemini|antigravity> so it knows what to launch");
return Ok(2);
}
if let Some(d) = dir {
if !d.is_dir() {
eprintln!("swapdex: --dir is not a directory: {}", d.display());
return Ok(2);
}
}
use_account_inner(paths, name, sel, false, true, dir, force)
}
fn use_account_inner(
paths: &Paths,
name: &str,
sel: Option<ToolSel>,
dry_run: bool,
open: bool,
open_dir: Option<&std::path::Path>,
force: bool,
) -> Result<i32> {
crate::atomic::ensure_not_root()?;
let store = Store::open(paths)?;
let name = match resolve_use_name(&store, paths, name, sel)? {
Some(n) => n,
None => return Ok(5),
};
let name = name.as_str();
if let Some(c) = reject_bad_name(name) {
return Ok(c);
}
let _lock = match store.lock() {
Ok(g) => g,
Err(crate::store::LockError::Busy) => {
eprintln!(
"swapdex: another swapdex is busy (a switch, or a `swapdex login` waiting \
for a sign-in). Finish or close it, then retry."
);
return Ok(4);
}
Err(crate::store::LockError::Unwritable(e)) => {
eprintln!(
"swapdex: the store is not writable ({e}) - check permissions/mount of \
the store directory"
);
return Ok(4);
}
};
if !store.list().iter().any(|p| p.name == name) {
eprintln!("swapdex: no profile named '{name}'");
return Ok(5);
}
let mut matched = 0; let mut changed = 0; let mut failed: Vec<&str> = Vec::new();
let running = if dry_run || std::env::var_os("SWAPDEX_ROOT").is_some() {
Vec::new()
} else {
crate::proc::running_process_names()
};
let claude_guard = if dry_run {
crate::proc::GuardVerdict::Clear
} else if std::env::var_os("SWAPDEX_ROOT").is_some() {
match std::env::var("SWAPDEX_TEST_CLAUDE_GUARD").ok().as_deref() {
Some("same-slot") => crate::proc::GuardVerdict::SameSlot,
Some("unknown") => crate::proc::GuardVerdict::Unknown,
_ => crate::proc::GuardVerdict::Clear,
}
} else {
crate::proc::claude_switch_guard(
std::env::var("CLAUDE_SECURESTORAGE_CONFIG_DIR")
.ok()
.as_deref(),
std::env::var("CLAUDE_CONFIG_DIR").ok().as_deref(),
&crate::proc::running_claude_procs(),
)
};
let switch_ts = now_secs();
let switch_inv = now_nanos();
for adapter in selected_adapters(sel) {
let tool = adapter.name();
if !is_explicit(sel) && macos_keychain_note(paths, tool).is_some() {
println!(
"{tool}: skipped - the login lives in the macOS Keychain \
(github.com/youdie006/swapdex/issues/1); other tools continue"
);
continue;
}
let target = match store.load(name, tool)? {
Some(s) => s,
None => {
if is_explicit(sel) {
eprintln!("swapdex: profile '{name}' has no {tool} login");
return Ok(5);
}
if adapter.present(paths) {
println!("{tool}: profile '{name}' has no {tool} login - left unchanged");
}
continue;
}
};
matched += 1;
let live = adapter.identity(paths).ok().flatten();
let live_id = live
.as_ref()
.map(|i| i.account_id.clone())
.filter(|s| !s.is_empty());
let target_id = profile_account_id(&store, name, tool).filter(|s| !s.is_empty());
if live_id.is_some() && live_id == target_id {
println!("{tool}: '{name}' is already active");
if !dry_run {
if let (Ok(snap), Some(id)) = (adapter.capture(paths), &live_id) {
for pname in matching_profile_names(&store, tool, id) {
store.save(&pname, &snap)?;
}
}
}
continue;
}
warn_if_expired(&target, tool);
if dry_run {
match profile_detail(&store, name, tool).and_then(|(email, _, _)| email) {
Some(email) => println!("would switch {tool} -> {name} ({email})"),
None => println!("would switch {tool} -> {name}"),
}
continue;
}
if tool == "claude-code" && !force {
match claude_guard {
crate::proc::GuardVerdict::SameSlot => {
eprintln!(
"swapdex: {tool}: a Claude session is running on THIS login slot. \
Switching now would log that account out on its next token refresh \
(the refresh token rotates and the saved copy is revoked). Quit that \
`claude` and retry, or `swapdex use {name} --tool claude --force` to \
switch anyway."
);
failed.push(tool);
continue;
}
crate::proc::GuardVerdict::Unknown => {
eprintln!(
"swapdex: {tool}: a Claude session is running but swapdex could not read \
which login slot it uses, so it can't rule out that switching would log \
it out. Quit `claude` and retry, or `--force` to switch anyway."
);
failed.push(tool);
continue;
}
crate::proc::GuardVerdict::Clear => {}
}
}
if matches!(tool, "codex" | "gemini" | "antigravity")
&& !force
&& tool_session_running(tool, &running, dry_run)
{
eprintln!(
"swapdex: {tool}: a running {tool} session was detected. {tool} rotates its \
OAuth token on refresh, so switching now can log that account out on the \
session's next refresh. Quit it and retry, or `swapdex use {name} --tool \
{} --force` to switch anyway.",
pretty_tool_flag(tool)
);
failed.push(tool);
continue;
}
let _cred_lock = match store.lock_tool(tool) {
Ok(g) => g,
Err(_) => {
eprintln!(
"swapdex: {tool}: a `swapdex login` is signing this tool in right now; \
skipped. Retry after it finishes."
);
failed.push(tool);
continue;
}
};
if adapter.present(paths) {
match adapter.capture(paths) {
Ok(live_snap) => {
store.backup(&live_snap)?;
if let Some(id) = &live_id {
for pname in matching_profile_names(&store, tool, id) {
store.save(&pname, &live_snap)?;
}
if matched_profile_name(&store, tool, id).is_none() {
let who = live
.as_ref()
.map(identity_line)
.unwrap_or_else(|| "current".into());
eprintln!(
"swapdex: note - the outgoing {tool} login ({who}) is not \
saved as a profile; only the last 2 backups keep it. \
`swapdex restore` undoes this switch; `swapdex add <name>` \
would keep it for good."
);
}
}
}
Err(e) if live.is_some() => {
eprintln!(
"swapdex: {tool}: the current login is present but could not be backed \
up ({e:#}) - refusing to overwrite it without a backup. Repair the file \
named above (or re-login), then retry."
);
failed.push(tool);
continue;
}
Err(e) => eprintln!(
"swapdex: note - the current {tool} login could not be read ({e:#}); \
switching without a backup of it"
),
}
}
if let Err(e) = adapter.apply(paths, &target) {
eprintln!(
"swapdex: {tool}: switch failed - {:#}\n (if the error is about the \
SNAPSHOT: log in to that account and re-save with `swapdex add {name} \
--tool {} --update`)",
e,
pretty_tool_flag(tool)
);
failed.push(tool);
continue;
}
store.append_timeline_inv(tool, name, "use", switch_ts, switch_inv)?;
if let Some(id) = adapter.identity(paths).ok().flatten() {
println!("switched {tool} -> {}", identity_line(&id));
}
if crate::proc::tool_running(tool, &running) {
eprintln!(
"swapdex: note - a {tool} session looks like it's running. Restart it \
to use '{name}'; a live session can overwrite the switched login on \
its next token refresh."
);
}
changed += 1;
}
if matched == 0 {
eprintln!("swapdex: no profile named '{name}'");
return Ok(5);
}
if changed > 0 {
println!("(takes effect on your next message)");
}
if !failed.is_empty() {
eprintln!(
"swapdex: {} tool(s) failed to switch ({}); the tools above did switch - \
`swapdex restore` undoes this switch entirely",
failed.len(),
failed.join(", ")
);
return Ok(1);
}
if open {
if let Some(adapter) = selected_adapters(sel).into_iter().next() {
let tool = adapter.name();
println!("opening {}...", pretty_tool(tool));
return Err(exec_tool(tool, open_dir));
}
}
Ok(0)
}
pub fn restore(paths: &Paths, sel: Option<ToolSel>, dry_run: bool) -> Result<i32> {
crate::atomic::ensure_not_root()?;
let store = Store::open(paths)?;
let _lock = match store.lock() {
Ok(g) => g,
Err(crate::store::LockError::Busy) => {
eprintln!(
"swapdex: another swapdex is busy (a switch, or a `swapdex login` waiting \
for a sign-in). Finish or close it, then retry."
);
return Ok(4);
}
Err(crate::store::LockError::Unwritable(e)) => {
eprintln!(
"swapdex: the store is not writable ({e}) - check permissions/mount of \
the store directory"
);
return Ok(4);
}
};
let running = if dry_run || std::env::var_os("SWAPDEX_ROOT").is_some() {
Vec::new()
} else {
crate::proc::running_process_names()
};
let last_switch = last_switch_tools(paths);
let restore_ts = now_secs();
let restore_inv = now_nanos();
let mut found = 0; let mut changed = 0; for adapter in selected_adapters(sel) {
let tool = adapter.name();
if !is_explicit(sel) {
if let Some(tools) = &last_switch {
if !tools.iter().any(|t| t == tool) {
continue;
}
}
if macos_keychain_note(paths, tool).is_some() {
println!(
"{tool}: skipped - the login lives in the macOS Keychain \
(github.com/youdie006/swapdex/issues/1); other tools continue"
);
continue;
}
}
let Some((stamp, target)) = store.load_backup(tool)? else {
if is_explicit(sel) {
eprintln!("swapdex: no backup for {tool} (a backup is taken on every `use`)");
return Ok(5);
}
continue;
};
found += 1;
let live_id = adapter
.identity(paths)
.ok()
.flatten()
.map(|i| i.account_id)
.filter(|s| !s.is_empty());
let backup_id = snapshot_account_id(&target, tool).filter(|s| !s.is_empty());
if live_id.is_some() && live_id == backup_id {
println!("{tool}: the newest backup is already the active login");
continue;
}
let age = age_line(stamp);
if dry_run {
println!("would restore {tool} from the backup taken {age}");
continue;
}
let _cred_lock = match store.lock_tool(tool) {
Ok(g) => g,
Err(_) => {
eprintln!(
"swapdex: {tool}: a `swapdex login` is signing this tool in right now; \
skipped. Retry after it finishes."
);
continue;
}
};
let live_snap = if adapter.present(paths) {
match adapter.capture(paths) {
Ok(s) => Some(s),
Err(e) => {
eprintln!(
"swapdex: note - the current {tool} login could not be read ({e:#}); \
restoring without a backup of it"
);
None
}
}
} else {
None
};
adapter.apply(paths, &target)?;
if let Some(live_snap) = &live_snap {
store.backup(live_snap)?;
if let Some(id) = &live_id {
for pname in matching_profile_names(&store, tool, id) {
store.save(&pname, live_snap)?;
}
}
}
let restored = adapter.identity(paths).ok().flatten();
let event_name = restored
.as_ref()
.and_then(|id| matched_profile_name(&store, tool, &id.account_id))
.unwrap_or_else(|| "(backup)".into());
store.append_timeline_inv(tool, &event_name, "restore", restore_ts, restore_inv)?;
match restored {
Some(id) => println!("restored {tool} -> {} (backup {age})", identity_line(&id)),
None => println!("restored {tool} from the backup taken {age}"),
}
if crate::proc::tool_running(tool, &running) {
eprintln!(
"swapdex: note - a {tool} session looks like it's running. Restart it \
to pick up the restored login."
);
}
changed += 1;
}
if found == 0 {
eprintln!("swapdex: no backup to restore (a backup is taken on every `use`)");
return Ok(5);
}
if changed > 0 {
println!("(takes effect on your next message)");
}
Ok(0)
}
fn resolve_use_name(
store: &Store,
paths: &Paths,
raw: &str,
sel: Option<ToolSel>,
) -> Result<Option<String>> {
if raw.is_empty() {
return Ok(Some(raw.to_string()));
}
let profiles: Vec<String> = store.list().into_iter().map(|p| p.name).collect();
if raw == "-" {
let mut act: Vec<String> = active_by_tool(store, paths)
.into_iter()
.filter(|(t, _)| sel.map(|s| s.wants(t)).unwrap_or(true))
.map(|(_, n)| n)
.collect();
act.sort();
act.dedup();
if profiles.len() == 2 && act.len() == 1 {
if let Some(other) = profiles.iter().find(|p| **p != act[0]) {
eprintln!("swapdex: '-' -> '{other}'");
return Ok(Some(other.clone()));
}
}
if let Some(prev) = last_switch_name_excluding(paths, &act, &profiles, sel) {
eprintln!("swapdex: '-' -> '{prev}'");
return Ok(Some(prev));
}
if act.len() > 1 {
eprintln!(
"swapdex: both profiles are active ({}) - '-' is ambiguous here; \
say which: swapdex use <{}>",
act.join(", "),
profiles.join("|")
);
} else {
eprintln!(
"swapdex: can't tell which profile '-' means yet. \
Pick one: swapdex use <{}>",
profiles.join("|")
);
}
return Ok(None);
}
if profiles.iter().any(|p| p == raw) {
return Ok(Some(raw.to_string()));
}
let cands: Vec<&String> = profiles.iter().filter(|p| p.starts_with(raw)).collect();
match cands.len() {
1 => {
let n = cands[0].clone();
eprintln!("swapdex: '{raw}' matched profile '{n}'");
Ok(Some(n))
}
0 => Ok(Some(raw.to_string())),
_ => {
eprintln!(
"swapdex: '{raw}' is ambiguous: {}",
cands
.iter()
.map(|s| s.as_str())
.collect::<Vec<_>>()
.join(", ")
);
Ok(None)
}
}
}
fn last_switch_name_excluding(
paths: &Paths,
exclude: &[String],
profiles: &[String],
sel: Option<ToolSel>,
) -> Option<String> {
let text = std::fs::read_to_string(paths.store_dir().join("timeline.jsonl")).ok()?;
let mut events: Vec<(i64, String)> = Vec::new();
for line in text.lines() {
let Ok(v) = serde_json::from_str::<Value>(line) else {
continue;
};
if !matches!(v["action"].as_str(), Some("use") | Some("restore")) {
continue;
}
if let Some(tool) = v["tool"].as_str() {
if !sel.map(|s| s.wants(tool)).unwrap_or(true) {
continue;
}
}
let (Some(ts), Some(name)) = (v["ts"].as_i64(), v["account"].as_str()) else {
continue;
};
events.push((ts, name.to_string()));
}
let newest = events
.iter()
.max_by_key(|(ts, _)| *ts)
.map(|(_, n)| n.clone());
let mut best: Option<(i64, String)> = None;
for (ts, name) in events {
if exclude.contains(&name)
|| newest.as_deref() == Some(name.as_str())
|| !profiles.contains(&name)
{
continue;
}
if best.as_ref().map(|(t, _)| ts >= *t).unwrap_or(true) {
best = Some((ts, name.to_string()));
}
}
best.map(|(_, n)| n)
}
fn last_switch_tools(paths: &Paths) -> Option<Vec<String>> {
let path = paths.store_dir().join("timeline.jsonl");
let text = std::fs::read_to_string(path).ok()?;
let mut events: Vec<(i64, String, String)> = Vec::new();
for line in text.lines() {
let Ok(v) = serde_json::from_str::<Value>(line) else {
continue;
};
if !matches!(v["action"].as_str(), Some("use") | Some("restore")) {
continue;
}
if let (Some(ts), Some(tool)) = (v["ts"].as_i64(), v["tool"].as_str()) {
let inv = v["inv"].as_str().unwrap_or("").to_string();
events.push((ts, inv, tool.to_string()));
}
}
let (last_ts, last_inv) = events
.iter()
.map(|(ts, inv, _)| (*ts, inv.clone()))
.next_back()?;
let mut tools: Vec<String> = events
.into_iter()
.filter(|(ts, inv, _)| {
if last_inv.is_empty() {
*ts == last_ts && inv.is_empty()
} else {
*inv == last_inv
}
})
.map(|(_, _, tool)| tool)
.collect();
tools.sort();
tools.dedup();
Some(tools)
}
fn age_line(stamp_nanos: u128) -> String {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let secs = (now.saturating_sub(stamp_nanos) / 1_000_000_000) as u64;
if secs < 60 {
format!("{secs}s ago")
} else if secs < 3600 {
format!("{}m ago", secs / 60)
} else if secs < 86400 {
format!("{}h ago", secs / 3600)
} else {
format!("{}d ago", secs / 86400)
}
}
const STALE_DAYS: i64 = 30;
fn profile_detail(
store: &Store,
name: &str,
tool: &str,
) -> Option<(Option<String>, Option<String>, Option<&'static str>)> {
let snap = store.load(name, tool).ok()??;
let unreadable = (None, None, Some("unreadable"));
match tool {
"claude-code" => {
let (Some(cred_part), Some(oauth_part)) =
(snap.part("credentials"), snap.part("oauth_account"))
else {
return Some(unreadable);
};
let (Ok(creds), Ok(oauth)) = (
serde_json::from_slice::<Value>(cred_part.expose()),
serde_json::from_slice::<Value>(oauth_part.expose()),
) else {
return Some(unreadable);
};
let marker = creds["claudeAiOauth"]["expiresAt"]
.as_i64()
.filter(|ms| now_ms() - ms > STALE_DAYS * 86400 * 1000)
.map(|_| "stale");
Some((
oauth["emailAddress"].as_str().map(String::from),
creds["claudeAiOauth"]["subscriptionType"]
.as_str()
.map(String::from),
marker,
))
}
"codex" => {
let Some(auth_part) = snap.part("auth") else {
return Some(unreadable);
};
let Ok(auth) = serde_json::from_slice::<Value>(auth_part.expose()) else {
return Some(unreadable);
};
let email = crate::adapters::codex::decode_email_from_id_token(
auth["tokens"]["id_token"].as_str(),
);
let marker = auth["last_refresh"]
.as_str()
.and_then(crate::session_link::rfc3339_to_secs)
.filter(|&secs| now_ms() / 1000 - secs > STALE_DAYS * 86400)
.map(|_| "stale");
Some((email, auth["auth_mode"].as_str().map(String::from), marker))
}
"gemini" => {
let oauth: Value = serde_json::from_slice(snap.part("oauth")?.expose()).ok()?;
let email = snap
.part("accounts")
.and_then(|a| serde_json::from_slice::<Value>(a.expose()).ok())
.and_then(|v| v["active"].as_str().map(String::from))
.or_else(|| crate::adapters::gemini_jwt_claim(oauth["id_token"].as_str(), "email"));
let marker = oauth["expiry_date"]
.as_i64()
.filter(|ms| now_ms() - ms > STALE_DAYS * 86400 * 1000)
.map(|_| "stale");
Some((email, None, marker))
}
"antigravity" => {
let v: Value = serde_json::from_slice(snap.part("token")?.expose()).ok()?;
let marker = v["token"]["expiry"]
.as_str()
.and_then(crate::session_link::rfc3339_to_secs)
.filter(|&secs| now_ms() / 1000 - secs > STALE_DAYS * 86400)
.map(|_| "stale");
Some((None, v["auth_method"].as_str().map(String::from), marker))
}
_ => None,
}
}
fn profile_summary(
store: &Store,
name: &str,
tools: &[String],
) -> (Option<String>, Option<String>, Option<&'static str>) {
let mut email = None;
let mut tier = None;
let mut marker = None;
for a in adapters::all() {
let t = a.name();
if !tools.iter().any(|x| x == t) {
continue;
}
if let Some((e, ti, m)) = profile_detail(store, name, t) {
email = email.or(e);
tier = tier.or(ti);
marker = marker.or(m);
}
}
(email, tier, marker)
}
pub(crate) fn active_by_tool(store: &Store, paths: &Paths) -> Vec<(&'static str, String)> {
adapters::all()
.iter()
.filter_map(|a| {
a.identity(paths)
.ok()
.flatten()
.and_then(|id| matched_profile_name(store, a.name(), &id.account_id))
.map(|name| (a.name(), name))
})
.collect()
}
fn fit(s: &str, w: usize) -> String {
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
let n = UnicodeWidthStr::width(s);
if n <= w {
let mut out = String::from(s);
out.extend(std::iter::repeat_n(' ', w - n));
return out;
}
let mut out = String::new();
let mut used = 0usize;
for c in s.chars() {
let cw = UnicodeWidthChar::width(c).unwrap_or(0);
if used + cw > w.saturating_sub(1) {
break;
}
out.push(c);
used += cw;
}
out.push('…');
out.extend(std::iter::repeat_n(' ', w.saturating_sub(used + 1)));
out
}
fn identity_column(email: Option<String>, tier: Option<String>) -> String {
match (email.filter(|e| !e.is_empty()), tier) {
(Some(e), Some(t)) => format!("{e} [{t}]"),
(Some(e), None) => e,
(None, Some(t)) => format!("[{t}]"),
(None, None) => String::new(),
}
}
pub fn ls(paths: &Paths, json: bool, names: bool) -> Result<i32> {
let store = Store::open(paths)?;
if names {
for p in store.list() {
println!("{}", p.name);
}
return Ok(0);
}
let active = active_by_tool(&store, paths);
let active_tools_for = |name: &str| -> Vec<&'static str> {
active
.iter()
.filter(|(_, n)| n == name)
.map(|(t, _)| *t)
.collect()
};
let profiles = store.list();
if json {
let rows: Vec<Value> = profiles
.iter()
.map(|p| {
let (email, tier, marker) = profile_summary(&store, &p.name, &p.tools);
serde_json::json!({
"name": p.name,
"tools": p.tools,
"active_tools": active_tools_for(&p.name),
"email": email,
"tier": tier,
"warning": marker,
})
})
.collect();
println!("{}", serde_json::to_string(&rows)?);
return Ok(0);
}
if profiles.is_empty() {
println!("No accounts saved yet.");
println!(" guided setup: swapdex setup");
println!(" or add one: swapdex login <name>");
return Ok(0);
}
struct Row {
name: String,
ident: String,
tools: String,
warn: Option<&'static str>,
active: bool,
}
let rows: Vec<Row> = profiles
.iter()
.map(|p| {
let (email, tier, marker) = profile_summary(&store, &p.name, &p.tools);
let at = active_tools_for(&p.name);
let tools = p
.tools
.iter()
.map(|t| {
if at.contains(&t.as_str()) {
format!("{t}*")
} else {
t.clone()
}
})
.collect::<Vec<_>>()
.join(", ");
Row {
name: p.name.clone(),
ident: identity_column(email, tier),
tools,
warn: marker,
active: !at.is_empty(),
}
})
.collect();
let name_w = rows
.iter()
.map(|r| unicode_width::UnicodeWidthStr::width(r.name.as_str()))
.max()
.unwrap_or(4)
.clamp(4, 24);
let ident_w = rows
.iter()
.map(|r| unicode_width::UnicodeWidthStr::width(r.ident.as_str()))
.max()
.unwrap_or(0)
.clamp(0, 40);
let mut saw_refreshable = false;
let mut saw_unreadable = false;
for r in &rows {
let mark = if r.active { "* " } else { " " };
let warn = r.warn.map(|m| format!(" ({m})")).unwrap_or_default();
saw_unreadable |= r.warn == Some("unreadable");
saw_refreshable |= matches!(r.warn, Some("expired") | Some("stale"));
println!(
"{mark}{} {} [{}]{warn}",
fit(&r.name, name_w),
fit(&r.ident, ident_w),
r.tools
);
}
if saw_refreshable {
println!(
" (expired/stale: re-run `swapdex add --update <name>` while logged in to refresh)"
);
}
if saw_unreadable {
println!(
" (unreadable: the saved snapshot is corrupt - log in to that account and \
re-save it with `swapdex add <name> --update`)"
);
}
if active
.iter()
.map(|(_, n)| n)
.collect::<std::collections::HashSet<_>>()
.len()
> 1
{
println!(" (* marks the active account per tool)");
}
Ok(0)
}
pub fn short_line(paths: &Paths) -> Option<String> {
let store = Store::open(paths).ok()?;
let parts: Vec<String> = adapters::all()
.iter()
.filter_map(|a| {
let id = a.identity(paths).ok().flatten()?;
let tool = match a.name() {
"claude-code" => "claude",
t => t,
};
let who = matched_profile_name(&store, a.name(), &id.account_id)
.or(id.email)
.unwrap_or_else(|| "?".into());
Some(format!("{tool}:{who}"))
})
.collect();
if parts.is_empty() {
None
} else {
Some(parts.join(" "))
}
}
pub fn status(paths: &Paths, json: bool, short: bool) -> Result<i32> {
if short {
println!("{}", short_line(paths).unwrap_or_default());
return Ok(0);
}
let store = Store::open(paths)?;
if json {
let rows: Vec<Value> = adapters::all()
.iter()
.map(|adapter| {
let tool = adapter.name();
match adapter.identity(paths) {
Err(_) => serde_json::json!({
"tool": tool, "logged_in": false, "unreadable": true,
"email": null, "tier": null, "profile": null, "expired": null,
}),
Ok(None) => serde_json::json!({
"tool": tool, "logged_in": false, "unreadable": false,
"email": null, "tier": null, "profile": null, "expired": null,
}),
Ok(Some(id)) => serde_json::json!({
"tool": tool,
"logged_in": true,
"unreadable": false,
"email": id.email,
"tier": id.tier,
"profile": matched_profile_name(&store, tool, &id.account_id),
"expired": id.expires_at.map(|ms| ms < now_ms()),
}),
}
})
.collect();
println!("{}", serde_json::to_string(&rows)?);
return Ok(0);
}
for adapter in adapters::all() {
let tool = adapter.name();
match adapter.identity(paths) {
Err(_) => println!(
"{tool}: login file unreadable - `swapdex use <profile>` can replace it \
(or log in again in the tool)"
),
Ok(None) => match macos_keychain_note(paths, tool) {
Some(note) => println!("{tool}: not manageable - {note}"),
None => println!("{tool}: not logged in"),
},
Ok(Some(id)) => {
let name = matched_profile_name(&store, tool, &id.account_id);
let saved = match &name {
Some(n) => format!("profile '{n}'"),
None => "not saved - run `swapdex add <name>`".to_string(),
};
let exp = expiry_note(id.expires_at);
println!("{tool}: {} ({saved}){exp}", identity_line(&id));
}
}
}
if let Ok(meta) = std::fs::metadata(paths.claude_config_json()) {
use std::os::unix::fs::PermissionsExt;
if meta.permissions().mode() & 0o077 != 0 {
println!(
"note: {} is group/world-readable (holds your account email/org); `chmod 600` it",
crate::util::redact_path(&paths.claude_config_json().display().to_string())
);
}
}
if let Some(line) = crate::session_link::status_line(paths) {
println!("{line}");
}
Ok(0)
}
#[allow(clippy::too_many_arguments)]
pub fn proxy(
paths: &Paths,
port: u16,
account: Option<String>,
sel: Option<ToolSel>,
auto: bool,
no_auto: bool,
ensure: bool,
threshold: Option<f64>,
) -> Result<i32> {
if ensure {
return proxy_ensure(paths, port, slot_tool(sel));
}
let auto = if auto {
true
} else if no_auto {
false
} else {
crate::settings::load(paths).auto()
};
let cfg = crate::settings::load(paths);
let threshold = threshold
.map(|t| t.clamp(0.05, 1.0))
.or_else(|| cfg.threshold());
let opts = crate::proxy::Opts {
port,
account,
tool: slot_tool(sel).to_string(),
auto,
threshold,
};
crate::proxy::serve(paths, &opts)?;
Ok(0)
}
pub const DEFAULT_PROXY_PORT: u16 = 8787;
fn proxy_ensure(paths: &Paths, port: u16, tool: &str) -> Result<i32> {
let mut port = if tool == "codex" && port == DEFAULT_PROXY_PORT {
port + 1
} else {
port
};
if let Some((pid, running, build)) = crate::proxy::running_proxy_for(paths, tool) {
if build == crate::proxy::build_id() {
println!("{running}");
return Ok(0);
}
unsafe { libc::kill(pid, libc::SIGTERM) };
for _ in 0..40 {
if crate::proxy::running_proxy_for(paths, tool).is_none() {
break;
}
std::thread::sleep(std::time::Duration::from_millis(50));
}
port = running;
}
if crate::slots::Slots::open_for(paths, tool)
.map(|s| s.list().is_empty())
.unwrap_or(true)
{
return Ok(1);
}
let Ok(exe) = std::env::current_exe() else {
return Ok(1);
};
let mut cmd = std::process::Command::new(exe);
cmd.arg("proxy")
.arg("--port")
.arg(port.to_string())
.arg("--tool")
.arg(tool)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null());
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
unsafe {
cmd.pre_exec(|| {
libc::setsid();
Ok(())
});
}
}
if cmd.spawn().is_err() {
return Ok(1);
}
for _ in 0..40 {
if let Some((_, p, _)) = crate::proxy::running_proxy_for(paths, tool) {
println!("{p}");
return Ok(0);
}
std::thread::sleep(std::time::Duration::from_millis(50));
}
Ok(1)
}
fn slash_body(tool: &str, host: &str) -> String {
format!(
"**If arguments were given**, run `swapdex use $ARGUMENTS --tool {tool}`, then \
report the result in one line.\n\
\n\
**If not**, do not make the user recall account names:\n\
\n\
1. Run `swapdex ls` and keep ONLY the accounts tagged `{tool}` - this is {host}, \
so accounts for other tools are not offered and never switched.\n\
2. Ask the user to choose one with the AskUserQuestion tool, so they can pick with \
the arrow keys. One option per account, labelled with the account name, with its \
email and current state as the description. Put the active one first and say it is \
active. If there are more accounts than the tool allows, offer the ones not \
currently active and let the rest come from free text.\n\
3. Run `swapdex serve <the account they chose> --tool {tool}` and report the \
result in one line. `serve` is the right verb here: it changes which account \
pays for the turns and leaves this conversation exactly where it is. `use` \
would move the store the conversation lives in, which is never what someone \
asking mid-conversation means.\n\
\n\
Report what swapdex printed and nothing beyond it. Its last line says whether \
anything running actually moved: with a proxy the next turn of THIS session is \
served by the new account, and without one the change reaches only the next \
launch while this conversation keeps the account it began with. Do not promise \
the stronger of the two - a switch announced as live when it was not is worse \
than no switch, because the work continues on the account the user thinks they \
left. If the output says the shim is not taking effect, say so and stop.\n"
)
}
fn claude_command_body() -> String {
format!(
"---\ndescription: Switch the Claude account serving this session (swapdex)\n---\n\n{}",
slash_body("claude-code", "Claude Code")
)
}
fn codex_skill_body() -> String {
format!(
"---\nname: swap\ndescription: >-\n Switch the Codex account serving this session \
(swapdex). Use when the user asks to change accounts, says an account is out of \
quota, or types /swap.\n---\n\n{}",
slash_body("codex", "Codex")
)
}
pub fn threshold(paths: &Paths, value: Option<&str>) -> Result<i32> {
let mut cfg = crate::settings::load(paths);
let Some(value) = value else {
match cfg.threshold() {
Some(t) => println!(
"stepping off an account at {:.0}% used",
(t * 100.0).round()
),
None => println!(
"no threshold - the proxy waits for an account to refuse a turn \
(`swapdex threshold 0.9` steps off earlier)"
),
}
return Ok(0);
};
let v = value.trim();
if v.eq_ignore_ascii_case("off") || v.eq_ignore_ascii_case("none") {
cfg.proxy_threshold = None;
crate::settings::save(paths, &cfg)?;
println!("threshold off - the proxy waits for a refusal before moving");
return Ok(0);
}
let parsed =
v.trim_end_matches('%')
.parse::<f64>()
.ok()
.map(|n| if n > 1.0 { n / 100.0 } else { n });
let Some(t) = parsed.filter(|t| *t > 0.0 && *t <= 1.0) else {
eprintln!(
"swapdex: expected a fraction like 0.9, a percentage like 90%, or `off` - got '{v}'"
);
return Ok(2);
};
cfg.proxy_threshold = Some(t);
crate::settings::save(paths, &cfg)?;
let eff = cfg.threshold().unwrap_or(t);
println!(
"stepping off an account at {:.0}% used - it hands the session on before \
being refused",
(eff * 100.0).round()
);
Ok(0)
}
pub fn install_slash(paths: &Paths) -> Result<i32> {
let _ = paths; let Some(home) = dirs::home_dir() else {
eprintln!("swapdex: cannot find your home directory");
return Ok(1);
};
let mut installed = 0;
let claude_dir = home.join(".claude").join("commands");
match std::fs::create_dir_all(&claude_dir)
.and_then(|()| std::fs::write(claude_dir.join("swap.md"), claude_command_body()))
{
Ok(()) => {
println!(
"Claude Code: /swap ({})",
crate::util::redact_path(&claude_dir.join("swap.md").display().to_string())
);
installed += 1;
}
Err(e) => eprintln!("swapdex: could not install the Claude command: {e}"),
}
let codex_dir = home.join(".codex").join("skills").join("swap");
match std::fs::create_dir_all(&codex_dir)
.and_then(|()| std::fs::write(codex_dir.join("SKILL.md"), codex_skill_body()))
{
Ok(()) => {
println!(
"Codex: /swap ({})",
crate::util::redact_path(&codex_dir.join("SKILL.md").display().to_string())
);
installed += 1;
}
Err(e) => eprintln!("swapdex: could not install the Codex skill: {e}"),
}
if installed == 0 {
return Ok(1);
}
println!(" type `/swap` to pick an account, or `/swap <name>` to go straight there");
println!(" (a plain `!swapdex use <account>` works too, without installing anything)");
Ok(0)
}
pub fn auto(paths: &Paths, state: Option<&str>) -> Result<i32> {
let mut s = crate::settings::load(paths);
let Some(state) = state else {
println!("auto-continue is {}", if s.auto() { "on" } else { "off" });
return Ok(0);
};
let on = match state.trim().to_ascii_lowercase().as_str() {
"on" | "true" | "yes" | "1" => true,
"off" | "false" | "no" | "0" => false,
other => {
eprintln!("swapdex: expected `on` or `off`, got '{other}'");
return Ok(2);
}
};
s.proxy_auto = Some(on);
crate::settings::save(paths, &s)?;
println!(
"auto-continue {}{}",
if on { "on" } else { "off" },
if on {
" - a spent account hands the running session to another one"
} else {
" - the proxy stays on the account you chose"
}
);
Ok(0)
}
pub fn ui(paths: &Paths) -> Result<i32> {
use std::io::IsTerminal;
let real_tty = std::io::stdin().is_terminal() && std::io::stdout().is_terminal();
let tty = real_tty || std::env::var_os("SWAPDEX_ASSUME_TTY").is_some();
if !tty {
eprintln!("swapdex: `ui` is interactive and needs a terminal (try `swapdex use <name>`)");
return Ok(2);
}
let dumb = std::env::var("TERM")
.map(|t| t.is_empty() || t == "dumb")
.unwrap_or(true);
if real_tty && !dumb {
return ui_tui(paths);
}
let store = Store::open(paths)?;
let profiles = store.list();
if profiles.is_empty() {
println!("No accounts saved yet.");
println!(" guided setup: swapdex setup");
return Ok(0);
}
let active = active_by_tool(&store, paths);
let color = crate::util::color_enabled();
println!();
for (i, p) in profiles.iter().enumerate() {
let (email, tier, marker) = profile_summary(&store, &p.name, &p.tools);
let at: Vec<&str> = active
.iter()
.filter(|(_, n)| n == &p.name)
.map(|(t, _)| *t)
.collect();
let star = if at.is_empty() { " " } else { "* " };
let ident = identity_column(email, tier);
let warn = marker.map(|m| format!(" ({m})")).unwrap_or_default();
let line = format!(
" {}) {star}{} {} [{}]{warn}",
i + 1,
fit(&p.name, 16),
fit(&ident, 32),
p.tools.join(", ")
);
if color && !at.is_empty() {
println!("\x1b[1m{line}\x1b[0m");
} else {
println!("{line}");
}
}
if let Some(line) = crate::session_link::status_line(paths) {
println!("\n {line}");
}
println!();
loop {
let Some(ans) = prompt(
&format!("switch to [1-{}] (Enter cancels): ", profiles.len()),
"",
) else {
println!("cancelled - nothing switched.");
return Ok(0);
};
if ans.is_empty() || ans.eq_ignore_ascii_case("q") {
println!("cancelled - nothing switched.");
return Ok(0);
}
match ans.parse::<usize>() {
Ok(n) if (1..=profiles.len()).contains(&n) => {
let name = profiles[n - 1].name.clone();
println!();
let first_time = crate::session_link::read_timeline(paths).is_empty();
let rc = use_account(paths, &name, None, false, false)?;
if rc == 0 {
ui_session_hints(paths, &name, first_time)?;
}
return Ok(rc);
}
_ => {
println!(
" pick a number between 1 and {} (Enter cancels)",
profiles.len()
);
}
}
}
}
pub(crate) enum MenuSession {
Wiki(crate::session_link::RecentSession),
Native(crate::native_sessions::NativeSession),
}
impl MenuSession {
pub(crate) fn describe(&self) -> (String, i64, String, String) {
match self {
MenuSession::Wiki(s) => (
s.id.chars().take(6).collect(),
s.started,
s.tool.clone(),
s.title.clone(),
),
MenuSession::Native(s) => (
s.id.chars().take(6).collect(),
s.started,
s.tool.to_string(),
s.title.clone(),
),
}
}
}
pub(crate) fn recent_menu_sessions(
paths: &Paths,
name: &str,
first_time: bool,
n: usize,
) -> (Vec<MenuSession>, String) {
if let Some(r) = crate::session_link::recent_sessions_for(paths, name, n) {
if !r.is_empty() {
return (
r.into_iter().map(MenuSession::Wiki).collect(),
format!("recent sessions on '{name}' (sessionwiki):"),
);
}
if let Some(any) = crate::session_link::recent_sessions_any(n) {
if !any.is_empty() {
let label = if first_time {
"recent sessions (any account - attribution starts with your first switch):"
} else {
"recent sessions (any account):"
};
return (
any.into_iter().map(MenuSession::Wiki).collect(),
label.to_string(),
);
}
}
}
let events = crate::session_link::read_timeline(paths);
let all = crate::native_sessions::recent(paths, n * 4);
let mine: Vec<crate::native_sessions::NativeSession> = all
.iter()
.filter(|s| {
crate::session_link::attribute(&events, s.tool, s.started).as_deref() == Some(name)
})
.map(|s| crate::native_sessions::NativeSession {
tool: s.tool,
id: s.id.clone(),
title: s.title.clone(),
cwd: s.cwd.clone(),
started: s.started,
})
.take(n)
.collect();
if !mine.is_empty() {
return (
mine.into_iter().map(MenuSession::Native).collect(),
format!("recent sessions on '{name}':"),
);
}
let any: Vec<MenuSession> = all.into_iter().take(n).map(MenuSession::Native).collect();
if !any.is_empty() {
let label = if first_time {
"recent sessions (any account - attribution starts with your first switch):"
} else {
"recent sessions (any account):"
};
return (any, label.to_string());
}
(Vec::new(), String::new())
}
fn exec_menu_resume(s: &MenuSession) -> anyhow::Error {
match s {
MenuSession::Wiki(w) => {
println!("opening session {} via sessionwiki...", w.id);
exec_sessionwiki_resume(&w.id)
}
MenuSession::Native(nat) => {
println!("resuming {} session {}...", pretty_tool(nat.tool), nat.id);
crate::native_sessions::exec_resume(nat)
}
}
}
fn ui_session_hints(paths: &Paths, name: &str, first_time: bool) -> Result<()> {
let (recent, label) = recent_menu_sessions(paths, name, first_time, 3);
let ptools = profile_tools(paths, name);
let choices: Vec<(&str, &str, &str)> = [
("c", "claude-code", "claude"),
("x", "codex", "codex"),
("g", "gemini", "gemini"),
("a", "antigravity", "agy"),
]
.into_iter()
.filter(|(_, tool, _)| ptools.iter().any(|t| t == tool))
.collect();
let new_hint = if choices.is_empty() {
String::new()
} else {
let keys = choices
.iter()
.map(|(k, _, p)| format!("{k} new {p}"))
.collect::<Vec<_>>()
.join(", ");
format!(", {keys}")
};
let pick = |ans: &str| -> Option<&'static str> {
launch_letter(ans).filter(|t| choices.iter().any(|(_, ct, _)| ct == t))
};
if !recent.is_empty() {
println!("\n{label}");
for (i, s) in recent.iter().enumerate() {
let (id6, started, tool, title) = s.describe();
let age = age_line((started.max(0) as u128) * 1_000_000_000);
let line = format!(
" {}) {id6} {:>7} {} {}",
i + 1,
age,
fit(&format!("[{tool}]"), 13),
fit(&title, 44)
);
println!("{}", line.trim_end());
}
if let Some(ans) = prompt(
&format!(
"open: [1-{}] resume that session{new_hint}, Enter skips: ",
recent.len()
),
"",
) {
if let Ok(k) = ans.parse::<usize>() {
if (1..=recent.len()).contains(&k) {
return Err(exec_menu_resume(&recent[k - 1]));
}
}
if let Some(tool) = pick(&ans) {
return Err(launch_in_folder(tool));
}
}
} else if !choices.is_empty() {
if let Some(ans) = prompt(&format!("open now?{new_hint} (Enter skips): "), "") {
if let Some(tool) = pick(&ans) {
return Err(launch_in_folder(tool));
}
}
}
Ok(())
}
fn profile_tools(paths: &Paths, name: &str) -> Vec<String> {
Store::open(paths)
.ok()
.and_then(|s| {
s.list()
.into_iter()
.find(|p| p.name == name)
.map(|p| p.tools)
})
.unwrap_or_default()
}
fn launch_in_folder(tool: &str) -> anyhow::Error {
let dir = prompt("folder to open in [current dir]: ", "")
.filter(|d| !d.is_empty())
.map(|d| {
if d == "~" {
if let Some(home) = dirs::home_dir() {
return home;
}
}
if let Some(rest) = d.strip_prefix("~/") {
if let Some(home) = dirs::home_dir() {
return home.join(rest);
}
}
std::path::PathBuf::from(d)
});
if let Some(d) = &dir {
if !d.is_dir() {
return anyhow::anyhow!("not a directory: {}", d.display());
}
}
println!("opening {}...", pretty_tool(tool));
exec_tool(tool, dir.as_deref())
}
fn launch_letter(ans: &str) -> Option<&'static str> {
match ans.to_ascii_lowercase().as_str() {
"c" => Some("claude-code"),
"x" => Some("codex"),
"g" => Some("gemini"),
"a" => Some("antigravity"),
_ => None,
}
}
fn ui_tui(paths: &Paths) -> Result<i32> {
struct Ctx<'a> {
paths: &'a Paths,
last_sessions: Vec<MenuSession>,
pre_switch_first: bool,
}
fn run_self(args: &[&str]) -> (bool, String) {
let exe = match std::env::current_exe() {
Ok(e) => e,
Err(e) => return (false, format!("cannot find own binary: {e}")),
};
match Command::new(exe)
.args(args)
.stdin(std::process::Stdio::null())
.output()
{
Ok(out) => {
let mut text = String::from_utf8_lossy(&out.stdout).into_owned();
text.push_str(&String::from_utf8_lossy(&out.stderr));
let mut msg = text
.lines()
.filter(|l| !l.trim().is_empty())
.collect::<Vec<_>>()
.join(" | ");
if msg.chars().count() > 160 {
msg = msg.chars().take(159).collect::<String>() + "…";
}
(out.status.success(), msg)
}
Err(e) => (false, format!("failed: {e}")),
}
}
fn read_quota_usage(paths: &Paths) -> Vec<(String, crate::tui::Usage)> {
let Ok(exe) = std::env::current_exe() else {
return Vec::new();
};
let Ok(out) = Command::new(exe)
.arg("quota")
.arg("--json")
.stdin(std::process::Stdio::null())
.output()
else {
return Vec::new();
};
let Ok(v) = serde_json::from_slice::<serde_json::Value>(&out.stdout) else {
return Vec::new();
};
let arr = v
.as_array()
.cloned()
.or_else(|| v.get("accounts").and_then(|a| a.as_array()).cloned())
.unwrap_or_default();
let mut claude: Vec<(String, crate::tui::Usage)> = arr
.iter()
.filter_map(|acc| {
let name = acc
.get("name")?
.as_str()?
.trim_end_matches(" (active)")
.to_string();
let win = |key: &str| -> (Option<f64>, Option<i64>) {
let w = acc.get(key);
(
w.and_then(|w| w.get("used_pct")).and_then(|v| v.as_f64()),
w.and_then(|w| w.get("resets_at")).and_then(|v| v.as_i64()),
)
};
let (five_h, five_h_reset) = win("five_hour");
let (seven_d, seven_d_reset) = win("seven_day");
let note = match acc.get("status").and_then(|s| s.as_str()) {
Some("ok") | None => None,
Some("throttled") => Some("endpoint busy - retrying".to_string()),
Some("expired") => Some("login expired".to_string()),
Some("offline") => acc
.get("detail")
.and_then(|d| d.as_str())
.map(|d| d.split(" - ").next().unwrap_or(d).to_string()),
Some(other) => Some(other.to_string()),
};
if five_h.is_none() && seven_d.is_none() && note.is_none() {
return None;
}
Some((
name,
crate::tui::Usage {
five_h,
five_h_reset,
seven_d,
seven_d_reset,
observed_at: None,
note,
},
))
})
.collect();
for (name, e) in crate::quota_cache::load(paths) {
if let Some((_, u)) = claude
.iter_mut()
.find(|(n, u)| *n == name && u.five_h.is_none() && u.seven_d.is_none())
{
u.five_h = e.five_h;
u.five_h_reset = e.five_h_reset;
u.seven_d = e.seven_d;
u.seven_d_reset = e.seven_d_reset;
u.observed_at = Some(e.at);
u.note = None;
continue;
}
if claude.iter().any(|(n, _)| *n == name) {
continue;
}
claude.push((
name,
crate::tui::Usage {
five_h: e.five_h,
five_h_reset: e.five_h_reset,
seven_d: e.seven_d,
seven_d_reset: e.seven_d_reset,
observed_at: Some(e.at),
note: None,
},
));
}
if let Ok(store) = Store::open(paths) {
let active = active_by_tool(&store, paths)
.into_iter()
.find(|(tool, _)| *tool == "codex")
.map(|(_, name)| name);
if let Some(name) = active {
if let Some(l) = crate::codex_limits::latest(paths, now_secs(), 7 * 86_400) {
let mut u = crate::tui::Usage {
observed_at: l.observed_at,
..Default::default()
};
for w in [l.short, l.long].into_iter().flatten() {
if w.window_minutes <= 600 {
u.five_h = Some(w.used_pct);
u.five_h_reset = w.resets_at;
} else {
u.seven_d = Some(w.used_pct);
u.seven_d_reset = w.resets_at;
}
}
claude.push((name, u));
}
}
}
claude
}
impl crate::tui::TuiCtx for Ctx<'_> {
fn rows(&mut self) -> Vec<crate::tui::Row> {
let Ok(store) = Store::open(self.paths) else {
return Vec::new();
};
let active = active_by_tool(&store, self.paths);
let cfg = crate::settings::load(self.paths);
let slot_dirs: Vec<(String, std::path::PathBuf)> =
crate::slots::Slots::open(self.paths)
.map(|s| {
s.list()
.into_iter()
.map(|r| (r.name, r.config_dir))
.collect()
})
.unwrap_or_default();
let pointer = crate::slots::Slots::open(self.paths)
.ok()
.and_then(|s| s.default_dir());
let serving = crate::proxy::serving_account(self.paths);
let slot_dir_of = |name: &str| {
slot_dirs
.iter()
.find(|(n, _)| n == name)
.map(|(_, d)| d.clone())
};
let codex_slots: Vec<(String, std::path::PathBuf)> =
crate::slots::Slots::open_for(self.paths, "codex")
.map(|s| {
s.list()
.into_iter()
.map(|r| (r.name, r.config_dir))
.collect()
})
.unwrap_or_default();
let codex_pointer = crate::slots::Slots::open_for(self.paths, "codex")
.ok()
.and_then(|s| s.default_dir());
let codex_dir_of = |name: &str| {
codex_slots
.iter()
.find(|(n, _)| n == name)
.map(|(_, d)| d.clone())
};
let list: Vec<crate::tui::Row> = store
.list()
.iter()
.map(|p| {
let (email, tier, marker) = profile_summary(&store, &p.name, &p.tools);
let at: Vec<&str> = active
.iter()
.filter(|(_, n)| n == &p.name)
.map(|(t, _)| *t)
.collect();
let is_claude = p.tools.iter().any(|t| t == "claude-code");
let is_codex = p.tools.iter().any(|t| t == "codex");
let by_pointer = match (&serving, &pointer, is_claude) {
(Some(s), _, true) => Some(s == &p.name),
(None, Some(ptr), true) => {
Some(slot_dir_of(&p.name).as_deref() == Some(ptr.as_path()))
}
_ => match (&codex_pointer, is_codex) {
(Some(ptr), true) => {
Some(codex_dir_of(&p.name).as_deref() == Some(ptr.as_path()))
}
_ => None,
},
};
crate::tui::Row {
is_slot: slot_dir_of(&p.name).is_some(),
disabled: cfg.is_disabled(&p.name),
needs_login: slot_dir_of(&p.name)
.is_some_and(|d| crate::proxy::creds::slot_token(&d).is_none()),
name: p.name.clone(),
ident: identity_column(email, tier),
tools: p
.tools
.iter()
.map(|t| {
if at.contains(&t.as_str()) {
format!("{t}*")
} else {
t.clone()
}
})
.collect::<Vec<_>>()
.join(", "),
active: by_pointer.unwrap_or(!at.is_empty()),
warn: marker,
also: Vec::new(),
stale: slot_dir_of(&p.name)
.is_some_and(|d| crate::proxy::creds::slot_token_expired(&d, now_ms())),
}
})
.collect::<Vec<_>>();
let mut list = list;
for (name, dir) in &codex_slots {
let r = crate::slots::SlotRecord {
name: name.clone(),
id: String::new(),
config_dir: dir.clone(),
adopted: false,
tool: "codex".into(),
};
list.push(crate::tui::Row {
is_slot: true,
disabled: cfg.is_disabled(&r.name),
needs_login: crate::proxy::codex::slot_auth(&r.config_dir).is_none(),
name: r.name.clone(),
ident: identity_column(codex_slot_email(&r.config_dir), None),
tools: "codex".into(),
active: codex_pointer.as_deref() == Some(r.config_dir.as_path()),
warn: None,
also: Vec::new(),
stale: false,
});
}
for (name, dir) in &slot_dirs {
if list.iter().any(|r| &r.name == name) {
continue;
}
list.push(crate::tui::Row {
is_slot: true,
disabled: cfg.is_disabled(name),
needs_login: crate::proxy::creds::slot_token(dir).is_none(),
name: name.clone(),
ident: identity_column(crate::proxy::creds::slot_email(dir), None),
tools: "claude-code".into(),
active: match &serving {
Some(s) => s == name,
None => pointer.as_deref() == Some(dir.as_path()),
},
warn: None,
also: Vec::new(),
stale: crate::proxy::creds::slot_token_expired(dir, now_ms()),
});
}
crate::tui::group_sorted(crate::tui::dedupe_by_identity(list))
}
fn switch(&mut self, name: &str) -> (bool, String) {
self.pre_switch_first = crate::session_link::read_timeline(self.paths).is_empty();
let tool = Store::open(self.paths)
.ok()
.and_then(|st| st.list().into_iter().find(|p| p.name == name))
.and_then(|p| {
["claude-code", "codex", "gemini", "antigravity"]
.into_iter()
.find(|t| p.tools.iter().any(|pt| pt == t))
});
match tool {
Some(t) => run_self(&["serve", name, "--tool", t]),
None => {
let t = ["claude-code", "codex"]
.into_iter()
.find(|t| {
crate::slots::Slots::open_for(self.paths, t)
.map(|s| s.get(name).is_some())
.unwrap_or(false)
})
.unwrap_or("claude-code");
run_self(&["serve", name, "--tool", t])
}
}
}
fn toggle_rotation(&mut self, name: &str) -> String {
let mut cfg = crate::settings::load(self.paths);
let paused = cfg.toggle_disabled(name);
match crate::settings::save(self.paths, &cfg) {
Ok(()) if paused => {
format!("{name} paused - the proxy will not pick it (Enter still switches)")
}
Ok(()) => format!("{name} back in rotation"),
Err(e) => format!("could not save that: {e}"),
}
}
fn delete(&mut self, name: &str) -> String {
run_self(&["rm", name, "--yes"]).1
}
fn rename(&mut self, old: &str, new: &str) -> (bool, String) {
run_self(&["rename", old, new])
}
fn sign_in(&mut self, name: &str) -> (bool, String) {
let tool = ["claude-code", "codex"]
.into_iter()
.find(|t| {
crate::slots::Slots::open_for(self.paths, t)
.map(|s| s.get(name).is_some())
.unwrap_or(false)
})
.unwrap_or("claude-code");
sign_in_child(self.paths, name, tool)
}
fn save_current(&mut self, name: &str) -> (bool, String) {
run_self(&["add", name])
}
fn doctor(&mut self) -> Vec<String> {
let exe = match std::env::current_exe() {
Ok(e) => e,
Err(e) => return vec![format!("cannot find own binary: {e}")],
};
match Command::new(exe)
.arg("doctor")
.stdin(std::process::Stdio::null())
.output()
{
Ok(out) => {
let mut text = String::from_utf8_lossy(&out.stdout).into_owned();
text.push_str(&String::from_utf8_lossy(&out.stderr));
text.lines().map(|l| l.to_string()).collect()
}
Err(e) => vec![format!("doctor failed: {e}")],
}
}
fn usage(&mut self) -> Vec<String> {
let exe = match std::env::current_exe() {
Ok(e) => e,
Err(e) => return vec![format!("cannot find own binary: {e}")],
};
match Command::new(exe)
.arg("usage")
.stdin(std::process::Stdio::null())
.output()
{
Ok(out) => {
let text = String::from_utf8_lossy(&out.stdout);
let mut lines: Vec<String> = text.lines().map(|l| l.to_string()).collect();
lines.push(String::new());
lines.push(
"swapdex is local: this is tokens USED here, not remaining quota."
.to_string(),
);
lines
}
Err(e) => vec![format!("usage failed: {e}")],
}
}
fn quota(&mut self) -> Vec<String> {
let exe = match std::env::current_exe() {
Ok(e) => e,
Err(e) => return vec![format!("cannot find own binary: {e}")],
};
match Command::new(exe)
.arg("quota")
.stdin(std::process::Stdio::null())
.output()
{
Ok(out) => {
let mut text = String::from_utf8_lossy(&out.stdout).into_owned();
text.push_str(&String::from_utf8_lossy(&out.stderr));
text.lines().map(|l| l.to_string()).collect()
}
Err(e) => vec![format!("quota failed: {e}")],
}
}
fn cached_quota(&mut self) -> Vec<(String, crate::tui::Usage)> {
crate::quota_cache::load(self.paths)
.into_iter()
.map(|(name, e)| {
(
name,
crate::tui::Usage {
five_h: e.five_h,
five_h_reset: e.five_h_reset,
seven_d: e.seven_d,
seven_d_reset: e.seven_d_reset,
observed_at: Some(e.at),
note: None,
},
)
})
.collect()
}
fn quota_pct(&mut self) -> Vec<(String, crate::tui::Usage)> {
read_quota_usage(self.paths)
}
fn quota_pct_async(
&mut self,
) -> std::sync::mpsc::Receiver<Vec<(String, crate::tui::Usage)>> {
let (tx, rx) = std::sync::mpsc::channel();
let paths = self.paths.clone();
std::thread::spawn(move || {
let _ = tx.send(read_quota_usage(&paths));
});
rx
}
fn proxy_running(&mut self) -> bool {
crate::proxy::running_port(self.paths).is_some()
}
fn sessionwiki_present(&mut self) -> bool {
command_exists("sessionwiki")
}
fn live_tools(&mut self) -> Vec<String> {
adapters::all()
.iter()
.filter(|a| a.present(self.paths))
.map(|a| pretty_tool(a.name()).to_string())
.collect()
}
fn sessions(
&mut self,
name: &str,
) -> (String, Vec<crate::tui::SessionEntry>, Vec<&'static str>) {
let first_time = self.pre_switch_first;
let (sessions, label) = recent_menu_sessions(self.paths, name, first_time, 5);
let tools: Vec<&'static str> = Store::open(self.paths)
.ok()
.and_then(|st| st.list().into_iter().find(|p| p.name == name))
.map(|p| {
["claude-code", "codex", "gemini", "antigravity"]
.into_iter()
.filter(|t| p.tools.iter().any(|x| x == t))
.collect()
})
.unwrap_or_default();
let entries = sessions
.iter()
.map(|s| {
let (id6, started, tool, title) = s.describe();
let age = age_line((started.max(0) as u128) * 1_000_000_000);
crate::tui::SessionEntry {
line: format!(
"{id6} {:>7} {} {}",
age,
fit(&format!("[{tool}]"), 13),
fit(&title, 44)
)
.trim_end()
.to_string(),
}
})
.collect();
self.last_sessions = sessions;
let label = if label.is_empty() {
format!("open a conversation on '{name}'")
} else {
label.trim_end_matches(':').to_string()
};
(label, entries, tools)
}
}
let mut ctx = Ctx {
paths,
last_sessions: Vec::new(),
pre_switch_first: crate::session_link::read_timeline(paths).is_empty(),
};
loop {
let has_slots = ["claude-code", "codex"].iter().any(|t| {
crate::slots::Slots::open_for(paths, t)
.map(|s| !s.list().is_empty())
.unwrap_or(false)
});
if Store::open(paths)?.list().is_empty()
&& !has_slots
&& adapters::all().iter().all(|a| !a.present(paths))
{
println!("No accounts saved yet, and you're not logged into any tool.");
println!(
" sign in to Claude Code / Codex / Gemini / Antigravity, then run `swapdex`."
);
return Ok(0);
}
match crate::tui::run(&mut ctx)? {
crate::tui::Outcome::Quit => return Ok(0),
crate::tui::Outcome::OpenSession(i) => {
let Some(sess) = ctx.last_sessions.get(i) else {
return Ok(0);
};
return Err(exec_menu_resume(sess));
}
crate::tui::Outcome::NewConv { tool, dir } => {
println!("opening {}...", pretty_tool(tool));
return Err(exec_tool(tool, dir.as_deref()));
}
crate::tui::Outcome::AddAccount(tool) => {
let sel = match tool {
"claude-code" => Some(ToolSel::Claude),
"codex" => Some(ToolSel::Codex),
"gemini" => Some(ToolSel::Gemini),
_ => Some(ToolSel::Antigravity),
};
let who = adapters::by_name(tool)
.and_then(|a| a.identity(paths).ok().flatten())
.and_then(|id| id.email)
.unwrap_or_else(|| "account".into());
let store = Store::open(paths)?;
let Some(name) = ask_name(
&store,
&format!("name for the new account [{}]: ", suggest_name(&who)),
&suggest_name(&who),
) else {
continue;
};
drop(store);
let rc = login(paths, &name, sel)?;
if rc != 0 {
return Ok(rc);
}
println!("(press Enter to go back to the picker)");
let _ = prompt("", "");
}
}
}
}
fn exec_tool(tool: &str, dir: Option<&std::path::Path>) -> anyhow::Error {
use std::os::unix::process::CommandExt;
let bin = match tool {
"claude-code" => "claude",
"codex" => "codex",
"gemini" => "gemini",
"antigravity" => "agy",
other => return anyhow::anyhow!("unknown tool '{other}'"),
};
let mut cmd = Command::new(bin);
if let Some(d) = dir {
cmd.current_dir(d);
}
let err = cmd.exec();
anyhow::anyhow!("could not launch `{bin}`: {err}")
}
fn exec_sessionwiki_resume(id: &str) -> anyhow::Error {
use std::os::unix::process::CommandExt;
let err = Command::new("sessionwiki")
.args(["resume", "--no-sync", "--", id])
.exec();
anyhow::anyhow!("could not launch `sessionwiki resume {id}`: {err}")
}
fn keychain_verdict(
found: &[String],
target: Option<&str>,
computed: &str,
) -> Option<(bool, String)> {
if found.is_empty() {
return None;
}
let list = found
.iter()
.map(|s| format!("'{s}'"))
.collect::<Vec<_>>()
.join(", ");
let Some(t) = target else {
return Some((
false,
format!(
"this environment's profile item ('{computed}') does not exist; the items \
present ({list}) belong to other CLAUDE_CONFIG_DIR profiles, and swapdex \
refuses to guess between them. Run swapdex with the profile's \
CLAUDE_CONFIG_DIR, or log in once with plain `claude` to create '{computed}'."
),
));
};
if !found.iter().any(|s| s == t) {
return Some((
false,
format!(
"swapdex resolves '{t}' but the Keychain currently shows {list} - re-run \
`swapdex doctor`; if this persists, launch swapdex with the same \
CLAUDE_CONFIG_DIR you launch `claude` with."
),
));
}
if t != computed {
return Some((
true,
format!(
"this environment derives '{computed}' (not present); managing the only \
Claude login, '{t}' - if your `claude` runs with a CLAUDE_CONFIG_DIR, \
launch swapdex with the same one"
),
));
}
let msg = if found.len() > 1 {
format!(
"managing this environment's profile ('{t}'); {} other Claude item(s) belong to \
other CLAUDE_CONFIG_DIR profiles (or are leftovers) - swapdex never touches them",
found.len() - 1
)
} else {
format!("managing this environment's profile ('{t}')")
};
Some((true, msg))
}
pub fn doctor(paths: &Paths) -> Result<i32> {
use std::os::unix::fs::PermissionsExt;
let pre_mode = std::fs::metadata(paths.store_dir())
.ok()
.map(|m| m.permissions().mode() & 0o777);
let store = Store::open(paths)?;
let mut problems = 0u32;
let color = crate::util::color_enabled();
let mut report = |label: &str, ok: bool, msg: String| {
let verdict = match (ok, color) {
(true, true) => "\x1b[32mok\x1b[0m".to_string(),
(false, true) => "\x1b[31mproblem\x1b[0m".to_string(),
(true, false) => "ok".to_string(),
(false, false) => "problem".to_string(),
};
println!("{label:<13} {verdict} - {msg}");
if !ok {
problems += 1;
}
};
let sd = paths.store_dir();
let profiles = store.list();
let count = format!(
"{} profile{}",
profiles.len(),
if profiles.len() == 1 { "" } else { "s" }
);
match (pre_mode, std::fs::metadata(&sd)) {
(Some(m), Ok(now)) if m & 0o077 != 0 && now.permissions().mode() & 0o077 == 0 => report(
"store",
true,
format!("was mode {m:03o} - tightened to 0700 just now; {count}"),
),
(_, Ok(now)) if now.permissions().mode() & 0o077 != 0 => report(
"store",
false,
format!(
"directory is group/world-accessible; run `chmod 700 {}`",
crate::util::redact_path(&sd.display().to_string())
),
),
(_, Ok(_)) => report("store", true, format!("0700, {count}")),
(_, Err(e)) => report("store", false, format!("cannot stat store dir: {e}")),
}
for adapter in adapters::all() {
let tool = adapter.name();
match adapter.identity(paths) {
Ok(Some(id)) => {
let saved = matched_profile_name(&store, tool, &id.account_id)
.map(|n| format!("profile '{n}'"))
.unwrap_or_else(|| "not saved - `swapdex add <name>` keeps it".into());
report(
tool,
true,
format!("live login {} ({saved})", identity_line(&id)),
);
}
Ok(None) => match macos_keychain_note(paths, tool) {
Some(note) => report(tool, true, format!("not manageable - {note}")),
None => report(tool, true, "not logged in".into()),
},
Err(_) => report(
tool,
false,
"live login file unreadable; `swapdex use <profile>` can replace it, \
or log in again in the tool"
.into(),
),
}
}
if let Some(diag) = crate::adapters::claude::keychain_diagnostic() {
if let Some((ok, msg)) =
keychain_verdict(&diag.found, diag.target.as_deref(), &diag.computed)
{
report("keychain", ok, msg);
}
if let Some(dir) = &diag.config_dir {
report(
"config-dir",
true,
format!(
"CLAUDE_CONFIG_DIR={} (swapdex must see the same value)",
crate::util::redact_path(dir)
),
);
}
}
if let Ok(slots) = crate::slots::Slots::open(paths) {
let list = slots.list();
if !list.is_empty() {
report("slots", true, format!("{} account(s)", list.len()));
match slots.default_dir() {
Some(dir) => {
let name = list
.iter()
.find(|r| r.config_dir == dir)
.map(|r| r.name.as_str())
.unwrap_or("(unknown)");
report("default", true, format!("plain `claude` -> '{name}'"));
}
None => report(
"default",
true,
"no default account set - `swapdex use <name>`".into(),
),
}
{
let bare = paths.claude_dir().to_path_buf();
let registered = crate::slots::Slots::open_for(paths, "claude-code")
.map(|s| s.list().iter().any(|r| r.config_dir == bare))
.unwrap_or(false);
let held = std::fs::read_dir(bare.join("projects"))
.map(|rd| rd.flatten().count())
.unwrap_or(0);
if !registered && held > 0 {
report(
"default store",
false,
format!(
"~/.claude holds {held} project(s) of conversations but is not a \
swapdex account, so a plain `claude -r` cannot reach them while \
another account is active - register it to switch back: \
`swapdex adopt personal {}`",
crate::util::redact_path(&bare.display().to_string())
),
);
}
}
{
let mixed: Vec<String> = crate::slots::Slots::open_for(paths, "claude-code")
.map(|s| s.list())
.unwrap_or_default()
.into_iter()
.filter_map(|r| {
crate::proxy::creds::identity_contradicts_login(&r.config_dir)
.map(|why| format!("'{}' is {why}", r.name))
})
.collect();
for line in &mixed {
report(
"account identity",
false,
format!("{line} - sign in again to make them agree"),
);
}
}
{
let colliding: Vec<String> = crate::slots::Slots::open_for(paths, "claude-code")
.map(|s| s.list())
.unwrap_or_default()
.into_iter()
.chain(
crate::slots::Slots::open_for(paths, "codex")
.map(|s| s.list())
.unwrap_or_default(),
)
.filter(|r| crate::slots::name_reads_as_a_tool_home(&r.name))
.map(|r| r.name)
.collect();
if !colliding.is_empty() {
report(
"account names",
false,
format!(
"account '{}' reads as the tool's own home directory but points \
somewhere else - rename it so the two are not confused: \
`swapdex rename {} <name>` (the folder and login are untouched)",
colliding.join("', '"),
colliding[0]
),
);
}
}
let shim_file = crate::shim::shim_path(paths);
let (shim_ok, shim_msg) = if !shim_file.exists() {
(
true,
"claude shim not installed - run `swapdex shim` so a plain \
`claude` follows `swapdex use`"
.to_string(),
)
} else {
let shim_dir = shim_file.parent().unwrap_or(&shim_file).display();
match crate::shim::resolved_claude() {
Some((_, true)) => (
true,
"claude shim active - plain `claude` follows `swapdex use`".to_string(),
),
Some((found, false)) => (
false,
format!(
"claude shim installed but NOT taking effect - plain `claude` \
runs {} instead; add the shim first on PATH: \
export PATH=\"{shim_dir}:$PATH\"",
found.display()
),
),
None => (
false,
format!(
"claude shim installed but PATH has no `claude` at all - \
add it: export PATH=\"{shim_dir}:$PATH\""
),
),
}
};
report("shim", shim_ok, shim_msg);
use crate::adapters::claude::SlotLogin;
for r in &list {
let key = format!("slot:{}", r.name);
match crate::adapters::claude::slot_login(&r.config_dir) {
SlotLogin::Absent => report(
&key,
true,
format!("no login yet - `swapdex run {}` once signs it in", r.name),
),
SlotLogin::Present(Some(ts)) if now_ms() - ts > STALE_DAYS * 86_400_000 => {
let days = (now_ms() - ts) / 86_400_000;
report(
&key,
true,
format!(
"login idle ~{days}d - `swapdex run {}` once refreshes \
it (re-login if it asks)",
r.name
),
);
}
SlotLogin::Present(_) => {}
}
}
}
}
for f in [
paths.claude_credentials(),
paths.codex_auth(),
paths.gemini_oauth(),
paths.antigravity_token(),
] {
if let Ok(meta) = std::fs::metadata(&f) {
use std::os::unix::fs::PermissionsExt;
if meta.permissions().mode() & 0o077 != 0 {
report(
"perms",
false,
format!(
"{} is group/world-readable (holds tokens); run `chmod 600` on it",
crate::util::redact_path(&f.display().to_string())
),
);
}
}
}
if paths.claude_config_json().exists() {
if let Ok(bytes) = std::fs::read(paths.claude_config_json()) {
if serde_json::from_slice::<Value>(&bytes).is_err() {
report(
"claude-config",
false,
format!(
"{} is not valid JSON - claude switches will fail until it is \
repaired or removed (removing loses local settings like \
project trust)",
crate::util::redact_path(&paths.claude_config_json().display().to_string())
),
);
}
}
}
if let Ok(meta) = std::fs::metadata(paths.claude_config_json()) {
if meta.permissions().mode() & 0o077 != 0 {
report(
"claude-config",
false,
format!(
"{} is group/world-readable; run `chmod 600` on it",
crate::util::redact_path(&paths.claude_config_json().display().to_string())
),
);
}
}
for p in &profiles {
for tool in &p.tools {
match profile_detail(&store, &p.name, tool) {
Some((_, _, Some("unreadable"))) => report(
&format!("profile:{}", p.name),
false,
format!(
"{tool} snapshot unreadable; log in to that account and run \
`swapdex add {} --tool {tool} --update`",
p.name
),
),
Some((_, _, Some(m))) => report(
&format!("profile:{}", p.name),
true,
format!(
"{tool} snapshot {m} - log in to that account and run \
`swapdex add {} --tool {tool} --update`",
p.name
),
),
_ => {}
}
}
}
let mut kept = Vec::new();
for tool in ["claude-code", "codex", "gemini", "antigravity"] {
if let Ok(Some((stamp, _))) = store.load_backup(tool) {
kept.push(format!("{tool} (newest {})", age_line(stamp)));
}
}
if kept.is_empty() {
report(
"backups",
true,
"none yet (one is taken on every `use`; `swapdex restore` brings it back)".into(),
);
} else {
report("backups", true, format!("intact - {}", kept.join(", ")));
}
let mut found = Vec::new();
for cli in ["claude", "codex", "gemini", "agy"] {
if command_exists(cli) {
found.push(cli);
}
}
report(
"tools",
true,
if found.is_empty() {
"none of `claude`, `codex`, `gemini`, `agy` found on PATH".into()
} else {
format!("on PATH: {}", found.join(", "))
},
);
if problems > 0 {
println!(
"\n{problems} problem{} found - each line above ends with its fix.",
if problems == 1 { "" } else { "s" }
);
return Ok(9);
}
println!("\neverything looks healthy.");
Ok(0)
}
pub fn rm(paths: &Paths, name: &str, yes: bool) -> Result<i32> {
if let Some(c) = reject_bad_name(name) {
return Ok(c);
}
let store = Store::open(paths)?;
let slot_tool = ["claude-code", "codex"].into_iter().find(|t| {
crate::slots::Slots::open_for(paths, t)
.map(|s| s.get(name).is_some())
.unwrap_or(false)
});
let is_slot = slot_tool.is_some();
let is_profile = store.list().iter().any(|p| p.name == name);
if !is_slot && !is_profile {
eprintln!("swapdex: no account named '{name}'");
return Ok(5);
}
if is_slot {
if !yes {
use std::io::IsTerminal;
let tty =
std::io::stdin().is_terminal() || std::env::var_os("SWAPDEX_ASSUME_TTY").is_some();
if !tty {
eprintln!(
"swapdex: `rm {name}` unregisters that account (its login and folder \
stay). Re-run with --yes to confirm."
);
return Ok(7);
}
if !yes_no(
&format!(
"stop managing account '{name}'? Its login and folder stay, so \
`swapdex adopt` can bring it back. [y/N]: "
),
false,
) {
println!("kept '{name}'.");
return Ok(0);
}
}
let mut slots = crate::slots::Slots::open_for(paths, slot_tool.unwrap_or("claude-code"))?;
let dir = slots.get(name).map(|r| r.config_dir);
slots.remove(name)?;
println!("stopped managing '{name}'.");
if let Some(d) = dir {
println!(
" its login is untouched at {}",
crate::util::redact_path(&d.display().to_string())
);
}
return Ok(0);
}
if !yes {
use std::io::IsTerminal;
let tty =
std::io::stdin().is_terminal() || std::env::var_os("SWAPDEX_ASSUME_TTY").is_some();
if !tty {
eprintln!(
"swapdex: `rm {name}` deletes the saved profile. Re-run with --yes to confirm."
);
return Ok(7);
}
if !yes_no(
&format!("delete saved profile '{name}'? The live login stays. [y/N]: "),
false,
) {
println!("kept '{name}'.");
return Ok(0);
}
}
let _lock = match store.lock() {
Ok(g) => g,
Err(crate::store::LockError::Busy) => {
eprintln!(
"swapdex: another swapdex is busy (a switch, or a `swapdex login` waiting \
for a sign-in). Finish or close it, then retry."
);
return Ok(4);
}
Err(crate::store::LockError::Unwritable(e)) => {
eprintln!(
"swapdex: the store is not writable ({e}) - check permissions/mount of \
the store directory"
);
return Ok(4);
}
};
if !store.remove(name)? {
eprintln!("swapdex: no profile named '{name}'");
return Ok(5);
}
println!("removed profile '{name}' (any live login it matched keeps running, now unsaved)");
Ok(0)
}
pub fn rename(paths: &Paths, old: &str, new: &str) -> Result<i32> {
if let Some(c) = reject_bad_name(old) {
return Ok(c);
}
if let Some(c) = reject_bad_name(new).or_else(|| reject_reserved_name(new)) {
return Ok(c);
}
if let Ok(mut slots) = crate::slots::Slots::open(paths) {
if slots.get(old).is_some() {
return match slots.rename(old, new) {
Ok(true) => {
println!("renamed account '{old}' to '{new}'");
Ok(0)
}
Ok(false) => {
eprintln!("swapdex: no account named '{old}'");
Ok(5)
}
Err(e) => {
eprintln!("swapdex: {e}");
Ok(6)
}
};
}
}
let store = Store::open(paths)?;
let _lock = match store.lock() {
Ok(g) => g,
Err(crate::store::LockError::Busy) => {
eprintln!(
"swapdex: another swapdex is busy (a switch, or a `swapdex login` waiting \
for a sign-in). Finish or close it, then retry."
);
return Ok(4);
}
Err(crate::store::LockError::Unwritable(e)) => {
eprintln!(
"swapdex: the store is not writable ({e}) - check permissions/mount of \
the store directory"
);
return Ok(4);
}
};
if !store.list().iter().any(|p| p.name == old) {
eprintln!("swapdex: no profile named '{old}'");
return Ok(5);
}
if store.profile_dir_exists(new) {
eprintln!("swapdex: a profile named '{new}' already exists");
return Ok(6);
}
if store.rename(old, new)? {
println!("renamed profile '{old}' -> '{new}'");
Ok(0)
} else {
eprintln!("swapdex: no profile named '{old}'");
Ok(5)
}
}
pub fn switch_outcome_line(tool: &str, name: &str, proxy_running: bool) -> String {
let bin = tool_binary(tool);
if proxy_running {
format!("{name} serves this session from the next turn ({bin} proxy is running)")
} else {
let flag = if tool == "codex" { " --tool codex" } else { "" };
let mut out = format!("default {bin} account -> {name}\n");
out.push_str(&format!(
" this applies to the NEXT {bin} you start; a session already open keeps the account it began with\n"
));
out.push_str(&format!(
" to move one that is already running: swapdex proxy{flag}\n"
));
out.push_str(
" note: past conversations stay with the account they were started in - \
`swapdex whereis` finds one",
);
out
}
}
fn use_slot_default(paths: &Paths, name: &str, tool: &str, dry_run: bool) -> Result<i32> {
let bin = tool_binary(tool);
if dry_run {
println!("would set the default {bin} account -> {name}");
return Ok(0);
}
let slots = crate::slots::Slots::open_for(paths, tool)?;
slots.set_default(name)?;
let proxy = crate::proxy::running_proxy_for(paths, tool).is_some();
println!("{}", switch_outcome_line(tool, name, proxy));
if !crate::shim::shim_path_for(paths, tool).exists() {
println!(
" tip: run `swapdex shim` once so a plain `{bin}` follows your switches\n\
\x20 (or launch directly with `swapdex run {name}`)"
);
}
Ok(0)
}
pub fn install_shim(paths: &Paths) -> Result<i32> {
let (shim, shim_dir) = crate::shim::install(paths)?;
println!("installed the claude shim at {}", shim.display());
match crate::shim::install_codex(paths)? {
Some(p) => println!("installed the codex shim at {}", p.display()),
None => println!(" (no `codex` on PATH - skipped its shim)"),
}
match crate::shim::ensure_on_path(&shim_dir)? {
crate::shim::PathSetup::AlreadyThere => {
println!(" it is already on your PATH - a plain `claude` goes through it");
}
crate::shim::PathSetup::Added(profile) => {
println!(
" added it to {} - open a new terminal (or `source` that file) and a plain \
`claude` goes through it",
crate::util::redact_path(&profile.display().to_string())
);
}
crate::shim::PathSetup::Manual => {
println!(
" add this to your shell profile so it wins over the real claude:\n\
\x20 export PATH=\"{}:$PATH\"",
shim_dir.display()
);
}
}
Ok(0)
}
fn ask_yes(question: &str) -> bool {
use std::io::IsTerminal;
let tty = std::io::stdin().is_terminal() || std::env::var_os("SWAPDEX_ASSUME_TTY").is_some();
if !tty {
return false;
}
matches!(
prompt(&format!("{question} [Y/n]"), "y").as_deref(),
Some("y") | Some("Y") | Some("")
)
}
fn onboarded_marker(paths: &Paths) -> std::path::PathBuf {
paths.store_dir().join("onboarded")
}
pub fn needs_onboarding(paths: &Paths) -> bool {
if onboarded_marker(paths).exists() {
return false;
}
let Ok(slots) = crate::slots::Slots::open(paths) else {
return false;
};
let has_unregistered = paths
.discover_claude_config_dirs()
.iter()
.any(|d| !slots.list().iter().any(|r| &r.config_dir == d));
if has_unregistered {
return true;
}
if let Ok(store) = Store::open(paths) {
return store
.list()
.iter()
.any(|p| p.tools.iter().any(|t| t == "claude-code") && slots.get(&p.name).is_none());
}
false
}
pub fn onboard(paths: &Paths) -> Result<i32> {
println!("swapdex gives each account its own space, so switching never logs you out.\n");
let slots_now = crate::slots::Slots::open(paths)?;
let unregistered: Vec<std::path::PathBuf> = paths
.discover_claude_config_dirs()
.into_iter()
.filter(|d| !slots_now.list().iter().any(|r| &r.config_dir == d))
.collect();
if !unregistered.is_empty() {
println!("Found Claude config dirs you already use:");
for d in &unregistered {
println!(" {}", d.display());
}
if ask_yes("Register them as swapdex accounts?") {
let mut s = crate::slots::Slots::open(paths)?;
for d in &unregistered {
let name = d
.file_name()
.and_then(|n| n.to_str())
.map(|n| n.trim_start_matches(".claude-").to_string())
.filter(|n| !n.is_empty())
.unwrap_or_else(|| "account".into());
match s.adopt(&name, d) {
Ok(r) => println!(" registered '{}'", r.name),
Err(e) => eprintln!(" skipped {}: {e}", d.display()),
}
}
}
println!();
}
if let Ok(store) = Store::open(paths) {
let s = crate::slots::Slots::open(paths)?;
let legacy = store
.list()
.into_iter()
.filter(|p| p.tools.iter().any(|t| t == "claude-code") && s.get(&p.name).is_none())
.count();
if legacy > 0 {
println!(
"You have {legacy} saved Claude profile(s) on the old copy-switch model \
(the one that could log you out)."
);
if ask_yes("Give each its own space now?") {
migrate(paths)?;
println!();
}
}
}
if !crate::shim::shim_path(paths).exists()
&& ask_yes("Make a plain `claude` follow `swapdex use`? (installs a small shim)")
{
install_shim(paths)?;
println!();
}
let _ = std::fs::create_dir_all(paths.store_dir());
let _ = std::fs::write(onboarded_marker(paths), b"1");
if crate::slots::Slots::open(paths)?.list().is_empty() {
println!("No accounts yet. Log into Claude, then run: swapdex run <name>");
} else {
println!("You're set. `swapdex ui` shows your accounts and switches between them.");
}
Ok(0)
}
pub fn migrate(paths: &Paths) -> Result<i32> {
let store = Store::open(paths)?;
let mut slots = crate::slots::Slots::open(paths)?;
let mut created = Vec::new();
for p in store.list() {
if !p.tools.iter().any(|t| t == "claude-code") {
continue;
}
if slots.get(&p.name).is_some() {
continue;
}
let taken: Vec<String> = slots.list().into_iter().map(|r| r.name).collect();
let name = if crate::slots::name_reads_as_a_tool_home(&p.name) {
let safe = crate::slots::suggest_non_colliding(&p.name, &taken);
println!(
" '{}' would read as the tool's own home, so the account is named '{safe}'",
p.name
);
safe
} else {
p.name.clone()
};
if let Ok(rec) = slots.create(&name) {
crate::slots::link_shared_config(&rec.config_dir, paths.claude_dir(), "claude-code");
created.push(name);
}
}
if created.is_empty() {
println!("Nothing to migrate - every Claude account already has its own space.");
return Ok(0);
}
println!(
"Created slots for: {}. Each account now has its own space - the surprise\n\
logouts when switching are gone.",
created.join(", ")
);
println!(" Log into each once (creates its own login):");
for n in &created {
println!(" swapdex run {n}");
}
if !crate::shim::shim_path(paths).exists() {
println!(" Then `swapdex shim` so a plain `claude` follows `swapdex use`.");
}
Ok(0)
}
pub fn sync_mcp(paths: &Paths) -> Result<i32> {
let src_path = paths.claude_config_json();
let src: Value = if src_path.exists() {
serde_json::from_slice(&crate::atomic::read_regular(&src_path)?).unwrap_or(Value::Null)
} else {
Value::Null
};
let mcp = src
.get("mcpServers")
.cloned()
.unwrap_or_else(|| serde_json::json!({}));
let n = mcp.as_object().map(|o| o.len()).unwrap_or(0);
if n == 0 {
println!("No MCP servers in ~/.claude.json to share.");
return Ok(0);
}
let slots = crate::slots::Slots::open(paths)?;
let mut synced = 0;
let mut pending = 0;
for r in slots.list() {
let target = r.config_dir.join(".claude.json");
if !target.exists() {
pending += 1;
continue;
}
let mut cfg: Value =
serde_json::from_slice(&crate::atomic::read_regular(&target)?).unwrap_or(Value::Null);
let Some(obj) = cfg.as_object_mut() else {
continue;
};
obj.insert("mcpServers".into(), mcp.clone());
crate::atomic::write_secret(&target, &serde_json::to_vec(&cfg)?)?;
synced += 1;
}
println!("shared {n} MCP server(s) into {synced} account(s).");
if pending > 0 {
println!(
" {pending} account(s) have no login yet - sign them in from `swapdex ui` (the `l` key)."
);
}
Ok(0)
}
pub fn adopt_slot(
paths: &Paths,
name: &str,
dir: &std::path::Path,
sel: Option<ToolSel>,
) -> Result<i32> {
let tool = slot_tool(sel);
let mut slots = crate::slots::Slots::open_for(paths, tool)?;
let rec = slots.adopt(name, dir)?;
println!(
"registered '{}' ({tool}) -> {}",
rec.name,
rec.config_dir.display()
);
Ok(0)
}
pub fn run_account(
paths: &Paths,
name: &str,
sel: Option<ToolSel>,
no_launch: bool,
args: &[String],
) -> Result<i32> {
use std::os::unix::process::CommandExt;
let tool = slot_tool(sel);
let Some(home_var) = crate::slots::home_var(tool) else {
eprintln!(
"swapdex: {tool} has no per-account home to launch into - only claude and codex do"
);
return Ok(2);
};
let mut slots = crate::slots::Slots::open_for(paths, tool)?;
let rec = match slots.get(name) {
Some(r) => r,
None => {
let r = slots.create(name)?;
crate::slots::link_shared_config(&r.config_dir, &shared_source(paths, tool), tool);
r
}
};
if no_launch {
println!("account '{name}' is ready ({tool})");
println!(
" its home: {}",
crate::util::redact_path(&rec.config_dir.display().to_string())
);
return Ok(0);
}
let bin = tool_binary(tool);
if !command_exists(bin) {
eprintln!("swapdex: `{bin}` isn't on your PATH. Install it, then retry.");
return Ok(3);
}
let mut cmd = std::process::Command::new(bin);
cmd.args(args).env(home_var, &rec.config_dir);
for var in ["ANTHROPIC_BASE_URL", "ANTHROPIC_API_KEY"] {
cmd.env_remove(var);
}
let err = cmd.exec();
Err(anyhow::anyhow!("failed to launch {bin}: {err}"))
}
pub(crate) fn sign_in_child(paths: &Paths, name: &str, tool: &str) -> (bool, String) {
let Some(home_var) = crate::slots::home_var(tool) else {
return (
false,
format!("{tool} has no per-account home to sign into"),
);
};
let mut slots = match crate::slots::Slots::open_for(paths, tool) {
Ok(s) => s,
Err(e) => return (false, format!("cannot open the account list: {e}")),
};
let rec = match slots.get(name) {
Some(r) => r,
None => match slots.create(name) {
Ok(r) => {
crate::slots::link_shared_config(&r.config_dir, &shared_source(paths, tool), tool);
r
}
Err(e) => return (false, format!("could not make a space for '{name}': {e}")),
},
};
let bin = tool_binary(tool);
if !command_exists(bin) {
return (false, format!("`{bin}` isn't on your PATH"));
}
let mut cmd = Command::new(bin);
cmd.env(home_var, &rec.config_dir);
for var in ["ANTHROPIC_BASE_URL", "ANTHROPIC_API_KEY"] {
cmd.env_remove(var);
}
match cmd.status() {
Ok(_) => {
let signed_in = match tool {
"codex" => crate::proxy::codex::slot_auth(&rec.config_dir).is_some(),
_ => crate::proxy::creds::slot_token(&rec.config_dir).is_some(),
};
if signed_in {
(true, format!("'{name}' is signed in"))
} else {
(
false,
format!("'{name}' still has no login - run it again and complete the sign-in"),
)
}
}
Err(e) => (false, format!("could not start {bin}: {e}")),
}
}
pub(crate) fn slot_tool(sel: Option<ToolSel>) -> &'static str {
match sel {
Some(ToolSel::Codex) => "codex",
Some(ToolSel::Gemini) => "gemini",
Some(ToolSel::Antigravity) => "antigravity",
_ => "claude-code",
}
}
fn tool_binary(tool: &str) -> &'static str {
match tool {
"codex" => "codex",
_ => "claude",
}
}
fn shared_source(paths: &Paths, tool: &str) -> std::path::PathBuf {
match tool {
"codex" => paths.codex_dir().to_path_buf(),
_ => paths.claude_dir().to_path_buf(),
}
}
pub fn whereis(paths: &Paths, project: Option<&str>) -> Result<i32> {
let mut found = crate::whereis::find(paths, project, 15);
found.extend(crate::whereis::find_codex(paths, project, 15));
found.sort_by_key(|f| std::cmp::Reverse(f.modified));
found.truncate(15);
if found.is_empty() {
match project {
Some(p) => println!("No conversation under a path matching '{p}', in any account."),
None => println!("No conversations found in any account's store yet."),
}
return Ok(0);
}
println!("conversations, newest first - the account column is whose store holds it\n");
let width = found
.iter()
.map(|f| f.account.chars().count())
.max()
.unwrap_or(8);
for f in &found {
println!(
" {:<width$} {:>8} {}",
f.account,
age_line(f.modified as u128 * 1_000_000_000),
f.project,
);
println!(" {}", f.resume_command());
}
println!(
"\nnaming the config dir is what makes these work from any account - the shim\n\
only fills that variable in when it is unset, so an explicit one always wins."
);
Ok(0)
}
pub fn resume(paths: &Paths, project: Option<&str>) -> Result<i32> {
use std::os::unix::process::CommandExt;
let cwd = std::env::current_dir().ok();
let filter = project
.map(str::to_string)
.or_else(|| cwd.as_ref().map(|d| d.display().to_string()));
let found = crate::whereis::find(paths, filter.as_deref(), 5);
let Some(top) = found.first() else {
match project {
Some(p) => {
eprintln!("swapdex: no conversation under a path matching '{p}', in any account")
}
None => eprintln!(
"swapdex: no conversation for this directory in any account - \
`swapdex whereis` lists what there is"
),
}
return Ok(5);
};
println!(
"resuming in '{}' ({})",
top.account,
crate::util::redact_path(&top.config_dir.display().to_string())
);
if found.len() > 1 {
println!(
" (newest of {} here - `swapdex whereis` lists the rest)",
found.len()
);
}
if !command_exists("claude") {
eprintln!("swapdex: `claude` isn't on your PATH. Install it, then retry.");
return Ok(3);
}
let err = std::process::Command::new("claude")
.arg("-r")
.arg(&top.session_id)
.env("CLAUDE_CONFIG_DIR", &top.config_dir)
.exec();
Err(anyhow::anyhow!("failed to launch claude: {err}"))
}
pub fn serve(paths: &Paths, name: Option<&str>, off: bool, sel: Option<ToolSel>) -> Result<i32> {
let tool = slot_tool(sel);
let bin = tool_binary(tool);
let slots = crate::slots::Slots::open_for(paths, tool)?;
if off {
slots.clear_serving()?;
println!("each session pays for itself again ({bin})");
return Ok(0);
}
let Some(name) = name else {
match slots.serving_dir() {
Some(dir) => {
let who = slots
.list()
.into_iter()
.find(|r| r.config_dir == dir)
.map(|r| r.name)
.unwrap_or_else(|| "(unknown)".into());
println!("turns are served by '{who}' ({bin})");
}
None => println!(
"no account is directing turns ({bin}) - each session pays for itself\n `swapdex serve <name>` hands them to one without moving your conversations"
),
}
return Ok(0);
};
if slots.get(name).is_none() {
eprintln!("swapdex: no account named '{name}' - `swapdex ui` lists them");
return Ok(5);
}
slots.set_serving(name)?;
if crate::proxy::running_proxy_for(paths, tool).is_none() {
let _ = proxy_ensure(paths, DEFAULT_PROXY_PORT, tool);
}
let live = crate::proxy::running_proxy_for(paths, tool).is_some();
println!("turns -> {name}");
if live {
println!(" the session you have open moves from its next turn");
} else {
println!(
" but nothing is carrying them yet - `swapdex proxy{}` in another terminal",
if tool == "codex" { " --tool codex" } else { "" }
);
}
println!(
" your conversations stay where they are - this changed who pays, not where you work"
);
Ok(0)
}
pub fn refresh(paths: &Paths, name: Option<&str>) -> Result<i32> {
let slots = crate::slots::Slots::open_for(paths, "claude-code")?;
let list: Vec<_> = match name {
Some(n) => match slots.get(n) {
Some(r) => vec![r],
None => {
eprintln!("swapdex: no account named '{n}' - `swapdex ui` lists them");
return Ok(5);
}
},
None => slots.list(),
};
if list.is_empty() {
println!("No Claude accounts to renew.");
return Ok(0);
}
let now = now_ms();
let mut renewed = 0;
for r in &list {
if !crate::proxy::creds::slot_token_expired(&r.config_dir, now) {
println!(" {} is already current", r.name);
continue;
}
match crate::refresh::refresh_slot(&r.config_dir, now) {
Ok(()) => {
println!(" {} renewed", r.name);
renewed += 1;
}
Err(why) => println!(" {}", why.remedy(&r.name)),
}
}
if renewed > 0 {
println!("\n{renewed} account(s) renewed - no sign-in needed.");
}
Ok(0)
}
pub fn list_slots(paths: &Paths) -> Result<i32> {
let mut any = false;
for tool in ["claude-code", "codex"] {
let list = crate::slots::Slots::open_for(paths, tool)?.list();
if list.is_empty() {
continue;
}
let pointer = crate::slots::Slots::open_for(paths, tool)?.default_dir();
println!("{tool}:");
for r in list {
let mark = if pointer.as_deref() == Some(r.config_dir.as_path()) {
"*"
} else {
" "
};
println!("{mark} {} {}", r.name, r.config_dir.display());
}
any = true;
}
if !any {
println!("No accounts yet. Run `swapdex onboard` to set them up,");
println!(" or launch one directly: swapdex run <name>");
return Ok(0);
}
println!(" (* is the account a plain launch uses)");
Ok(0)
}
pub fn login(paths: &Paths, name: &str, sel: Option<ToolSel>) -> Result<i32> {
crate::atomic::ensure_not_root()?;
if let Some(c) = reject_bad_name(name).or_else(|| reject_reserved_name(name)) {
return Ok(c);
}
let tool = match sel {
Some(ToolSel::Claude) => "claude-code",
Some(ToolSel::Codex) => "codex",
Some(ToolSel::Gemini) => "gemini",
Some(ToolSel::Antigravity) => "antigravity",
_ => {
use std::io::IsTerminal;
let tty =
std::io::stdin().is_terminal() || std::env::var_os("SWAPDEX_ASSUME_TTY").is_some();
if !tty {
eprintln!("swapdex: say which tool: swapdex login {name} --tool <claude|codex|gemini|antigravity>");
return Ok(2);
}
println!("Which tool do you want to log '{name}' into?");
println!(" 1) Claude Code 2) Codex 3) Gemini CLI 4) Antigravity");
loop {
match prompt("pick [1-4] (Enter cancels): ", "").as_deref() {
Some("1") => break "claude-code",
Some("2") => break "codex",
Some("3") => break "gemini",
Some("4") => break "antigravity",
Some("") | None => {
println!("cancelled.");
return Ok(0);
}
_ => println!("pick a number between 1 and 4 (Enter cancels)"),
}
}
}
};
let bin = match tool {
"claude-code" => "claude",
"codex" => "codex",
"gemini" => "gemini",
_ => "agy",
};
if !command_exists(bin) {
eprintln!("swapdex: `{bin}` isn't on your PATH. Install it, then retry.");
return Ok(3);
}
let adapter = adapters::by_name(tool).expect("known tool");
let flag = pretty_tool_flag(tool);
let Some(cur) = adapter.identity(paths).ok().flatten() else {
println!(
"Opening {} to sign in. Complete the login{}",
pretty_tool(tool),
if tool == "codex" {
" in your browser.".to_string()
} else {
", then exit it.".to_string()
}
);
spawn_tool_login(bin, tool)?;
if adapter.identity(paths).ok().flatten().is_none() {
eprintln!(
"swapdex: no {} login was completed - nothing saved.",
pretty_tool(tool)
);
return Ok(8);
}
println!();
return add(paths, Some(name), sel_for_tool(tool), true);
};
use std::io::IsTerminal;
let tty = std::io::stdin().is_terminal() || std::env::var_os("SWAPDEX_ASSUME_TTY").is_some();
if !tty {
println!(
"You're already logged into {} ({}).",
pretty_tool(tool),
identity_line(&cur)
);
println!(" save the current account: swapdex add {name} --tool {flag}");
println!(
" add a DIFFERENT account: swapdex login {name} --tool {flag} (on a terminal)"
);
return Ok(3);
}
println!("Currently logged in as {}.", identity_line(&cur));
if !yes_no(
&format!(
"Sign in to a DIFFERENT account as '{name}'? swapdex will save the \
current login, sign you out locally, and open {} for the \
new sign-in. [Y/n]: ",
pretty_tool(tool)
),
true,
) {
println!("cancelled - nothing changed.");
return Ok(0);
}
let store = Store::open(paths)?;
let lock1 = match store.lock() {
Ok(g) => g,
Err(crate::store::LockError::Busy) => {
eprintln!(
"swapdex: another swapdex is busy (a switch, or a `swapdex login` waiting \
for a sign-in). Finish or close it, then retry."
);
return Ok(4);
}
Err(crate::store::LockError::Unwritable(e)) => {
eprintln!(
"swapdex: the store is not writable ({e}) - check permissions/mount of \
the store directory"
);
return Ok(4);
}
};
let _tool_lock = match store.lock_tool(tool) {
Ok(g) => g,
Err(_) => {
eprintln!(
"swapdex: another swapdex is signing {tool} in/out right now. \
Finish or close it, then retry."
);
return Ok(4);
}
};
let stash = adapter.capture(paths)?;
store.backup(&stash)?;
for pname in matching_profile_names(&store, tool, &cur.account_id) {
store.save(&pname, &stash)?;
}
if matched_profile_name(&store, tool, &cur.account_id).is_none() {
let suggestion = match &cur.email {
Some(e) => suggest_name(e),
None => "main".to_string(),
};
while let Some(keep) = ask_name(
&store,
&format!("name to keep the CURRENT account under [{suggestion}]: "),
&suggestion,
) {
if keep == name {
println!("'{name}' is the name for the NEW account - pick another.");
continue;
}
store.save(&keep, &stash)?;
println!("saved current login as '{keep}'.");
break;
}
}
sign_out_locally(paths, tool);
let still_same = adapter
.identity(paths)
.ok()
.flatten()
.is_some_and(|still| still.account_id == cur.account_id);
if still_same || adapter.present(paths) {
adapter.apply(paths, &stash)?;
drop(lock1);
eprintln!(
"swapdex: couldn't sign {} out of the current account ({}), so a new \
account can't be added this way - your login is unchanged.",
pretty_tool(tool),
identity_line(&cur)
);
eprintln!(" {}", same_account_hint(tool));
return Ok(0);
}
drop(lock1);
println!(
"Opening {} - sign in with the OTHER account{}",
pretty_tool(tool),
if tool == "codex" {
" in your browser.".to_string()
} else {
", then exit it.".to_string()
}
);
println!(" tip: {}", same_account_hint(tool));
let spawn = spawn_tool_login(bin, tool);
let mut relock = store.lock().ok();
for _ in 0..25 {
if relock.is_some() {
break;
}
std::thread::sleep(std::time::Duration::from_millis(120));
relock = store.lock().ok();
}
let _lock = relock;
let new_id = adapter.identity(paths).ok().flatten();
match (spawn, new_id) {
(Ok(status), Some(new)) if !new.account_id.is_empty() => {
if new.account_id == cur.account_id {
adapter.apply(paths, &stash)?;
eprintln!(
"swapdex: you were signed back into the SAME account ({}), so \
nothing was saved as '{name}'.",
identity_line(&new)
);
eprintln!(" {}", same_account_hint(tool));
eprintln!(
" (to just save THIS account under a name, use `swapdex add {name} \
--tool {}`.)",
pretty_tool_flag(tool)
);
return Ok(0);
}
let has_tool_snapshot = store
.list()
.iter()
.any(|p| p.name == name && p.tools.iter().any(|t| t == tool));
let same_account = profile_account_id(&store, name, tool)
.filter(|s| !s.is_empty())
.as_deref()
== Some(new.account_id.as_str());
if has_tool_snapshot
&& !same_account
&& !yes_no(
&format!(
"profile '{name}' already holds a different (or unreadable) \
{tool} account. Repoint it to this new login? [y/N]: "
),
false,
)
{
if let Some(rescue) = ask_name(
&store,
"save the NEW account under a different name instead (Enter discards it): ",
"",
) {
if rescue != name {
let snap = adapter.capture(paths)?;
store.save(&rescue, &snap)?;
println!(
"saved profile '{rescue}' ({}). '{name}' is untouched.",
identity_line(&new)
);
println!("switch back any time: swapdex use <name> (or `swapdex ui`)");
return Ok(0);
}
}
adapter.apply(paths, &stash)?;
println!(
"the new sign-in was DISCARDED and your previous login restored - \
'{name}' is untouched. Re-run `swapdex login <other-name>` to \
redo it under another name."
);
return Ok(0);
}
let snap = adapter.capture(paths)?;
store.save(name, &snap)?;
println!("saved profile '{name}' ({}).", identity_line(&new));
if tool == "antigravity" {
println!(
"note: Antigravity stores no account identity on disk - swapdex \
cannot confirm WHICH Google account this is; verify inside agy."
);
}
if !status.success() {
println!(
"note: {} exited with an error after signing in - if anything \
looks off, `swapdex restore --tool {flag}` undoes this.",
pretty_tool(tool)
);
}
println!("switch back any time: swapdex use <name> (or `swapdex ui`)");
Ok(0)
}
_ => {
adapter.apply(paths, &stash)?;
eprintln!(
"swapdex: no new {} login was completed - your previous \
login ({}) was restored.",
pretty_tool(tool),
identity_line(&cur)
);
Ok(8)
}
}
}
fn sel_for_tool(tool: &str) -> Option<ToolSel> {
match tool {
"claude-code" => Some(ToolSel::Claude),
"codex" => Some(ToolSel::Codex),
"gemini" => Some(ToolSel::Gemini),
"antigravity" => Some(ToolSel::Antigravity),
_ => None,
}
}
fn codex_device_auth(opt_out_browser: bool) -> bool {
!opt_out_browser
}
fn codex_login_opts_out_of_device() -> bool {
std::env::var("SWAPDEX_CODEX_LOGIN").is_ok_and(|v| v.eq_ignore_ascii_case("browser"))
}
fn spawn_tool_login(bin: &str, tool: &str) -> Result<std::process::ExitStatus> {
unsafe extern "C" fn ride_out(_: libc::c_int) {}
#[allow(function_casts_as_integer)]
let prev_int = unsafe { libc::signal(libc::SIGINT, ride_out as libc::sighandler_t) };
#[allow(function_casts_as_integer)]
let prev_quit = unsafe { libc::signal(libc::SIGQUIT, ride_out as libc::sighandler_t) };
let mut cmd = Command::new(bin);
match tool {
"codex" => {
cmd.arg("login");
if codex_device_auth(codex_login_opts_out_of_device()) {
cmd.arg("--device-auth");
}
}
"claude-code" => {
cmd.args(["auth", "login"]);
}
_ => {}
}
let status = cmd.status();
unsafe {
libc::signal(libc::SIGINT, prev_int);
libc::signal(libc::SIGQUIT, prev_quit);
}
status.map_err(|e| anyhow::anyhow!("could not run {bin}: {e}"))
}
fn sign_out_locally(paths: &Paths, tool: &str) {
match tool {
"claude-code" => {
std::fs::remove_file(paths.claude_credentials()).ok();
crate::adapters::claude::keychain_delete();
if let Ok(bytes) = std::fs::read(paths.claude_config_json()) {
if let Ok(mut cfg) = serde_json::from_slice::<Value>(&bytes) {
if let Some(obj) = cfg.as_object_mut() {
obj.remove("oauthAccount");
if let Ok(out) = serde_json::to_vec(&cfg) {
let _ = crate::atomic::write_secret(&paths.claude_config_json(), &out);
}
}
}
}
}
"codex" => {
std::fs::remove_file(paths.codex_auth()).ok();
}
"gemini" => {
std::fs::remove_file(paths.gemini_oauth()).ok();
std::fs::remove_file(paths.gemini_accounts()).ok();
}
_ => {
std::fs::remove_file(paths.antigravity_token()).ok();
}
}
}
fn prompt(question: &str, default: &str) -> Option<String> {
use std::io::Write;
print!("{question}");
let _ = std::io::stdout().flush();
let mut line = String::new();
match std::io::stdin().read_line(&mut line) {
Ok(0) | Err(_) => return None, Ok(_) => {}
}
let t = line.trim();
Some(if t.is_empty() {
default.to_string()
} else {
t.to_string()
})
}
fn suggest_name(who: &str) -> String {
let base = who.split('@').next().unwrap_or(who);
let clean: String = base
.chars()
.filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_')
.collect();
if crate::store::valid_profile_name(&clean) {
clean
} else {
"account".to_string()
}
}
fn same_account_hint(tool: &str) -> String {
match tool {
"claude-code" => "To add a different account: sign out at claude.ai in your browser \
first (or use /logout then /login inside Claude Code and pick the other \
account), then run this again."
.to_string(),
"codex" => "Codex re-used your ChatGPT browser session. Sign out at chatgpt.com \
(or open the login in a different browser / private window), then run this \
again."
.to_string(),
_ => "The tool re-used your signed-in Google account. Choose the OTHER account at \
Google's account picker (or sign the first one out in your browser), then \
run this again."
.to_string(),
}
}
fn pretty_tool(tool: &str) -> &str {
match tool {
"claude-code" => "Claude Code",
"codex" => "Codex",
"gemini" => "Gemini CLI",
"antigravity" => "Antigravity",
other => other,
}
}
fn yes_no(question: &str, default_yes: bool) -> bool {
let Some(a) = prompt(question, if default_yes { "y" } else { "n" }) else {
return false;
};
matches!(a.to_ascii_lowercase().as_str(), "y" | "yes")
}
fn ask_name(store: &Store, question: &str, default: &str) -> Option<String> {
loop {
let ans = prompt(question, default)?; if ans.eq_ignore_ascii_case("skip") || ans.is_empty() {
return None;
}
if !crate::store::valid_profile_name(&ans) {
println!(" '{ans}' can't be a name (1-64 bytes, not all spaces; no '/', '\\\\', leading '.', or control chars). Try again.");
continue;
}
if ans == "-" {
println!(
" '-' is reserved (`swapdex use -` toggles to the previous profile). Try again."
);
continue;
}
if store.list().iter().any(|p| p.name == ans)
&& !yes_no(
&format!(" '{ans}' already exists - replace it? [y/N]: "),
false,
)
{
continue;
}
return Some(ans);
}
}
pub fn setup(paths: &Paths) -> Result<i32> {
use std::io::IsTerminal;
crate::atomic::ensure_not_root()?;
if !std::io::stdin().is_terminal() && std::env::var_os("SWAPDEX_ASSUME_TTY").is_none() {
eprintln!(
"swapdex setup is interactive - run it in a terminal, or use `swapdex login <name>`."
);
return Ok(1);
}
let store = Store::open(paths)?;
println!(
"swapdex keeps several Claude Code / Codex / Gemini / Antigravity logins and switches between them."
);
println!(
"Let's save the accounts you use. Press Enter to accept a [default], Ctrl-C to quit.\n"
);
for adapter in adapters::all() {
let tool = adapter.name();
let id = match adapter.identity(paths) {
Ok(Some(id)) => id,
Ok(None) => {
println!("{}: not logged in - skipping.\n", pretty_tool(tool));
continue;
}
Err(e) => {
println!(
"{}: login present but unreadable ({}) - skipping.\n",
pretty_tool(tool),
crate::util::redact_path(&format!("{e:#}"))
);
continue;
}
};
if let Some(existing) = matched_profile_name(&store, tool, &id.account_id) {
println!("{}: already saved as '{existing}'.\n", pretty_tool(tool));
continue;
}
let who = id.email.clone().unwrap_or_else(|| id.display.clone());
let default = suggest_name(&who);
println!("{}: you're logged in as {who}.", pretty_tool(tool));
if let Some(p) = store.list().into_iter().find(|p| p.name == default) {
if !p.tools.iter().any(|t| t == tool) {
match adapter.capture(paths) {
Ok(snap) => {
store.save(&default, &snap)?;
println!(" attached {} to '{default}'.\n", pretty_tool(tool));
}
Err(e) => {
eprintln!(" could not read this login ({e:#}) - skipped.\n");
}
}
continue;
}
}
match ask_name(
&store,
&format!(" save it as [{default}] (Enter to accept, 'skip' to skip): "),
&default,
) {
Some(name) => match adapter.capture(paths) {
Ok(snap) => {
store.save(&name, &snap)?;
println!(" saved as '{name}'.\n");
}
Err(e) => {
eprintln!(" could not read this login ({e:#}) - skipped.\n");
}
},
None => println!(" skipped.\n"),
}
}
println!("You can keep several accounts per tool (e.g. work and personal).");
loop {
if !yes_no(" add another account now? [y/N]: ", false) {
break;
}
println!(" which tool? 1) Claude Code 2) Codex 3) Gemini CLI 4) Antigravity");
let sel = loop {
match prompt(" pick [1-4] (Enter cancels): ", "").as_deref() {
Some("1") => break Some(ToolSel::Claude),
Some("2") => break Some(ToolSel::Codex),
Some("3") => break Some(ToolSel::Gemini),
Some("4") => break Some(ToolSel::Antigravity),
Some("") | None => break None,
_ => println!(" pick a number between 1 and 4 (Enter cancels)"),
}
};
let Some(sel) = sel else {
println!(" skipped.\n");
continue;
};
let name = match ask_name(&store, " name for it (e.g. personal): ", "") {
Some(n) => n,
None => {
println!(" skipped.\n");
continue;
}
};
let _ = login(paths, &name, Some(sel))?;
println!();
}
let names: Vec<String> = store.list().into_iter().map(|p| p.name).collect();
println!();
if names.is_empty() {
println!(
"No accounts saved yet. Log into Claude Code or Codex, then run `swapdex setup` again."
);
} else {
println!("You're set - saved: {}.", names.join(", "));
println!(" switch: swapdex use <name>");
println!(" see all: swapdex ls");
if names.len() > 1 {
println!("Switching takes effect on your next message - no restart needed.");
}
}
Ok(0)
}
fn now_ms() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0)
}
fn now_secs() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
pub fn usage(paths: &Paths, json: bool) -> Result<i32> {
let rows = crate::usage::tool_usage(paths);
if json {
let out: Vec<Value> = rows
.iter()
.map(|r| {
let accounts: serde_json::Map<String, Value> = r
.accounts
.iter()
.map(|(name, (t5, t7))| {
(
name.clone(),
serde_json::json!({"last_5h_tokens": t5, "last_7d_tokens": t7}),
)
})
.collect();
serde_json::json!({
"tool": r.tool,
"last_5h": {"sessions": r.w5h.sessions, "tokens": r.w5h.tokens},
"last_7d": {"sessions": r.w7d.sessions, "tokens": r.w7d.tokens},
"accounts": accounts,
})
})
.collect();
println!("{}", serde_json::to_string(&out)?);
return Ok(0);
}
if rows.iter().all(|r| r.w7d.sessions == 0) {
println!("No recent session activity found (reads ~/.claude and ~/.codex, locally).");
return Ok(0);
}
println!("Local usage - this machine, approximate (not the billed quota):");
for r in &rows {
if r.w7d.sessions == 0 {
continue;
}
println!(
" {:<12} 5h: {:>7} tok / {} sess 7d: {:>8} tok / {} sess",
r.tool,
crate::usage::human(r.w5h.tokens),
r.w5h.sessions,
crate::usage::human(r.w7d.tokens),
r.w7d.sessions,
);
for (name, (t5, t7)) in &r.accounts {
println!(
" @{:<11} 5h: {:>7} tok 7d: {:>8} tok",
name,
crate::usage::human(*t5),
crate::usage::human(*t7),
);
}
let attributed7: u64 = r.accounts.values().map(|(_, t7)| *t7).sum();
let rest = r.w7d.tokens.saturating_sub(attributed7);
if !r.accounts.is_empty() && rest > 0 {
println!(
" {:<12} 5h: 7d: {:>8} tok (before your first switch)",
"(untagged)",
crate::usage::human(rest),
);
}
}
let uncovered: Vec<&str> = ["gemini", "antigravity"]
.into_iter()
.filter(|t| {
adapters::by_name(t)
.map(|a| a.present(paths))
.unwrap_or(false)
})
.collect();
if !uncovered.is_empty() {
println!(
"note: {} not shown - those CLIs keep no local token transcripts to read",
uncovered.join(" and ")
);
}
println!("(summed locally from session transcripts; accounts via the switch timeline)");
Ok(0)
}
fn codex_slot_email(dir: &std::path::Path) -> Option<String> {
let bytes = std::fs::read(dir.join("auth.json")).ok()?;
let v: Value = serde_json::from_slice(&bytes).ok()?;
adapters::codex::decode_email_from_id_token(v["tokens"]["id_token"].as_str())
}
fn slot_dir_named(paths: &Paths, name: &str) -> Option<std::path::PathBuf> {
crate::slots::Slots::open(paths)
.ok()?
.list()
.into_iter()
.find(|r| r.name == name)
.map(|r| r.config_dir)
}
pub fn quota(paths: &Paths, json: bool) -> Result<i32> {
use crate::quota::{self as q, Fetch};
struct Row {
label: String,
name: String,
email: Option<String>,
token: Option<String>,
active: bool,
expired: bool,
}
let live_id = adapters::claude::Claude.identity(paths).ok().flatten();
let live_uuid = live_id
.as_ref()
.map(|a| a.account_id.clone())
.filter(|s| !s.is_empty());
let live_token = adapters::claude::live_credentials(paths)
.as_deref()
.and_then(q::token_from_credentials);
let mut rows: Vec<Row> = Vec::new();
let mut matched_live = false;
if let Ok(store) = Store::open(paths) {
for p in store.list() {
if !p.tools.iter().any(|t| t == "claude-code") {
continue;
}
let snap = store.load(&p.name, "claude-code").ok().flatten();
let (mut email, mut uuid, mut token) = (None, None, None);
let mut expired = false;
if let Some(s) = &snap {
if let Some(o) = s
.part("oauth_account")
.and_then(|o| serde_json::from_slice::<Value>(o.expose()).ok())
{
email = o["emailAddress"].as_str().map(str::to_string);
uuid = o["accountUuid"].as_str().map(str::to_string);
}
token = s
.part("credentials")
.and_then(|c| q::token_from_credentials(c.expose()));
expired = s
.part("credentials")
.is_some_and(|c| q::credentials_expired(c.expose(), now_ms()));
}
if let Some(dir) = slot_dir_named(paths, &p.name) {
if let Some(t) = crate::proxy::creds::slot_token(&dir) {
token = Some(String::from_utf8_lossy(t.expose()).to_string());
expired = crate::proxy::creds::slot_token_expired(&dir, now_ms());
email = crate::proxy::creds::slot_email(&dir).or(email);
uuid = crate::proxy::creds::slot_account_uuid(&dir).or(uuid);
}
}
let active = live_uuid.is_some() && uuid == live_uuid;
matched_live |= active;
rows.push(Row {
label: if active {
format!("{} (active)", p.name)
} else {
p.name.clone()
},
name: p.name.clone(),
email: if active {
live_id.as_ref().and_then(|a| a.email.clone()).or(email)
} else {
email
},
token: if active { live_token.clone() } else { token },
active,
expired: expired && !active,
});
}
}
if let Ok(slots) = crate::slots::Slots::open(paths) {
for r in slots.list() {
if rows.iter().any(|x| x.name == r.name) {
continue;
}
let token = crate::proxy::creds::slot_token(&r.config_dir)
.map(|t| String::from_utf8_lossy(t.expose()).to_string());
let uuid = crate::proxy::creds::slot_account_uuid(&r.config_dir);
let active = live_uuid.is_some() && uuid == live_uuid;
matched_live |= active;
rows.push(Row {
label: if active {
format!("{} (active)", r.name)
} else {
r.name.clone()
},
name: r.name.clone(),
email: crate::proxy::creds::slot_email(&r.config_dir),
token,
active,
expired: {
if crate::proxy::creds::slot_token_expired(&r.config_dir, now_ms()) {
let _ = crate::refresh::refresh_slot(&r.config_dir, now_ms());
}
crate::proxy::creds::slot_token_expired(&r.config_dir, now_ms())
},
});
}
}
if !matched_live && live_token.is_some() {
rows.insert(
0,
Row {
label: "(active login, not saved)".into(),
name: "(active login, not saved)".into(),
email: live_id.as_ref().and_then(|a| a.email.clone()),
token: live_token.clone(),
active: true,
expired: false,
},
);
}
rows.sort_by_key(|r| !r.active);
if rows.is_empty() {
if json {
println!("{}", serde_json::json!({"accounts": [], "offline": null}));
} else {
println!(
"No Claude accounts found. Log in with `claude`, or `swapdex add` to save one."
);
}
return Ok(0);
}
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
let mut results: Vec<(usize, Fetch)> = Vec::new();
let mut to_fetch: Vec<(usize, String)> = Vec::new();
let mut offline: Option<String> = None;
for (i, r) in rows.iter().enumerate() {
match &r.token {
None => results.push((i, Fetch::Offline("no saved token".into()))),
Some(_) if r.expired => results.push((
i,
Fetch::Offline(
"saved token expired - snapshots go stale as refresh tokens rotate; \
`swapdex run <name>` gives this account a slot that stays fresh"
.into(),
),
)),
Some(t) if !q::token_usable(t) => results.push((
i,
Fetch::Offline(
"saved token unusable (corrupt snapshot?) - `swapdex add <name> --update` \
re-saves it"
.into(),
),
)),
Some(t) => to_fetch.push((i, t.clone())),
}
}
if !to_fetch.is_empty() {
let got = q::fetch_many(to_fetch);
let any_reached = got.iter().any(|(_, f)| !matches!(f, Fetch::Offline(_)));
if !any_reached {
if let Some((_, Fetch::Offline(msg))) = got.first() {
offline = Some(msg.clone());
}
}
results.extend(got);
}
results.sort_by_key(|(i, _)| *i);
let remembered: Vec<(String, crate::quota_cache::Entry)> = results
.iter()
.filter_map(|(i, f)| match f {
Fetch::Ok(qd) => Some((
rows[*i].name.clone(),
crate::quota_cache::Entry {
five_h: qd.five_hour.map(|w| w.used_pct),
five_h_reset: qd.five_hour.and_then(|w| w.resets_at),
seven_d: qd.seven_day.map(|w| w.used_pct),
seven_d_reset: qd.seven_day.and_then(|w| w.resets_at),
at: now,
},
)),
_ => None,
})
.collect();
crate::quota_cache::update(paths, &remembered);
if json {
let accounts: Vec<Value> = results
.iter()
.map(|(i, f)| {
quota_json(
&rows[*i].name,
rows[*i].email.as_deref(),
rows[*i].active,
f,
)
})
.collect();
println!(
"{}",
serde_json::json!({"accounts": accounts, "offline": offline})
);
return Ok(0);
}
if let Some(msg) = offline {
println!("swapdex quota: could not reach api.anthropic.com - {msg}");
println!(
"(quota is the only swapdex command that uses the network; everything else is local)"
);
return Ok(0);
}
println!("quota - remaining on your Claude accounts");
println!("live from Anthropic's usage endpoint; opt-in network, spends 0 message quota.\n");
for (i, f) in &results {
let r = &rows[*i];
match &r.email {
Some(e) => println!("{} {}", r.label, e),
None => println!("{}", r.label),
}
match f {
Fetch::Ok(qd) => {
let mut any = false;
if let Some(w) = qd.five_hour {
println!(" {}", win_line("5h", &w, now));
any = true;
}
if let Some(w) = qd.seven_day {
println!(" {}", win_line("7d", &w, now));
any = true;
}
for (label, w) in &qd.scoped {
println!(" {}", win_line(label, w, now));
any = true;
}
if !any {
println!(
" (endpoint reported no windows - `swapdex quota --json` to inspect)"
);
}
}
Fetch::Unauthorized => {
if r.active {
println!(" active token rejected - run `claude` once to refresh, then retry");
} else {
println!(
" snapshot token expired - `swapdex use {}` to refresh, then `swapdex quota`",
r.name
);
}
}
Fetch::Unexpected(code, _) => {
println!(
" unexpected response (HTTP {code}) - run `swapdex quota --json` to see it"
);
}
Fetch::Offline(msg) => println!(" {msg}"),
Fetch::Throttled => println!(
" usage endpoint busy just now - the account is fine, try again in a moment"
),
}
println!();
}
println!("this is the only swapdex command that touches the network.");
Ok(0)
}
fn win_line(label: &str, w: &crate::quota::Window, now: i64) -> String {
let rem = w.remaining_pct();
let filled = ((rem / 100.0) * 10.0).round().clamp(0.0, 10.0) as usize;
let bar: String = "\u{2593}".repeat(filled) + &"\u{2591}".repeat(10 - filled);
let reset = match w.resets_at {
Some(ts) => format!(" resets in {}", human_until(now, ts)),
None => String::new(),
};
format!("{label:<9} {bar} {rem:>3.0}% left{reset}")
}
fn human_until(now: i64, ts: i64) -> String {
let d = ts - now;
if d <= 0 {
return "now".into();
}
let (days, hrs, mins) = (d / 86400, (d % 86400) / 3600, (d % 3600) / 60);
if days > 0 {
format!("{days}d {hrs}h")
} else if hrs > 0 {
format!("{hrs}h {mins}m")
} else {
format!("{mins}m")
}
}
fn quota_json(label: &str, email: Option<&str>, active: bool, f: &crate::quota::Fetch) -> Value {
use crate::quota::{Fetch, Window};
fn win(w: &Window) -> Value {
serde_json::json!({
"used_pct": (w.used_pct * 10.0).round() / 10.0,
"remaining_pct": (w.remaining_pct() * 10.0).round() / 10.0,
"resets_at": w.resets_at,
})
}
let mut o = serde_json::json!({"name": label, "email": email, "active": active});
let m = o.as_object_mut().expect("json object");
match f {
Fetch::Ok(q) => {
m.insert("status".into(), Value::String("ok".into()));
m.insert(
"five_hour".into(),
q.five_hour.as_ref().map(win).unwrap_or(Value::Null),
);
m.insert(
"seven_day".into(),
q.seven_day.as_ref().map(win).unwrap_or(Value::Null),
);
let scoped: Vec<Value> = q
.scoped
.iter()
.map(|(n, w)| {
let mut wj = win(w);
wj.as_object_mut()
.unwrap()
.insert("label".into(), Value::String(n.clone()));
wj
})
.collect();
m.insert("scoped".into(), Value::Array(scoped));
}
Fetch::Unauthorized => {
m.insert("status".into(), Value::String("expired".into()));
}
Fetch::Unexpected(code, body) => {
m.insert("status".into(), Value::String("unexpected".into()));
m.insert("http".into(), Value::from(*code));
m.insert("raw".into(), Value::String(body.clone()));
}
Fetch::Throttled => {
m.insert("status".into(), Value::String("throttled".into()));
m.insert(
"note".into(),
Value::String("the usage endpoint is rate-limited, not this account".into()),
);
}
Fetch::Offline(msg) => {
m.insert("status".into(), Value::String("offline".into()));
m.insert("detail".into(), Value::String(msg.clone()));
}
}
o
}
pub fn sessions(paths: &Paths, json: bool) -> Result<i32> {
if json {
let out = match crate::session_link::sessions_by_account(paths) {
None => serde_json::json!({"available": false, "accounts": {}, "total": 0}),
Some(counts) => {
let total: usize = counts.values().sum();
serde_json::json!({"available": true, "accounts": counts, "total": total})
}
};
println!("{}", serde_json::to_string(&out)?);
return Ok(0);
}
match crate::session_link::sessions_by_account(paths) {
None => {
println!(
"session data unavailable - install sessionwiki to group sessions by account \
(`swapdex ui` already lists your recent sessions without it)"
);
}
Some(counts) if counts.is_empty() => {
println!("no sessions found (sessionwiki index empty - run `sessionwiki sync` once)");
}
Some(counts) => {
for (account, n) in &counts {
println!("{:<20} {n}", account);
}
}
}
Ok(0)
}
fn identity_line(id: &Account) -> String {
let who = id.email.clone().unwrap_or_else(|| id.display.clone());
match &id.tier {
Some(t) => format!("{who} [{t}]"),
None => who,
}
}
fn expiry_note(expires_at: Option<i64>) -> String {
match expires_at {
Some(ms) if now_ms() - ms > STALE_DAYS * 86400 * 1000 => {
" - login is old; may re-prompt if its refresh token has expired".to_string()
}
_ => String::new(),
}
}
fn warn_if_expired(target: &crate::adapters::Snapshot, tool: &str) {
if tool != "claude-code" {
return;
}
if let Some(cred) = target.part("credentials") {
if let Ok(v) = serde_json::from_slice::<Value>(cred.expose()) {
if let Some(ms) = v["claudeAiOauth"]["expiresAt"].as_i64() {
if now_ms() - ms > STALE_DAYS * 86400 * 1000 {
eprintln!("swapdex: note - this saved login is old; Claude may re-prompt for login if its refresh token has expired");
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::{human_until, keychain_verdict, win_line};
fn s(items: &[&str]) -> Vec<String> {
items.iter().map(|i| i.to_string()).collect()
}
#[test]
fn human_until_formats_countdowns() {
assert_eq!(human_until(1000, 900), "now", "past resets read as now");
assert_eq!(human_until(0, 30), "0m", "sub-minute rounds down");
assert_eq!(human_until(0, 2 * 3600 + 14 * 60), "2h 14m");
assert_eq!(human_until(0, 3 * 86400 + 4 * 3600), "3d 4h");
}
#[test]
fn win_line_shows_remaining_bar_and_reset() {
let w = crate::quota::Window {
used_pct: 61.0,
resets_at: Some(2 * 3600 + 14 * 60),
};
let line = win_line("5h", &w, 0);
assert!(line.contains("39% left"), "{line}");
assert!(line.contains("resets in 2h 14m"), "{line}");
let full = crate::quota::Window {
used_pct: 0.0,
resets_at: None,
};
let line = win_line("7d", &full, 0);
assert!(line.contains("100% left"), "{line}");
assert!(!line.contains("resets"), "no reset when absent: {line}");
}
const BARE: &str = "Claude Code-credentials";
fn three_profiles() -> Vec<String> {
s(&[
BARE,
"Claude Code-credentials-5953ba74",
"Claude Code-credentials-feeb5ea6",
])
}
#[test]
fn keychain_verdict_silent_when_no_item() {
assert!(
keychain_verdict(&[], Some(BARE), BARE).is_none(),
"nothing to report if Claude has no Keychain item"
);
}
#[test]
fn keychain_verdict_manages_own_env_profile_among_aliased_siblings() {
let (ok, msg) = keychain_verdict(&three_profiles(), Some(BARE), BARE).unwrap();
assert!(ok, "coexisting aliased profiles are healthy: {msg}");
assert!(msg.contains("other CLAUDE_CONFIG_DIR profiles"), "{msg}");
assert!(msg.contains("never touches"), "{msg}");
}
#[test]
fn keychain_verdict_single_profile_is_plain_ok() {
let (ok, msg) = keychain_verdict(&s(&[BARE]), Some(BARE), BARE).unwrap();
assert!(ok);
assert!(msg.contains("managing this environment's profile"), "{msg}");
}
#[test]
fn device_auth_policy() {
use super::codex_device_auth;
assert!(codex_device_auth(false), "device-auth by default");
assert!(!codex_device_auth(true), "opt-out -> browser flow");
}
#[test]
fn failed_restore_keeps_the_requested_backup_newest() {
use crate::adapters::claude::Claude;
use crate::adapters::AuthTool;
use crate::paths::Paths;
use crate::store::Store;
fn seed(p: &Paths, uuid: &str, email: &str) {
std::fs::create_dir_all(p.claude_credentials().parent().unwrap()).unwrap();
std::fs::write(
p.claude_credentials(),
serde_json::to_vec(&serde_json::json!({"claudeAiOauth": {
"accessToken": "AT", "refreshToken": "RT", "expiresAt": 9999999999999i64,
"scopes": ["x"], "subscriptionType": "max", "rateLimitTier": "default"}}))
.unwrap(),
)
.unwrap();
std::fs::write(
p.claude_config_json(),
serde_json::to_vec(&serde_json::json!({
"oauthAccount": {"accountUuid": uuid, "emailAddress": email}}))
.unwrap(),
)
.unwrap();
}
let liveroot = tempfile::tempdir().unwrap();
let plive = Paths::rooted(liveroot.path());
let aroot = tempfile::tempdir().unwrap();
let pa = Paths::rooted(aroot.path());
seed(&pa, "uuid-A", "a@x.com");
let snap_a = Claude.capture(&pa).unwrap();
let store = Store::open(&plive).unwrap();
store.backup(&snap_a).unwrap();
seed(&plive, "uuid-B", "b@y.com");
let cfg = plive.claude_config_json();
let tmp = cfg.parent().unwrap().join(format!(
".{}.swapdex.tmp",
cfg.file_name().unwrap().to_str().unwrap()
));
std::fs::create_dir(&tmp).unwrap();
assert!(
super::restore(&plive, None, false).is_err(),
"apply must fail (planted temp dir)"
);
let (_stamp, newest) = store.load_backup("claude-code").unwrap().unwrap();
assert_eq!(
super::snapshot_account_id(&newest, "claude-code").as_deref(),
Some("uuid-A"),
"a failed restore must not strand A by making B the newest backup"
);
}
#[test]
fn keychain_verdict_flags_refused_ambiguity() {
let found = s(&[
"Claude Code-credentials-5953ba74",
"Claude Code-credentials-feeb5ea6",
]);
let (ok, msg) = keychain_verdict(&found, None, BARE).unwrap();
assert!(!ok, "refused ambiguity is a finding");
assert!(msg.contains("refuses to guess"), "{msg}");
assert!(msg.contains("CLAUDE_CONFIG_DIR"), "{msg}");
assert!(msg.contains(BARE), "names the missing derived item: {msg}");
}
#[test]
fn keychain_verdict_single_item_fallback_is_ok_with_note() {
let (ok, msg) = keychain_verdict(
&s(&["Claude Code-credentials-5953ba74"]),
Some("Claude Code-credentials-5953ba74"),
BARE,
)
.unwrap();
assert!(ok, "managing the only existing login works: {msg}");
assert!(msg.contains("only"), "{msg}");
assert!(msg.contains("CLAUDE_CONFIG_DIR"), "{msg}");
}
#[test]
fn keychain_verdict_flags_target_not_in_found() {
let (ok, msg) =
keychain_verdict(&s(&["Claude Code-credentials-5953ba74"]), Some(BARE), BARE).unwrap();
assert!(!ok);
assert!(msg.contains("re-run"), "{msg}");
}
}