use crate::adapters::{self, Account, AuthTool};
use crate::paths::Paths;
use crate::store::Store;
use anyhow::{bail, Context, 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 {
one_tool(self).is_none_or(|t| t == tool)
}
}
#[derive(Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
pub enum MigrationToolSel {
#[value(alias = "claude-code")]
Claude,
Codex,
}
impl MigrationToolSel {
fn wants(self, tool: &str) -> bool {
match self {
MigrationToolSel::Claude => tool == "claude-code",
MigrationToolSel::Codex => tool == "codex",
}
}
}
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 migration_tools(sel: Option<MigrationToolSel>) -> Vec<&'static str> {
crate::adapters::names()
.into_iter()
.filter(|tool| crate::slots::home_var(tool).is_some())
.filter(|tool| sel.map(|selected| selected.wants(tool)).unwrap_or(true))
.collect()
}
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)
}
const MIGRATED_ACCOUNT_MARKER: &str = ".swapdex-migrated-account";
fn slot_account_under(paths: &Paths, tool: &str, name: &str) -> Option<String> {
crate::slots::Slots::open_for(paths, tool)
.ok()
.and_then(|sl| {
sl.list()
.into_iter()
.find(|r| r.name == name)
.and_then(|r| slot_account_id_for(tool, &r.config_dir))
})
}
struct NameHolder<'a> {
paths: &'a Paths,
tool: &'a str,
account_id: &'a str,
}
fn slot_account_id_for(tool: &str, dir: &std::path::Path) -> Option<String> {
let connected = match tool {
"claude-code" => crate::proxy::creds::slot_account_uuid(dir),
"codex" => crate::proxy::codex::slot_account_id(dir),
_ => None,
};
connected.or_else(|| {
std::fs::read_to_string(dir.join(MIGRATED_ACCOUNT_MARKER))
.ok()
.map(|id| id.trim().to_string())
.filter(|id| !id.is_empty())
})
}
#[derive(Clone, Debug, PartialEq, Eq)]
enum MigrationClass {
AlreadySlotted,
CopyOfSlot(String),
NeedsSlot,
Unreadable,
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct MigrationProfile {
name: String,
account_id: Option<String>,
class: MigrationClass,
}
fn classify_migration_profile(
profile_name: &str,
profile_account: Option<&str>,
slots: &[(String, Option<String>)],
) -> MigrationClass {
let Some(account) = profile_account.filter(|id| !id.is_empty()) else {
return MigrationClass::Unreadable;
};
if slots
.iter()
.any(|(name, id)| name == profile_name && id.as_deref() == Some(account))
{
return MigrationClass::AlreadySlotted;
}
if let Some((name, _)) = slots.iter().find(|(_, id)| id.as_deref() == Some(account)) {
return MigrationClass::CopyOfSlot(name.clone());
}
MigrationClass::NeedsSlot
}
fn classify_migrations(
store: &Store,
slots: &crate::slots::Slots,
tool: &str,
) -> Vec<MigrationProfile> {
let slot_accounts: Vec<(String, Option<String>)> = slots
.list()
.into_iter()
.map(|slot| {
let account = slot_account_id_for(tool, &slot.config_dir).filter(|id| !id.is_empty());
(slot.name, account)
})
.collect();
store
.list()
.into_iter()
.filter(|profile| profile.tools.iter().any(|saved| saved == tool))
.map(|profile| {
let account =
profile_account_id(store, &profile.name, tool).filter(|id| !id.is_empty());
let class =
classify_migration_profile(&profile.name, account.as_deref(), &slot_accounts);
MigrationProfile {
name: profile.name,
account_id: account,
class,
}
})
.collect()
}
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.trim().starts_with('-') {
eprintln!(
"swapdex: '{name}' cannot start with '-': every command that names a \
profile reads it there as an option, and `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 suggestion = suggested_profile_name(paths);
match ask_name(
&store,
&format!("name for this account [{suggestion}]: "),
&suggestion,
None,
) {
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 slot_id = slot_account_under(paths, tool, name);
let live_id = adapter
.identity(paths)
.ok()
.flatten()
.map(|i| i.account_id)
.filter(|s| !s.is_empty());
if name_means_two_accounts(live_id.as_deref(), slot_id.as_deref()) {
eprintln!(
"swapdex: '{name}' is already a {tool} slot holding a different account \
than the one you're logged into."
);
eprintln!(
" keep both: swapdex add <new-name> --tool {} | free the name: \
swapdex rename {name} <other-name>",
pretty_tool_flag(tool)
);
return Ok(7);
}
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)?;
if let Some(id) = snapshot_account_id(&snap, tool) {
let twin =
store.list().into_iter().map(|p| p.name).find(|n| {
n != name && profile_account_id(&store, n, tool).as_deref() == Some(&id)
});
if let Some(twin) = twin {
println!(" note: that account is already saved as '{twin}'");
}
}
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> {
use_account_switch(paths, name, sel, dry_run, force, false, None)
}
#[allow(clippy::too_many_arguments)]
fn use_account_switch(
paths: &Paths,
name: &str,
sel: Option<ToolSel>,
dry_run: bool,
force: bool,
open: bool,
open_dir: Option<&std::path::Path>,
) -> Result<i32> {
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();
let wanted = |t: &str| sel.map(|s| s.wants(t)).unwrap_or(true);
let held: Vec<&'static str> = crate::adapters::names()
.into_iter()
.filter(|t| wanted(t))
.filter(|t| {
crate::slots::Slots::open_for(paths, t)
.map(|s| s.get(name).is_some())
.unwrap_or(false)
})
.collect();
let mut code = 0;
if held.is_empty() {
code = use_account_inner(paths, name, sel, dry_run, force, &[])?;
}
for tool in &held {
let c = use_slot_default(paths, name, tool, dry_run)?;
if c != 0 {
code = c;
}
}
if !held.is_empty() {
let rest: Vec<&str> = crate::adapters::names()
.into_iter()
.filter(|t| wanted(t) && !held.contains(t))
.collect();
let spans_more = store
.list()
.iter()
.any(|p| p.name == name && p.tools.iter().any(|t| rest.contains(&t.as_str())));
if spans_more {
let c = use_account_inner(paths, name, sel, dry_run, force, &held)?;
if c != 0 {
code = c;
}
}
}
if open && code == 0 {
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(code)
}
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_switch(paths, name, sel, false, force, true, dir)
}
fn use_account_inner(
paths: &Paths,
name: &str,
sel: Option<ToolSel>,
dry_run: bool,
force: bool,
skip: &[&str],
) -> 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(),
std::env::var("HOME").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 skip.contains(&tool) {
continue;
}
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)?;
}
}
if clear_serving_choice(paths, tool)? {
store.append_timeline_inv(tool, name, "use", switch_ts, switch_inv)?;
changed += 1;
}
}
continue;
}
warn_if_expired(&target, tool);
if snapshot_rotated_away(paths, &target, tool) {
eprintln!(
"swapdex: {tool}: this saved login's refresh token was retired when another \
holder of the same account renewed it - these tokens are single-use, so the \
copy that missed a renewal is dead. Restoring it would leave {tool} unable to \
renew. Switch to the slot that holds the current token, or sign in again and \
re-save with `swapdex add {name} --tool {} --update`.",
pretty_tool_flag(tool)
);
failed.push(tool);
continue;
}
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;
}
clear_serving_choice(paths, tool)?;
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);
}
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; let mut repointed = 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 names = switch_names_for(paths, tool);
let last_dest = names.last().cloned().unwrap_or_default();
let slot_undo = crate::slots::Slots::open_for(paths, tool)
.ok()
.filter(|s| !last_dest.is_empty() && s.get(&last_dest).is_some());
if let Some(slots) = slot_undo {
let bin = tool_binary(tool);
let prev = names
.iter()
.rev()
.skip(1)
.find(|n| **n != last_dest && slots.get(n).is_some());
let Some(prev) = prev else {
eprintln!(
"swapdex: {tool}: the last switch repointed the default to {last_dest}, \
and no earlier slot is on record to put back"
);
if is_explicit(sel) {
return Ok(5);
}
repointed += 1;
continue;
};
if dry_run {
println!("would put the default {bin} account back to {prev}");
repointed += 1;
continue;
}
slots.set_default(prev)?;
store.append_timeline_inv(tool, prev, "restore", restore_ts, restore_inv)?;
println!(
"{}",
switch_outcome_line(
tool,
prev,
crate::proxy::running_proxy_for(paths, tool).is_some(),
crate::slots::history_is_shared(paths, tool)
)
);
repointed += 1;
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");
if !dry_run && clear_serving_choice(paths, tool)? {
let event_name = backup_id
.as_deref()
.and_then(|id| matched_profile_name(&store, tool, id))
.unwrap_or_else(|| "(backup)".into());
store.append_timeline_inv(tool, &event_name, "restore", restore_ts, restore_inv)?;
changed += 1;
}
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
};
warn_if_expired(&target, tool);
if snapshot_rotated_away(paths, &target, tool) {
eprintln!(
"swapdex: {tool}: this backup's refresh token was retired when another holder \
of the same account renewed it - these tokens are single-use. The account is \
going back, but it will need a fresh sign-in before it can renew \
(`swapdex run <name>` signs a slot in)."
);
}
adapter.apply(paths, &target)?;
clear_serving_choice(paths, tool)?;
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 && repointed == 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 use_candidates(store: &Store, paths: &Paths, sel: Option<ToolSel>) -> Vec<String> {
let mut names: Vec<String> = store.list().into_iter().map(|p| p.name).collect();
for tool in crate::adapters::names() {
if !sel.map(|s| s.wants(tool)).unwrap_or(true) {
continue;
}
if let Ok(slots) = crate::slots::Slots::open_for(paths, tool) {
names.extend(slots.list().into_iter().map(|r| r.name));
}
}
names.sort();
names.dedup();
names
}
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 = use_candidates(store, paths, sel);
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 switch_names_for(paths: &Paths, tool: &str) -> Vec<String> {
let path = paths.store_dir().join("timeline.jsonl");
let Ok(text) = std::fs::read_to_string(path) else {
return Vec::new();
};
text.lines()
.filter_map(|line| serde_json::from_str::<Value>(line).ok())
.filter(|v| matches!(v["action"].as_str(), Some("use") | Some("restore")))
.filter(|v| v["tool"].as_str() == Some(tool))
.filter_map(|v| v["account"].as_str().map(str::to_string))
.collect()
}
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 name_means_two_accounts(profile: Option<&str>, slot: Option<&str>) -> bool {
matches!((profile, slot), (Some(a), Some(b)) if a != b)
}
fn login_is_ancient(expires_at_ms: Option<i64>, now_ms: i64) -> Option<bool> {
expires_at_ms.map(|ms| now_ms - ms > STALE_DAYS * 86400 * 1000)
}
fn snapshot_refreshed_at(snap: &crate::adapters::Snapshot, tool: &str) -> Option<i64> {
let part = |n: &str| {
snap.part(n)
.and_then(|b| serde_json::from_slice::<Value>(b.expose()).ok())
};
match tool {
"claude-code" => part("credentials")?["claudeAiOauth"]["expiresAt"]
.as_i64()
.map(|ms| ms / 1000),
"codex" => part("auth")?["last_refresh"]
.as_str()
.and_then(crate::session_link::rfc3339_to_secs),
"gemini" => part("oauth")?["expiry_date"].as_i64().map(|ms| ms / 1000),
"antigravity" => part("token")?["token"]["expiry"]
.as_str()
.and_then(crate::session_link::rfc3339_to_secs),
_ => None,
}
}
fn slot_login_of(
paths: &Paths,
tool: &str,
dir: &std::path::Path,
) -> crate::adapters::claude::SlotLogin {
use crate::adapters::claude::SlotLogin;
if tool == "claude-code" {
return crate::adapters::claude::slot_login(dir);
}
let Some(p) = paths.try_with_tool_dir(tool, dir) else {
return SlotLogin::Present(None);
};
let file = match tool {
"codex" => p.codex_auth(),
"gemini" => p.gemini_oauth(),
"antigravity" => p.antigravity_token(),
_ => return SlotLogin::Present(None),
};
let Ok(bytes) = std::fs::read(&file) else {
return SlotLogin::Absent;
};
let secs = serde_json::from_slice::<Value>(&bytes)
.ok()
.and_then(|v| match tool {
"codex" => v["last_refresh"]
.as_str()
.and_then(crate::session_link::rfc3339_to_secs),
"gemini" => v["expiry_date"].as_i64().map(|ms| ms / 1000),
_ => v["token"]["expiry"]
.as_str()
.and_then(crate::session_link::rfc3339_to_secs),
});
SlotLogin::Present(secs.map(|s| s * 1000))
}
fn renewal_deferred_for(paths: &Paths, tool: &str, dir: &std::path::Path) -> bool {
match tool {
"codex" => crate::refresh::codex_renewal_deferred(paths, dir, now_ms() / 1000),
"claude-code" => crate::refresh::claude_renewal_deferred(paths, dir, now_ms()),
_ => false,
}
}
fn snapshot_is_stale(snap: &crate::adapters::Snapshot, tool: &str) -> bool {
snapshot_refreshed_at(snap, tool)
.is_some_and(|secs| now_ms() / 1000 - secs > STALE_DAYS * 86400)
}
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 = snapshot_is_stale(&snap, tool).then_some("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 = snapshot_is_stale(&snap, tool).then_some("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 = snapshot_is_stale(&snap, tool).then_some("stale");
Some((email, None, marker))
}
"antigravity" => {
let v: Value = serde_json::from_slice(snap.part("token")?.expose()).ok()?;
let marker = snapshot_is_stale(&snap, tool).then_some("stale");
Some((None, v["auth_method"].as_str().map(String::from), marker))
}
_ => None,
}
}
pub fn stale_hint(stale: &[&str], healthy: &[&str]) -> String {
if stale.is_empty() {
return String::new();
}
let mut out = format!(
" ({}: this saved copy is old, so its token may no longer work - run that \
tool once, then `add --update` saves whatever it signed in as)",
stale.join(", ")
);
if !healthy.is_empty() {
out.push_str(&format!(
"\n (the account still serves {} - a stale tool does not hold the others back)",
healthy.join(", ")
));
}
out
}
fn bar_age(age_secs: i64, refresh_secs: i64) -> Option<String> {
let overdue = refresh_secs.saturating_mul(2).max(600);
if age_secs < overdue {
return None;
}
let m = age_secs / 60;
Some(if m < 60 {
format!(" · {m}m old")
} else {
format!(" · {}h old", m / 60)
})
}
fn cached_brief(e: &crate::quota_cache::Entry, now: i64) -> String {
if e.token_rejected_at.is_some_and(|at| at >= e.at) {
return "token rejected".to_string();
}
let refresh =
crate::proxy::pick::measure_after(crate::proxy::pick::headroom(e.five_h, e.seven_d))
.as_secs() as i64;
let age = bar_age(now.saturating_sub(e.at), refresh).unwrap_or_default();
format!("{}{age}", quota_brief(e.five_h, e.seven_d))
}
pub fn quota_brief(five_h_used: Option<f64>, seven_d_used: Option<f64>) -> String {
let left = |u: f64| (100.0 - u).clamp(0.0, 100.0);
match (five_h_used, seven_d_used) {
(Some(a), Some(b)) => format!("5h {:.0}% | 7d {:.0}%", left(a), left(b)),
(Some(a), None) => format!("5h {:.0}%", left(a)),
(None, Some(b)) => format!("7d {:.0}%", left(b)),
(None, None) => "usage unread".to_string(),
}
}
pub fn sign_in_remedy(name: &str, tool: &str) -> String {
let flag = match tool {
"claude-code" | "claude" => String::new(),
other => format!(" --tool {other}"),
};
format!("`swapdex run {name}{flag}` signs it in once")
}
pub fn unknown_account_or_unservable(
asked: &str,
servable: &[&str],
saved: &[&str],
tool: &str,
) -> String {
if saved.contains(&asked) && !servable.contains(&asked) {
return format!(
"'{asked}' is saved but has never been signed in on this machine, so it \
cannot pay for turns - {}",
sign_in_remedy(asked, tool)
);
}
if servable.is_empty() && !saved.is_empty() {
let flag = if tool == "claude-code" {
String::new()
} else {
format!(" --tool {tool}")
};
return format!(
"no account named '{asked}'. Saved, but not yet signed in on this machine: \
{} - `swapdex run <name>{flag}` signs one in",
saved.join(", ")
);
}
unknown_account(asked, servable)
}
pub fn pause_rotation(paths: &Paths, name: &str, pause: bool) -> Result<i32> {
let store = Store::open(paths)?;
let (profiles, _, _) = merged_accounts(paths, &store);
if !profiles.iter().any(|p| p.name == name) {
let known: Vec<&str> = profiles.iter().map(|p| p.name.as_str()).collect();
eprintln!("swapdex: {}", unknown_account(name, &known));
return Ok(5);
}
crate::settings::update(paths, |cfg| {
if cfg.is_disabled(name) != pause {
cfg.toggle_disabled(name);
}
})?;
let serve_flag = profiles
.iter()
.find(|p| p.name == name)
.filter(|p| !p.tools.iter().any(|t| t == "claude-code"))
.and_then(|p| p.tools.first())
.map(|t| format!(" --tool {t}"))
.unwrap_or_default();
if pause {
println!(
"{name} paused - the proxy will not pick it (`swapdex use {name}` and \
`swapdex serve {name}{serve_flag}` still work)"
);
} else {
println!("{name} back in rotation");
}
Ok(0)
}
pub fn unknown_account(asked: &str, known: &[&str]) -> String {
if known.is_empty() {
return "no accounts saved yet - `swapdex add <name>` saves the login you are on"
.to_string();
}
let near = known.iter().find(|k| {
let (a, b) = (asked.to_lowercase(), k.to_lowercase());
a != b && (a.starts_with(&b) || b.starts_with(&a)) && a.len().abs_diff(b.len()) <= 2
});
match near {
Some(hit) => {
let others: Vec<&str> = known.iter().copied().filter(|k| k != hit).collect();
if others.is_empty() {
format!("no account named '{asked}' - did you mean '{hit}'?")
} else {
format!(
"no account named '{asked}' - did you mean '{hit}'? (also: {})",
others.join(", ")
)
}
}
None => format!(
"no account named '{asked}' - you have: {}",
known.join(", ")
),
}
}
pub fn switch_line(name: &str, email: Option<&str>, week_left_pct: Option<f64>) -> String {
let who = match email {
Some(e) => format!("now {name} ({e})"),
None => format!("now {name}"),
};
match week_left_pct {
Some(p) if p < 0.5 => format!("{who} - no week left"),
Some(p) => format!("{who} - {:.0}% of the week left", p),
None => who,
}
}
pub fn best_identity(from_snapshot: Option<String>, from_slot: Option<String>) -> Option<String> {
from_snapshot.or(from_slot)
}
pub fn listable(snapshots: &[&str], slots: &[&str]) -> Vec<String> {
let mut all: Vec<String> = snapshots
.iter()
.chain(slots.iter())
.map(|s| s.to_string())
.collect();
all.sort();
all.dedup();
all
}
pub fn payer_of_any(per_tool: &[(&str, Option<&str>)]) -> Option<String> {
let pick = |want: &str| {
per_tool
.iter()
.find(|(t, _)| *t == want)
.and_then(|(_, p)| p.map(str::to_string))
};
pick("claude-code").or_else(|| per_tool.iter().find_map(|(_, p)| p.map(str::to_string)))
}
pub fn row_suffix(
row: &str,
_signed_in: Option<&str>,
paying: Option<&str>,
) -> Option<&'static str> {
(row == paying?).then_some("pays")
}
pub fn payer_note(signed_in: Option<&str>, paying: Option<&str>) -> Option<String> {
let paying = paying?;
if signed_in == Some(paying) {
return None;
}
Some(format!("{paying} pays"))
}
pub fn stale_marker(per_tool: &[(&str, Option<&str>)]) -> Option<String> {
let named: Vec<String> = per_tool
.iter()
.filter_map(|(tool, m)| m.map(|m| format!("{tool} {m}")))
.collect();
(!named.is_empty()).then(|| named.join(", "))
}
fn profile_summary(
store: &Store,
name: &str,
tools: &[String],
) -> (Option<String>, Option<String>, Option<String>) {
let mut email = None;
let mut tier = None;
let mut per_tool: Vec<(&str, Option<&str>)> = Vec::new();
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);
per_tool.push((t, m));
}
}
(email, tier, stale_marker(&per_tool))
}
#[derive(Default)]
struct AccountHealth {
warning: Option<String>,
access_expired: bool,
refresh_rejected_at_ms: std::collections::BTreeMap<String, i64>,
renewal_owner: std::collections::BTreeMap<String, String>,
}
fn account_health(
paths: &Paths,
name: &str,
tools: &[String],
snapshot_warning: Option<String>,
now_ms: i64,
) -> AccountHealth {
let mut warnings: Vec<String> = snapshot_warning
.into_iter()
.flat_map(|warning| warning.split(", ").map(str::to_owned).collect::<Vec<_>>())
.collect();
let mut access_expired = false;
let mut refresh_rejected_at_ms = std::collections::BTreeMap::new();
let mut renewal_owner = std::collections::BTreeMap::new();
for tool in tools {
let Some(slot) = crate::slots::Slots::open_for(paths, tool)
.ok()
.and_then(|slots| slots.get(name))
else {
continue;
};
warnings.retain(|warning| !warning.starts_with(&format!("{tool} ")));
if let Some(login) = crate::live_login::resolve(paths, &slot.config_dir, tool, now_ms) {
renewal_owner.insert(tool.clone(), "native".to_owned());
if let Some(rejected_at) = login.refresh_rejected_at_ms {
warnings.push(format!("{tool} refresh rejected - re-login required"));
refresh_rejected_at_ms.insert(tool.clone(), rejected_at);
}
continue;
}
match tool.as_str() {
"codex" => {
if let Some(rejected_at) = crate::refresh_health::codex_rejection(&slot.config_dir)
{
warnings.push("codex refresh rejected - re-login required".to_string());
refresh_rejected_at_ms.insert("codex".to_string(), rejected_at);
} else if crate::proxy::codex::slot_token_expired(&slot.config_dir, now_ms / 1000) {
warnings.push("codex expired - needs refresh".to_string());
access_expired = true;
} else if crate::refresh::codex_renewal_deferred(
paths,
&slot.config_dir,
now_ms / 1000,
) {
warnings.push("codex renewal deferred - refresh unverified".to_string());
}
}
"claude-code"
if crate::proxy::creds::slot_token_expired_for(paths, &slot.config_dir, now_ms) =>
{
warnings.push("claude-code expired - needs refresh".to_string());
access_expired = true;
}
"claude-code" => {
if matches!(
crate::proxy::creds::slot_token_detail_for(paths, &slot.config_dir),
Err(crate::proxy::creds::TokenUnavailable::KeychainLocked)
) {
warnings.push(
"claude-code keychain unavailable - verify in a local terminal".to_string(),
);
}
}
_ => {}
}
}
AccountHealth {
warning: (!warnings.is_empty()).then(|| warnings.join(", ")),
access_expired: access_expired && refresh_rejected_at_ms.is_empty(),
refresh_rejected_at_ms,
renewal_owner,
}
}
fn tui_row_names_tool(row: &crate::tui::Row, name: &str, tool: &str) -> bool {
row.name == name
&& row
.tools
.split(',')
.map(|listed| listed.trim().trim_end_matches('*'))
.any(|listed| listed == tool)
}
#[cfg(test)]
mod account_health_tests {
use super::*;
#[test]
fn a_codex_row_does_not_hide_a_claude_slot_with_the_same_name() {
let codex = crate::tui::Row {
name: "shared".into(),
ident: String::new(),
tools: "codex".into(),
active: false,
warn: None,
disabled: false,
needs_login: false,
stale: false,
is_slot: true,
also: Vec::new(),
};
assert!(!tui_row_names_tool(&codex, "shared", "claude-code"));
assert!(tui_row_names_tool(&codex, "shared", "codex"));
}
}
pub(crate) fn active_by_tool(store: &Store, paths: &Paths) -> Vec<(&'static str, String)> {
adapters::all()
.iter()
.filter_map(|a| {
let tool = a.name();
if let Some(name) = slot_default_name(paths, tool) {
return Some((tool, name));
}
a.identity(paths)
.ok()
.flatten()
.and_then(|id| matched_profile_name(store, tool, &id.account_id))
.map(|name| (tool, name))
})
.collect()
}
pub(crate) fn slot_default_name(paths: &Paths, tool: &str) -> Option<String> {
let slots = crate::slots::Slots::open_for(paths, tool).ok()?;
let dir = slots.default_dir()?;
slots
.list()
.into_iter()
.find(|r| r.config_dir == dir)
.map(|r| r.name)
}
pub(crate) fn slot_active_identity(paths: &Paths, tool: &str) -> Option<(String, Option<String>)> {
slot_active_login(paths, tool).map(|(name, email, _)| (name, email))
}
pub(crate) fn slot_active_login(
paths: &Paths,
tool: &str,
) -> Option<(String, Option<String>, bool)> {
let slots = crate::slots::Slots::open_for(paths, tool).ok()?;
let dir = slots.default_dir()?;
let rec = slots.list().into_iter().find(|r| r.config_dir == dir)?;
let email = crate::proxy::creds::any_slot_email(&rec.config_dir);
let signed_in = crate::proxy::has_login(paths, tool, &rec.config_dir);
Some((rec.name, email, signed_in))
}
fn slot_who(name: &str, email: Option<String>, signed_in: bool, tool: &str) -> String {
match (signed_in, email) {
(true, Some(e)) => e,
(true, None) => "signed in, email unreadable in the slot".into(),
(false, _) => format!(
"selected but not signed in - `swapdex run {name}{}` signs it in",
tool_flag(tool)
),
}
}
pub(crate) fn tool_dir_disagrees(
store: &Store,
paths: &Paths,
tool: &str,
pointed_at: &str,
) -> Option<String> {
let adapter = adapters::all().into_iter().find(|a| a.name() == tool)?;
let id = adapter.identity(paths).ok().flatten()?;
let live = matched_profile_name(store, tool, &id.account_id)?;
(live != pointed_at).then_some(live)
}
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(crate) fn merged_accounts(
paths: &Paths,
store: &Store,
) -> (
Vec<crate::store::ProfileInfo>,
std::collections::HashMap<String, std::path::PathBuf>,
Vec<&'static str>,
) {
let mut profiles = store.list();
let mut slot_dirs: std::collections::HashMap<String, std::path::PathBuf> =
std::collections::HashMap::new();
let mut unreadable: Vec<&'static str> = Vec::new();
for tool in crate::adapters::names() {
let Ok(sl) = crate::slots::Slots::open_for(paths, tool) else {
unreadable.push(tool);
continue;
};
for r in sl.list() {
match profiles.iter_mut().find(|p| p.name == r.name) {
Some(p) => {
if !p.tools.iter().any(|t| t == tool) {
p.tools.push(tool.to_string());
}
}
None => profiles.push(crate::store::ProfileInfo {
name: r.name.clone(),
tools: vec![tool.to_string()],
}),
}
slot_dirs
.entry(r.name.clone())
.or_insert_with(|| r.config_dir.clone());
}
}
profiles.sort_by(|a, b| a.name.cmp(&b.name));
(profiles, slot_dirs, unreadable)
}
pub fn ls(paths: &Paths, json: bool, names: bool) -> Result<i32> {
let store = Store::open(paths)?;
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, _, unreadable_registry) = merged_accounts(paths, &store);
if names {
let mut out: Vec<&str> = profiles.iter().map(|p| p.name.as_str()).collect();
out.sort_unstable();
for n in out {
println!("{n}");
}
return Ok(0);
}
let mut crossed: Vec<String> = Vec::new();
let mut crossed_names = std::collections::HashSet::new();
let mut crossed_tools = std::collections::HashSet::new();
if let Ok(st) = Store::open(paths) {
for tool in crate::adapters::names() {
if let Ok(sl) = crate::slots::Slots::open_for(paths, tool) {
for r in sl.list() {
let p_id = profile_account_id(&st, &r.name, tool);
let s_id = slot_account_id_for(tool, &r.config_dir);
if name_means_two_accounts(p_id.as_deref(), s_id.as_deref()) {
crossed.push(format!("{} ({tool})", r.name));
crossed_names.insert(r.name.clone());
crossed_tools.insert((r.name.clone(), tool));
}
}
}
}
}
if !crossed.is_empty() {
eprintln!(
"swapdex: {} names a saved profile AND a slot holding a DIFFERENT \
account. For that tool, listing and selection use the slot's identity. \
`swapdex slots` shows which directory each one is.",
crossed.join(", ")
);
}
if !unreadable_registry.is_empty() {
eprintln!(
"swapdex: the account registry could not be read ({}) - \
~/.local/share/swapdex/slots.json is damaged, so accounts are missing \
from this listing. The slot directories still hold their logins; \
restore the file from a backup or re-register with `swapdex adopt`.",
unreadable_registry.join(", ")
);
}
let summary_for = |p: &crate::store::ProfileInfo| {
let (_, _, marker) = profile_summary(&store, &p.name, &p.tools);
let mut health = account_health(paths, &p.name, &p.tools, marker, now_ms());
let primary = adapters::names()
.into_iter()
.find(|tool| p.tools.iter().any(|held| held == tool));
let (mut email, mut tier) = primary
.and_then(|tool| profile_detail(&store, &p.name, tool))
.map(|(email, tier, _)| (email, tier))
.unwrap_or_default();
if let Some(tool) = primary {
if let Some(slot) = crate::slots::Slots::open_for(paths, tool)
.ok()
.and_then(|slots| slots.get(&p.name))
{
email = match tool {
"claude-code" => crate::proxy::creds::slot_email(&slot.config_dir),
"codex" => codex_slot_email(&slot.config_dir),
_ => paths
.try_with_tool_dir(tool, &slot.config_dir)
.and_then(|at| adapters::by_name(tool)?.identity(&at).ok().flatten())
.and_then(|identity| identity.email),
};
}
if crossed_tools.contains(&(p.name.clone(), tool)) {
tier = None;
}
};
if crossed_names.contains(&p.name) {
let conflict =
"saved profile and slot hold different accounts; selection uses that tool's slot";
health.warning = Some(match health.warning {
Some(warning) => format!("{warning}, {conflict}"),
None => conflict.to_string(),
});
}
(email, tier, health)
};
if json {
let rows: Vec<Value> = profiles
.iter()
.map(|p| {
let (email, tier, health) = summary_for(p);
serde_json::json!({
"name": p.name,
"tools": p.tools,
"active_tools": active_tools_for(&p.name),
"paused": crate::settings::load(paths).is_disabled(&p.name),
"email": email,
"tier": tier,
"warning": health.warning,
"refresh_rejected_at_ms": health.refresh_rejected_at_ms,
"renewal_owner": health.renewal_owner,
})
})
.collect();
println!("{}", serde_json::to_string(&rows)?);
return Ok(0);
}
if profiles.is_empty() {
if unreadable_registry.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<String>,
active: bool,
pays: bool,
}
let payers: Vec<(&str, Option<String>)> = crate::adapters::names()
.into_iter()
.map(|t| {
(
t,
crate::slots::Slots::open_for(paths, t)
.ok()
.and_then(|s| s.payer()),
)
})
.collect();
let refs: Vec<(&str, Option<&str>)> = payers.iter().map(|(t, p)| (*t, p.as_deref())).collect();
let paying = payer_of_any(&refs);
let payer_has_login = paying.as_deref().is_some_and(|who| {
payers.iter().any(|(t, p)| {
p.as_deref() == Some(who)
&& crate::slots::Slots::open_for(paths, t).is_ok_and(|s| {
s.get(who)
.is_some_and(|r| crate::proxy::has_login(paths, t, &r.config_dir))
})
})
});
let signed_in = active_by_tool(&store, paths)
.into_iter()
.find(|(t, _)| *t == "claude-code")
.map(|(_, n)| n);
let rows: Vec<Row> = profiles
.iter()
.map(|p| {
let (email, tier, health) = summary_for(p);
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 {
pays: row_suffix(&p.name, signed_in.as_deref(), paying.as_deref()).is_some(),
name: p.name.clone(),
ident: identity_column(email, tier),
tools,
warn: health.warning,
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;
let mut stale_tools: Vec<String> = Vec::new();
let mut healthy_tools: Vec<String> = Vec::new();
let cfg = crate::settings::load(paths);
for r in &rows {
let mark = if r.active { "* " } else { " " };
let warn = r
.warn
.as_deref()
.map(|m| format!(" ({m})"))
.unwrap_or_default();
saw_unreadable |= r.warn.as_deref() == Some("unreadable");
saw_refreshable |= r
.warn
.as_deref()
.is_some_and(|w| w.contains("expired") || w.contains("stale"));
if let Some(w) = r.warn.as_deref() {
for t in r.tools.split(',').map(|t| t.trim().trim_end_matches('*')) {
if t.is_empty() {
continue;
}
let bucket = if w.contains(t) {
&mut stale_tools
} else {
&mut healthy_tools
};
if !bucket.iter().any(|x| x == t) {
bucket.push(t.to_string());
}
}
}
let pays = match (r.pays, payer_has_login) {
(true, true) => " <- pays",
(true, false) => " <- pays (no login)",
(false, _) => "",
};
let paused = if cfg.is_disabled(&r.name) {
" (rotation paused)"
} else {
""
};
println!(
"{mark}{} {} [{}]{warn}{pays}{paused}",
fit(&r.name, name_w),
fit(&r.ident, ident_w),
r.tools
);
}
if saw_refreshable {
let stale: Vec<&str> = stale_tools.iter().map(String::as_str).collect();
let healthy: Vec<&str> = healthy_tools.iter().map(String::as_str).collect();
let hint = stale_hint(&stale, &healthy);
if hint.is_empty() {
println!(
" (expired/stale: run that tool once and sign in - re-saving the profile \
cannot refresh a login that has already lapsed)"
);
} else {
println!("{hint}");
}
}
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)");
}
for (tool, pointed_at) in &active {
if slot_default_name(paths, tool).is_none() {
continue;
}
if let Some(live) = tool_dir_disagrees(&store, paths, tool, pointed_at) {
println!(
" (a plain `{}` would launch on '{live}', not the account marked here - \
run `swapdex shim` so it follows your switches)",
tool_binary(tool)
);
}
}
Ok(0)
}
fn absence_reason(any_unreadable: bool) -> &'static str {
if any_unreadable {
"logins unreadable from this shell"
} else {
"not signed in to any tool"
}
}
pub fn short_line(paths: &Paths) -> Option<String> {
let store = Store::open(paths).ok()?;
let parts: Vec<String> = adapters::all()
.iter()
.filter_map(|a| {
if crate::slots::Slots::open_for(paths, a.name())
.is_ok_and(|slots| slots.serving_is_off())
{
return None;
}
let tool = match a.name() {
"claude-code" => "claude",
t => t,
};
match slot_active_login(paths, a.name()) {
Some((name, _, true)) => return Some(format!("{tool}:{name}")),
Some((_, _, false)) => return None,
None => {}
}
let id = a.identity(paths).ok().flatten()?;
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_else(|| {
let unreadable = adapters::all().iter().any(|a| a.identity(paths).is_err());
absence_reason(unreadable).to_string()
})
);
return Ok(0);
}
let store = Store::open(paths)?;
if json {
let rows: Vec<Value> = adapters::all()
.iter()
.map(|adapter| {
let tool = adapter.name();
if let Some((name, email, signed_in)) = slot_active_login(paths, tool) {
return serde_json::json!({
"tool": tool, "logged_in": signed_in, "unreadable": false,
"email": email, "tier": null, "profile": name, "expired": null,
});
}
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": login_is_ancient(id.expires_at, now_ms()),
}),
}
})
.collect();
println!("{}", serde_json::to_string(&rows)?);
return Ok(0);
}
for adapter in adapters::all() {
let tool = adapter.name();
if let Some((name, email, signed_in)) = slot_active_login(paths, tool) {
let who = slot_who(&name, email, signed_in, tool);
println!("{tool}: {who} (profile '{name}')");
if let Some(live) = tool_dir_disagrees(&store, paths, tool, &name) {
println!(
" (a plain `{}` would launch on '{live}' instead - `swapdex shim` \
makes it follow your switches)",
tool_binary(tool)
);
}
continue;
}
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, tool);
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> {
let tool = slot_tool(sel);
if !crate::proxy::carries(tool) {
eprintln!("{}", crate::proxy::cannot_carry(tool));
return Ok(2);
}
if threshold.is_some_and(|value| !crate::settings::valid_threshold(value)) {
eprintln!("swapdex: --threshold expects a finite fraction greater than 0 and at most 1");
return Ok(2);
}
if ensure {
return proxy_ensure(paths, port, tool);
}
let auto = match (auto, no_auto) {
(true, _) => Some(true),
(_, true) => Some(false),
_ => None,
};
let threshold_pinned = threshold.is_some();
let opts = crate::proxy::Opts {
port,
account,
tool: slot_tool(sel).to_string(),
auto,
threshold,
threshold_pinned,
};
crate::proxy::serve(paths, &opts)?;
println!(" the proxy stopped serving and is exiting; nothing asked it to");
std::io::Write::flush(&mut std::io::stdout()).ok();
Ok(0)
}
pub const DEFAULT_PROXY_PORT: u16 = 8787;
pub fn default_port_for(tool: &str) -> u16 {
match tool {
"codex" => DEFAULT_PROXY_PORT + 1,
"gemini" => DEFAULT_PROXY_PORT + 2,
"antigravity" => DEFAULT_PROXY_PORT + 3,
_ => DEFAULT_PROXY_PORT,
}
}
fn proxy_ensure(paths: &Paths, port: u16, tool: &str) -> Result<i32> {
let startup = crate::slots::proxy_startup_route(paths, tool)?;
if startup == crate::slots::ProxyStartupRoute::Unmanaged {
return Ok(crate::shim::PROXY_PASSTHROUGH_EXIT_STATUS);
}
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);
}
if let Err(why) = crate::service::restart_via_supervisor(Some(paths.home()), tool) {
eprintln!("swapdex: replacing the proxy by signal instead ({why})");
unsafe { libc::kill(pid, libc::SIGTERM) };
}
for _ in 0..60 {
match crate::proxy::running_proxy_for(paths, tool) {
None => break,
Some((_, p, b)) if b == crate::proxy::build_id() => {
println!("{p}");
return Ok(0);
}
_ => std::thread::sleep(std::time::Duration::from_millis(50)),
}
}
port = running;
}
if startup == crate::slots::ProxyStartupRoute::ExplicitPassthrough {
return Ok(crate::shim::PROXY_PASSTHROUGH_EXIT_STATUS);
}
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());
let log = paths.proxy_log(tool);
let piped = std::fs::create_dir_all(log.parent().unwrap_or(&log))
.ok()
.and_then(|()| {
std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&log)
.ok()
});
match piped.and_then(|f| f.try_clone().ok().map(|e| (f, e))) {
Some((out, err)) => {
cmd.stdout(std::process::Stdio::from(out))
.stderr(std::process::Stdio::from(err));
}
None => {
cmd.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 serve $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 cfg = crate::settings::load(paths);
let Some(value) = value else {
match cfg.threshold() {
Some(t) => println!(
"stepping off an account at {} used",
crate::settings::threshold_label(t)
),
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") {
crate::settings::update(paths, |c| c.proxy_threshold = None)?;
println!("threshold off - the proxy waits for a refusal before moving");
return Ok(0);
}
let percentage = v.strip_suffix('%');
let parsed = percentage.unwrap_or(v).parse::<f64>().ok().map(|n| {
if percentage.is_some() || n > 1.0 {
n / 100.0
} else {
n
}
});
let Some(t) = parsed.filter(|t| crate::settings::valid_threshold(*t)) else {
eprintln!(
"swapdex: expected a fraction like 0.9, a percentage like 90%, or `off` - got '{v}'"
);
return Ok(2);
};
crate::settings::update(paths, |c| c.proxy_threshold = Some(t))?;
println!(
"stepping off an account at {} used - it hands the session on before \
being refused",
crate::settings::threshold_label(t)
);
Ok(0)
}
const CODEX_THREAD_STORE: &[&str] = &["thread_history_1.sqlite", "session_index.jsonl"];
fn share_codex_thread_store(
tool: &str,
bare: &std::path::Path,
slot: &std::path::Path,
name: &str,
dry_run: bool,
) -> usize {
if tool != "codex" {
return 0;
}
let mut touched = 0;
for file in CODEX_THREAD_STORE {
let src = bare.join(file);
let own = slot.join(file);
if !src.exists() {
continue;
}
if std::fs::symlink_metadata(&own).is_ok_and(|m| m.file_type().is_symlink()) {
continue;
}
if own.exists() {
println!(
" {name} - keeps its own {file}; linking would hide the conversations \
only it has, so it was left alone"
);
continue;
}
if !dry_run {
#[cfg(unix)]
std::os::unix::fs::symlink(&src, &own).ok();
}
println!(" {name} - {file} linked to the shared history");
touched += 1;
}
touched
}
pub fn share_history(paths: &Paths, tool: &str, dry_run: bool) -> Result<i32> {
let bare = match tool {
"codex" => paths.codex_dir().to_path_buf(),
_ => paths.claude_dir().to_path_buf(),
};
let dir_name = if tool == "codex" {
"sessions"
} else {
"projects"
};
let shared = bare.join(dir_name);
let slots = crate::slots::Slots::open_for(paths, tool)
.map(|s| s.list())
.unwrap_or_default();
let mut touched = 0;
for r in slots {
touched += share_codex_thread_store(tool, &bare, &r.config_dir, &r.name, dry_run);
let own = r.config_dir.join(dir_name);
if own == shared {
continue;
}
if std::fs::symlink_metadata(&own).is_ok_and(|m| m.file_type().is_symlink()) {
continue; }
if !own.is_dir() {
if !dry_run {
std::fs::create_dir_all(&shared)?;
#[cfg(unix)]
std::os::unix::fs::symlink(&shared, &own).ok();
}
println!(" {} - linked to the shared history", r.name);
touched += 1;
continue;
}
let carried = crate::slots::carry_history_into_shared(&own, &shared, dry_run)?;
println!(
" {} - {carried} conversation(s) only it had, carried over",
r.name
);
if !dry_run {
let aside = r.config_dir.join(format!("{dir_name}.before-sharing"));
if aside.exists() {
anyhow::bail!(
"{} already has {} - move it away first; nothing was changed for this account",
r.name,
aside.display()
);
}
std::fs::rename(&own, &aside)?;
#[cfg(unix)]
std::os::unix::fs::symlink(&shared, &own).ok();
println!(" its old copy kept at {}", aside.display());
}
touched += 1;
}
if touched == 0 {
println!("every account already shares its history - nothing to do");
} else if dry_run {
println!("(dry run - nothing changed)");
} else {
println!("done - every conversation is now reachable from every account");
}
Ok(0)
}
pub fn install_slash(paths: &Paths) -> Result<i32> {
let home = paths.home().to_path_buf();
let mut installed = 0;
let claude_dir = home.join(".claude").join("commands");
match crate::atomic::write_managed(
&claude_dir.join("swap.md"),
claude_command_body().as_bytes(),
) {
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 crate::atomic::write_managed(&codex_dir.join("SKILL.md"), codex_skill_body().as_bytes()) {
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!(" shell: `swapdex serve <name> --tool claude` or `--tool codex`");
Ok(0)
}
pub fn auto(paths: &Paths, state: Option<&str>) -> Result<i32> {
let 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);
}
};
crate::settings::update(paths, |c| c.proxy_auto = Some(on))?;
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, slot_dirs, _) = merged_accounts(paths, &store);
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 email = best_identity(
email,
slot_dirs
.get(&p.name)
.and_then(|d| crate::proxy::creds::any_slot_email(d)),
);
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) {
recent_menu_sessions_for_tool(paths, name, first_time, n, None)
}
pub(crate) fn recent_menu_sessions_for_tool(
paths: &Paths,
name: &str,
first_time: bool,
n: usize,
tool: Option<&str>,
) -> (Vec<MenuSession>, String) {
if let Some(r) = crate::session_link::recent_sessions_for_tool(paths, name, n, tool) {
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_for_tool(n, tool) {
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 mine = crate::native_sessions::recent_for_tool_matching(paths, n, tool, |tool, started| {
crate::session_link::active_at(&events, tool, started).as_deref() == Some(name)
});
if !mine.is_empty() {
return (
mine.into_iter().map(MenuSession::Native).collect(),
format!("recent sessions on '{name}':"),
);
}
let any: Vec<MenuSession> = crate::native_sessions::recent_for_tool(paths, n, tool)
.into_iter()
.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| {
merged_accounts(paths, &s)
.0
.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 merge_cached_claude_usage(
claude: &mut Vec<(String, crate::tui::Usage)>,
cached: crate::quota_cache::Cache,
) {
for (name, e) in cached {
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.on_credits = e.on_credits;
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,
on_credits: e.on_credits,
ident: 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) {
match crate::self_exe::output(args) {
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<(crate::tui::AccountKey, crate::tui::Usage)> {
let Ok(out) = crate::self_exe::output(&["quota", "--json"]) 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("usage lookup limited - retrying".to_string()),
Some("unavailable") => acc
.get("detail")
.and_then(|detail| detail.as_str())
.map(|detail| format!("usage lookup coordination unavailable: {detail}")),
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,
on_credits: acc
.get("on_credits")
.and_then(|v| v.as_bool())
.unwrap_or(false),
ident: None,
},
))
})
.collect();
merge_cached_claude_usage(&mut claude, crate::quota_cache::load(paths));
let mut codex: Vec<(String, crate::tui::Usage)> = Vec::new();
let codex_homes: Vec<(String, std::path::PathBuf)> =
crate::slots::Slots::open_for(paths, "codex")
.map(|s| {
s.list()
.into_iter()
.map(|r| (r.name, r.config_dir))
.collect()
})
.unwrap_or_default();
let codex_seen = crate::quota_cache::load_for(paths, "codex");
let fetched = fetch_codex_live(codex_homes.clone());
let live_by_name: std::collections::HashMap<String, crate::codex_usage::Account> = fetched
.iter()
.filter_map(|(n, a, _)| a.clone().map(|a| (n.clone(), a)))
.collect();
let refused_names: std::collections::HashSet<String> = fetched
.iter()
.filter(|(_, _, refused)| *refused)
.map(|(n, _, _)| n.clone())
.collect();
for name in &refused_names {
crate::quota_cache::note_token_rejected(paths, "codex", name, now_secs() as i64);
}
for (name, dir) in &codex_homes {
let live = live_by_name.get(name).cloned();
let all_dirs: Vec<std::path::PathBuf> =
codex_homes.iter().map(|(_, d)| d.clone()).collect();
let transcript = crate::codex_limits::transcript_is_private(dir, &all_dirs)
.then(|| crate::codex_limits::for_slot(dir, now_secs(), 7 * 86_400))
.flatten();
let seen_by_proxy = if refused_names.contains(name) {
None
} else {
codex_seen.get(name).cloned()
};
if let Some(row) = codex_row(
name,
live.as_ref(),
codex_slot_email(dir).as_deref(),
seen_by_proxy,
transcript,
now_secs() as i64,
) {
if !refused_names.contains(name) {
remember_codex_reading(paths, &row.0, &row.1);
}
codex.push(row);
}
}
{
let slot_names: Vec<String> = codex_homes.iter().map(|(n, _)| n.clone()).collect();
let stored: Vec<String> = Store::open(paths)
.map(|st| {
st.list()
.into_iter()
.filter(|p| p.tools.iter().any(|t| t == "codex"))
.map(|p| p.name)
.collect()
})
.unwrap_or_default();
let extra: Vec<String> = codex_names_without_a_slot(&stored, &slot_names)
.into_iter()
.filter(|n| !codex.iter().any(|(have, _)| have == n))
.collect();
let extra_live: std::collections::HashMap<String, crate::codex_usage::Account> = extra
.iter()
.map(|n| (n.clone(), snapshot_codex_auth(paths, n)))
.filter_map(|(n, a)| a.map(|a| (n, a)))
.collect::<Vec<_>>()
.into_iter()
.enumerate()
.filter_map(|(i, (n, auth))| {
if i > 0 {
std::thread::sleep(std::time::Duration::from_millis(
crate::quota::pace_ms(),
));
}
let f = crate::codex_usage::fetch(&auth);
crate::codex_usage::note_token_outcome(paths, &n, &f, now_secs() as i64);
match f {
crate::codex_usage::Fetch::Ok(a) => Some((n, *a)),
_ => None,
}
})
.collect();
for name in extra {
if codex.iter().any(|(n, _)| *n == name) {
continue;
}
let live = extra_live.get(&name).cloned();
let seen_by_proxy = codex_seen.get(&name).cloned();
if let Some(row) = codex_row(
&name,
live.as_ref(),
None,
seen_by_proxy,
None,
now_secs() as i64,
) {
remember_codex_reading(paths, &row.0, &row.1);
codex.push(row);
}
}
}
if let Some(why) = unhonoured_ask(
crate::slots::Slots::open_for(paths, "claude-code")
.ok()
.and_then(|sl| {
sl.serving_dir()
.and_then(|d| sl.list().into_iter().find(|r| r.config_dir == d))
.map(|r| r.name)
})
.as_deref(),
crate::proxy::serving_account_for(paths, "claude-code").as_deref(),
proxy_acted_since_ask(paths, "claude-code"),
) {
if let Some((_, u)) = claude.iter_mut().find(|(n, _)| why.contains(n.as_str())) {
u.note = Some(why);
}
}
claude
.into_iter()
.map(|(name, usage)| (("claude-code".to_string(), name), usage))
.chain(
codex
.into_iter()
.map(|(name, usage)| (("codex".to_string(), name), usage)),
)
.collect()
}
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 slots_by_tool: Vec<(&'static str, Vec<crate::slots::SlotRecord>)> =
crate::adapters::names()
.into_iter()
.map(|tool| {
let rows = crate::slots::Slots::open_for(self.paths, tool)
.map(|slots| slots.list())
.unwrap_or_default();
(tool, rows)
})
.collect();
let active_by_provider: Vec<(&'static str, Option<String>)> = crate::adapters::names()
.into_iter()
.map(|tool| (tool, active_slot_name(self.paths, tool)))
.collect();
let slot_dir_of = |tool: &str, name: &str| {
slots_by_tool
.iter()
.find(|(held, _)| *held == tool)
.and_then(|(_, rows)| rows.iter().find(|row| row.name == name))
.map(|row| row.config_dir.clone())
};
let pointer_active = |tool: &str, name: &str| {
active_by_provider
.iter()
.find(|(held, _)| *held == tool)
.and_then(|(_, active)| active.as_ref())
.map(|active| active == name)
};
let slot_email = |tool: &str, dir: &std::path::Path| match tool {
"claude-code" => crate::proxy::creds::slot_email(dir),
"codex" => codex_slot_email(dir),
_ => self
.paths
.try_with_tool_dir(tool, dir)
.and_then(|at| {
crate::adapters::by_name(tool)
.and_then(|adapter| adapter.identity(&at).ok().flatten())
})
.and_then(|identity| identity.email),
};
let mut list: Vec<crate::tui::Row> = store
.list()
.iter()
.flat_map(|profile| {
profile.tools.iter().filter_map(|tool| {
if !crate::adapters::names().contains(&tool.as_str()) {
return None;
}
let tools = vec![tool.clone()];
let (snapshot_email, snapshot_tier, marker) =
profile_summary(&store, &profile.name, &tools);
let slot_dir = slot_dir_of(tool, &profile.name);
let snapshot_warning = if slot_dir.is_some() { None } else { marker };
let health = account_health(
self.paths,
&profile.name,
&tools,
snapshot_warning,
now_ms(),
);
let ident = match slot_dir.as_deref() {
Some(dir) => identity_column(slot_email(tool, dir), None),
None => identity_column(snapshot_email, snapshot_tier),
};
let live = active
.iter()
.any(|(held, name)| *held == tool && name == &profile.name);
let can_serve = matches!(tool.as_str(), "claude-code" | "codex");
Some(crate::tui::Row {
is_slot: can_serve && slot_dir.is_some(),
disabled: cfg.is_disabled(&profile.name),
needs_login: row_needs_login(self.paths, tool, slot_dir.as_deref()),
name: profile.name.clone(),
ident,
tools: if live {
format!("{tool}*")
} else {
tool.clone()
},
active: pointer_active(tool, &profile.name).unwrap_or(live),
warn: health.warning,
also: Vec::new(),
stale: health.access_expired,
})
})
})
.collect();
for (tool, records) in &slots_by_tool {
let can_serve = matches!(*tool, "claude-code" | "codex");
for record in records {
if list
.iter()
.any(|row| tui_row_names_tool(row, &record.name, tool))
{
continue;
}
let tools = vec![(*tool).to_string()];
let health = account_health(self.paths, &record.name, &tools, None, now_ms());
let email = slot_email(tool, &record.config_dir);
list.push(crate::tui::Row {
is_slot: can_serve,
disabled: cfg.is_disabled(&record.name),
needs_login: row_needs_login(self.paths, tool, Some(&record.config_dir)),
name: record.name.clone(),
ident: identity_column(email, None),
tools: (*tool).to_string(),
active: pointer_active(tool, &record.name).unwrap_or(false),
warn: health.warning,
also: Vec::new(),
stale: health.access_expired,
});
}
}
crate::tui::group_sorted(crate::tui::dedupe_by_identity(list))
}
fn switch(&mut self, name: &str, tool: &str, is_slot: bool) -> (bool, String) {
self.pre_switch_first = crate::session_link::read_timeline(self.paths).is_empty();
run_self(&[crate::tui::switch_verb(is_slot), name, "--tool", tool])
}
fn toggle_rotation(&mut self, name: &str) -> String {
let mut paused = false;
match crate::settings::update(self.paths, |cfg| paused = cfg.toggle_disabled(name)) {
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, tool: &str) -> String {
run_self(&["rm", name, "--yes", "--tool", pretty_tool_flag(tool)]).1
}
fn rename(&mut self, old: &str, new: &str) -> (bool, String) {
run_self(&["rename", old, new])
}
fn sign_in(&mut self, name: &str, tool: &str) -> (bool, String) {
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> {
match crate::self_exe::output(&["doctor"]) {
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> {
match crate::self_exe::output(&["usage"]) {
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> {
match crate::self_exe::output(&["quota"]) {
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 usage_async(&mut self) -> crate::tui::PanelReceiver {
crate::tui::PanelReceiver::command("usage")
}
fn quota_async(&mut self) -> crate::tui::PanelReceiver {
crate::tui::PanelReceiver::command("quota")
}
fn cached_quota(&mut self) -> Vec<(crate::tui::AccountKey, crate::tui::Usage)> {
cached_quota_tools()
.into_iter()
.flat_map(|tool| {
crate::quota_cache::load_for(self.paths, tool)
.into_iter()
.map(move |(name, entry)| (tool, name, entry))
})
.collect::<Vec<_>>()
.into_iter()
.map(|(tool, name, e)| {
(
crate::tui::account_key(tool, &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,
on_credits: e.on_credits,
ident: None,
},
)
})
.collect()
}
fn quota_pct(&mut self) -> Vec<(crate::tui::AccountKey, crate::tui::Usage)> {
read_quota_usage(self.paths)
}
fn quota_pct_async(
&mut self,
) -> std::sync::mpsc::Receiver<Vec<(crate::tui::AccountKey, 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, tool: &str) -> bool {
crate::proxy::running_proxy_for(self.paths, tool).is_some()
}
fn paying_account(&mut self) -> Option<String> {
crate::adapters::names()
.into_iter()
.find_map(|t| crate::proxy::serving_account_for(self.paths, t))
}
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 suggested_name(&mut self) -> String {
suggested_profile_name(self.paths)
}
fn sessions(
&mut self,
name: &str,
tool: &str,
) -> (String, Vec<crate::tui::SessionEntry>, Vec<&'static str>) {
let first_time = self.pre_switch_first;
let (sessions, label) =
recent_menu_sessions_for_tool(self.paths, name, first_time, 5, Some(tool));
let tools: Vec<&'static str> = ["claude-code", "codex", "gemini", "antigravity"]
.into_iter()
.filter(|held| *held == tool)
.collect();
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 = crate::adapters::names().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 store = Store::open(paths)?;
let Some(name) = ask_name(&store, new_account_prompt(), "", None) else {
println!("nothing added.");
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;
if !matches!(tool, "claude-code" | "codex" | "gemini" | "antigravity") {
return anyhow::anyhow!("unknown tool '{tool}'");
}
let bin = tool_binary(tool);
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))
}
fn serving_reach(has_proxy: bool, accounts: usize) -> Option<String> {
if accounts == 0 {
return None;
}
Some(if has_proxy {
format!("{accounts} account(s), proxy carrying traffic - a switch moves a running session")
} else {
format!(
"{accounts} account(s), no proxy - a switch takes effect in the NEXT session, \
not one already running (`swapdex service install --tool <tool>` to change that)"
)
})
}
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}")),
}
{
let f = crate::settings::file(paths);
match std::fs::read(&f) {
Ok(bytes) if serde_json::from_slice::<serde_json::Value>(&bytes).is_err() => report(
"settings",
false,
format!(
"{} will not parse, so it is being read as no settings at all - any \
account you paused is back in rotation. Fix the file, or delete it to \
start from defaults.",
crate::util::redact_path(&f.display().to_string())
),
),
_ => {}
}
}
for adapter in adapters::all() {
let tool = adapter.name();
if let Some((name, email, signed_in)) = slot_active_login(paths, tool) {
let who = slot_who(&name, email, signed_in, tool);
report(tool, true, format!("{who} (profile '{name}')"));
if let Some(live) = tool_dir_disagrees(&store, paths, tool, &name) {
report(
tool,
true,
format!("a plain `{}` would launch on '{live}'", tool_binary(tool)),
);
}
continue;
}
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)
),
);
}
}
let unreadable: Vec<&str> = crate::adapters::names()
.into_iter()
.filter(|t| crate::slots::Slots::open_for(paths, t).is_err())
.collect();
if !unreadable.is_empty() {
report(
"registry",
false,
format!(
"the account registry will not parse ({}), so the slot checks below were \
skipped - the slot directories still hold their logins. Restore {} from a \
backup, or re-register each with `swapdex adopt <name> <dir>`.",
unreadable.join(", "),
crate::util::redact_path(
&paths.store_dir().join("slots.json").display().to_string()
)
),
);
}
if let Ok(slots) = crate::slots::Slots::open(paths) {
let list = slots.list();
if let Ok(me) = std::env::current_exe() {
let me = std::fs::canonicalize(&me).unwrap_or(me);
let mut stale: Vec<(String, std::path::PathBuf)> = Vec::new();
for tool in crate::adapters::names() {
let f = crate::shim::shim_path_for(paths, tool);
let Ok(text) = std::fs::read_to_string(&f) else {
continue;
};
let Some(called) = crate::shim::swapdex_path_in(&text) else {
continue;
};
let called = std::fs::canonicalize(&called).unwrap_or(called);
if called != me {
stale.push((tool_binary(tool).to_string(), called));
}
}
report(
"shim target",
stale.is_empty(),
if stale.is_empty() {
format!("the shims call this swapdex ({})", me.display())
} else {
format!(
"the {} shim calls a DIFFERENT swapdex ({}) - updating this one changes nothing it does; re-run `swapdex shim`",
stale[0].0,
stale[0].1.display()
)
},
);
}
let copies = crate::shim::swapdex_copies_on(&std::env::var("PATH").unwrap_or_default());
report(
"install",
copies.len() < 2,
match copies.len() {
0 => "swapdex is not on PATH (running it by full path)".to_string(),
1 => format!("one copy on PATH ({})", copies[0].display()),
_ => format!(
"{} copies on PATH - `{}` wins and the rest are shadowed; keep one installer and remove the others",
copies.len(),
copies[0].display()
),
},
);
let running = env!("CARGO_PKG_VERSION");
match if paths.sandboxed() {
None
} else {
crate::quota::latest_published()
} {
Some(latest) if crate::quota::is_behind(running, &latest) => report(
"version",
false,
format!(
"running {running}, but {latest} is published - update, then check \
`swapdex --version` actually changed"
),
),
Some(latest) => report("version", true, format!("{running} (latest is {latest})")),
None => report(
"version",
true,
format!("{running} - could not reach the registry to compare"),
),
}
if !list.is_empty() {
report("slots", true, format!("{} account(s)", list.len()));
match slots.default_dir() {
Some(dir) => match list.iter().find(|r| r.config_dir == dir) {
Some(r) => report("default", true, format!("plain `claude` -> '{}'", r.name)),
None => report(
"default",
false,
format!(
"the default pointer names {}, which no account holds - a plain \
`claude` would start there and ask to log in again. Repoint it \
with `swapdex use <name>`.",
crate::util::redact_path(&dir.display().to_string())
),
),
},
None => report(
"default",
true,
"no default for a plain `claude` - `swapdex use <name>` sets it".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", true, line.clone());
}
}
{
let named: Vec<(String, Option<String>)> = crate::adapters::names()
.into_iter()
.flat_map(|t| {
crate::slots::Slots::open_for(paths, t)
.map(|s| s.list())
.unwrap_or_default()
})
.map(|r| {
(
r.name.clone(),
crate::proxy::creds::slot_account_uuid(&r.config_dir),
)
})
.collect();
for group in crate::slots::slots_sharing_an_account(&named) {
report(
"shared account",
true,
format!(
"{} hold the same login in different directories - rotation \
counts them once, because a rate limit belongs to the account",
group.join(" and ")
),
);
}
}
{
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_path = shim_file.parent().unwrap_or(&shim_file).to_path_buf();
let shim_dir = shim_dir_path.display();
let resolved = crate::shim::resolved_claude();
let active = matches!(resolved, Some((_, true)));
let profile = crate::shim::shell_profile_text_for(paths);
match crate::shim::shim_reach_for(
paths,
active,
profile.as_ref().map(|(_, t)| t.as_str()),
&shim_dir_path,
) {
crate::shim::ShimReach::Active => (
true,
"claude shim active - plain `claude` follows `swapdex use`".to_string(),
),
crate::shim::ShimReach::ConfiguredElsewhere => {
let p = profile
.as_ref()
.map(|(p, _)| crate::util::redact_path(&p.display().to_string()))
.unwrap_or_else(|| "your shell profile".to_string());
(
true,
format!(
"claude shim set up in {p} but not on THIS shell's PATH - \
open a new terminal (or `source {p}`); a shell that does \
not read that file, like a script or `ssh host cmd`, \
never will"
),
)
}
crate::shim::ShimReach::Missing => (
false,
match resolved {
Some((found, _)) => 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 => 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 tool in crate::adapters::names() {
let Ok(slots) = crate::slots::Slots::open_for(paths, tool) else {
continue;
};
for r in slots.list() {
let key = format!("slot:{}", r.name);
match slot_login_of(paths, tool, &r.config_dir) {
SlotLogin::Absent => report(
&key,
true,
format!(
"no login yet - `swapdex run {}{}` once signs it in",
r.name,
tool_flag(tool)
),
),
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,
tool_flag(tool)
),
);
}
SlotLogin::Present(_) => {}
}
if renewal_deferred_for(paths, tool, &r.config_dir) {
let why = match tool {
"claude-code" => "renewal deferred to native session - the running claude \
session refreshes this login itself"
.to_string(),
_ => format!(
"renewal deferred - a running {} session holds this login; \
it renews once that session exits",
tool_binary(tool)
),
};
report(&key, true, why);
}
}
}
let mut to_check = vec![
paths.claude_credentials(),
paths.codex_auth(),
paths.gemini_oauth(),
paths.antigravity_token(),
];
for tool in crate::adapters::names() {
if let Ok(slots) = crate::slots::Slots::open_for(paths, tool) {
for r in slots.list() {
if let Some(p) = paths.try_with_tool_dir(tool, &r.config_dir) {
to_check.extend([
p.claude_credentials(),
p.codex_auth(),
p.gemini_oauth(),
p.antigravity_token(),
]);
}
}
}
}
to_check.sort();
to_check.dedup();
for f in to_check {
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 home = (!paths.sandboxed()).then(|| paths.home());
for tool in crate::store::KNOWN_TOOLS {
let Some(path) = home.as_ref().map(|h| {
if cfg!(target_os = "macos") {
crate::service::launchd_path(h, tool)
} else {
crate::service::systemd_path(h, tool)
}
}) else {
continue;
};
let Ok(body) = std::fs::read_to_string(&path) else {
continue; };
let label = format!("service:{}", crate::commands::tool_binary(tool));
match crate::service::unit_program(&body) {
Some(prog) if std::path::Path::new(&prog).exists() => {
let running = crate::proxy::running_proxy_for(paths, tool);
if let Some((_, _, build)) = &running {
match stale_proxy_note(build, &crate::proxy::build_id(), tool) {
Some(note) => report(&label, false, note),
None => report(&label, true, "installed and running".into()),
}
} else {
report(
&label,
false,
format!(
"installed but not running - `swapdex service install --tool {}` \
writes the unit and hands it back to the supervisor \
(sessions fall back to your own login until it is up)",
pretty_tool_flag(tool)
),
);
}
}
Some(prog) => report(
&label,
false,
format!(
"points at {} which no longer exists - reinstall with \
`swapdex service install --tool {}` (an npm path carries the Node \
version, so upgrading Node breaks it)",
crate::util::redact_path(&prog),
pretty_tool_flag(tool)
),
),
None => report(
&label,
false,
format!("{} names no program - reinstall it", path.display()),
),
}
}
{
let settings_file = paths.claude_dir().join("settings.json");
let pinned = std::fs::read_to_string(&settings_file)
.ok()
.and_then(|t| serde_json::from_str::<serde_json::Value>(&t).ok())
.and_then(|v| crate::shim::pinned_port(&v));
if let Some(port) = pinned {
let addr = std::net::SocketAddr::from(([127, 0, 0, 1], port));
let answers =
std::net::TcpStream::connect_timeout(&addr, std::time::Duration::from_millis(700))
.is_ok();
if answers {
report(
"proxy pin",
true,
format!("Claude Code -> 127.0.0.1:{port}, answering"),
);
} else {
report(
"proxy pin",
false,
format!(
"settings.json sends Claude Code to 127.0.0.1:{port} and nothing is \
answering there - every session gets 'Connection refused'. Start the \
proxy (`swapdex service install --tool claude`, from a terminal \
on this machine so it can read the Keychain), or remove the pin from \
{} to go direct.",
crate::util::redact_path(&settings_file.display().to_string())
),
);
}
}
}
for tool in crate::adapters::names() {
let accounts = crate::slots::Slots::open_for(paths, tool)
.map(|s| s.list().len())
.unwrap_or(0);
let has_proxy = crate::proxy::running_proxy_for(paths, tool).is_some();
if let Some(msg) = serving_reach(has_proxy, accounts) {
report(&format!("reach:{}", tool_binary(tool)), true, msg);
}
}
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, sel: Option<ToolSel>) -> Result<i32> {
let tool = sel.and_then(one_tool);
if let Some(c) = reject_bad_name(name) {
return Ok(c);
}
let store = Store::open(paths)?;
let is_slot = crate::adapters::names().into_iter().any(|t| {
crate::slots::Slots::open_for(paths, t)
.map(|s| s.get(name).is_some())
.unwrap_or(false)
});
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 let Some(t) = tool {
let dropped_snapshot = store.drop_tool(name, t)?;
let dropped_slot = crate::slots::Slots::open_for_update(paths, t)
.and_then(|mut sl| sl.remove(name))
.unwrap_or(false);
if !dropped_snapshot && !dropped_slot {
eprintln!("swapdex: profile '{name}' has no {t} login");
return Ok(5);
}
println!("dropped {t} from '{name}' (its live login keeps running, now unsaved)");
if !name_still_known(paths, name) {
drop_side_state(paths, name);
}
return Ok(0);
}
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 dirs: Vec<std::path::PathBuf> = Vec::new();
let mut warnings: Vec<String> = Vec::new();
for tool in crate::adapters::names() {
let Ok(mut slots) = crate::slots::Slots::open_for_update(paths, tool) else {
continue;
};
let Some(dir) = slots.get(name).map(|r| r.config_dir) else {
continue;
};
slots.remove(name)?;
if !dirs.contains(&dir) {
dirs.push(dir);
}
if let Some(w) = last_slot_warning(
crate::slots::Slots::open_for(paths, tool)
.map(|s| s.list().len())
.unwrap_or(0),
crate::proxy::running_proxy_for(paths, tool).is_some(),
tool,
) {
warnings.push(w);
}
}
println!("stopped managing '{name}'.");
for d in &dirs {
println!(
" its login is untouched at {}",
crate::util::redact_path(&d.display().to_string())
);
}
for w in &warnings {
println!("{w}");
}
if is_profile {
println!(
" a saved profile named '{name}' is still here - `swapdex rm {name} --yes` \
again removes it"
);
}
if !name_still_known(paths, name) {
drop_side_state(paths, name);
}
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)");
drop(_lock);
if !name_still_known(paths, name) {
drop_side_state(paths, name);
}
Ok(0)
}
fn carry_side_state(paths: &Paths, old: &str, new: &str) {
if crate::settings::update(paths, |s| s.rename_account(old, new)).is_err() {
eprintln!("swapdex: account renamed, but its rotation preferences could not be moved; check pause and order settings");
}
crate::quota_cache::rename_account(paths, old, new);
if let Ok(st) = Store::open(paths) {
let _ = st.rename_timeline_account(old, new);
}
}
fn drop_side_state(paths: &Paths, name: &str) {
if crate::settings::update(paths, |s| s.forget_account(name)).is_err() {
eprintln!("swapdex: account removed, but its rotation preferences could not be cleared; check pause and order settings before reusing the name");
}
crate::quota_cache::forget_account(paths, name);
if let Ok(st) = Store::open(paths) {
let _ = st.retire_timeline_account(name);
}
}
fn name_still_known(paths: &Paths, name: &str) -> bool {
Store::open(paths).is_ok_and(|st| st.list().iter().any(|p| p.name == name))
|| crate::adapters::names()
.into_iter()
.any(|t| crate::slots::Slots::open_for(paths, t).is_ok_and(|s| s.get(name).is_some()))
}
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);
}
let old_exists = Store::open(paths).is_ok_and(|st| st.list().iter().any(|p| p.name == old))
|| crate::adapters::names()
.into_iter()
.any(|t| crate::slots::Slots::open_for(paths, t).is_ok_and(|s| s.get(old).is_some()));
if old_exists && Store::open(paths).is_ok_and(|st| st.profile_dir_exists(new)) {
eprintln!("swapdex: a profile named '{new}' already exists");
return Ok(6);
}
let mut slot_renamed = false;
for tool in adapters::all().iter().map(|a| a.name()) {
if let Ok(mut slots) = crate::slots::Slots::open_for_update(paths, tool) {
match slots.rename(old, new) {
Ok(true) => slot_renamed = true,
Ok(false) => {}
Err(e) => {
eprintln!("swapdex: {e}");
return Ok(6);
}
}
}
}
if slot_renamed
&& Store::open(paths)
.map(|st| !st.list().iter().any(|p| p.name == old))
.unwrap_or(true)
{
carry_side_state(paths, old, new);
println!("renamed account '{old}' to '{new}'");
return Ok(0);
}
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)? {
drop(_lock);
carry_side_state(paths, 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,
history_shared: 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 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"
));
if crate::proxy::carries(tool) {
out.push_str(&format!(
" to move one that is already running: swapdex proxy{}\n",
tool_flag(tool)
));
}
if !history_shared {
out.push_str(
" note: this account keeps its own conversation history - \
`swapdex share-history` makes them all reachable from every account",
);
}
out
}
}
fn store_lock_or_exit(store: &Store) -> std::result::Result<crate::store::LockGuard, i32> {
match store.lock() {
Ok(g) => Ok(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."
);
Err(4)
}
Err(crate::store::LockError::Unwritable(e)) => {
eprintln!(
"swapdex: the store is not writable ({e}) - check permissions/mount of \
the store directory"
);
Err(4)
}
}
}
fn clear_serving_choice(paths: &Paths, tool: &str) -> Result<bool> {
let slots = crate::slots::Slots::open_for(paths, tool)?;
let existed = slots.serving_pointer_file().exists();
slots.clear_serving()?;
Ok(existed)
}
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)?;
let store = Store::open(paths)?;
let lock = match store_lock_or_exit(&store) {
Ok(g) => g,
Err(code) => return Ok(code),
};
slots.set_default(name)?;
store.append_timeline(tool, name, "use")?;
drop(lock);
let proxy = crate::proxy::running_proxy_for(paths, tool).is_some();
println!(
"{}",
switch_outcome_line(
tool,
name,
proxy,
crate::slots::history_is_shared(paths, tool)
)
);
if crate::slots::home_var(tool).is_some() && !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}{}`)",
tool_flag(tool)
);
}
Ok(0)
}
pub fn install_shim(paths: &Paths) -> Result<i32> {
install_requested_shims(paths, &["claude-code", "codex"])
}
fn install_requested_shims(paths: &Paths, requested: &[&str]) -> Result<i32> {
let shim_dir = crate::shim::shim_bin_dir(paths);
let mut installed = Vec::new();
if requested.contains(&"claude-code") {
match crate::shim::install_claude(paths)? {
Some((shim, _)) => {
println!("installed the claude shim at {}", shim.display());
installed.push("claude");
}
None => println!(" (no `claude` on PATH - skipped its shim)"),
}
}
if requested.contains(&"codex") {
match crate::shim::install_codex(paths)? {
Some(shim) => {
println!("installed the codex shim at {}", shim.display());
installed.push("codex");
}
None => println!(" (no `codex` on PATH - skipped its shim)"),
}
}
if installed.is_empty() {
eprintln!(
"swapdex: no `claude` or `codex` executable found on PATH - install Claude Code or \
Codex, then rerun `swapdex shim`"
);
return Ok(1);
}
if installed.contains(&"claude") {
let svc = {
let h = paths.home();
if cfg!(target_os = "macos") {
crate::service::launchd_path(h, "claude-code").exists()
} else {
crate::service::systemd_path(h, "claude-code").exists()
}
};
match crate::shim::pin_base_url(paths, 8787, svc)? {
Some(f) => println!(
" pinned the proxy address in {} - a plain `claude` reaches it \
however it is started",
crate::util::redact_path(&f.display().to_string())
),
None => println!(
" not pinning the proxy address: no service keeps the proxy alive, and a \
pinned address with nothing behind it would stop `claude` from starting.\n\
\x20 run `swapdex service install --tool claude` first"
),
}
}
match crate::shim::ensure_on_path_for(paths, &shim_dir)? {
crate::shim::PathSetup::AlreadyThere => {
let entries: Vec<String> = std::env::var("PATH")
.unwrap_or_default()
.split(':')
.map(str::to_string)
.collect();
let refs: Vec<&str> = entries.iter().map(String::as_str).collect();
for binary in &installed {
match crate::shim::path_verdict_for(&shim_dir, &refs, binary) {
crate::shim::PathVerdict::Wins => println!(
" it is already on your PATH - a plain `{binary}` goes through its shim"
),
crate::shim::PathVerdict::Shadowed(winner) => println!(
" it is on your PATH but {} comes first and holds `{binary}`, so the \
{binary} shim never runs - `swapdex serve` would change nothing.\n\
\x20 put the shim ahead of it: export PATH=\"{}:$PATH\"",
crate::util::redact_path(&winner),
shim_dir.display()
),
crate::shim::PathVerdict::Absent => println!(
" add this to your shell profile so it wins over the real {binary}:\n\
\x20 export PATH=\"{}:$PATH\"",
shim_dir.display()
),
}
}
}
crate::shim::PathSetup::Added(profile) => {
println!(
" added it to {} - open a new terminal (or `source` that file) and a plain \
{} goes through the installed shim",
crate::util::redact_path(&profile.display().to_string()),
installed
.iter()
.map(|binary| format!("`{binary}`"))
.collect::<Vec<_>>()
.join(" or ")
);
}
crate::shim::PathSetup::Manual => {
println!(
" add this to your shell profile so the installed shims win:\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")
}
fn available_missing_shims(paths: &Paths) -> Vec<&'static str> {
["claude-code", "codex"]
.into_iter()
.filter(|tool| {
!crate::shim::shim_path_for(paths, tool).exists()
&& crate::shim::real_tool(paths, tool).is_some()
})
.collect()
}
pub fn needs_onboarding(paths: &Paths) -> bool {
if onboarded_marker(paths).exists() {
return false;
}
if !available_missing_shims(paths).is_empty() {
return true;
}
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 migration_tools(None).into_iter().any(|tool| {
crate::slots::Slots::open_for(paths, tool)
.map(|tool_slots| {
classify_migrations(&store, &tool_slots, tool)
.iter()
.any(|profile| profile.class == MigrationClass::NeedsSlot)
})
.unwrap_or(false)
});
}
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_for_update(paths, "claude-code")?;
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 mut legacy = 0;
let mut copies = 0;
for tool in migration_tools(None) {
let slots = crate::slots::Slots::open_for(paths, tool)?;
for profile in classify_migrations(&store, &slots, tool) {
match profile.class {
MigrationClass::NeedsSlot => legacy += 1,
MigrationClass::CopyOfSlot(_) => copies += 1,
MigrationClass::AlreadySlotted | MigrationClass::Unreadable => {}
}
}
}
let mut reported_copies = false;
if legacy > 0 {
let profile_label = if legacy == 1 {
"profile login"
} else {
"profile logins"
};
println!(
"You have {legacy} saved Claude/Codex {profile_label} on the old copy-switch model \
(the one that could log you out)."
);
if ask_yes("Give each its own space now?") {
migrate(paths, None)?;
reported_copies = true;
println!();
}
}
if copies > 0 && !reported_copies {
let copy_label = if copies == 1 {
"profile copy"
} else {
"profile copies"
};
println!(
"You also have {copies} saved {copy_label} whose account already lives in a \
differently named slot; nothing needs to be migrated for those."
);
}
}
let missing_shims = available_missing_shims(paths);
let question = match missing_shims.as_slice() {
["claude-code"] => Some(
"Make a plain `claude` follow `swapdex use`? (installs a small shim)",
),
["codex"] => Some(
"Make a plain `codex` follow `swapdex use --tool codex`? (installs a small shim)",
),
["claude-code", "codex"] => Some(
"Make plain `claude` and `codex` follow their `swapdex use` selections? (installs small shims)",
),
_ => None,
};
if let Some(question) = question {
if ask_yes(question) {
install_requested_shims(paths, &missing_shims)?;
println!();
}
}
let _ = crate::atomic::write_managed(&onboarded_marker(paths), b"1");
if !has_any_account(paths) {
println!(
"No accounts yet. Install your native client if needed, then sign in inside a new slot:\n\
\x20 Codex: swapdex run work --tool codex -- login --device-auth\n\
\x20 Claude: swapdex run work --tool claude -- auth login\n\
Use the command for your client; `work` is an example account name.\n\
Continue with shim activation and launch selection:\n\
\x20 https://github.com/youdie006/swapdex#quick-start"
);
} else {
println!("You're set. `swapdex ui` shows your accounts and switches between them.");
}
Ok(0)
}
pub fn migrate(paths: &Paths, sel: Option<MigrationToolSel>) -> Result<i32> {
let store = Store::open(paths)?;
let tools = migration_tools(sel);
if tools.is_empty() {
eprintln!("swapdex: migrate supports only claude and codex");
return Ok(2);
}
for (index, tool) in tools.into_iter().enumerate() {
if index > 0 {
println!();
}
println!("{}:", pretty_tool(tool));
let mut slots = crate::slots::Slots::open_for_update(paths, tool)?;
let classified = classify_migrations(&store, &slots, tool);
let mut created = Vec::new();
let mut renamed = Vec::new();
let mut copies = Vec::new();
let mut unreadable = Vec::new();
let mut assigned_accounts: Vec<(String, String)> = Vec::new();
let taken: Vec<String> = slots.list().into_iter().map(|r| r.name).collect();
let mut taken = taken;
for profile in classified {
match profile.class {
MigrationClass::AlreadySlotted => {}
MigrationClass::CopyOfSlot(slot_name) => {
copies.push((profile.name, slot_name));
}
MigrationClass::Unreadable => unreadable.push(profile.name),
MigrationClass::NeedsSlot => {
if let Some((_, slot_name)) = assigned_accounts.iter().find(|(account, _)| {
profile.account_id.as_deref() == Some(account.as_str())
}) {
copies.push((profile.name, slot_name.clone()));
continue;
}
let collides = taken.iter().any(|name| name == &profile.name);
let name = if crate::slots::name_reads_as_a_tool_home(&profile.name)
|| crate::slots::name_is_reserved(&profile.name)
|| collides
{
crate::slots::suggest_non_colliding(&profile.name, &taken)
} else {
profile.name.clone()
};
if name != profile.name {
renamed.push((profile.name, name.clone()));
}
let account = profile.account_id.as_deref().ok_or_else(|| {
anyhow::anyhow!("migration account disappeared after classification")
})?;
let rec = slots.create_initialized(&name, |dir| {
crate::atomic::write_secret(
&dir.join(MIGRATED_ACCOUNT_MARKER),
account.as_bytes(),
)
})?;
if let Some(account) = &profile.account_id {
assigned_accounts.push((account.clone(), name.clone()));
}
crate::slots::link_shared_config(
&rec.config_dir,
&shared_source(paths, tool),
tool,
);
taken.push(name.clone());
created.push(name);
}
}
}
if !created.is_empty() {
for (profile, slot_name) in renamed {
println!(
" '{profile}' cannot be used as this slot name, so the account is named '{slot_name}'"
);
}
println!(" Created slots for: {}", created.join(", "));
println!(" Sign in to each once:");
for name in &created {
println!(" swapdex run {name} --tool {}", pretty_tool_flag(tool));
}
}
for (profile, slot_name) in &copies {
println!(
" Saved profile '{profile}' is a saved copy of slot '{slot_name}' - nothing to \
do; leave it alone or remove it with `swapdex rm {profile} --tool {}`.",
pretty_tool_flag(tool)
);
}
for profile in &unreadable {
println!(
" Could not read the account ID from saved profile '{profile}'; no slot was created."
);
}
if created.is_empty() && copies.is_empty() && unreadable.is_empty() {
println!(" Nothing to migrate.");
}
}
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_update(paths, tool)?;
let rec = slots.adopt(name, dir)?;
println!(
"registered '{}' ({tool}) -> {}",
rec.name,
rec.config_dir.display()
);
Ok(0)
}
fn claude_slot_credential_absent(paths: &Paths, dir: &std::path::Path) -> Result<bool> {
if cfg!(target_os = "macos") && !paths.sandboxed() && std::env::var_os("SWAPDEX_ROOT").is_none()
{
use std::process::Stdio;
use std::time::{Duration, Instant};
let service = crate::adapters::claude::slot_service(dir);
let mut check = std::process::Command::new("/usr/bin/security")
.args(["find-generic-password", "-s", &service])
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.context("check selected Claude Keychain service")?;
let deadline = Instant::now() + Duration::from_secs(3);
loop {
if let Some(status) = check.try_wait()? {
return match status.code() {
Some(44) => Ok(true),
Some(0) => Ok(false),
_ => bail!("cannot verify whether the selected Claude Keychain item exists"),
};
}
if Instant::now() >= deadline {
check.kill().ok();
check.wait().ok();
bail!("timed out checking the selected Claude Keychain item");
}
std::thread::sleep(Duration::from_millis(20));
}
}
match std::fs::symlink_metadata(dir.join(".credentials.json")) {
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(true),
Err(error) => Err(error).context("inspect selected Claude credential"),
Ok(_) => Ok(false),
}
}
fn prepare_claude_launch(
paths: &Paths,
dir: &std::path::Path,
authentication: bool,
known_slot: bool,
) -> Result<Option<crate::claude_authority::Authority>> {
let marker = dir.join(crate::claude_authority::RECORD);
let bound = match std::fs::symlink_metadata(&marker) {
Ok(_) => true,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => false,
Err(error) => return Err(error).context("inspect Claude authority marker"),
};
let registered = if known_slot || bound {
true
} else {
let slots = crate::slots::Slots::open_for(paths, "claude-code")?;
let mut directories: Vec<_> = slots
.list()
.into_iter()
.map(|slot| slot.config_dir)
.collect();
directories.extend(slots.default_dir());
if directories.iter().any(|registered| registered == dir) {
true
} else {
if let Ok(actual) = std::fs::canonicalize(dir) {
if directories.iter().any(|registered| {
std::fs::canonicalize(registered).is_ok_and(|registered| registered == actual)
}) {
bail!("Claude config aliases a managed account; use its registered config directory or `swapdex run <account>`");
}
}
false
}
};
if !bound && !registered {
return Ok(None); }
if !bound && !claude_slot_credential_absent(paths, dir)? {
crate::claude_authority::reconcile_live(paths, dir)?;
}
let authority = if authentication {
crate::claude_authority::resolve_for_authentication(paths, dir)?
} else {
crate::claude_authority::resolve(paths, dir)?
};
Ok(Some(authority))
}
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_update(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
}
};
drop(slots);
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 Some(exe) = crate::shim::real_tool(paths, tool) else {
eprintln!(
"swapdex: real `{bin}` executable not found behind the installed shim. \
Reinstall `{bin}`, then retry."
);
return Ok(3);
};
let mut cmd = std::process::Command::new(exe);
cmd.args(args);
configure_managed_tool_home(&mut cmd, tool, home_var, &rec.config_dir);
if tool == "claude-code" {
let authentication = crate::shim::claude_authentication_command(args);
let authority = prepare_claude_launch(paths, &rec.config_dir, authentication, true)?
.context("selected Claude account has no launch authority")?;
authority.configure_launch(paths, &mut cmd, authentication)?;
}
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 fn launch_claude_authority(
paths: &Paths,
native: &std::path::Path,
args: &[String],
) -> Result<i32> {
use std::os::unix::process::CommandExt;
let dir = std::env::var_os("CLAUDE_CONFIG_DIR")
.map(std::path::PathBuf::from)
.ok_or_else(|| {
anyhow::anyhow!("a linked Claude launch requires its session config directory")
})?;
let authentication = crate::shim::claude_authentication_command(args);
let mut cmd = Command::new(native);
cmd.args(args);
if let Some(authority) = prepare_claude_launch(paths, &dir, authentication, false)? {
authority.configure_launch(paths, &mut cmd, authentication)?;
}
Err(anyhow::anyhow!("could not launch Claude: {}", cmd.exec()))
}
pub fn unusable_token_reason(token: &str, unreadable: Option<&str>) -> String {
if !token.is_empty() {
return "saved token unusable (corrupt snapshot?) - `swapdex add <name> --update` \
re-saves it"
.to_string();
}
match unreadable {
Some(why) => why.to_string(),
None => "its credential read back empty here - nothing was saved wrong; \
run this where the login can be read"
.to_string(),
}
}
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
pub enum CaptureSource {
LiveHome,
Slot,
}
pub fn capture_source(from_slot: bool) -> CaptureSource {
if from_slot {
CaptureSource::Slot
} else {
CaptureSource::LiveHome
}
}
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_update(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}")),
},
};
drop(slots);
let bin = tool_binary(tool);
if !command_exists(bin) {
return (false, format!("`{bin}` isn't on your PATH"));
}
let Some(exe) = crate::shim::real_tool(paths, tool) else {
return (
false,
format!(
"real `{bin}` executable not found behind the installed shim; \
reinstall `{bin}`, then retry"
),
);
};
match spawn_tool_login_in(
paths,
&exe,
tool,
Some((home_var, rec.config_dir.as_path())),
) {
Ok(_) => {
let signed_in = crate::proxy::has_login(paths, tool, &rec.config_dir);
if signed_in {
match paths
.try_with_tool_dir(tool, &rec.config_dir)
.ok_or_else(|| {
anyhow::anyhow!(
"cannot point '{tool}' at its slot, so a capture would \
read the live default instead"
)
})
.and_then(|at_slot| {
crate::adapters::by_name(tool)
.ok_or_else(|| anyhow::anyhow!("unknown tool"))
.and_then(|a| a.capture(&at_slot))
})
.and_then(|snap| {
crate::store::Store::open(paths)?.save(name, &snap)?;
Ok(())
}) {
Ok(()) => (
true,
format!("'{name}' is signed in, and its saved copy is fresh"),
),
Err(e) => (
true,
format!(
"'{name}' is signed in, but its saved copy could not be \
refreshed ({e})"
),
),
}
} 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 one_tool(sel: ToolSel) -> Option<&'static str> {
match sel {
ToolSel::Claude => Some("claude-code"),
ToolSel::Codex => Some("codex"),
ToolSel::Gemini => Some("gemini"),
ToolSel::Antigravity => Some("antigravity"),
ToolSel::All => None,
}
}
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",
}
}
pub(crate) fn tool_binary(tool: &str) -> &'static str {
match tool {
"codex" => "codex",
"gemini" => "gemini",
"antigravity" => "agy",
_ => "claude",
}
}
fn shared_source(paths: &Paths, tool: &str) -> std::path::PathBuf {
match tool {
"codex" => paths.codex_dir().to_path_buf(),
"gemini" | "antigravity" => paths.gemini_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 has_any_account(paths: &Paths) -> bool {
if Store::open(paths)
.map(|st| !st.list().is_empty())
.unwrap_or(false)
{
return true;
}
crate::adapters::names().into_iter().any(|t| {
crate::slots::Slots::open_for(paths, t)
.map(|s| !s.list().is_empty())
.unwrap_or(false)
})
}
pub fn active_slot_name(paths: &Paths, tool: &str) -> Option<String> {
let slots = crate::slots::Slots::open_for(paths, tool).ok()?;
if slots.serving_is_off() {
return None;
}
let name_of = |dir: std::path::PathBuf| {
slots
.list()
.into_iter()
.find(|r| r.config_dir == dir)
.map(|r| r.name)
};
pick_active(
slots.serving_dir().and_then(&name_of),
crate::proxy::serving_account_for(paths, tool),
slots.default_dir().and_then(&name_of),
proxy_acted_since_ask(paths, tool),
)
}
fn proxy_acted_since_ask(paths: &Paths, tool: &str) -> bool {
let asked = crate::slots::Slots::open_for(paths, tool)
.ok()
.map(|s| s.serving_pointer_file());
let did = crate::proxy::serving_record_file(paths, tool);
let stamp = |p: Option<std::path::PathBuf>| {
p.and_then(|p| std::fs::metadata(p).ok())
.and_then(|m| m.modified().ok())
};
match (stamp(asked), stamp(Some(did))) {
(Some(a), Some(d)) => d > a,
_ => false,
}
}
pub fn pick_active(
asked_for: Option<String>,
proxy_did: Option<String>,
default: Option<String>,
proxy_acted_since_ask: bool,
) -> Option<String> {
if proxy_acted_since_ask {
if let (Some(a), Some(d)) = (asked_for.as_deref(), proxy_did.as_deref()) {
if a != d {
return proxy_did;
}
}
}
asked_for.or(proxy_did).or(default)
}
pub fn unhonoured_ask(
asked_for: Option<&str>,
proxy_did: Option<&str>,
proxy_acted_since_ask: bool,
) -> Option<String> {
if !proxy_acted_since_ask {
return None;
}
match (asked_for, proxy_did) {
(Some(a), Some(d)) if a != d => Some(format!(
"asked for {a} - it cannot serve, so turns are going to {d}"
)),
_ => None,
}
}
pub fn payer_label(paths: &Paths, tool: &str) -> Option<String> {
let slots = crate::slots::Slots::open_for(paths, tool).ok()?;
let who = slots.payer()?;
let rec = slots.get(&who)?;
let email = match tool {
"codex" => codex_slot_email(&rec.config_dir),
_ => crate::proxy::creds::slot_email(&rec.config_dir),
};
let home = slots.default_dir().and_then(|d| {
slots
.list()
.into_iter()
.find(|r| r.config_dir == d)
.map(|r| r.name)
});
Some(format!(
"{}{}",
payer_line(
&who,
email.as_deref(),
crate::proxy::has_login(paths, tool, &rec.config_dir),
),
home_note(&who, home.as_deref())
))
}
pub fn home_note(payer: &str, home: Option<&str>) -> String {
match home {
Some(h) if h != payer => format!(" - home: {h}"),
_ => String::new(),
}
}
pub fn payer_line(name: &str, email: Option<&str>, has_login: bool) -> String {
match (email.filter(|e| *e != name), has_login) {
(Some(e), true) => format!("{name} ({e})"),
(Some(e), false) => format!("{name} ({e}, no login)"),
(None, true) => name.to_string(),
(None, false) => format!("{name} (no login)"),
}
}
fn tool_flag(tool: &str) -> String {
match tool {
"claude-code" | "claude" => String::new(),
other => format!(" --tool {other}"),
}
}
pub fn serve(
paths: &Paths,
name: Option<&str>,
off: bool,
sel: Option<ToolSel>,
quiet: bool,
) -> Result<i32> {
let tool = slot_tool(sel);
if !crate::proxy::carries(tool) {
eprintln!("{}", crate::proxy::cannot_carry(tool));
return Ok(2);
}
let bin = tool_binary(tool);
let slots = crate::slots::Slots::open_for(paths, tool)?;
slots.prune_serving();
if off {
let store = Store::open(paths)?;
let _lock = match store_lock_or_exit(&store) {
Ok(g) => g,
Err(code) => return Ok(code),
};
slots.set_serving_off()?;
store.append_timeline(tool, "", crate::session_link::SERVE_OFF)?;
match std::fs::remove_file(crate::proxy::serving_record_file(paths, tool)) {
Ok(()) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => return Err(error.into()),
}
println!("passthrough is on - each session pays with its own login ({bin})");
return Ok(0);
}
if quiet {
if let Some(label) = payer_label(paths, tool) {
let who = label
.split_whitespace()
.next()
.unwrap_or(&label)
.to_string();
let cache = crate::quota_cache::load_for(paths, tool);
let brief = cache
.get(&who)
.map(|e| cached_brief(e, now_secs() as i64))
.unwrap_or_else(|| quota_brief(None, None));
if brief.is_empty() {
print!("{label}");
} else {
print!("{label} - {brief}");
}
}
return Ok(0);
}
let Some(name) = name else {
if slots.serving_is_off() {
println!("passthrough is on ({bin}) - each session pays with its own login");
return Ok(0);
}
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",
tool_flag(tool)
),
}
return Ok(0);
};
let Some(rec) = slots.get(name) else {
let mut servable: Vec<String> = crate::slots::Slots::open_for(paths, tool)
.map(|sl| sl.list().into_iter().map(|r| r.name).collect())
.unwrap_or_default();
servable.sort();
let mut saved: Vec<String> = Store::open(paths)
.map(|st| st.list().into_iter().map(|p| p.name).collect())
.unwrap_or_default();
saved.sort();
let sv: Vec<&str> = servable.iter().map(String::as_str).collect();
let sa: Vec<&str> = saved.iter().map(String::as_str).collect();
eprintln!(
"swapdex: {}",
unknown_account_or_unservable(name, &sv, &sa, tool)
);
return Ok(5);
};
if !crate::proxy::has_login(paths, tool, &rec.config_dir) {
eprintln!(
"swapdex: '{name}' has no {bin} login, so it cannot pay for turns - your own account would, while every screen named '{name}'"
);
eprintln!(
" sign it in first: `swapdex run {name}{}`",
tool_flag(tool)
);
return Ok(6);
}
let store = Store::open(paths)?;
let _lock = match store_lock_or_exit(&store) {
Ok(g) => g,
Err(code) => return Ok(code),
};
slots.set_serving(name)?;
store.append_timeline(tool, name, crate::session_link::SERVE)?;
if !paths.sandboxed() && 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();
{
let email = crate::slots::Slots::open_for(paths, tool)
.ok()
.and_then(|sl| sl.get(name).map(|r| r.config_dir.clone()))
.and_then(|d| crate::proxy::creds::slot_email(&d));
let left = crate::quota_cache::load_for(paths, tool)
.get(name)
.and_then(|e| e.seven_d)
.map(|used| (100.0 - used).clamp(0.0, 100.0));
println!("{}", switch_line(name, email.as_deref(), left));
}
if live {
println!(" the session you have open moves from its next turn");
if tool == "codex" {
println!(" check the paying account with `swapdex serve --tool codex --quiet`");
}
} 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 codex_identity(
live_email: Option<&str>,
live_plan: Option<&str>,
saved_email: Option<&str>,
) -> String {
fn clean(s: Option<&str>) -> Option<&str> {
s.map(str::trim).filter(|s| !s.is_empty())
}
let (live, saved) = (clean(live_email), clean(saved_email));
let mut out = identity_column(
live.or(saved).map(str::to_string),
clean(live_plan).map(str::to_string),
);
if let (Some(l), Some(s)) = (live, saved) {
if !l.eq_ignore_ascii_case(s) {
out.push_str(&format!(" (saved as {s})"));
}
}
out
}
pub fn codex_row(
home: &str,
live: Option<&crate::codex_usage::Account>,
saved_email: Option<&str>,
seen_by_proxy: Option<crate::quota_cache::Entry>,
transcript: Option<crate::codex_limits::Limits>,
now: i64,
) -> Option<(String, crate::tui::Usage)> {
if let Some(a) = live {
let mut l = a.limits;
l.observed_at = Some(now);
let (name, mut u) = codex_usage_row(home, &l);
u.note = a.refused.as_deref().map(crate::codex_usage::refusal_words);
u.on_credits = a
.credits
.as_ref()
.is_some_and(|c| (c.has_credits || c.unlimited) && !c.overage_limit_reached);
u.ident = Some(codex_identity(
a.email.as_deref(),
a.plan.as_deref(),
saved_email,
));
return Some((name, u));
}
let from_transcript = transcript.map(|l| codex_usage_row(home, &l));
let from_proxy = seen_by_proxy.map(|e| {
(
home.to_string(),
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),
on_credits: e.on_credits,
note: e.refused,
..Default::default()
},
)
});
match (from_proxy, from_transcript) {
(Some(p), Some(t)) => Some(if p.1.observed_at >= t.1.observed_at {
p
} else {
t
}),
(p, t) => p.or(t),
}
}
fn remember_codex_reading(paths: &Paths, name: &str, u: &crate::tui::Usage) {
if u.five_h.is_none() && u.seven_d.is_none() {
return; }
let entry = crate::quota_cache::Entry {
five_h: u.five_h,
five_h_reset: u.five_h_reset,
seven_d: u.seven_d,
seven_d_reset: u.seven_d_reset,
at: u.observed_at.unwrap_or(now_secs() as i64),
on_credits: u.on_credits,
refused: None,
token_rejected_at: None,
};
crate::quota_cache::update_for(paths, "codex", &[(name.to_string(), entry)]);
}
pub const CLAUDE_TOOL: &str = "claude-code";
pub const CODEX_TOOL: &str = "codex";
pub fn cached_quota_tools() -> [&'static str; 2] {
[CLAUDE_TOOL, CODEX_TOOL]
}
fn fetch_codex_live(
items: Vec<(String, std::path::PathBuf)>,
) -> Vec<(String, Option<crate::codex_usage::Account>, bool)> {
let handles: Vec<_> = items
.into_iter()
.enumerate()
.map(|(i, (name, dir))| {
std::thread::spawn(move || {
if i > 0 {
std::thread::sleep(std::time::Duration::from_millis(
crate::quota::pace_ms() * i as u64,
));
}
let mut live = None;
let mut refused = false;
if let Some(auth) = crate::proxy::codex::slot_auth(&dir) {
let f = crate::codex_usage::fetch(&auth);
match crate::codex_usage::outcome_of(&f) {
crate::codex_usage::LiveOutcome::Reading => {
if let crate::codex_usage::Fetch::Ok(a) = f {
live = Some(*a);
}
}
crate::codex_usage::LiveOutcome::Refused => refused = true,
crate::codex_usage::LiveOutcome::KeepRemembered => {}
}
}
(name, live, refused)
})
})
.collect();
handles.into_iter().filter_map(|h| h.join().ok()).collect()
}
pub fn codex_names_without_a_slot(store: &[String], slots: &[String]) -> Vec<String> {
store
.iter()
.filter(|n| !slots.contains(n))
.cloned()
.collect()
}
pub fn codex_usage_row(home: &str, l: &crate::codex_limits::Limits) -> (String, crate::tui::Usage) {
let p = crate::codex_limits::place(l);
let u = crate::tui::Usage {
observed_at: l.observed_at,
five_h: p.five_h.map(|w| w.used_pct),
five_h_reset: p.five_h.and_then(|w| w.resets_at),
seven_d: p.seven_d.map(|w| w.used_pct),
seven_d_reset: p.seven_d.and_then(|w| w.resets_at),
..Default::default()
};
(home.to_string(), u)
}
pub fn row_needs_login(paths: &Paths, tool: &str, dir: Option<&std::path::Path>) -> bool {
dir.is_some_and(|d| !crate::proxy::has_login(paths, tool, d))
}
pub fn export(paths: &Paths, out: Option<&std::path::Path>) -> Result<i32> {
use anyhow::Context;
let portable = crate::portable::export(paths);
if !portable.unreadable.is_empty() {
eprintln!(
"swapdex: the account registry could not be read ({}) - this export is \
INCOMPLETE and does not list every account.",
portable.unreadable.join(", ")
);
}
let text = serde_json::to_string_pretty(&portable)?;
match out {
Some(p) => {
std::fs::write(p, format!("{text}\n"))
.with_context(|| format!("write {}", p.display()))?;
println!(
"wrote {} - names and settings only; every account still signs in on its own machine",
crate::util::redact_path(&p.display().to_string())
);
}
None => println!("{text}"),
}
Ok(0)
}
pub fn import(paths: &Paths, file: &std::path::Path, dry_run: bool) -> Result<i32> {
use anyhow::Context;
let bytes = std::fs::read(file).with_context(|| format!("read {}", file.display()))?;
let incoming: crate::portable::Portable =
serde_json::from_slice(&bytes).context("that file is not a swapdex export")?;
if incoming.version > crate::portable::FORMAT_VERSION {
eprintln!(
"swapdex: that export was written by a newer swapdex (format {}) - upgrade first",
incoming.version
);
return Ok(2);
}
let store = Store::open(paths)?;
let here: Vec<(String, String)> = merged_accounts(paths, &store)
.0
.into_iter()
.flat_map(|p| p.tools.into_iter().map(move |tool| (p.name.clone(), tool)))
.collect();
let settings = incoming.settings.clone();
let todo = crate::portable::plan(&here, &incoming);
if todo.is_empty() {
println!("every account in that file is already here");
}
let mut unknown_tools: Vec<&str> = Vec::new();
for a in &todo {
if crate::adapters::by_name(&a.tool).is_none() {
eprintln!(
"swapdex: skipped '{}': this build does not know the tool '{}' - \
upgrade swapdex and import again",
a.name, a.tool
);
unknown_tools.push(&a.tool);
continue;
}
if dry_run {
println!("would create {} ({})", a.name, tool_binary(&a.tool));
continue;
}
match crate::slots::Slots::open_for_update(paths, &a.tool)
.and_then(|mut s| s.create(&a.name))
{
Ok(rec) => {
crate::slots::link_shared_config(
&rec.config_dir,
&shared_source(paths, &a.tool),
&a.tool,
);
println!("created {} ({})", a.name, tool_binary(&a.tool));
}
Err(e) => eprintln!("swapdex: could not create '{}': {e}", a.name),
}
}
if let Some(s) = settings {
if !dry_run {
crate::settings::save(paths, &s)?;
println!("settings applied");
}
}
if !todo.is_empty() && !dry_run {
println!(" each one still needs its own sign-in:");
for a in &todo {
println!(" swapdex run {}{}", a.name, tool_flag(&a.tool));
}
}
if !unknown_tools.is_empty() {
return Ok(2);
}
Ok(0)
}
pub fn fallback_model(paths: &Paths, value: Option<&str>) -> Result<i32> {
let cfg = crate::settings::load(paths);
let Some(value) = value else {
match cfg.fallback_model.as_deref() {
Some(m) => {
println!("{m}");
println!(" asked for only when every account is past the threshold");
}
None => println!(
"off - a turn with nowhere left to go gets the refusal, not a different model"
),
}
return Ok(0);
};
if value.eq_ignore_ascii_case("off") || value.is_empty() {
crate::settings::update(paths, |c| c.fallback_model = None)?;
println!("fallback model off");
return Ok(0);
}
crate::settings::update(paths, |c| c.fallback_model = Some(value.to_string()))?;
println!("fallback model: {value}");
println!(" used ONLY when every account is past the threshold - rotating to an account");
println!(" with room comes first, because that gives you the model you asked for");
Ok(0)
}
pub fn strategy(paths: &Paths, value: Option<&str>) -> Result<i32> {
let cfg = crate::settings::load(paths);
let Some(value) = value else {
let s = cfg.strategy();
println!("{}", s.as_str());
println!(
"{}",
match s {
crate::proxy::pick::Strategy::Roomiest =>
" reaches for the account with the most left - the largest buffer for a burst",
crate::proxy::pick::Strategy::ConsumeFirst =>
" reaches for the window about to reset, so quota does not lapse unused",
}
);
return Ok(0);
};
let Some(parsed) = crate::proxy::pick::Strategy::parse(value) else {
eprintln!("swapdex: unknown strategy '{value}' - use `roomiest` or `consume-first`");
return Ok(2);
};
crate::settings::update(paths, |c| {
c.proxy_strategy = Some(parsed.as_str().to_string())
})?;
println!("strategy: {}", parsed.as_str());
println!(" a running proxy follows this from its next turn");
Ok(0)
}
fn install_verdict(came_up: bool, tool: &str) -> (bool, String) {
if came_up {
return (true, "installed and running".to_string());
}
(
false,
format!(
"the unit is written but the proxy did not start. On macOS this is \
usually the Keychain: a service started by launchd cannot open it, \
and swapdex refuses to run a proxy that would forward your own login \
without saying so. Start it from a terminal on this machine instead: \
`swapdex proxy --ensure --tool {tool}`, then `swapdex shim`"
),
)
}
pub fn service_install(paths: &Paths, sel: Option<ToolSel>) -> Result<i32> {
let tool = slot_tool(sel);
if !crate::proxy::carries(tool) {
eprintln!("{}", crate::proxy::cannot_carry(tool));
return Ok(2);
}
let path = crate::service::install(paths, tool)?;
println!(
"the {} proxy is now a service: {}",
tool_binary(tool),
crate::util::redact_path(&path.display().to_string())
);
println!(
" it writes what it says to {}",
crate::util::redact_path(&crate::service::log_dir(paths).display().to_string())
);
let mut came_up = false;
for _ in 0..10 {
if crate::proxy::running_proxy_for(paths, tool).is_some() {
came_up = true;
break;
}
std::thread::sleep(std::time::Duration::from_millis(400));
}
let (ok, msg) = install_verdict(came_up, tool);
if ok {
println!(" {msg}");
Ok(0)
} else {
eprintln!("swapdex: {msg}");
Ok(2)
}
}
pub fn service_uninstall(paths: &Paths, sel: Option<ToolSel>) -> Result<i32> {
let tool = slot_tool(sel);
match crate::service::uninstall(paths, tool)? {
Some(p) => println!(
"removed {}",
crate::util::redact_path(&p.display().to_string())
),
None => println!("no {} service was installed", tool_binary(tool)),
}
Ok(0)
}
pub fn service_status(paths: &Paths) -> Result<i32> {
for tool in ["claude-code", "codex", "gemini", "antigravity"] {
let path = crate::service::unit_path(paths, tool);
let installed = path.exists();
let running = crate::proxy::running_proxy_for(paths, tool).is_some();
let note = if installed {
let (restarts, last_exit, uptime) = crate::service::supervisor_report(tool);
crate::service::supervision_note(restarts, last_exit, uptime)
} else {
None
};
println!(
"{:<12} service: {:<13} proxy: {}{}",
tool_binary(tool),
if installed {
"installed"
} else {
"not installed"
},
if running { "running" } else { "not running" },
note.map(|n| format!(" - {n}")).unwrap_or_default()
);
}
println!(
" logs: {}",
crate::util::redact_path(&crate::service::log_dir(paths).display().to_string())
);
Ok(0)
}
pub fn keep_alive(paths: &Paths) -> Result<i32> {
let listed = |tool: &str| -> Vec<(String, std::path::PathBuf)> {
crate::slots::Slots::open_for(paths, tool)
.map(|s| {
s.list()
.into_iter()
.map(|r| (r.name, r.config_dir))
.collect()
})
.unwrap_or_default()
};
let slots = listed("claude-code");
let codex = listed("codex");
if slots.is_empty() && codex.is_empty() {
println!("no accounts to keep alive");
return Ok(0);
}
let now = now_ms();
let mut claude = crate::refresh::keep_alive_sweep_report(paths, &slots, now);
let codex = crate::refresh::keep_alive_sweep_codex_report(paths, &codex, now);
claude.renewed.extend(codex.renewed);
let native_managed: Vec<(String, &str)> = claude
.native_managed
.into_iter()
.map(|name| (name, "Claude"))
.chain(codex.native_managed.into_iter().map(|name| (name, "Codex")))
.collect();
let deferred: Vec<(String, &str)> = claude
.deferred
.into_iter()
.map(|name| (name, "claude-code"))
.chain(codex.deferred.into_iter().map(|name| (name, "codex")))
.collect();
let failed: Vec<(String, &str, crate::refresh::RefreshError)> = claude
.failed
.into_iter()
.map(|(n, e)| (n, "claude-code", e))
.chain(codex.failed.into_iter().map(|(n, e)| (n, "codex", e)))
.collect();
for name in &claude.renewed {
println!("renewed {name}");
}
for (name, tool) in &native_managed {
println!("{name} uses a live {tool} login; renewal is managed by {tool}");
}
for (name, tool) in &deferred {
println!("{}", crate::refresh::RefreshError::InUse.remedy(name, tool));
}
for (name, tool, why) in &failed {
eprintln!("{}", why.remedy(name, tool));
}
if claude.renewed.is_empty()
&& native_managed.is_empty()
&& deferred.is_empty()
&& failed.is_empty()
{
println!("no scheduled renewals were needed");
}
Ok(i32::from(!failed.is_empty()) * 4)
}
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) => slots.get(n).into_iter().collect(),
None => slots.list(),
};
let codex = crate::slots::Slots::open_for(paths, "codex").ok();
let codex_list: Vec<_> = match (&codex, name) {
(Some(c), Some(n)) => c.get(n).into_iter().collect(),
(Some(c), None) => c.list(),
(None, _) => Vec::new(),
};
if name.is_some() && list.is_empty() && codex_list.is_empty() {
let mut known: Vec<String> = slots.list().into_iter().map(|r| r.name).collect();
known.extend(codex.iter().flat_map(|c| c.list()).map(|r| r.name));
let refs: Vec<&str> = known.iter().map(String::as_str).collect();
eprintln!("swapdex: {}", unknown_account(name.unwrap_or(""), &refs));
return Ok(5);
}
let now = now_ms();
let mut renewed = 0;
let mut failed = 0;
if list.is_empty() && codex_list.is_empty() {
println!("No accounts to renew.");
}
let mut done: Vec<String> = Vec::new();
for r in &list {
if !crate::proxy::has_login(paths, "claude-code", &r.config_dir) {
println!(
" {}",
crate::refresh::RefreshError::NoCredential.remedy(&r.name, "claude-code")
);
failed += 1;
continue;
}
if !crate::proxy::creds::slot_token_expired_for(paths, &r.config_dir, now) {
println!(" {} is already current", r.name);
continue;
}
let account = slot_account_of(&r.config_dir, "claude-code");
if let Some(note) = already_renewed(&account, &done, &r.name) {
println!(" {note}");
continue;
}
match crate::refresh::refresh_slot(paths, &r.config_dir, now) {
Ok(crate::refresh::RefreshOutcome::Renewed) => {
println!(" {} renewed", r.name);
if let Some(a) = account {
done.push(a);
}
renewed += 1;
}
Ok(crate::refresh::RefreshOutcome::NativeManaged) => {
println!(
" {} uses its live Claude login; renewal is managed by Claude",
r.name
);
}
Err(why) => {
println!(" {}", why.remedy(&r.name, "claude-code"));
failed += 1;
}
}
}
let (codex_renewed, codex_failed) = refresh_codex(paths, &codex_list, now, &mut done);
renewed += codex_renewed;
failed += codex_failed;
if renewed > 0 {
println!("\n{renewed} account(s) renewed - no sign-in needed.");
}
Ok(if failed == 0 { 0 } else { 4 })
}
fn refresh_codex(
paths: &Paths,
list: &[crate::slots::SlotRecord],
now: i64,
done: &mut Vec<String>,
) -> (usize, usize) {
let mut renewed = 0;
let mut failed = 0;
for r in list {
if !crate::proxy::has_login(paths, "codex", &r.config_dir) {
println!(
" {}",
crate::refresh::RefreshError::NoCredential.remedy(&r.name, "codex")
);
failed += 1;
continue;
}
if !crate::proxy::codex::slot_token_expired(&r.config_dir, now / 1000) {
println!(" {} is already current", r.name);
continue;
}
let auth = std::fs::read(r.config_dir.join("auth.json")).ok();
let account = auth
.as_deref()
.and_then(|bytes| crate::refresh::credential_identity(bytes, "codex"));
let token_identity = auth
.as_deref()
.and_then(|bytes| crate::refresh::refresh_token_identity(bytes, "codex"));
if let Some(note) = already_renewed(&account, done, &r.name)
.or_else(|| already_renewed(&token_identity, done, &r.name))
{
println!(" {note}");
continue;
}
match crate::refresh::refresh_codex_slot(paths, &r.config_dir, now) {
Ok(crate::refresh::RefreshOutcome::Renewed) => {
println!(" {} renewed", r.name);
if let Some(a) = account {
done.push(a);
}
if let Some(identity) = token_identity {
done.push(identity);
}
renewed += 1;
}
Ok(crate::refresh::RefreshOutcome::NativeManaged) => {
println!(
" {} uses its live Codex login; renewal is managed by Codex",
r.name
);
}
Err(why) => {
println!(" {}", why.remedy(&r.name, "codex"));
failed += 1;
}
}
}
(renewed, failed)
}
fn slot_account_of(dir: &std::path::Path, tool: &str) -> Option<String> {
let file = match tool {
"claude-code" => ".claude.json",
"codex" => "auth.json",
_ => return None,
};
crate::refresh::credential_identity(&std::fs::read(dir.join(file)).ok()?, tool)
}
fn already_renewed(account: &Option<String>, done: &[String], name: &str) -> Option<String> {
let a = account.as_ref()?;
done.contains(a).then(|| {
format!(
"{name} holds the login just renewed - left alone rather than spending \
the token that renewal retired; `swapdex run {name}` signs this \
directory in again if you want its own copy current"
)
})
}
pub fn list_slots(paths: &Paths) -> Result<i32> {
let mut any = false;
for tool in crate::adapters::names() {
let slots = crate::slots::Slots::open_for(paths, tool)?;
let list = slots.list();
if list.is_empty() {
continue;
}
let pointer = slots.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());
}
if crate::slots::home_var(tool).is_some() {
if let Ok(store) = Store::open(paths) {
for profile in classify_migrations(&store, &slots, tool) {
if let MigrationClass::CopyOfSlot(slot_name) = profile.class {
println!(
" saved profile '{}' is a copy of slot '{}'",
profile.name, slot_name
);
}
}
}
}
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(paths, 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,
Some(NameHolder {
paths,
tool,
account_id: &cur.account_id,
}),
) {
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.managed_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!(" {}", sign_out_blocked_remedy(still_same, tool));
return Ok(8);
}
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(paths, 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 slot_id = slot_account_under(paths, tool, name);
if name_means_two_accounts(Some(&new.account_id), slot_id.as_deref()) {
println!("'{name}' is already a {tool} slot holding a different account.");
if let Some(rescue) = ask_name(
&store,
"save the NEW account under a different name instead (Enter discards it): ",
"",
Some(NameHolder {
paths,
tool,
account_id: &new.account_id,
}),
) {
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 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): ",
"",
Some(NameHolder {
paths,
tool,
account_id: &new.account_id,
}),
) {
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(paths: &Paths, bin: &str, tool: &str) -> Result<std::process::ExitStatus> {
spawn_tool_login_in(paths, std::path::Path::new(bin), tool, None)
}
fn spawn_tool_login_in(
paths: &Paths,
bin: &std::path::Path,
tool: &str,
home: Option<(&str, &std::path::Path)>,
) -> Result<std::process::ExitStatus> {
let mut cmd = Command::new(bin);
if let Some((var, dir)) = home {
configure_managed_tool_home(&mut cmd, tool, var, dir);
if tool == "claude-code" {
crate::claude_authority::resolve_for_authentication(paths, dir)?
.configure_launch(paths, &mut cmd, true)?;
}
}
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) };
for var in ["ANTHROPIC_BASE_URL", "ANTHROPIC_API_KEY"] {
cmd.env_remove(var);
}
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 {}: {e}", bin.display()))
}
fn configure_managed_tool_home(
command: &mut Command,
tool: &str,
home_var: &str,
dir: &std::path::Path,
) {
command.env(home_var, dir);
if tool == "claude-code" {
command.env_remove("CLAUDE_SECURESTORAGE_CONFIG_DIR");
}
}
#[cfg(test)]
mod managed_tool_home_tests {
use super::configure_managed_tool_home;
use std::ffi::{OsStr, OsString};
use std::process::Command;
fn command_env(command: &Command, key: &str) -> Option<Option<OsString>> {
command
.get_envs()
.find(|(name, _)| *name == OsStr::new(key))
.map(|(_, value)| value.map(OsStr::to_os_string))
}
#[test]
fn a_managed_claude_login_targets_one_coherent_child_environment() {
let slot = std::path::Path::new("/tmp/swapdex-managed-claude");
let mut command = Command::new("claude");
command.env(
"CLAUDE_SECURESTORAGE_CONFIG_DIR",
"/tmp/a-different-claude-login",
);
configure_managed_tool_home(&mut command, "claude-code", "CLAUDE_CONFIG_DIR", slot);
assert_eq!(
command_env(&command, "CLAUDE_CONFIG_DIR"),
Some(Some(slot.as_os_str().to_os_string()))
);
assert_eq!(
command_env(&command, "CLAUDE_SECURESTORAGE_CONFIG_DIR"),
Some(None),
"the conflicting higher-precedence source must be removed"
);
}
#[test]
fn another_tools_managed_home_does_not_rewrite_claude_storage() {
let mut command = Command::new("codex");
command.env("CLAUDE_SECURESTORAGE_CONFIG_DIR", "/tmp/intentional-live");
configure_managed_tool_home(
&mut command,
"codex",
"CODEX_HOME",
std::path::Path::new("/tmp/swapdex-managed-codex"),
);
assert_eq!(
command_env(&command, "CLAUDE_SECURESTORAGE_CONFIG_DIR"),
Some(Some(OsString::from("/tmp/intentional-live")))
);
}
}
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 sign_out_blocked_remedy(identity_survived: bool, tool: &str) -> String {
if identity_survived {
return same_account_hint(tool);
}
"a credential is still readable after the sign-out, which usually means \
another CLAUDE_CONFIG_DIR profile's Keychain item is answering - signing out \
in a browser will not change that. `swapdex doctor` names what it found."
.to_string()
}
fn last_slot_warning(remaining: usize, proxy_running: bool, tool: &str) -> Option<String> {
(remaining == 0 && proxy_running).then(|| {
format!(
" the {} proxy has no account left to serve - sessions on it will fail \
with \"no account slots yet\" until one is back (`swapdex run <name> \
--tool {}`, or `swapdex adopt <name> <dir>`)",
pretty_tool(tool),
pretty_tool_flag(tool)
)
})
}
fn stale_proxy_note(running: &str, current: &str, tool: &str) -> Option<String> {
fn ver(b: &str) -> &str {
b.rsplit_once('-').map_or(b, |(v, _)| v)
}
(!running.is_empty() && running != current).then(|| {
let difference = if ver(running) == ver(current) {
format!("a different build of {}", ver(current))
} else {
format!("{} while this swapdex is {}", ver(running), ver(current))
};
format!(
"running {difference} - an upgrade does not restart it, \
so it still behaves like the older build: `swapdex service install \
--tool {}`",
crate::commands::tool_binary(tool)
)
})
}
fn new_account_prompt() -> &'static str {
"name for the new account (e.g. personal): "
}
fn suggested_profile_name(paths: &Paths) -> String {
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());
suggest_name(&who)
}
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,
holder: Option<NameHolder<'_>>,
) -> 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 let Some(h) = &holder {
let mine = Some(h.account_id).filter(|s| !s.is_empty());
let slot = slot_account_under(h.paths, h.tool, &ans);
if name_means_two_accounts(mine, slot.as_deref()) {
println!(
" '{ans}' is already a {} slot holding a different account \
(`swapdex slots` shows it). Try again.",
pretty_tool(h.tool)
);
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) {
let slot_id = slot_account_under(paths, tool, &default);
let crosses_slot = name_means_two_accounts(Some(&id.account_id), slot_id.as_deref());
if !p.tools.iter().any(|t| t == tool) && !crosses_slot {
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(NameHolder {
paths,
tool,
account_id: &id.account_id,
}),
) {
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): ", "", None) {
Some(n) => n,
None => {
println!(" skipped.\n");
continue;
}
};
let _ = login(paths, &name, Some(sel))?;
println!();
}
let accounts = merged_accounts(paths, &store).0;
let names: Vec<String> = accounts.iter().map(|p| p.name.clone()).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 {
let mut described_managed_session = false;
for tool in ["claude-code", "codex"] {
let has_slots = crate::slots::Slots::open_for(paths, tool)
.map(|slots| !slots.list().is_empty())
.unwrap_or(false);
if !has_slots {
continue;
}
described_managed_session = true;
let bin = tool_binary(tool);
if crate::shim::shim_path_for(paths, tool).exists() {
println!(
" {bin}: launch or relaunch a plain `{bin}` through its installed shim; \
that is a managed session."
);
} else if crate::shim::real_tool(paths, tool).is_some() {
println!(
" {bin}: run `swapdex shim`, then launch or relaunch a plain `{bin}`; \
a launch through the shim is a managed session."
);
} else {
println!(
" {bin}: install the client, run `swapdex shim`, then launch it through \
the shim to start a managed session."
);
}
println!(
" choose the payer for managed sessions: `swapdex serve <name>{}`",
tool_flag(tool)
);
println!(
" a `{bin}` session opened directly outside Swapdex keeps its own login; \
relaunch it through the shim once before `serve` can manage it."
);
}
if !described_managed_session {
println!(
" saved profiles apply when you launch the client again; an already-running \
direct session keeps its current login."
);
}
}
}
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,
unreadable: Option<String>,
}
struct SlotQuotaState {
email: Option<String>,
uuid: Option<String>,
identity: Option<crate::live_login::LoginIdentity>,
token: Option<String>,
expired: bool,
unreadable: Option<String>,
}
struct SlotQuotaRead {
email: Option<String>,
uuid: Option<String>,
token: Option<String>,
expired: bool,
unreadable: Option<String>,
}
fn slot_quota_state(paths: &Paths, dir: &std::path::Path, now: i64) -> SlotQuotaState {
use crate::adapters::claude::KeychainReadError as K;
let identity_path = dir.join(".claude.json");
let identity_before = std::fs::read(&identity_path).ok();
let credential = crate::claude_authority::credential(paths, dir);
let identity_after = std::fs::read(&identity_path).ok();
let identity_value = identity_after
.as_deref()
.and_then(|bytes| serde_json::from_slice::<Value>(bytes).ok());
let email = identity_value.as_ref().and_then(|value| {
value["oauthAccount"]["emailAddress"]
.as_str()
.map(str::to_string)
});
let uuid = identity_value.as_ref().and_then(|value| {
value["oauthAccount"]["accountUuid"]
.as_str()
.map(str::to_string)
});
let identity = identity_after
.as_deref()
.and_then(|bytes| crate::live_login::identity_from_credential(bytes, "claude-code"));
if identity_before != identity_after {
return SlotQuotaState {
email,
uuid,
identity,
token: None,
expired: false,
unreadable: Some("slot identity changed while quota was reading its login".into()),
};
}
match credential {
Ok(credential) => SlotQuotaState {
email,
uuid,
identity,
token: q::token_from_credentials(credential.bytes()),
expired: q::credentials_expired(credential.bytes(), now.saturating_add(60_000)),
unreadable: None,
},
Err(error) => SlotQuotaState {
email,
uuid,
identity,
token: None,
expired: false,
unreadable: Some(match error {
K::Locked => "signed in, but this shell cannot read the keychain".into(),
K::Missing | K::NotApplicable => "no saved token".into(),
}),
},
}
}
fn slot_quota_read(paths: &Paths, dir: &std::path::Path) -> SlotQuotaRead {
let now = now_ms();
let native = crate::live_login::resolve(paths, dir, "claude-code", now);
let initial = slot_quota_state(paths, dir, now);
if let Some(native) = native {
if initial.identity.as_ref() == Some(&native.identity) {
return SlotQuotaRead {
email: initial.email,
uuid: initial.uuid,
token: Some(String::from_utf8_lossy(native.access_token.expose()).to_string()),
expired: false,
unreadable: None,
};
}
return SlotQuotaRead {
email: initial.email,
uuid: initial.uuid,
token: None,
expired: false,
unreadable: Some(
"slot identity changed while quota was reading its native login".into(),
),
};
}
SlotQuotaRead {
email: initial.email,
uuid: initial.uuid,
token: initial.token,
expired: initial.expired,
unreadable: initial.unreadable,
}
}
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_credential = adapters::claude::live_credentials(paths);
let live_token = live_credential
.as_deref()
.and_then(q::token_from_credentials);
let live_expired = live_credential
.as_deref()
.is_some_and(|credential| q::credentials_expired(credential, now_ms()));
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()));
}
let mut unreadable: Option<String> = None;
let slot = slot_dir_named(paths, &p.name);
if let Some(dir) = &slot {
let current = slot_quota_read(paths, dir);
email = current.email;
uuid = current.uuid;
token = current.token;
expired = current.expired;
unreadable = current.unreadable;
}
let matches_live = live_uuid.is_some() && uuid == live_uuid;
let active = if slot.is_some() {
slot_default_name(paths, "claude-code")
.map_or(matches_live, |pointed| pointed == p.name)
} else {
matches_live
};
let use_live = slot.is_none() && matches_live;
matched_live |= matches_live;
rows.push(Row {
label: if active {
format!("{} (active)", p.name)
} else {
p.name.clone()
},
name: p.name.clone(),
email: if use_live {
live_id.as_ref().and_then(|a| a.email.clone()).or(email)
} else {
email
},
token: if use_live { live_token.clone() } else { token },
active,
expired: if use_live { live_expired } else { expired },
unreadable,
});
}
}
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 current = slot_quota_read(paths, &r.config_dir);
let uuid = current.uuid;
let active = match slot_default_name(paths, "claude-code") {
Some(pointed) => pointed == r.name,
None => live_uuid.is_some() && uuid == live_uuid,
};
matched_live |= live_uuid.is_some() && uuid == live_uuid;
rows.push(Row {
label: if active {
format!("{} (active)", r.name)
} else {
r.name.clone()
},
name: r.name.clone(),
email: current.email,
token: current.token,
active,
expired: current.expired,
unreadable: current.unreadable,
});
}
}
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: live_expired,
unreadable: None,
},
);
}
rows.sort_by_key(|r| !r.active);
if rows.is_empty() {
if json {
let now = now_secs() as i64;
let codex = codex_quota_json(&read_codex_accounts(paths, now), now);
println!(
"{}",
serde_json::json!({"accounts": [], "codex": codex, "offline": null})
);
} else {
println!(
"No Claude accounts found. Log in with `claude`, or `swapdex add` to save one."
);
println!();
print_codex_quota(paths, now_secs() as i64);
}
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(
r.unreadable
.clone()
.unwrap_or_else(|| "no saved token".into()),
),
)),
Some(_) if r.expired => results.push((
i,
Fetch::Offline(match slot_dir_named(paths, &r.name) {
Some(dir) if crate::refresh::claude_renewal_deferred(paths, &dir, now_ms()) => {
"login expired · renewal deferred to native session - \
wait for the owning Claude session to refresh this login or exit"
.into()
}
Some(_) => "slot access expired - renewal has not completed; \
retry after background refresh or sign in to this slot"
.into(),
None => "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(unusable_token_reason(t, r.unreadable.as_deref())),
)),
Some(t) => to_fetch.push((i, t.clone())),
}
}
if !to_fetch.is_empty() {
let got = q::fetch_many(paths, 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,
on_credits: qd.can_serve_past_windows(),
refused: None,
token_rejected_at: None,
},
)),
_ => 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();
let codex = codex_quota_json(&read_codex_accounts(paths, now), now);
println!(
"{}",
serde_json::json!({"accounts": accounts, "codex": codex, "offline": offline})
);
return Ok(0);
}
if let Some(msg) = offline {
println!("swapdex quota: could not reach api.anthropic.com - {msg}");
println!(
"(this live quota lookup needs the network; `swapdex usage` reads local activity)"
);
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 => {
println!(
" usage endpoint rejected this credential - check `swapdex doctor`; \
renew or sign in to this account before retrying"
);
}
Fetch::Unexpected(code, _) => {
println!(
" unexpected response (HTTP {code}) - run `swapdex quota --json` to see it"
);
}
Fetch::Offline(msg) => println!(" {msg}"),
Fetch::Coordination(msg) => {
println!(" usage lookup coordination unavailable - {msg}")
}
Fetch::Throttled => println!(" {}", throttled_note()),
}
println!();
}
print_codex_quota(paths, now);
println!("quota reads provider usage; `swapdex usage` shows local session activity.");
Ok(0)
}
fn throttled_note() -> &'static str {
"usage endpoint declined to answer just now - that is no reading, \
not a verdict on this account either way; try again in a moment"
}
fn snapshot_codex_auth(paths: &Paths, name: &str) -> Option<crate::proxy::codex::Auth> {
let snap = Store::open(paths).ok()?.load(name, "codex").ok()??;
let v: Value = serde_json::from_slice(snap.part("auth")?.expose()).ok()?;
let token = v["tokens"]["access_token"]
.as_str()
.filter(|s| !s.is_empty())?;
let id = v["tokens"]["account_id"].as_str().unwrap_or_default();
Some(crate::proxy::codex::Auth {
token: crate::secret::Secret::new(token.as_bytes().to_vec()),
account_id: id.to_string(),
})
}
pub fn codex_account_sources(
slots: &[(String, std::path::PathBuf)],
snapshots: &[String],
) -> Vec<(String, Option<std::path::PathBuf>)> {
let mut out: Vec<(String, Option<std::path::PathBuf>)> = slots
.iter()
.map(|(n, d)| (n.clone(), Some(d.clone())))
.collect();
for n in snapshots {
if !out.iter().any(|(have, _)| have == n) {
out.push((n.clone(), None));
}
}
out
}
fn codex_no_number(
f: &crate::codex_usage::Fetch,
name: &str,
expired: bool,
src: CodexSource<'_>,
) -> String {
if !matches!(f, crate::codex_usage::Fetch::Unauthorized) {
return f.why_no_number().unwrap_or("no reading").to_string();
}
match src {
CodexSource::Snapshot { twin: Some(t) } => format!(
"saved copy of the account signed in as '{t}' - this copy's token lapsed, \
which says nothing about that account; `swapdex add {name} --tool codex --update` \
saves a fresh copy, or leave it"
),
CodexSource::Snapshot { twin: None } if expired => format!(
"saved login expired - log in to that account and \
`swapdex add {name} --tool codex --update` saves it again \
(a saved copy cannot renew itself; only the live login does)"
),
CodexSource::Slot if expired => format!(
"login expired - `swapdex run {name} --tool codex` once renews it \
(Codex renews its token when it runs, and this slot has not been opened since)"
),
_ => f.why_no_number().unwrap_or("no reading").to_string(),
}
}
#[derive(Clone, Copy)]
enum CodexSource<'a> {
Slot,
Snapshot { twin: Option<&'a str> },
}
struct CodexRow {
name: String,
dir: Option<std::path::PathBuf>,
auth: Option<crate::proxy::codex::Auth>,
fetch: Option<crate::codex_usage::Fetch>,
}
fn read_codex_accounts(paths: &Paths, now: i64) -> Vec<CodexRow> {
let slots: Vec<(String, std::path::PathBuf)> = crate::slots::Slots::open_for(paths, "codex")
.map(|s| {
s.list()
.into_iter()
.map(|r| (r.name, r.config_dir))
.collect()
})
.unwrap_or_default();
let snapshots: Vec<String> = Store::open(paths)
.map(|st| {
st.list()
.into_iter()
.filter(|p| p.tools.iter().any(|t| t == "codex"))
.map(|p| p.name)
.collect()
})
.unwrap_or_default();
codex_account_sources(&slots, &snapshots)
.into_iter()
.map(|(name, dir)| {
let auth = match &dir {
Some(d) => crate::proxy::codex::slot_auth(d),
None => snapshot_codex_auth(paths, &name),
};
let fetch = auth.as_ref().map(crate::codex_usage::fetch);
if let Some(f) = &fetch {
crate::codex_usage::note_reading(paths, &name, f, now);
}
CodexRow {
name,
dir,
auth,
fetch,
}
})
.collect()
}
fn print_codex_quota(paths: &Paths, now: i64) {
let rows = read_codex_accounts(paths, now);
if rows.is_empty() {
return;
}
println!("codex - remaining on your Codex accounts");
println!("live from ChatGPT's usage endpoint; opt-in network, spends 0 message quota.\n");
let healthy = codex_healthy_by_account(&rows);
for r in &rows {
let name = &r.name;
let saved = r.dir.as_deref().and_then(codex_slot_email);
match (&r.auth, &r.fetch) {
(None, _) | (_, None) => {
println!("{name}");
println!(
" no readable Codex login - `swapdex run {name} --tool codex` once signs it in"
);
}
(Some(_), Some(crate::codex_usage::Fetch::Ok(a))) => {
println!(
"{name} {}",
codex_identity(a.email.as_deref(), a.plan.as_deref(), saved.as_deref())
);
for line in codex_quota_lines(a, now) {
println!(" {line}");
}
}
(Some(auth), Some(f)) => {
let (expired, src) = codex_row_source(r.dir.as_deref(), auth, name, &healthy, now);
println!("{name} {}", saved.unwrap_or_default());
println!(" {}", codex_no_number(f, name, expired, src));
}
}
println!();
}
}
fn codex_quota_json(rows: &[CodexRow], now: i64) -> Vec<Value> {
fn win(w: &crate::codex_limits::Window) -> Value {
serde_json::json!({
"used_pct": (w.used_pct * 10.0).round() / 10.0,
"remaining_pct": ((100.0 - w.used_pct).max(0.0) * 10.0).round() / 10.0,
"window_minutes": w.window_minutes,
"resets_at": w.resets_at,
})
}
let healthy = codex_healthy_by_account(rows);
rows.iter()
.map(|r| {
let name = &r.name;
let saved = r.dir.as_deref().and_then(codex_slot_email);
let mut o = serde_json::json!({
"name": name,
"email": saved,
"slot": r.dir.is_some(),
});
let m = o.as_object_mut().expect("json object");
match (&r.auth, &r.fetch) {
(None, _) | (_, None) => {
m.insert("status".into(), Value::String("unreadable".into()));
}
(Some(_), Some(crate::codex_usage::Fetch::Ok(a))) => {
m.insert("status".into(), Value::String("ok".into()));
if let Some(e) = &a.email {
m.insert("email".into(), Value::String(e.clone()));
}
m.insert(
"plan".into(),
a.plan.clone().map(Value::String).unwrap_or(Value::Null),
);
let placed = crate::codex_limits::place(&a.limits);
m.insert(
"five_hour".into(),
placed.five_h.as_ref().map(win).unwrap_or(Value::Null),
);
m.insert(
"seven_day".into(),
placed.seven_d.as_ref().map(win).unwrap_or(Value::Null),
);
let scoped: Vec<Value> = a
.scoped
.iter()
.map(|(n, w)| {
let mut wj = win(w);
wj.as_object_mut()
.expect("json object")
.insert("label".into(), Value::String(n.clone()));
wj
})
.collect();
m.insert("scoped".into(), Value::Array(scoped));
}
(Some(auth), Some(f)) => {
let (expired, src) =
codex_row_source(r.dir.as_deref(), auth, name, &healthy, now);
match f {
crate::codex_usage::Fetch::Unauthorized => {
m.insert(
"status".into(),
Value::String(if expired { "expired" } else { "rejected" }.into()),
);
}
crate::codex_usage::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(),
),
);
}
crate::codex_usage::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()));
}
crate::codex_usage::Fetch::Offline(msg) => {
m.insert("status".into(), Value::String("offline".into()));
m.insert("detail".into(), Value::String(msg.clone()));
}
crate::codex_usage::Fetch::Ok(_) => unreachable!("handled above"),
}
if let CodexSource::Snapshot { twin: Some(t) } = src {
m.insert("copy_of".into(), Value::String(t.to_string()));
}
}
}
o
})
.collect()
}
fn codex_row_source<'a>(
dir: Option<&std::path::Path>,
auth: &crate::proxy::codex::Auth,
name: &str,
healthy: &std::collections::BTreeMap<&'a str, &'a str>,
now: i64,
) -> (bool, CodexSource<'a>) {
let expired = crate::proxy::codex::auth_token_expired(auth, now);
let src = match dir {
Some(_) => CodexSource::Slot,
None => CodexSource::Snapshot {
twin: healthy
.get(auth.account_id.as_str())
.filter(|t| ***t != *name)
.copied(),
},
};
(expired, src)
}
fn codex_healthy_by_account(rows: &[CodexRow]) -> std::collections::BTreeMap<&str, &str> {
let mut out = std::collections::BTreeMap::new();
for r in rows {
if let (Some(auth), Some(crate::codex_usage::Fetch::Ok(_))) = (&r.auth, &r.fetch) {
out.entry(auth.account_id.as_str())
.or_insert(r.name.as_str());
}
}
out
}
fn codex_quota_lines(a: &crate::codex_usage::Account, now: i64) -> Vec<String> {
let as_window = |w: &crate::codex_limits::Window| crate::quota::Window {
used_pct: w.used_pct,
resets_at: w.resets_at,
};
let mut out = Vec::new();
let placed = crate::codex_limits::place(&a.limits);
let pad = a
.scoped
.iter()
.map(|(l, _)| l.chars().count())
.chain(std::iter::once(9))
.max()
.unwrap_or(9);
let line = |label: &str, w: &crate::codex_limits::Window| {
win_line(&format!("{label:<pad$}"), &as_window(w), now)
};
for (label, w) in [("5h", placed.five_h), ("7d", placed.seven_d)] {
if let Some(w) = w {
out.push(line(label, &w));
}
}
for (label, w) in &a.scoped {
out.push(line(label, w));
}
if out.is_empty() {
out.push("(the endpoint reported no windows - `swapdex quota --json` to inspect)".into());
}
if let Some(c) = &a.credits {
out.push(match c {
_ if c.unlimited => "credits: unlimited".to_string(),
_ if c.overage_limit_reached => {
"credits: spend limit reached - a full window is the end until it is raised".into()
}
_ if c.has_credits => match &c.balance {
Some(b) => format!("credits: {b} - a full window is not the end"),
None => "credits available - a full window is not the end".into(),
},
_ => "no credits - a full window is the end of this account".to_string(),
});
}
if let Some(kind) = &a.refused {
out.push(format!(
"refusing turns: {}",
crate::codex_usage::refusal_words(kind)
));
}
out
}
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 {}",
crate::proxy::pick::reset_clock(ts, now, crate::proxy::tz_offset())
),
None => String::new(),
};
format!("{label:<9} {bar} {rem:>3.0}% left{reset}")
}
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("on_credits".into(), Value::Bool(q.can_serve_past_windows()));
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()));
}
Fetch::Coordination(msg) => {
m.insert("status".into(), Value::String("unavailable".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>, tool: &str) -> String {
if tool == "codex" {
return match expires_at {
Some(ms) if ms <= now_ms() => {
" - login expired; `swapdex refresh` renews it (or run codex once)".to_string()
}
_ => String::new(),
};
}
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 codex_cred_pair(bytes: &[u8]) -> Option<(String, String)> {
let v: Value = serde_json::from_slice(bytes).ok()?;
let t = v.get("tokens")?;
Some((
t["account_id"].as_str()?.to_string(),
t["refresh_token"].as_str()?.to_string(),
))
}
fn snapshot_rotated_away(paths: &Paths, target: &crate::adapters::Snapshot, tool: &str) -> bool {
let Some((account, refresh)) = snapshot_cred_pair(target, tool) else {
return false;
};
let Ok(slots) = crate::slots::Slots::open_for(paths, tool) else {
return false;
};
slots.list().into_iter().any(|r| {
slot_cred_pair(&r.config_dir, tool).is_some_and(|(a, rt)| a == account && rt != refresh)
})
}
fn snapshot_cred_pair(snap: &crate::adapters::Snapshot, tool: &str) -> Option<(String, String)> {
match tool {
"codex" => snap.part("auth").and_then(|s| codex_cred_pair(s.expose())),
"claude-code" => {
let id: Value = serde_json::from_slice(snap.part("oauth_account")?.expose()).ok()?;
let cred: Value = serde_json::from_slice(snap.part("credentials")?.expose()).ok()?;
claude_pair(&id["accountUuid"], &cred["claudeAiOauth"]["refreshToken"])
}
_ => None,
}
}
fn slot_cred_pair(dir: &std::path::Path, tool: &str) -> Option<(String, String)> {
match tool {
"codex" => codex_cred_pair(&std::fs::read(dir.join("auth.json")).ok()?),
"claude-code" => {
let id: Value =
serde_json::from_slice(&std::fs::read(dir.join(".claude.json")).ok()?).ok()?;
let cred: Value =
serde_json::from_slice(&std::fs::read(dir.join(".credentials.json")).ok()?).ok()?;
claude_pair(
&id["oauthAccount"]["accountUuid"],
&cred["claudeAiOauth"]["refreshToken"],
)
}
_ => None,
}
}
fn claude_pair(account: &Value, refresh: &Value) -> Option<(String, String)> {
let a = account.as_str().filter(|s| !s.is_empty())?;
let r = refresh.as_str().filter(|s| !s.is_empty())?;
Some((a.to_string(), r.to_string()))
}
fn warn_if_expired(target: &crate::adapters::Snapshot, tool: &str) {
if !snapshot_is_stale(target, tool) {
return;
}
let who = match tool {
"claude-code" => "Claude",
"codex" => "Codex",
"gemini" => "Gemini",
"antigravity" => "Antigravity",
other => other,
};
eprintln!(
"swapdex: note - this saved login is old; {who} may re-prompt for login if its \
refresh token has expired"
);
}
#[cfg(test)]
mod tests {
use super::{
best_identity, classify_migration_profile, codex_account_sources, codex_identity,
codex_quota_lines, codex_row, codex_usage_row, home_note, keychain_verdict,
last_slot_warning, listable, login_is_ancient, name_means_two_accounts, new_account_prompt,
payer_line, payer_note, payer_of_any, pick_active, quota_brief, row_needs_login,
row_suffix, sign_in_remedy, sign_out_blocked_remedy, stale_hint, stale_marker,
stale_proxy_note, suggested_profile_name, switch_line, unhonoured_ask, unknown_account,
win_line, MigrationClass,
};
fn s(items: &[&str]) -> Vec<String> {
items.iter().map(|i| i.to_string()).collect()
}
#[test]
fn cached_claude_numbers_keep_the_failed_refresh_reason_and_original_time() {
use crate::quota_cache::Entry;
use crate::tui::Usage;
let mut readings = vec![
(
"expired".to_string(),
Usage {
note: Some("login expired".into()),
..Default::default()
},
),
(
"throttled".to_string(),
Usage {
note: Some("endpoint busy".into()),
..Default::default()
},
),
(
"offline".to_string(),
Usage {
note: Some("network offline".into()),
..Default::default()
},
),
(
"current".to_string(),
Usage {
five_h: Some(12.0),
..Default::default()
},
),
];
let cached = ["expired", "throttled", "offline", "current"]
.into_iter()
.map(|name| {
(
name.to_string(),
Entry {
five_h: Some(93.0),
at: 123,
on_credits: name == "throttled",
..Default::default()
},
)
})
.collect();
super::merge_cached_claude_usage(&mut readings, cached);
for (name, reason) in [
("expired", "login expired"),
("throttled", "endpoint busy"),
("offline", "network offline"),
] {
let u = &readings.iter().find(|(n, _)| n == name).unwrap().1;
assert_eq!(u.five_h, Some(93.0), "{name}");
assert_eq!(u.observed_at, Some(123), "{name}");
assert_eq!(u.note.as_deref(), Some(reason), "{name}");
}
assert!(
readings
.iter()
.find(|(n, _)| n == "throttled")
.unwrap()
.1
.on_credits
);
let current = &readings.iter().find(|(n, _)| n == "current").unwrap().1;
assert_eq!(current.five_h, Some(12.0));
assert_eq!(current.observed_at, None);
assert_eq!(current.note, None);
}
#[test]
fn migration_classifier_matches_accounts_before_names() {
let slots = vec![
("same".to_string(), Some("account-a".to_string())),
("slot-b".to_string(), Some("account-b".to_string())),
("unknown".to_string(), None),
];
assert_eq!(
classify_migration_profile("same", Some("account-a"), &slots),
MigrationClass::AlreadySlotted
);
assert_eq!(
classify_migration_profile("copy", Some("account-b"), &slots),
MigrationClass::CopyOfSlot("slot-b".to_string())
);
assert_eq!(
classify_migration_profile("same", Some("account-b"), &slots),
MigrationClass::CopyOfSlot("slot-b".to_string())
);
assert_eq!(
classify_migration_profile("new", Some("account-c"), &slots),
MigrationClass::NeedsSlot
);
assert_eq!(
classify_migration_profile("broken", None, &slots),
MigrationClass::Unreadable
);
assert_eq!(
classify_migration_profile("empty", Some(""), &slots),
MigrationClass::Unreadable
);
}
#[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"), "{line}");
assert!(
line.contains("am") || line.contains("pm"),
"the reset is a time: {line}"
);
assert!(!line.contains("in 2h"), "no countdown: {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}");
}
#[test]
fn the_sign_out_remedy_names_the_check_that_actually_failed() {
let browser = sign_out_blocked_remedy(true, "claude-code");
assert!(
browser.contains("claude.ai") || browser.contains("browser"),
"the account is still signed in, so the session is the thing: {browser}"
);
let leftover = sign_out_blocked_remedy(false, "claude-code");
assert!(
leftover.contains("Keychain") || leftover.contains("doctor"),
"a credential still readable is not a browser session: {leftover}"
);
assert!(
!leftover.contains("claude.ai"),
"and must not send them somewhere that cannot help: {leftover}"
);
}
#[test]
fn removing_the_last_slot_says_what_it_costs() {
let w = last_slot_warning(0, true, "claude-code")
.expect("the last one, with a proxy running on it");
assert!(w.contains("502") || w.contains("no account slots"), "{w}");
assert!(
w.contains("swapdex run") || w.contains("adopt"),
"and how to get one back: {w}"
);
assert_eq!(
last_slot_warning(1, true, "claude-code"),
None,
"another slot remains - the proxy still has something to serve"
);
assert_eq!(
last_slot_warning(0, false, "claude-code"),
None,
"no proxy running - nothing is pinned to it, so nothing breaks"
);
}
#[test]
fn a_name_that_means_two_accounts_is_worth_saying() {
assert!(name_means_two_accounts(Some("uuid-a"), Some("uuid-b")));
assert!(
!name_means_two_accounts(Some("uuid-a"), Some("uuid-a")),
"the same account under one name is the ordinary case"
);
assert!(
!name_means_two_accounts(Some("uuid-a"), None),
"a profile with no slot of that name is not a conflict"
);
assert!(
!name_means_two_accounts(None, Some("uuid-b")),
"nor a slot with no profile"
);
assert!(
!name_means_two_accounts(None, None),
"and unknown is never a conflict - swapdex does not condemn on a blank"
);
}
#[test]
fn a_login_is_only_ancient_past_the_stale_window() {
let day = 86_400_000i64;
let now = 1_800_000_000_000i64;
assert_eq!(login_is_ancient(None, now), None, "no deadline, no verdict");
assert_eq!(
login_is_ancient(Some(now - 3_600_000), now),
Some(false),
"an hour ago is the ordinary state of a working login"
);
assert_eq!(
login_is_ancient(Some(now - 29 * day), now),
Some(false),
"still inside the window"
);
assert_eq!(
login_is_ancient(Some(now - 31 * day), now),
Some(true),
"past it the refresh token may be gone"
);
}
#[test]
fn a_proxy_running_an_older_build_is_not_called_healthy() {
let note = stale_proxy_note("0.141.0-1788417540", "0.153.0-1788850863", "codex")
.expect("an older build is worth saying out loud");
assert!(note.contains("0.141.0"), "names what is running: {note}");
assert!(note.contains("0.153.0"), "and what would start now: {note}");
assert!(
note.contains("swapdex service install --tool codex"),
"and the command that replaces it: {note}"
);
assert_eq!(
stale_proxy_note("0.153.0-1788850863", "0.153.0-1788850863", "codex"),
None,
"the current build is not news"
);
assert_eq!(
stale_proxy_note("", "0.153.0-1788850863", "codex"),
None,
"a marker with no build id says nothing about age"
);
}
#[test]
fn a_different_build_of_one_version_says_what_actually_differs() {
let note = stale_proxy_note("0.165.9-1700000000", "0.165.9-1800000000", "codex")
.expect("a different build is still a different build");
assert!(
!note.contains("running 0.165.9 while this swapdex is 0.165.9"),
"the sentence contrasts two values it prints identically: {note}"
);
assert!(
note.contains("0.165.9"),
"the version is still worth naming: {note}"
);
assert!(
note.contains("swapdex service install --tool codex"),
"and the command that replaces it: {note}"
);
}
#[test]
fn the_new_account_prompt_does_not_offer_the_account_you_are_leaving() {
let p = new_account_prompt();
assert!(
!p.contains('['),
"no default, because the only one available is the wrong account: {p}"
);
assert!(
p.contains("e.g."),
"it still says what shape a name takes: {p}"
);
}
#[test]
fn the_name_to_offer_comes_from_the_login_you_are_signed_into() {
let dir = tempfile::tempdir().unwrap();
let paths = crate::paths::Paths::rooted(dir.path());
assert_eq!(suggested_profile_name(&paths), "account");
std::fs::create_dir_all(dir.path().join(".claude")).unwrap();
std::fs::write(
dir.path().join(".claude/.credentials.json"),
br#"{"claudeAiOauth":{"accessToken":"T","expiresAt":9999999999000}}"#,
)
.unwrap();
std::fs::write(
dir.path().join(".claude.json"),
br#"{"oauthAccount":{"accountUuid":"u","emailAddress":"you@example.com"}}"#,
)
.unwrap();
assert_eq!(
suggested_profile_name(&paths),
"you",
"the local part of the address you are signed in as"
);
}
#[test]
fn a_row_with_a_readable_login_does_not_ask_for_one() {
let dir = tempfile::tempdir().unwrap();
assert!(row_needs_login(
&crate::paths::Paths::rooted(dir.path()),
"claude-code",
Some(dir.path())
));
std::fs::write(
dir.path().join(".credentials.json"),
br#"{"claudeAiOauth":{"accessToken":"T"}}"#,
)
.unwrap();
assert!(!row_needs_login(
&crate::paths::Paths::rooted(dir.path()),
"claude-code",
Some(dir.path())
));
assert!(!row_needs_login(
&crate::paths::Paths::rooted(std::path::Path::new("/nonexistent")),
"claude-code",
None
));
}
#[test]
fn the_payer_line_names_the_account_not_just_the_slot() {
assert_eq!(
payer_line("work", Some("work@example.com"), true),
"work (work@example.com)"
);
assert_eq!(payer_line("work", None, true), "work");
assert_eq!(payer_line("me@x.com", Some("me@x.com"), true), "me@x.com");
assert_eq!(
payer_line("work", Some("a@b.c"), false),
"work (a@b.c, no login)"
);
assert_eq!(payer_line("work", None, false), "work (no login)");
}
#[test]
fn the_home_is_named_only_when_it_differs_from_the_payer() {
assert_eq!(home_note("work", Some("codex-main")), " - home: codex-main");
assert_eq!(
home_note("work", Some("work")),
"",
"nothing to disambiguate when they agree"
);
assert_eq!(home_note("work", None), "", "no pointer, nothing to say");
}
#[test]
fn codex_accounts_are_gathered_from_slots_and_snapshots_alike() {
let got = codex_account_sources(
&[("live".into(), std::path::PathBuf::from("/slots/live"))],
&["saved".into(), "live".into()],
);
let names: Vec<&str> = got.iter().map(|(n, _)| n.as_str()).collect();
assert!(names.contains(&"live"), "{names:?}");
assert!(
names.contains(&"saved"),
"a snapshot is an account too: {names:?}"
);
assert_eq!(names.iter().filter(|n| **n == "live").count(), 1);
assert!(
got.iter().any(|(n, src)| n == "live" && src.is_some()),
"the slot keeps its directory: {got:?}"
);
assert!(
got.iter().any(|(n, src)| n == "saved" && src.is_none()),
"a snapshot has no directory to read: {got:?}"
);
}
#[test]
fn the_payer_quota_renders_from_cache_without_a_request() {
assert_eq!(quota_brief(Some(17.0), Some(98.0)), "5h 83% | 7d 2%");
assert_eq!(quota_brief(None, Some(50.0)), "7d 50%");
assert_eq!(quota_brief(Some(0.0), None), "5h 100%");
assert_eq!(quota_brief(None, None), "usage unread");
}
#[test]
fn a_payer_of_any_tool_is_marked() {
assert_eq!(
payer_of_any(&[("claude-code", Some("alpha")), ("codex", None)]).as_deref(),
Some("alpha")
);
assert_eq!(
payer_of_any(&[("claude-code", None), ("codex", Some("cx"))]).as_deref(),
Some("cx")
);
assert_eq!(
payer_of_any(&[("claude-code", Some("alpha")), ("codex", Some("cx"))]).as_deref(),
Some("alpha")
);
assert_eq!(
payer_of_any(&[("claude-code", None), ("codex", None)]),
None
);
assert_eq!(payer_of_any(&[]), None);
}
#[test]
fn the_sign_in_remedy_names_the_tool() {
assert_eq!(
sign_in_remedy("codex-test", "codex"),
"`swapdex run codex-test --tool codex` signs it in once"
);
assert_eq!(
sign_in_remedy("alpha", "claude-code"),
"`swapdex run alpha` signs it in once"
);
assert_eq!(
sign_in_remedy("g", "gemini"),
"`swapdex run g --tool gemini` signs it in once"
);
}
#[test]
fn a_wrong_name_is_answered_with_the_right_ones() {
assert_eq!(
unknown_account("alicee", &["alice", "bob"]),
"no account named 'alicee' - did you mean 'alice'? (also: bob)"
);
assert_eq!(
unknown_account("zzz", &["alice", "bob"]),
"no account named 'zzz' - you have: alice, bob"
);
assert_eq!(
unknown_account("alice", &[]),
"no accounts saved yet - `swapdex add <name>` saves the login you are on"
);
}
#[test]
fn a_switch_confirms_itself_with_the_account_it_moved_to() {
assert_eq!(
switch_line("rnd", Some("rnd@x.com"), Some(68.0)),
"now rnd (rnd@x.com) - 68% of the week left"
);
assert_eq!(
switch_line("rnd", Some("rnd@x.com"), None),
"now rnd (rnd@x.com)"
);
assert_eq!(
switch_line("bsgong", None, Some(12.0)),
"now bsgong - 12% of the week left"
);
assert_eq!(switch_line("bsgong", None, None), "now bsgong");
assert_eq!(
switch_line("kong", Some("k@x.com"), Some(0.0)),
"now kong (k@x.com) - no week left"
);
}
#[test]
fn a_slot_only_account_still_shows_its_email() {
assert_eq!(
best_identity(Some("snap@x.com".into()), Some("slot@x.com".into())).as_deref(),
Some("snap@x.com")
);
assert_eq!(
best_identity(None, Some("slot@x.com".into())).as_deref(),
Some("slot@x.com")
);
assert_eq!(best_identity(None, None), None);
}
#[test]
fn every_switchable_account_gets_a_row() {
assert_eq!(
listable(&["claude", "rnd", "work"], &["rnd", "personal", "bsgong"]),
vec!["bsgong", "claude", "personal", "rnd", "work"]
);
assert_eq!(listable(&[], &["personal"]), vec!["personal"]);
assert_eq!(listable(&["claude"], &[]), vec!["claude"]);
assert!(listable(&[], &[]).is_empty());
}
#[test]
fn the_list_shows_which_account_pays() {
assert_eq!(row_suffix("rnd", Some("kong"), Some("rnd")), Some("pays"));
assert_eq!(row_suffix("kong", Some("kong"), Some("rnd")), None);
assert_eq!(row_suffix("kong", Some("kong"), Some("kong")), Some("pays"));
assert_eq!(row_suffix("kong", Some("kong"), None), None);
assert_eq!(row_suffix("bsgong", Some("kong"), Some("rnd")), None);
}
#[test]
fn the_list_separates_who_is_signed_in_from_who_pays() {
assert_eq!(
payer_note(Some("kong"), Some("bsgong")).as_deref(),
Some("bsgong pays")
);
assert_eq!(payer_note(Some("kong"), Some("kong")), None);
assert_eq!(payer_note(Some("kong"), None), None);
assert_eq!(
payer_note(None, Some("bsgong")).as_deref(),
Some("bsgong pays")
);
}
#[test]
fn a_stale_hint_says_what_to_do_per_tool_and_what_still_works() {
let h = stale_hint(&["gemini", "antigravity"], &["claude-code", "codex"]);
assert!(h.contains("gemini"), "{h}");
assert!(h.contains("antigravity"), "{h}");
assert!(
h.contains("run that tool once"),
"signing the tool in is the first step: {h}"
);
let (run_at, save_at) = (
h.find("run that tool once").unwrap(),
h.find("add --update").expect("and re-saving is the second"),
);
assert!(run_at < save_at, "in that order: {h}");
assert!(
h.contains("claude-code") && h.contains("codex"),
"must say what still works: {h}"
);
}
#[test]
fn no_stale_tool_means_no_hint() {
assert!(stale_hint(&[], &["claude-code"]).is_empty());
}
#[test]
fn the_stale_hint_does_not_assert_a_lapse_it_cannot_know() {
let h = stale_hint(&["g-cli"], &["codex"]);
assert!(h.contains("g-cli"), "{h}");
assert!(
h.contains("may"),
"what is known is that the copy is old, not that the token is dead: {h}"
);
assert!(
!h.contains("has already lapsed"),
"a lapse swapdex has not established: {h}"
);
assert!(h.contains("still serves codex"), "{h}");
}
#[test]
fn all_tools_stale_promises_nothing() {
let h = stale_hint(&["gemini"], &[]);
assert!(h.contains("gemini"), "{h}");
assert!(!h.contains("still"), "nothing is still working: {h}");
}
#[test]
fn a_stale_marker_names_the_tool_it_belongs_to() {
assert_eq!(
stale_marker(&[("claude-code", None), ("gemini", Some("stale"))]).as_deref(),
Some("gemini stale"),
"name the one that is stale, not the account"
);
assert_eq!(
stale_marker(&[("gemini", Some("stale")), ("codex", Some("expired"))]).as_deref(),
Some("gemini stale, codex expired")
);
assert_eq!(
stale_marker(&[("gemini", Some("stale")), ("codex", Some("stale"))]).as_deref(),
Some("gemini stale, codex stale")
);
assert_eq!(stale_marker(&[("claude-code", None)]), None);
assert_eq!(stale_marker(&[]), None);
}
#[test]
fn the_codex_report_prints_what_a_row_cannot_hold() {
let w = |pct: f64, mins: i64| crate::codex_limits::Window {
used_pct: pct,
window_minutes: mins,
resets_at: Some(1_787_196_620),
};
let a = crate::codex_usage::Account {
email: Some("someone@example.com".into()),
plan: Some("pro".into()),
limits: crate::codex_limits::Limits {
short: Some(w(84.0, 10080)),
long: None,
observed_at: None,
},
scoped: vec![("GPT-5.3-Codex-Spark".into(), w(40.0, 10080))],
credits: Some(crate::codex_usage::Credits {
has_credits: false,
unlimited: false,
overage_limit_reached: false,
balance: Some("0".into()),
}),
refused: Some("workspace_member_credits_depleted".into()),
};
let out = codex_quota_lines(&a, 1_786_600_000).join("\n");
assert!(out.contains("7d"), "{out}");
assert!(!out.contains("5h"), "a window Codex did not send: {out}");
assert!(out.contains("GPT-5.3-Codex-Spark"), "{out}");
let starts: Vec<_> = out
.lines()
.filter(|l| l.contains('\u{2593}') || l.contains('\u{2591}'))
.map(|l| l.find(['\u{2593}', '\u{2591}']).unwrap())
.collect();
assert_eq!(starts.len(), 2, "{out}");
assert_eq!(starts[0], starts[1], "bars must line up: {out}");
assert!(out.contains("no credits"), "{out}");
assert!(out.contains("its owner has to top them up"), "{out}");
}
#[test]
fn a_codex_report_with_no_windows_says_so() {
let a = crate::codex_usage::Account::default();
let out = codex_quota_lines(&a, 1_786_600_000).join("\n");
assert!(out.contains("no windows"), "{out}");
assert!(!out.contains("credits"), "{out}");
}
#[test]
fn reality_outranks_the_ask_once_the_proxy_has_acted_on_it() {
let ask = || Some("rnd".to_string());
let did = || Some("bsgong".to_string());
assert_eq!(
pick_active(ask(), did(), None, false).as_deref(),
Some("rnd")
);
assert_eq!(
pick_active(ask(), did(), None, true).as_deref(),
Some("bsgong")
);
assert_eq!(
pick_active(ask(), ask(), None, true).as_deref(),
Some("rnd")
);
assert_eq!(
pick_active(None, did(), Some("x".into()), false).as_deref(),
Some("bsgong")
);
assert_eq!(
pick_active(None, None, Some("x".into()), false).as_deref(),
Some("x")
);
}
#[test]
fn a_thwarted_ask_is_said_out_loud() {
assert_eq!(
unhonoured_ask(Some("rnd"), Some("bsgong"), true).as_deref(),
Some("asked for rnd - it cannot serve, so turns are going to bsgong")
);
assert_eq!(unhonoured_ask(Some("rnd"), Some("bsgong"), false), None);
assert_eq!(unhonoured_ask(Some("rnd"), Some("rnd"), true), None);
assert_eq!(unhonoured_ask(None, Some("bsgong"), true), None);
}
#[test]
fn a_live_identity_outranks_the_saved_one_and_a_mismatch_is_reported() {
assert_eq!(
codex_identity(Some("a@example.com"), Some("pro"), Some("a@example.com")),
"a@example.com [pro]"
);
assert_eq!(
codex_identity(
Some("live@example.com"),
Some("pro"),
Some("saved@example.com")
),
"live@example.com [pro] (saved as saved@example.com)"
);
assert_eq!(
codex_identity(None, None, Some("saved@example.com")),
"saved@example.com"
);
assert_eq!(
codex_identity(Some("live@example.com"), None, None),
"live@example.com"
);
assert!(codex_identity(Some("a@example.com"), Some("pro"), None).ends_with("[pro]"));
}
#[test]
fn a_proxy_reading_brings_its_reason_and_its_credits_along() {
let seen = crate::quota_cache::Entry {
seven_d: Some(100.0),
at: 1_786_590_000,
on_credits: true,
refused: Some("out of quota".into()),
..Default::default()
};
let (_, u) =
codex_row("work", None, None, Some(seen), None, 1_786_600_000).expect("a reading");
assert_eq!(u.note.as_deref(), Some("out of quota"));
assert!(u.on_credits);
}
#[test]
fn between_two_local_readings_the_newer_one_answers() {
let w = |pct: f64| crate::codex_limits::Window {
used_pct: pct,
window_minutes: 10080,
resets_at: Some(1_787_011_538),
};
let transcript = crate::codex_limits::Limits {
short: Some(w(12.0)),
long: None,
observed_at: Some(1_786_500_000),
};
let seen_by_proxy = crate::quota_cache::Entry {
seven_d: Some(55.0),
seven_d_reset: Some(1_787_011_538),
at: 1_786_590_000,
..Default::default()
};
let now = 1_786_600_000;
let (_, u) = codex_row(
"work",
None,
None,
Some(seen_by_proxy.clone()),
Some(transcript),
now,
)
.expect("a reading");
assert_eq!(u.seven_d, Some(55.0));
assert_eq!(u.observed_at, Some(1_786_590_000));
let older_proxy = crate::quota_cache::Entry {
at: 1_786_400_000,
..seen_by_proxy.clone()
};
let (_, u) = codex_row(
"work",
None,
None,
Some(older_proxy.clone()),
Some(transcript),
now,
)
.expect("a reading");
assert_eq!(u.seven_d, Some(12.0));
let (_, u) = codex_row("work", None, None, Some(seen_by_proxy.clone()), None, now)
.expect("a reading");
assert_eq!(u.seven_d, Some(55.0));
let live = crate::codex_usage::Account {
limits: crate::codex_limits::Limits {
short: Some(w(84.0)),
long: None,
observed_at: None,
},
..Default::default()
};
let (_, u) = codex_row(
"work",
Some(&live),
None,
Some(seen_by_proxy.clone()),
Some(transcript),
now,
)
.expect("a reading");
assert_eq!(u.seven_d, Some(84.0));
assert_eq!(u.observed_at, Some(now));
}
#[test]
fn a_live_reading_wins_over_a_transcript_and_a_transcript_over_nothing() {
let w = |pct: f64| crate::codex_limits::Window {
used_pct: pct,
window_minutes: 10080,
resets_at: Some(1_787_011_538),
};
let live = crate::codex_usage::Account {
email: Some("someone@example.com".into()),
limits: crate::codex_limits::Limits {
short: Some(w(84.0)),
long: None,
observed_at: None,
},
..Default::default()
};
let stale = crate::codex_limits::Limits {
short: Some(w(12.0)),
long: None,
observed_at: Some(1_786_000_000),
};
let now = 1_786_600_000;
let (who, u) =
codex_row("work", Some(&live), None, None, Some(stale), now).expect("live answers");
assert_eq!(who, "work");
assert_eq!(u.seven_d, Some(84.0));
assert_eq!(u.observed_at, Some(now));
let (_, u) =
codex_row("work", None, None, None, Some(stale), now).expect("transcript answers");
assert_eq!(u.seven_d, Some(12.0));
assert_eq!(u.observed_at, Some(1_786_000_000));
let (_, u) =
codex_row("work", Some(&live), None, None, None, now).expect("live alone answers");
assert_eq!(u.seven_d, Some(84.0));
assert!(codex_row("work", None, None, None, None, now).is_none());
}
#[test]
fn a_codex_reading_is_keyed_by_the_home_it_came_from() {
let l = crate::codex_limits::Limits {
short: None,
long: Some(crate::codex_limits::Window {
used_pct: 45.0,
window_minutes: 10080,
resets_at: Some(1_787_011_538),
}),
observed_at: Some(1_786_600_000),
};
let (who, u) = codex_usage_row("codex-main", &l);
assert_eq!(who, "codex-main");
assert_eq!(u.seven_d, Some(45.0));
assert_eq!(u.five_h, None);
assert_eq!(u.seven_d_reset, Some(1_787_011_538));
}
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();
std::fs::remove_file(&cfg).ok();
std::fs::create_dir(&cfg).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}");
}
}
#[cfg(test)]
mod short_absence_tests {
use super::*;
#[test]
fn an_empty_short_status_distinguishes_unreadable_from_signed_out() {
assert_eq!(absence_reason(true), "logins unreadable from this shell");
assert_eq!(absence_reason(false), "not signed in to any tool");
}
}
#[cfg(test)]
mod bar_age_tests {
use super::*;
#[test]
fn the_bar_marks_a_number_that_stopped_being_refreshed() {
let roomy = 900; assert_eq!(
bar_age(840, roomy),
None,
"14m old on a 15m schedule is fine"
);
assert_eq!(bar_age(1_799, roomy), None);
assert_eq!(bar_age(1_800, roomy), Some(" · 30m old".to_string()));
let tight = 60;
assert_eq!(bar_age(300, tight), None);
assert_eq!(bar_age(599, tight), None);
assert_eq!(bar_age(600, tight), Some(" · 10m old".to_string()));
assert_eq!(bar_age(9_000, tight), Some(" · 2h old".to_string()));
}
#[test]
fn status_says_a_codex_login_has_lapsed_and_stays_quiet_for_claude() {
let hour_ago = now_ms() - 3_600_000;
let long_ago = now_ms() - 40 * 86_400 * 1000;
assert!(
expiry_note(Some(hour_ago), "codex").contains("login expired"),
"{}",
expiry_note(Some(hour_ago), "codex")
);
assert_eq!(expiry_note(Some(now_ms() + 86_400_000), "codex"), "");
assert_eq!(expiry_note(None, "codex"), "", "unknown is not expired");
assert_eq!(expiry_note(Some(hour_ago), "claude-code"), "");
assert!(expiry_note(Some(long_ago), "claude-code").contains("login is old"));
}
#[test]
fn a_lapsed_codex_login_is_not_reported_as_a_rejected_account() {
use crate::codex_usage::Fetch;
let lapsed = codex_no_number(&Fetch::Unauthorized, "work", true, CodexSource::Slot);
assert!(lapsed.contains("login expired"), "{lapsed}");
assert!(
lapsed.contains("swapdex run work --tool codex"),
"and names the remedy: {lapsed}"
);
assert_eq!(
codex_no_number(&Fetch::Unauthorized, "work", false, CodexSource::Slot),
"token rejected"
);
for expired in [true, false] {
assert_eq!(
codex_no_number(&Fetch::Throttled, "work", expired, CodexSource::Slot),
"usage endpoint throttled"
);
}
}
#[test]
fn every_tool_reports_when_its_snapshot_was_last_refreshed() {
use crate::adapters::Snapshot;
use crate::secret::Secret;
let now = now_ms() / 1000;
let old_secs = now - 60 * 86400;
let old_ms = old_secs * 1000;
let stamp = |secs: i64| crate::refresh::rfc3339_utc(secs);
let snap = |tool: &'static str, part: &str, body: String| Snapshot {
tool,
blobs: vec![(part.into(), Secret::new(body.into_bytes()))],
};
let cases = [
snap(
"claude-code",
"credentials",
format!(r#"{{"claudeAiOauth":{{"expiresAt":{old_ms}}}}}"#),
),
snap(
"codex",
"auth",
format!(r#"{{"last_refresh":"{}"}}"#, stamp(old_secs)),
),
snap("gemini", "oauth", format!(r#"{{"expiry_date":{old_ms}}}"#)),
snap(
"antigravity",
"token",
format!(r#"{{"token":{{"expiry":"{}"}}}}"#, stamp(old_secs)),
),
];
for c in &cases {
let read = snapshot_refreshed_at(c, c.tool)
.unwrap_or_else(|| panic!("{} reports its refresh time", c.tool));
assert!(
(read - old_secs).abs() <= 1,
"{}: read {read}, expected {old_secs}",
c.tool
);
assert!(snapshot_is_stale(c, c.tool), "{} is 60 days old", c.tool);
}
let fresh_ms = now * 1000;
let fresh = [
snap(
"claude-code",
"credentials",
format!(r#"{{"claudeAiOauth":{{"expiresAt":{fresh_ms}}}}}"#),
),
snap(
"codex",
"auth",
format!(r#"{{"last_refresh":"{}"}}"#, stamp(now)),
),
snap(
"gemini",
"oauth",
format!(r#"{{"expiry_date":{fresh_ms}}}"#),
),
snap(
"antigravity",
"token",
format!(r#"{{"token":{{"expiry":"{}"}}}}"#, stamp(now)),
),
];
for c in &fresh {
assert!(!snapshot_is_stale(c, c.tool), "{} is current", c.tool);
}
}
#[test]
fn a_stale_copy_of_a_live_login_does_not_report_that_login_as_rejected() {
use crate::codex_usage::Fetch;
let twin = codex_no_number(
&Fetch::Unauthorized,
"codex",
true,
CodexSource::Snapshot {
twin: Some("codex-main"),
},
);
assert!(
twin.contains("codex-main"),
"it names the account that answered: {twin}"
);
assert!(
!twin.starts_with("token rejected"),
"and does not lead with the account being rejected: {twin}"
);
assert!(
!twin.contains("swapdex run"),
"a copy is not renewed by running it: {twin}"
);
}
#[test]
fn a_stale_copy_with_no_live_twin_is_told_how_a_copy_is_actually_refreshed() {
use crate::codex_usage::Fetch;
let lone = codex_no_number(
&Fetch::Unauthorized,
"old",
true,
CodexSource::Snapshot { twin: None },
);
assert!(lone.contains("expired"), "{lone}");
assert!(
lone.contains("swapdex add old --tool codex --update"),
"a copy is refreshed by capturing it again: {lone}"
);
assert!(
!lone.contains("swapdex run"),
"and not by running a slot that does not exist: {lone}"
);
}
#[test]
fn a_saved_copy_can_be_seen_to_have_expired() {
use crate::proxy::codex::{auth_token_expired, Auth};
let now = 1_788_743_000i64;
let jwt = |exp: i64| {
use base64::Engine;
let b64 = |b: &[u8]| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b);
format!(
"{}.{}.sig",
b64(b"{\"alg\":\"none\"}"),
b64(format!("{{\"exp\":{exp}}}").as_bytes())
)
};
let auth = |exp: i64| Auth {
token: crate::secret::Secret::new(jwt(exp).into_bytes()),
account_id: "acct".into(),
};
assert!(auth_token_expired(&auth(now - 1), now), "a lapsed copy");
assert!(!auth_token_expired(&auth(now + 3600), now), "a live one");
assert!(!auth_token_expired(
&Auth {
token: crate::secret::Secret::new(b"not-a-jwt".to_vec()),
account_id: "acct".into(),
},
now
));
}
#[test]
fn a_row_with_no_slot_directory_still_gets_its_deadline_and_its_twin() {
use crate::proxy::codex::Auth;
let now = 1_788_743_000i64;
let lapsed = |acct: &str| {
use base64::Engine;
let b64 = |b: &[u8]| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b);
Auth {
token: crate::secret::Secret::new(
format!(
"{}.{}.sig",
b64(b"{\"alg\":\"none\"}"),
b64(format!("{{\"exp\":{}}}", now - 1).as_bytes())
)
.into_bytes(),
),
account_id: acct.into(),
}
};
let healthy = std::collections::BTreeMap::from([("acct-a", "codex-main")]);
let (expired, src) = codex_row_source(None, &lapsed("acct-a"), "codex", &healthy, now);
assert!(
expired,
"a saved copy has no directory but does have a deadline"
);
assert!(
matches!(
src,
CodexSource::Snapshot {
twin: Some("codex-main")
}
),
"and it is a copy of the login that answered"
);
let (_, lone) = codex_row_source(None, &lapsed("acct-b"), "old", &healthy, now);
assert!(matches!(lone, CodexSource::Snapshot { twin: None }));
let self_named = std::collections::BTreeMap::from([("acct-a", "codex")]);
let (_, own) = codex_row_source(None, &lapsed("acct-a"), "codex", &self_named, now);
assert!(matches!(own, CodexSource::Snapshot { twin: None }));
let (_, slot) = codex_row_source(
Some(std::path::Path::new("/nonexistent")),
&lapsed("acct-a"),
"work",
&healthy,
now,
);
assert!(matches!(slot, CodexSource::Slot));
}
#[test]
fn a_rejected_token_replaces_the_number_it_can_no_longer_refresh() {
let now = 1_788_743_000i64;
let read = crate::quota_cache::Entry {
seven_d: Some(5.0),
at: now - 6300,
..Default::default()
};
assert_eq!(cached_brief(&read, now), "7d 95% \u{b7} 1h old");
let rejected = crate::quota_cache::Entry {
token_rejected_at: Some(now - 60),
..read.clone()
};
assert_eq!(cached_brief(&rejected, now), "token rejected");
let recovered = crate::quota_cache::Entry {
at: now - 30,
token_rejected_at: Some(now - 60),
..read.clone()
};
assert_eq!(cached_brief(&recovered, now), "7d 95%");
}
}
#[cfg(test)]
mod throttled_note_tests {
use super::*;
#[test]
fn a_throttled_read_claims_nothing_about_the_account() {
let n = throttled_note();
assert!(
!n.contains("account is fine"),
"a declined read is not evidence the account is healthy: {n}"
);
assert!(
n.contains("no reading"),
"say what actually happened - there is no reading: {n}"
);
assert!(
n.contains("try again"),
"the remedy still belongs here: {n}"
);
}
}
#[cfg(test)]
mod install_verdict_tests {
use super::*;
#[test]
fn an_install_that_never_came_up_is_reported_as_a_failure() {
let (ok, msg) = install_verdict(true, "claude-code");
assert!(ok, "it came up: {msg}");
assert!(msg.contains("running"), "say it is running: {msg}");
let (ok, msg) = install_verdict(false, "claude-code");
assert!(
!ok,
"a service that did not start is not installed successfully"
);
assert!(
msg.contains("did not start"),
"say plainly that it did not start: {msg}"
);
assert!(
msg.to_lowercase().contains("keychain"),
"name the usual cause on this platform: {msg}"
);
assert!(
msg.contains("swapdex proxy --ensure"),
"give the way that does work: {msg}"
);
}
}
#[cfg(test)]
mod serving_reach_tests {
use super::*;
#[test]
fn a_tool_with_no_proxy_says_what_switching_can_and_cannot_do() {
let served = serving_reach(true, 3).expect("a tool with accounts has something to say");
assert!(
served.contains("running session"),
"say a running session can be moved: {served}"
);
let unserved = serving_reach(false, 3).unwrap();
assert!(
unserved.to_lowercase().contains("next session"),
"say when a switch takes effect: {unserved}"
);
assert!(
!unserved.to_lowercase().contains("problem"),
"not having a proxy is a choice, not a fault: {unserved}"
);
assert_eq!(serving_reach(false, 0), None);
}
}
#[cfg(test)]
mod codex_snapshot_rows_tests {
use super::*;
#[test]
fn codex_accounts_without_a_slot_are_still_asked_about() {
let slots = ["youdie".to_string()];
let store = ["work".to_string(), "youdie".to_string(), "kong".to_string()];
let mut missing = codex_names_without_a_slot(&store, &slots);
missing.sort();
assert_eq!(
missing,
vec!["kong".to_string(), "work".to_string()],
"every stored Codex account that has no slot"
);
assert!(codex_names_without_a_slot(&slots, &slots).is_empty());
}
#[test]
fn a_name_already_shown_is_not_given_a_second_row() {
let already = ["kong".to_string(), "rnd".to_string()];
let stored = ["kong".to_string(), "work".to_string()];
let add: Vec<String> = codex_names_without_a_slot(&stored, &[])
.into_iter()
.filter(|n| !already.contains(n))
.collect();
assert_eq!(
add,
vec!["work".to_string()],
"only the account that has no row yet"
);
}
}
#[cfg(test)]
mod cached_quota_tools_tests {
use super::*;
#[test]
fn the_first_frame_reads_a_cache_per_tool() {
let tools = cached_quota_tools();
assert!(
tools.contains(&CLAUDE_TOOL),
"claude was always read: {tools:?}"
);
assert!(
tools.contains(&CODEX_TOOL),
"a remembered reading has to be read back too: {tools:?}"
);
}
}
#[cfg(test)]
mod slot_capture_tests {
fn write_credentials(dir: &std::path::Path, who: &str) {
std::fs::create_dir_all(dir).unwrap();
let value = serde_json::json!({
"claudeAiOauth": {
"accessToken": format!("AT-{who}"),
"refreshToken": "RT",
"expiresAt": 9999999999999i64
}
});
std::fs::write(dir.join(".credentials.json"), value.to_string()).unwrap();
}
#[test]
fn a_slot_capture_reads_the_slot_not_the_default_home() {
let td = tempfile::tempdir().unwrap();
let root = td.path();
let paths = crate::paths::Paths::rooted(root);
write_credentials(paths.claude_dir(), "DEFAULT");
std::fs::write(
paths.claude_config_json(),
serde_json::json!({
"oauthAccount": {
"accountUuid": "UUID-DEFAULT",
"emailAddress": "default@example.com"
}
})
.to_string(),
)
.unwrap();
let slot = root.join("slotdir");
write_credentials(&slot, "SLOT");
std::fs::write(
slot.join(".claude.json"),
serde_json::json!({
"oauthAccount": {
"accountUuid": "UUID-SLOT",
"emailAddress": "slot@example.com"
}
})
.to_string(),
)
.unwrap();
let at = paths.with_tool_dir("claude-code", &slot);
let snap = crate::adapters::by_name("claude-code")
.unwrap()
.capture(&at)
.expect("the slot has a login");
let creds = String::from_utf8_lossy(snap.part("credentials").unwrap().expose()).to_string();
assert!(creds.contains("AT-SLOT"), "must read the slot: {creds}");
assert!(
!creds.contains("AT-DEFAULT"),
"must NOT read the default home: {creds}"
);
let oauth: serde_json::Value = serde_json::from_slice(
snap.part("oauth_account")
.expect("capture always includes identity metadata")
.expose(),
)
.unwrap();
assert_eq!(oauth["accountUuid"], "UUID-SLOT");
assert_eq!(oauth["emailAddress"], "slot@example.com");
}
#[test]
fn missing_slot_metadata_is_absent_instead_of_borrowed_from_default() {
let td = tempfile::tempdir().unwrap();
let paths = crate::paths::Paths::rooted(td.path());
write_credentials(paths.claude_dir(), "DEFAULT");
std::fs::write(
paths.claude_config_json(),
r#"{"oauthAccount":{"accountUuid":"UUID-DEFAULT","emailAddress":"default@example.com"}}"#,
)
.unwrap();
let slot = td.path().join("slotdir");
write_credentials(&slot, "SLOT");
let at = paths.with_tool_dir("claude-code", &slot);
let snap = crate::adapters::by_name("claude-code")
.unwrap()
.capture(&at)
.unwrap();
let oauth: serde_json::Value =
serde_json::from_slice(snap.part("oauth_account").unwrap().expose()).unwrap();
assert!(
oauth.is_null(),
"default metadata leaked into slot: {oauth}"
);
}
#[test]
fn corrupt_slot_metadata_is_reported_instead_of_borrowing_default() {
let td = tempfile::tempdir().unwrap();
let paths = crate::paths::Paths::rooted(td.path());
write_credentials(paths.claude_dir(), "DEFAULT");
std::fs::write(
paths.claude_config_json(),
r#"{"oauthAccount":{"accountUuid":"UUID-DEFAULT","emailAddress":"default@example.com"}}"#,
)
.unwrap();
let slot = td.path().join("slotdir");
write_credentials(&slot, "SLOT");
std::fs::write(slot.join(".claude.json"), b"not json {").unwrap();
let at = paths.with_tool_dir("claude-code", &slot);
let error = match crate::adapters::by_name("claude-code")
.unwrap()
.capture(&at)
{
Ok(_) => panic!("corrupt slot identity must stop capture"),
Err(error) => error,
};
assert!(error.to_string().contains("corrupt"), "{error:#}");
}
}
#[cfg(test)]
mod from_slot_flag_tests {
#[test]
fn capture_source_is_the_slot_when_asked() {
assert_eq!(
super::capture_source(true),
super::CaptureSource::Slot,
"--from-slot reads the account own directory"
);
assert_eq!(
super::capture_source(false),
super::CaptureSource::LiveHome,
"the default stays the live login, as every other capture"
);
}
}
#[cfg(test)]
mod unusable_token_tests {
use super::*;
#[test]
fn an_empty_token_says_it_was_not_read_not_that_it_is_corrupt() {
let empty = unusable_token_reason("", None);
assert!(
!empty.contains("corrupt") && !empty.contains("--update"),
"nothing was read - do not call it corrupt or offer to overwrite: {empty}"
);
let why = unusable_token_reason(
"",
Some("signed in, but this shell cannot read the keychain"),
);
assert!(why.contains("keychain"), "the known reason is used: {why}");
let bad = unusable_token_reason("abc\ndef", None);
assert!(
bad.contains("corrupt"),
"a malformed token is still corrupt: {bad}"
);
}
}
#[cfg(test)]
mod tool_binary_tests {
use super::*;
#[test]
fn tool_binary_names_the_binary_each_tool_actually_has() {
assert_eq!(tool_binary("claude-code"), "claude");
assert_eq!(tool_binary("codex"), "codex");
assert_eq!(tool_binary("gemini"), "gemini");
assert_eq!(tool_binary("antigravity"), "agy");
}
#[test]
fn no_two_tools_report_the_same_binary() {
let tools = ["claude-code", "codex", "gemini", "antigravity"];
for i in 0..tools.len() {
for j in (i + 1)..tools.len() {
assert_ne!(
tool_binary(tools[i]),
tool_binary(tools[j]),
"{} and {} both resolve to the same binary",
tools[i],
tools[j]
);
}
}
}
}
#[cfg(test)]
mod shared_source_tests {
use super::*;
#[test]
fn a_slot_borrows_from_its_own_tools_dir() {
let d = tempfile::tempdir().unwrap();
let paths = crate::paths::Paths::rooted(d.path());
assert_eq!(shared_source(&paths, "claude-code"), paths.claude_dir());
assert_eq!(shared_source(&paths, "codex"), paths.codex_dir());
assert_ne!(
shared_source(&paths, "gemini"),
paths.claude_dir(),
"gemini borrowed from Claude's dir"
);
assert_ne!(
shared_source(&paths, "antigravity"),
paths.claude_dir(),
"antigravity borrowed from Claude's dir"
);
}
}