use crate::adapters::{self, Account, AuthTool};
use crate::paths::Paths;
use crate::store::Store;
use anyhow::Result;
use serde_json::Value;
use std::process::Command;
fn pretty_tool_flag(tool: &str) -> &str {
if tool == "claude-code" {
"claude"
} else {
tool
}
}
fn now_nanos() -> u128 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
}
fn command_exists(cmd: &str) -> bool {
Command::new(cmd)
.arg("--version")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
#[derive(Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
pub enum ToolSel {
#[value(alias = "claude-code")]
Claude,
Codex,
Gemini,
Antigravity,
#[value(alias = "both")]
All,
}
impl ToolSel {
fn wants(self, tool: &str) -> bool {
match self {
ToolSel::Claude => tool == "claude-code",
ToolSel::Codex => tool == "codex",
ToolSel::Gemini => tool == "gemini",
ToolSel::Antigravity => tool == "antigravity",
ToolSel::All => true,
}
}
}
fn selected_adapters(sel: Option<ToolSel>) -> Vec<Box<dyn AuthTool>> {
adapters::all()
.into_iter()
.filter(|a| sel.map(|s| s.wants(a.name())).unwrap_or(true))
.collect()
}
fn is_explicit(sel: Option<ToolSel>) -> bool {
matches!(
sel,
Some(ToolSel::Claude)
| Some(ToolSel::Codex)
| Some(ToolSel::Gemini)
| Some(ToolSel::Antigravity)
)
}
fn macos_keychain_note(paths: &Paths, tool: &str) -> Option<&'static str> {
if !cfg!(target_os = "macos") || tool != "claude-code" {
return None;
}
if paths.claude_credentials().exists() {
return None;
}
let logged_in_by_config = std::fs::read(paths.claude_config_json())
.ok()
.and_then(|b| serde_json::from_slice::<Value>(&b).ok())
.map(|v| v["oauthAccount"].is_object())
.unwrap_or(false);
if logged_in_by_config {
Some(
"Claude Code on macOS keeps its login in the Keychain, which swapdex \
cannot snapshot yet - Codex switching works; Claude-on-macOS is on \
the roadmap",
)
} else {
None
}
}
fn snapshot_account_id(snap: &crate::adapters::Snapshot, tool: &str) -> Option<String> {
match tool {
"codex" => {
let v: Value = serde_json::from_slice(snap.part("auth")?.expose()).ok()?;
v["tokens"]["account_id"].as_str().map(|s| s.to_string())
}
"claude-code" => {
let v: Value = serde_json::from_slice(snap.part("oauth_account")?.expose()).ok()?;
v["accountUuid"].as_str().map(|s| s.to_string())
}
"gemini" => {
let v: Value = serde_json::from_slice(snap.part("oauth")?.expose()).ok()?;
crate::adapters::gemini_jwt_claim(v["id_token"].as_str(), "sub")
}
"antigravity" => {
let v: Value = serde_json::from_slice(snap.part("token")?.expose()).ok()?;
let fp = crate::adapters::antigravity_fingerprint(&v);
(!fp.is_empty()).then_some(fp)
}
_ => None,
}
}
fn profile_account_id(store: &Store, name: &str, tool: &str) -> Option<String> {
let snap = store.load(name, tool).ok()??;
snapshot_account_id(&snap, tool)
}
pub(crate) fn matched_profile_name(store: &Store, tool: &str, live_id: &str) -> Option<String> {
if live_id.is_empty() {
return None;
}
store
.list()
.into_iter()
.find(|p| {
p.tools.iter().any(|t| t == tool)
&& profile_account_id(store, &p.name, tool).as_deref() == Some(live_id)
})
.map(|p| p.name)
}
fn matching_profile_names(store: &Store, tool: &str, live_id: &str) -> Vec<String> {
if live_id.is_empty() {
return Vec::new();
}
store
.list()
.into_iter()
.filter(|p| {
p.tools.iter().any(|t| t == tool)
&& profile_account_id(store, &p.name, tool).as_deref() == Some(live_id)
})
.map(|p| p.name)
.collect()
}
fn reject_bad_name(name: &str) -> Option<i32> {
if crate::store::valid_profile_name(name) {
None
} else {
eprintln!(
"swapdex: invalid profile name '{name}' (1-64 bytes, not all spaces; \
no '/', '\\', leading '.', or control chars)"
);
Some(2)
}
}
fn reject_reserved_name(name: &str) -> Option<i32> {
if name == "-" {
eprintln!("swapdex: '-' is reserved (`swapdex use -` toggles to the previous profile)");
Some(2)
} else if name.trim().is_empty() {
eprintln!("swapdex: a profile name cannot be only whitespace");
Some(2)
} else {
None
}
}
pub fn add(paths: &Paths, name: Option<&str>, sel: Option<ToolSel>, update: bool) -> Result<i32> {
crate::atomic::ensure_not_root()?;
let store = Store::open(paths)?;
let asked;
let name: &str = match name {
Some(n) => n,
None => {
use std::io::IsTerminal;
let tty =
std::io::stdin().is_terminal() || std::env::var_os("SWAPDEX_ASSUME_TTY").is_some();
if !tty {
eprintln!(
"swapdex: a profile name is required: swapdex add <name> \
(or run `swapdex setup` for the guided flow)"
);
return Ok(2);
}
let who = adapters::all()
.iter()
.find_map(|a| a.identity(paths).ok().flatten())
.map(|id| id.email.unwrap_or(id.display))
.unwrap_or_else(|| "account".into());
let suggestion = suggest_name(&who);
match ask_name(
&store,
&format!("name for this account [{suggestion}]: "),
&suggestion,
) {
Some(n) => {
asked = n;
&asked
}
None => {
println!("nothing saved.");
return Ok(0);
}
}
}
};
if let Some(c) = reject_bad_name(name).or_else(|| reject_reserved_name(name)) {
return Ok(c);
}
let _lock = match store.lock() {
Ok(g) => g,
Err(crate::store::LockError::Busy) => {
eprintln!("swapdex: another swapdex is mid-switch; try again");
return Ok(4);
}
Err(crate::store::LockError::Unwritable(e)) => {
eprintln!(
"swapdex: the store is not writable ({e}) - check permissions/mount of \
the store directory"
);
return Ok(4);
}
};
let mut saved = Vec::new();
let mut skipped = Vec::new();
let mut capture_failed: Vec<&str> = Vec::new();
let mut declined: Vec<&str> = Vec::new(); for adapter in selected_adapters(sel) {
let tool = adapter.name();
if !adapter.present(paths) {
if is_explicit(sel) {
eprintln!("swapdex: not logged in to {tool}");
if let Some(note) = macos_keychain_note(paths, tool) {
eprintln!("swapdex: note - {note}");
}
return Ok(3);
}
continue;
}
if update {
let stored_id = profile_account_id(&store, name, tool).filter(|s| !s.is_empty());
let live_id = adapter
.identity(paths)
.ok()
.flatten()
.map(|i| i.account_id)
.filter(|s| !s.is_empty());
if let (Some(stored), Some(live)) = (&stored_id, &live_id) {
if stored != live {
use std::io::IsTerminal;
let tty = std::io::stdin().is_terminal()
|| std::env::var_os("SWAPDEX_ASSUME_TTY").is_some();
let msg = format!(
"profile '{name}' holds a different account for {tool} \
than the one you're logged into"
);
if !tty {
eprintln!("swapdex: {msg}.");
eprintln!(
" keep both: swapdex add <new-name> --tool {} | really \
repoint: swapdex rm {name} && swapdex add {name}",
pretty_tool_flag(tool)
);
return Ok(7);
}
if !yes_no(
&format!("{msg}. Repoint '{name}' to the current login? [y/N]: "),
false,
) {
println!("skipped {tool}.");
declined.push(tool);
continue;
}
}
}
}
if store.load(name, tool)?.is_some() && !update {
if is_explicit(sel) {
eprintln!(
"swapdex: profile '{name}' already has a {tool} login; pass --update to replace"
);
return Ok(6);
}
skipped.push(tool);
continue;
}
let snap = match adapter.capture(paths) {
Ok(s) => s,
Err(e) => {
eprintln!("swapdex: {tool}: could not read the live login ({e:#}) - skipped");
capture_failed.push(tool);
continue;
}
};
store.save(name, &snap)?;
saved.push(tool);
}
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);
}
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(", ")
)
};
println!("saved profile '{name}' ({}){note}", saved.join(", "));
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 })
}
pub fn use_account(paths: &Paths, name: &str, sel: Option<ToolSel>, dry_run: bool) -> Result<i32> {
use_account_inner(paths, name, sel, dry_run, false, None)
}
pub fn use_account_open(
paths: &Paths,
name: &str,
sel: Option<ToolSel>,
dir: Option<&std::path::Path>,
) -> Result<i32> {
if !is_explicit(sel) {
eprintln!("swapdex: --open needs --tool <claude|codex|gemini|antigravity> so it knows what to launch");
return Ok(2);
}
if let Some(d) = dir {
if !d.is_dir() {
eprintln!("swapdex: --dir is not a directory: {}", d.display());
return Ok(2);
}
}
use_account_inner(paths, name, sel, false, true, dir)
}
fn use_account_inner(
paths: &Paths,
name: &str,
sel: Option<ToolSel>,
dry_run: bool,
open: bool,
open_dir: Option<&std::path::Path>,
) -> 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 mid-switch; try again");
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 switch_ts = now_secs();
let switch_inv = now_nanos();
for adapter in selected_adapters(sel) {
let tool = adapter.name();
if !is_explicit(sel) && macos_keychain_note(paths, tool).is_some() {
println!(
"{tool}: skipped - the login lives in the macOS Keychain \
(github.com/youdie006/swapdex/issues/1); other tools continue"
);
continue;
}
let target = match store.load(name, tool)? {
Some(s) => s,
None => {
if is_explicit(sel) {
eprintln!("swapdex: profile '{name}' has no {tool} login");
return Ok(5);
}
if adapter.present(paths) {
println!("{tool}: profile '{name}' has no {tool} login - left unchanged");
}
continue;
}
};
matched += 1;
let live = adapter.identity(paths).ok().flatten();
let live_id = live
.as_ref()
.map(|i| i.account_id.clone())
.filter(|s| !s.is_empty());
let target_id = profile_account_id(&store, name, tool).filter(|s| !s.is_empty());
if live_id.is_some() && live_id == target_id {
println!("{tool}: '{name}' is already active");
if !dry_run {
if let (Ok(snap), Some(id)) = (adapter.capture(paths), &live_id) {
for pname in matching_profile_names(&store, tool, id) {
store.save(&pname, &snap)?;
}
}
}
continue;
}
warn_if_expired(&target, tool);
if dry_run {
match profile_detail(&store, name, tool).and_then(|(email, _, _)| email) {
Some(email) => println!("would switch {tool} -> {name} ({email})"),
None => println!("would switch {tool} -> {name}"),
}
continue;
}
if 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) => eprintln!(
"swapdex: note - the current {tool} login could not be read ({e:#}); \
switching without a backup of it"
),
}
}
if let Err(e) = adapter.apply(paths, &target) {
eprintln!(
"swapdex: {tool}: switch failed - {:#}\n (if the error is about the \
SNAPSHOT: log in to that account and re-save with `swapdex add {name} \
--tool {} --update`)",
e,
pretty_tool_flag(tool)
);
failed.push(tool);
continue;
}
store.append_timeline_inv(tool, name, "use", switch_ts, switch_inv)?;
if let Some(id) = adapter.identity(paths).ok().flatten() {
println!("switched {tool} -> {}", identity_line(&id));
}
if crate::proc::tool_running(tool, &running) {
eprintln!(
"swapdex: note - a {tool} session looks like it's running. Restart it \
to use '{name}'; a live session can overwrite the switched login on \
its next token refresh."
);
}
changed += 1;
}
if matched == 0 {
eprintln!("swapdex: no profile named '{name}'");
return Ok(5);
}
if changed > 0 {
println!("(takes effect on your next message)");
}
if !failed.is_empty() {
eprintln!(
"swapdex: {} tool(s) failed to switch ({}); the tools above did switch - \
`swapdex restore` undoes this switch entirely",
failed.len(),
failed.join(", ")
);
return Ok(1);
}
if open {
if let Some(adapter) = selected_adapters(sel).into_iter().next() {
let tool = adapter.name();
println!("opening {}...", pretty_tool(tool));
return Err(exec_tool(tool, open_dir));
}
}
Ok(0)
}
pub fn restore(paths: &Paths, sel: Option<ToolSel>, dry_run: bool) -> Result<i32> {
crate::atomic::ensure_not_root()?;
let store = Store::open(paths)?;
let _lock = match store.lock() {
Ok(g) => g,
Err(crate::store::LockError::Busy) => {
eprintln!("swapdex: another swapdex is mid-switch; try again");
return Ok(4);
}
Err(crate::store::LockError::Unwritable(e)) => {
eprintln!(
"swapdex: the store is not writable ({e}) - check permissions/mount of \
the store directory"
);
return Ok(4);
}
};
let running = if dry_run || std::env::var_os("SWAPDEX_ROOT").is_some() {
Vec::new()
} else {
crate::proc::running_process_names()
};
let last_switch = last_switch_tools(paths);
let restore_ts = now_secs();
let restore_inv = now_nanos();
let mut found = 0; let mut changed = 0; for adapter in selected_adapters(sel) {
let tool = adapter.name();
if !is_explicit(sel) {
if let Some(tools) = &last_switch {
if !tools.iter().any(|t| t == tool) {
continue;
}
}
if macos_keychain_note(paths, tool).is_some() {
println!(
"{tool}: skipped - the login lives in the macOS Keychain \
(github.com/youdie006/swapdex/issues/1); other tools continue"
);
continue;
}
}
let Some((stamp, target)) = store.load_backup(tool)? else {
if is_explicit(sel) {
eprintln!("swapdex: no backup for {tool} (a backup is taken on every `use`)");
return Ok(5);
}
continue;
};
found += 1;
let live_id = adapter
.identity(paths)
.ok()
.flatten()
.map(|i| i.account_id)
.filter(|s| !s.is_empty());
let backup_id = snapshot_account_id(&target, tool).filter(|s| !s.is_empty());
if live_id.is_some() && live_id == backup_id {
println!("{tool}: the newest backup is already the active login");
continue;
}
let age = age_line(stamp);
if dry_run {
println!("would restore {tool} from the backup taken {age}");
continue;
}
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)?;
}
}
}
Err(e) => eprintln!(
"swapdex: note - the current {tool} login could not be read ({e:#}); \
restoring without a backup of it"
),
}
}
adapter.apply(paths, &target)?;
let restored = adapter.identity(paths).ok().flatten();
let event_name = restored
.as_ref()
.and_then(|id| matched_profile_name(&store, tool, &id.account_id))
.unwrap_or_else(|| "(backup)".into());
store.append_timeline_inv(tool, &event_name, "restore", restore_ts, restore_inv)?;
match restored {
Some(id) => println!("restored {tool} -> {} (backup {age})", identity_line(&id)),
None => println!("restored {tool} from the backup taken {age}"),
}
if crate::proc::tool_running(tool, &running) {
eprintln!(
"swapdex: note - a {tool} session looks like it's running. Restart it \
to pick up the restored login."
);
}
changed += 1;
}
if found == 0 {
eprintln!("swapdex: no backup to restore (a backup is taken on every `use`)");
return Ok(5);
}
if changed > 0 {
println!("(takes effect on your next message)");
}
Ok(0)
}
fn resolve_use_name(
store: &Store,
paths: &Paths,
raw: &str,
sel: Option<ToolSel>,
) -> Result<Option<String>> {
if raw.is_empty() {
return Ok(Some(raw.to_string()));
}
let profiles: Vec<String> = store.list().into_iter().map(|p| p.name).collect();
if raw == "-" {
let mut act: Vec<String> = active_by_tool(store, paths)
.into_iter()
.filter(|(t, _)| sel.map(|s| s.wants(t)).unwrap_or(true))
.map(|(_, n)| n)
.collect();
act.sort();
act.dedup();
if profiles.len() == 2 && act.len() == 1 {
if let Some(other) = profiles.iter().find(|p| **p != act[0]) {
eprintln!("swapdex: '-' -> '{other}'");
return Ok(Some(other.clone()));
}
}
if let Some(prev) = last_switch_name_excluding(paths, &act, &profiles, sel) {
eprintln!("swapdex: '-' -> '{prev}'");
return Ok(Some(prev));
}
if act.len() > 1 {
eprintln!(
"swapdex: both profiles are active ({}) - '-' is ambiguous here; \
say which: swapdex use <{}>",
act.join(", "),
profiles.join("|")
);
} else {
eprintln!(
"swapdex: can't tell which profile '-' means yet. \
Pick one: swapdex use <{}>",
profiles.join("|")
);
}
return Ok(None);
}
if profiles.iter().any(|p| p == raw) {
return Ok(Some(raw.to_string()));
}
let cands: Vec<&String> = profiles.iter().filter(|p| p.starts_with(raw)).collect();
match cands.len() {
1 => {
let n = cands[0].clone();
eprintln!("swapdex: '{raw}' matched profile '{n}'");
Ok(Some(n))
}
0 => Ok(Some(raw.to_string())),
_ => {
eprintln!(
"swapdex: '{raw}' is ambiguous: {}",
cands
.iter()
.map(|s| s.as_str())
.collect::<Vec<_>>()
.join(", ")
);
Ok(None)
}
}
}
fn last_switch_name_excluding(
paths: &Paths,
exclude: &[String],
profiles: &[String],
sel: Option<ToolSel>,
) -> Option<String> {
let text = std::fs::read_to_string(paths.store_dir().join("timeline.jsonl")).ok()?;
let mut events: Vec<(i64, String)> = Vec::new();
for line in text.lines() {
let Ok(v) = serde_json::from_str::<Value>(line) else {
continue;
};
if !matches!(v["action"].as_str(), Some("use") | Some("restore")) {
continue;
}
if let Some(tool) = v["tool"].as_str() {
if !sel.map(|s| s.wants(tool)).unwrap_or(true) {
continue;
}
}
let (Some(ts), Some(name)) = (v["ts"].as_i64(), v["account"].as_str()) else {
continue;
};
events.push((ts, name.to_string()));
}
let newest = events
.iter()
.max_by_key(|(ts, _)| *ts)
.map(|(_, n)| n.clone());
let mut best: Option<(i64, String)> = None;
for (ts, name) in events {
if exclude.contains(&name)
|| newest.as_deref() == Some(name.as_str())
|| !profiles.contains(&name)
{
continue;
}
if best.as_ref().map(|(t, _)| ts >= *t).unwrap_or(true) {
best = Some((ts, name.to_string()));
}
}
best.map(|(_, n)| n)
}
fn last_switch_tools(paths: &Paths) -> Option<Vec<String>> {
let path = paths.store_dir().join("timeline.jsonl");
let text = std::fs::read_to_string(path).ok()?;
let mut events: Vec<(i64, String, String)> = Vec::new();
for line in text.lines() {
let Ok(v) = serde_json::from_str::<Value>(line) else {
continue;
};
if !matches!(v["action"].as_str(), Some("use") | Some("restore")) {
continue;
}
if let (Some(ts), Some(tool)) = (v["ts"].as_i64(), v["tool"].as_str()) {
let inv = v["inv"].as_str().unwrap_or("").to_string();
events.push((ts, inv, tool.to_string()));
}
}
let (last_ts, last_inv) = events
.iter()
.map(|(ts, inv, _)| (*ts, inv.clone()))
.next_back()?;
let mut tools: Vec<String> = events
.into_iter()
.filter(|(ts, inv, _)| {
if last_inv.is_empty() {
*ts == last_ts && inv.is_empty()
} else {
*inv == last_inv
}
})
.map(|(_, _, tool)| tool)
.collect();
tools.sort();
tools.dedup();
Some(tools)
}
fn age_line(stamp_nanos: u128) -> String {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let secs = (now.saturating_sub(stamp_nanos) / 1_000_000_000) as u64;
if secs < 60 {
format!("{secs}s ago")
} else if secs < 3600 {
format!("{}m ago", secs / 60)
} else if secs < 86400 {
format!("{}h ago", secs / 3600)
} else {
format!("{}d ago", secs / 86400)
}
}
const STALE_DAYS: i64 = 30;
fn profile_detail(
store: &Store,
name: &str,
tool: &str,
) -> Option<(Option<String>, Option<String>, Option<&'static str>)> {
let snap = store.load(name, tool).ok()??;
let unreadable = (None, None, Some("unreadable"));
match tool {
"claude-code" => {
let (Some(cred_part), Some(oauth_part)) =
(snap.part("credentials"), snap.part("oauth_account"))
else {
return Some(unreadable);
};
let (Ok(creds), Ok(oauth)) = (
serde_json::from_slice::<Value>(cred_part.expose()),
serde_json::from_slice::<Value>(oauth_part.expose()),
) else {
return Some(unreadable);
};
let marker = match creds["claudeAiOauth"]["expiresAt"].as_i64() {
Some(ms) if ms < now_ms() => Some("expired"),
_ => None,
};
Some((
oauth["emailAddress"].as_str().map(String::from),
creds["claudeAiOauth"]["subscriptionType"]
.as_str()
.map(String::from),
marker,
))
}
"codex" => {
let Some(auth_part) = snap.part("auth") else {
return Some(unreadable);
};
let Ok(auth) = serde_json::from_slice::<Value>(auth_part.expose()) else {
return Some(unreadable);
};
let email = crate::adapters::codex::decode_email_from_id_token(
auth["tokens"]["id_token"].as_str(),
);
let marker = auth["last_refresh"]
.as_str()
.and_then(crate::session_link::rfc3339_to_secs)
.filter(|&secs| now_ms() / 1000 - secs > STALE_DAYS * 86400)
.map(|_| "stale");
Some((email, auth["auth_mode"].as_str().map(String::from), marker))
}
"gemini" => {
let oauth: Value = serde_json::from_slice(snap.part("oauth")?.expose()).ok()?;
let email = snap
.part("accounts")
.and_then(|a| serde_json::from_slice::<Value>(a.expose()).ok())
.and_then(|v| v["active"].as_str().map(String::from))
.or_else(|| crate::adapters::gemini_jwt_claim(oauth["id_token"].as_str(), "email"));
let marker = oauth["expiry_date"]
.as_i64()
.filter(|ms| now_ms() - ms > STALE_DAYS * 86400 * 1000)
.map(|_| "stale");
Some((email, None, marker))
}
"antigravity" => {
let v: Value = serde_json::from_slice(snap.part("token")?.expose()).ok()?;
let marker = v["token"]["expiry"]
.as_str()
.and_then(crate::session_link::rfc3339_to_secs)
.filter(|&secs| now_ms() / 1000 - secs > STALE_DAYS * 86400)
.map(|_| "stale");
Some((None, v["auth_method"].as_str().map(String::from), marker))
}
_ => None,
}
}
fn profile_summary(
store: &Store,
name: &str,
tools: &[String],
) -> (Option<String>, Option<String>, Option<&'static str>) {
let mut email = None;
let mut tier = None;
let mut marker = None;
for a in adapters::all() {
let t = a.name();
if !tools.iter().any(|x| x == t) {
continue;
}
if let Some((e, ti, m)) = profile_detail(store, name, t) {
email = email.or(e);
tier = tier.or(ti);
marker = marker.or(m);
}
}
(email, tier, marker)
}
pub(crate) fn active_by_tool(store: &Store, paths: &Paths) -> Vec<(&'static str, String)> {
adapters::all()
.iter()
.filter_map(|a| {
a.identity(paths)
.ok()
.flatten()
.and_then(|id| matched_profile_name(store, a.name(), &id.account_id))
.map(|name| (a.name(), name))
})
.collect()
}
fn fit(s: &str, w: usize) -> String {
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
let n = UnicodeWidthStr::width(s);
if n <= w {
let mut out = String::from(s);
out.extend(std::iter::repeat_n(' ', w - n));
return out;
}
let mut out = String::new();
let mut used = 0usize;
for c in s.chars() {
let cw = UnicodeWidthChar::width(c).unwrap_or(0);
if used + cw > w.saturating_sub(1) {
break;
}
out.push(c);
used += cw;
}
out.push('…');
out.extend(std::iter::repeat_n(' ', w.saturating_sub(used + 1)));
out
}
fn identity_column(email: Option<String>, tier: Option<String>) -> String {
match (email.filter(|e| !e.is_empty()), tier) {
(Some(e), Some(t)) => format!("{e} [{t}]"),
(Some(e), None) => e,
(None, Some(t)) => format!("[{t}]"),
(None, None) => String::new(),
}
}
pub fn ls(paths: &Paths, json: bool, names: bool) -> Result<i32> {
let store = Store::open(paths)?;
if names {
for p in store.list() {
println!("{}", p.name);
}
return Ok(0);
}
let active = active_by_tool(&store, paths);
let active_tools_for = |name: &str| -> Vec<&'static str> {
active
.iter()
.filter(|(_, n)| n == name)
.map(|(t, _)| *t)
.collect()
};
let profiles = store.list();
if json {
let rows: Vec<Value> = profiles
.iter()
.map(|p| {
let (email, tier, marker) = profile_summary(&store, &p.name, &p.tools);
serde_json::json!({
"name": p.name,
"tools": p.tools,
"active_tools": active_tools_for(&p.name),
"email": email,
"tier": tier,
"warning": marker,
})
})
.collect();
println!("{}", serde_json::to_string(&rows)?);
return Ok(0);
}
if profiles.is_empty() {
println!("No accounts saved yet.");
println!(" guided setup: swapdex setup");
println!(" or add one: swapdex login <name>");
return Ok(0);
}
struct Row {
name: String,
ident: String,
tools: String,
warn: Option<&'static str>,
active: bool,
}
let rows: Vec<Row> = profiles
.iter()
.map(|p| {
let (email, tier, marker) = profile_summary(&store, &p.name, &p.tools);
let at = active_tools_for(&p.name);
let tools = p
.tools
.iter()
.map(|t| {
if at.contains(&t.as_str()) {
format!("{t}*")
} else {
t.clone()
}
})
.collect::<Vec<_>>()
.join(", ");
Row {
name: p.name.clone(),
ident: identity_column(email, tier),
tools,
warn: marker,
active: !at.is_empty(),
}
})
.collect();
let name_w = rows
.iter()
.map(|r| unicode_width::UnicodeWidthStr::width(r.name.as_str()))
.max()
.unwrap_or(4)
.clamp(4, 24);
let ident_w = rows
.iter()
.map(|r| unicode_width::UnicodeWidthStr::width(r.ident.as_str()))
.max()
.unwrap_or(0)
.clamp(0, 40);
let mut saw_refreshable = false;
let mut saw_unreadable = false;
for r in &rows {
let mark = if r.active { "* " } else { " " };
let warn = r.warn.map(|m| format!(" ({m})")).unwrap_or_default();
saw_unreadable |= r.warn == Some("unreadable");
saw_refreshable |= matches!(r.warn, Some("expired") | Some("stale"));
println!(
"{mark}{} {} [{}]{warn}",
fit(&r.name, name_w),
fit(&r.ident, ident_w),
r.tools
);
}
if saw_refreshable {
println!(
" (expired/stale: re-run `swapdex add --update <name>` while logged in to refresh)"
);
}
if saw_unreadable {
println!(
" (unreadable: the saved snapshot is corrupt - log in to that account and \
re-save it with `swapdex add <name> --update`)"
);
}
if active
.iter()
.map(|(_, n)| n)
.collect::<std::collections::HashSet<_>>()
.len()
> 1
{
println!(" (* marks the active account per tool)");
}
Ok(0)
}
pub fn short_line(paths: &Paths) -> Option<String> {
let store = Store::open(paths).ok()?;
let parts: Vec<String> = adapters::all()
.iter()
.filter_map(|a| {
let id = a.identity(paths).ok().flatten()?;
let tool = match a.name() {
"claude-code" => "claude",
t => t,
};
let who = matched_profile_name(&store, a.name(), &id.account_id)
.or(id.email)
.unwrap_or_else(|| "?".into());
Some(format!("{tool}:{who}"))
})
.collect();
if parts.is_empty() {
None
} else {
Some(parts.join(" "))
}
}
pub fn status(paths: &Paths, json: bool, short: bool) -> Result<i32> {
if short {
println!("{}", short_line(paths).unwrap_or_default());
return Ok(0);
}
let store = Store::open(paths)?;
if json {
let rows: Vec<Value> = adapters::all()
.iter()
.map(|adapter| {
let tool = adapter.name();
match adapter.identity(paths) {
Err(_) => serde_json::json!({
"tool": tool, "logged_in": false, "unreadable": true,
"email": null, "tier": null, "profile": null, "expired": null,
}),
Ok(None) => serde_json::json!({
"tool": tool, "logged_in": false, "unreadable": false,
"email": null, "tier": null, "profile": null, "expired": null,
}),
Ok(Some(id)) => serde_json::json!({
"tool": tool,
"logged_in": true,
"unreadable": false,
"email": id.email,
"tier": id.tier,
"profile": matched_profile_name(&store, tool, &id.account_id),
"expired": id.expires_at.map(|ms| ms < now_ms()),
}),
}
})
.collect();
println!("{}", serde_json::to_string(&rows)?);
return Ok(0);
}
for adapter in adapters::all() {
let tool = adapter.name();
match adapter.identity(paths) {
Err(_) => println!(
"{tool}: login file unreadable - `swapdex use <profile>` can replace it \
(or log in again in the tool)"
),
Ok(None) => match macos_keychain_note(paths, tool) {
Some(note) => println!("{tool}: not manageable - {note}"),
None => println!("{tool}: not logged in"),
},
Ok(Some(id)) => {
let name = matched_profile_name(&store, tool, &id.account_id);
let saved = match &name {
Some(n) => format!("profile '{n}'"),
None => "not saved - run `swapdex add <name>`".to_string(),
};
let exp = expiry_note(id.expires_at);
println!("{tool}: {} ({saved}){exp}", identity_line(&id));
}
}
}
if let Ok(meta) = std::fs::metadata(paths.claude_config_json()) {
use std::os::unix::fs::PermissionsExt;
if meta.permissions().mode() & 0o077 != 0 {
println!(
"note: {} is group/world-readable (holds your account email/org); `chmod 600` it",
crate::util::redact_path(&paths.claude_config_json().display().to_string())
);
}
}
if let Some(line) = crate::session_link::status_line(paths) {
println!("{line}");
}
Ok(0)
}
pub fn ui(paths: &Paths) -> Result<i32> {
use std::io::IsTerminal;
let real_tty = std::io::stdin().is_terminal() && std::io::stdout().is_terminal();
let tty = real_tty || std::env::var_os("SWAPDEX_ASSUME_TTY").is_some();
if !tty {
eprintln!("swapdex: `ui` is interactive and needs a terminal (try `swapdex use <name>`)");
return Ok(2);
}
let dumb = std::env::var("TERM")
.map(|t| t.is_empty() || t == "dumb")
.unwrap_or(true);
if real_tty && !dumb {
return ui_tui(paths);
}
let store = Store::open(paths)?;
let profiles = store.list();
if profiles.is_empty() {
println!("No accounts saved yet.");
println!(" guided setup: swapdex setup");
return Ok(0);
}
let active = active_by_tool(&store, paths);
let color = crate::util::color_enabled();
println!();
for (i, p) in profiles.iter().enumerate() {
let (email, tier, marker) = profile_summary(&store, &p.name, &p.tools);
let at: Vec<&str> = active
.iter()
.filter(|(_, n)| n == &p.name)
.map(|(t, _)| *t)
.collect();
let star = if at.is_empty() { " " } else { "* " };
let ident = identity_column(email, tier);
let warn = marker.map(|m| format!(" ({m})")).unwrap_or_default();
let line = format!(
" {}) {star}{} {} [{}]{warn}",
i + 1,
fit(&p.name, 16),
fit(&ident, 32),
p.tools.join(", ")
);
if color && !at.is_empty() {
println!("\x1b[1m{line}\x1b[0m");
} else {
println!("{line}");
}
}
if let Some(line) = crate::session_link::status_line(paths) {
println!("\n {line}");
}
println!();
loop {
let Some(ans) = prompt(
&format!("switch to [1-{}] (Enter cancels): ", profiles.len()),
"",
) else {
println!("cancelled - nothing switched.");
return Ok(0);
};
if ans.is_empty() || ans.eq_ignore_ascii_case("q") {
println!("cancelled - nothing switched.");
return Ok(0);
}
match ans.parse::<usize>() {
Ok(n) if (1..=profiles.len()).contains(&n) => {
let name = profiles[n - 1].name.clone();
println!();
let first_time = crate::session_link::read_timeline(paths).is_empty();
let rc = use_account(paths, &name, None, false)?;
if rc == 0 {
ui_session_hints(paths, &name, first_time)?;
}
return Ok(rc);
}
_ => {
println!(
" pick a number between 1 and {} (Enter cancels)",
profiles.len()
);
}
}
}
}
pub(crate) enum MenuSession {
Wiki(crate::session_link::RecentSession),
Native(crate::native_sessions::NativeSession),
}
impl MenuSession {
pub(crate) fn describe(&self) -> (String, i64, String, String) {
match self {
MenuSession::Wiki(s) => (
s.id.chars().take(6).collect(),
s.started,
s.tool.clone(),
s.title.clone(),
),
MenuSession::Native(s) => (
s.id.chars().take(6).collect(),
s.started,
s.tool.to_string(),
s.title.clone(),
),
}
}
}
pub(crate) fn recent_menu_sessions(
paths: &Paths,
name: &str,
first_time: bool,
n: usize,
) -> (Vec<MenuSession>, String) {
if let Some(r) = crate::session_link::recent_sessions_for(paths, name, n) {
if !r.is_empty() {
return (
r.into_iter().map(MenuSession::Wiki).collect(),
format!("recent sessions on '{name}' (sessionwiki):"),
);
}
if first_time {
if let Some(any) = crate::session_link::recent_sessions_any(n) {
if !any.is_empty() {
return (
any.into_iter().map(MenuSession::Wiki).collect(),
"recent sessions (any account - attribution starts with your first switch):"
.to_string(),
);
}
}
}
return (Vec::new(), String::new());
}
let events = crate::session_link::read_timeline(paths);
let all = crate::native_sessions::recent(paths, n * 4);
let mine: Vec<crate::native_sessions::NativeSession> = all
.iter()
.filter(|s| {
crate::session_link::attribute(&events, s.tool, s.started).as_deref() == Some(name)
})
.map(|s| crate::native_sessions::NativeSession {
tool: s.tool,
id: s.id.clone(),
title: s.title.clone(),
cwd: s.cwd.clone(),
started: s.started,
})
.take(n)
.collect();
if !mine.is_empty() {
return (
mine.into_iter().map(MenuSession::Native).collect(),
format!("recent sessions on '{name}':"),
);
}
if first_time {
let any: Vec<MenuSession> = all.into_iter().take(n).map(MenuSession::Native).collect();
if !any.is_empty() {
return (
any,
"recent sessions (any account - attribution starts with your first switch):"
.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);
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, c/x/g/a new claude/codex/gemini/agy, 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) = launch_letter(&ans) {
return Err(launch_in_folder(tool));
}
}
} else if let Some(ans) = prompt(
"open now? c/x/g/a = new claude/codex/gemini/agy (Enter skips): ",
"",
) {
if let Some(tool) = launch_letter(&ans) {
return Err(launch_in_folder(tool));
}
}
Ok(())
}
fn launch_in_folder(tool: &str) -> anyhow::Error {
let dir = prompt("folder to open in [current dir]: ", "")
.filter(|d| !d.is_empty())
.map(|d| {
if d == "~" {
if let Some(home) = dirs::home_dir() {
return home;
}
}
if let Some(rest) = d.strip_prefix("~/") {
if let Some(home) = dirs::home_dir() {
return home.join(rest);
}
}
std::path::PathBuf::from(d)
});
if let Some(d) = &dir {
if !d.is_dir() {
return anyhow::anyhow!("not a directory: {}", d.display());
}
}
println!("opening {}...", pretty_tool(tool));
exec_tool(tool, dir.as_deref())
}
fn launch_letter(ans: &str) -> Option<&'static str> {
match ans.to_ascii_lowercase().as_str() {
"c" => Some("claude-code"),
"x" => Some("codex"),
"g" => Some("gemini"),
"a" => Some("antigravity"),
_ => None,
}
}
fn ui_tui(paths: &Paths) -> Result<i32> {
struct Ctx<'a> {
paths: &'a Paths,
last_sessions: Vec<MenuSession>,
pre_switch_first: bool,
}
fn run_self(args: &[&str]) -> (bool, String) {
let exe = match std::env::current_exe() {
Ok(e) => e,
Err(e) => return (false, format!("cannot find own binary: {e}")),
};
match Command::new(exe)
.args(args)
.stdin(std::process::Stdio::null())
.output()
{
Ok(out) => {
let mut text = String::from_utf8_lossy(&out.stdout).into_owned();
text.push_str(&String::from_utf8_lossy(&out.stderr));
let mut msg = text
.lines()
.filter(|l| !l.trim().is_empty())
.collect::<Vec<_>>()
.join(" | ");
if msg.chars().count() > 160 {
msg = msg.chars().take(159).collect::<String>() + "…";
}
(out.status.success(), msg)
}
Err(e) => (false, format!("failed: {e}")),
}
}
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);
store
.list()
.iter()
.map(|p| {
let (email, tier, marker) = profile_summary(&store, &p.name, &p.tools);
let at: Vec<&str> = active
.iter()
.filter(|(_, n)| n == &p.name)
.map(|(t, _)| *t)
.collect();
crate::tui::Row {
name: p.name.clone(),
ident: identity_column(email, tier),
tools: p
.tools
.iter()
.map(|t| {
if at.contains(&t.as_str()) {
format!("{t}*")
} else {
t.clone()
}
})
.collect::<Vec<_>>()
.join(", "),
active: !at.is_empty(),
warn: marker,
}
})
.collect()
}
fn switch(&mut self, name: &str) -> (bool, String) {
self.pre_switch_first = crate::session_link::read_timeline(self.paths).is_empty();
run_self(&["use", name])
}
fn restore(&mut self) -> String {
run_self(&["restore"]).1
}
fn delete(&mut self, name: &str) -> String {
match Store::open(self.paths).and_then(|s| s.remove(name)) {
Ok(true) => format!("removed profile '{name}' (the live login stays)"),
Ok(false) => format!("no profile named '{name}'"),
Err(e) => format!("delete failed: {e}"),
}
}
fn rename(&mut self, old: &str, new: &str) -> (bool, String) {
if !crate::store::valid_profile_name(new) || new == "-" {
return (false, format!("'{new}' can't be a profile name"));
}
let store = match Store::open(self.paths) {
Ok(s) => s,
Err(e) => return (false, format!("cannot open store: {e}")),
};
let _lock = match store.lock() {
Ok(g) => g,
Err(_) => return (false, "another swapdex is busy; try again".into()),
};
if store.profile_dir_exists(new) {
return (false, format!("a profile named '{new}' already exists"));
}
match store.rename(old, new) {
Ok(true) => (true, format!("renamed '{old}' -> '{new}'")),
Ok(false) => (false, format!("no profile named '{old}'")),
Err(e) => (false, format!("rename failed: {e:#}")),
}
}
fn save_current(&mut self, name: &str) -> (bool, String) {
run_self(&["add", name])
}
fn doctor(&mut self) -> Vec<String> {
let exe = match std::env::current_exe() {
Ok(e) => e,
Err(e) => return vec![format!("cannot find own binary: {e}")],
};
match Command::new(exe)
.arg("doctor")
.stdin(std::process::Stdio::null())
.output()
{
Ok(out) => {
let mut text = String::from_utf8_lossy(&out.stdout).into_owned();
text.push_str(&String::from_utf8_lossy(&out.stderr));
text.lines().map(|l| l.to_string()).collect()
}
Err(e) => vec![format!("doctor failed: {e}")],
}
}
fn live_tools(&mut self) -> Vec<String> {
adapters::all()
.iter()
.filter(|a| a.present(self.paths))
.map(|a| pretty_tool(a.name()).to_string())
.collect()
}
fn sessions(&mut self, name: &str) -> (String, Vec<crate::tui::SessionEntry>) {
let first_time = self.pre_switch_first;
let (sessions, label) = recent_menu_sessions(self.paths, name, first_time, 5);
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)
}
}
let mut ctx = Ctx {
paths,
last_sessions: Vec::new(),
pre_switch_first: crate::session_link::read_timeline(paths).is_empty(),
};
loop {
if Store::open(paths)?.list().is_empty()
&& adapters::all().iter().all(|a| !a.present(paths))
{
println!("No accounts saved yet, and you're not logged into any tool.");
println!(
" sign in to Claude Code / Codex / Gemini / Antigravity, then run `swapdex`."
);
return Ok(0);
}
match crate::tui::run(&mut ctx)? {
crate::tui::Outcome::Quit => return Ok(0),
crate::tui::Outcome::OpenSession(i) => {
let Some(sess) = ctx.last_sessions.get(i) else {
return Ok(0);
};
return Err(exec_menu_resume(sess));
}
crate::tui::Outcome::NewConv { tool, dir } => {
println!("opening {}...", pretty_tool(tool));
return Err(exec_tool(tool, dir.as_deref()));
}
crate::tui::Outcome::AddAccount(tool) => {
let sel = match tool {
"claude-code" => Some(ToolSel::Claude),
"codex" => Some(ToolSel::Codex),
"gemini" => Some(ToolSel::Gemini),
_ => Some(ToolSel::Antigravity),
};
let who = adapters::by_name(tool)
.and_then(|a| a.identity(paths).ok().flatten())
.and_then(|id| id.email)
.unwrap_or_else(|| "account".into());
let store = Store::open(paths)?;
let Some(name) = ask_name(
&store,
&format!("name for the new account [{}]: ", suggest_name(&who)),
&suggest_name(&who),
) else {
continue;
};
drop(store);
let rc = login(paths, &name, sel)?;
if rc != 0 {
return Ok(rc);
}
println!("(press Enter to go back to the picker)");
let _ = prompt("", "");
}
}
}
}
fn exec_tool(tool: &str, dir: Option<&std::path::Path>) -> anyhow::Error {
use std::os::unix::process::CommandExt;
let bin = match tool {
"claude-code" => "claude",
"codex" => "codex",
"gemini" => "gemini",
"antigravity" => "agy",
other => return anyhow::anyhow!("unknown tool '{other}'"),
};
let mut cmd = Command::new(bin);
if let Some(d) = dir {
cmd.current_dir(d);
}
let err = cmd.exec();
anyhow::anyhow!("could not launch `{bin}`: {err}")
}
fn exec_sessionwiki_resume(id: &str) -> anyhow::Error {
use std::os::unix::process::CommandExt;
let err = Command::new("sessionwiki")
.args(["resume", "--no-sync", "--", id])
.exec();
anyhow::anyhow!("could not launch `sessionwiki resume {id}`: {err}")
}
pub fn doctor(paths: &Paths) -> Result<i32> {
use std::os::unix::fs::PermissionsExt;
let pre_mode = std::fs::metadata(paths.store_dir())
.ok()
.map(|m| m.permissions().mode() & 0o777);
let store = Store::open(paths)?;
let mut problems = 0u32;
let color = crate::util::color_enabled();
let mut report = |label: &str, ok: bool, msg: String| {
let verdict = match (ok, color) {
(true, true) => "\x1b[32mok\x1b[0m".to_string(),
(false, true) => "\x1b[31mproblem\x1b[0m".to_string(),
(true, false) => "ok".to_string(),
(false, false) => "problem".to_string(),
};
println!("{label:<13} {verdict} - {msg}");
if !ok {
problems += 1;
}
};
let sd = paths.store_dir();
let profiles = store.list();
let count = format!(
"{} profile{}",
profiles.len(),
if profiles.len() == 1 { "" } else { "s" }
);
match (pre_mode, std::fs::metadata(&sd)) {
(Some(m), Ok(now)) if m & 0o077 != 0 && now.permissions().mode() & 0o077 == 0 => report(
"store",
true,
format!("was mode {m:03o} - tightened to 0700 just now; {count}"),
),
(_, Ok(now)) if now.permissions().mode() & 0o077 != 0 => report(
"store",
false,
format!(
"directory is group/world-accessible; run `chmod 700 {}`",
crate::util::redact_path(&sd.display().to_string())
),
),
(_, Ok(_)) => report("store", true, format!("0700, {count}")),
(_, Err(e)) => report("store", false, format!("cannot stat store dir: {e}")),
}
for adapter in adapters::all() {
let tool = adapter.name();
match adapter.identity(paths) {
Ok(Some(id)) => {
let saved = matched_profile_name(&store, tool, &id.account_id)
.map(|n| format!("profile '{n}'"))
.unwrap_or_else(|| "not saved - `swapdex add <name>` keeps it".into());
report(
tool,
true,
format!("live login {} ({saved})", identity_line(&id)),
);
}
Ok(None) => match macos_keychain_note(paths, tool) {
Some(note) => report(tool, true, format!("not manageable - {note}")),
None => report(tool, true, "not logged in".into()),
},
Err(_) => report(
tool,
false,
"live login file unreadable; `swapdex use <profile>` can replace it, \
or log in again in the tool"
.into(),
),
}
}
for f in [
paths.claude_credentials(),
paths.codex_auth(),
paths.gemini_oauth(),
paths.antigravity_token(),
] {
if let Ok(meta) = std::fs::metadata(&f) {
use std::os::unix::fs::PermissionsExt;
if meta.permissions().mode() & 0o077 != 0 {
report(
"perms",
false,
format!(
"{} is group/world-readable (holds tokens); run `chmod 600` on it",
crate::util::redact_path(&f.display().to_string())
),
);
}
}
}
if paths.claude_config_json().exists() {
if let Ok(bytes) = std::fs::read(paths.claude_config_json()) {
if serde_json::from_slice::<Value>(&bytes).is_err() {
report(
"claude-config",
false,
format!(
"{} is not valid JSON - claude switches will fail until it is \
repaired or removed (removing loses local settings like \
project trust)",
crate::util::redact_path(&paths.claude_config_json().display().to_string())
),
);
}
}
}
if let Ok(meta) = std::fs::metadata(paths.claude_config_json()) {
if meta.permissions().mode() & 0o077 != 0 {
report(
"claude-config",
false,
format!(
"{} is group/world-readable; run `chmod 600` on it",
crate::util::redact_path(&paths.claude_config_json().display().to_string())
),
);
}
}
for p in &profiles {
for tool in &p.tools {
match profile_detail(&store, &p.name, tool) {
Some((_, _, Some("unreadable"))) => report(
&format!("profile:{}", p.name),
false,
format!(
"{tool} snapshot unreadable; log in to that account and run \
`swapdex add {} --tool {tool} --update`",
p.name
),
),
Some((_, _, Some(m))) => report(
&format!("profile:{}", p.name),
true,
format!(
"{tool} snapshot {m} - log in to that account and run \
`swapdex add {} --tool {tool} --update`",
p.name
),
),
_ => {}
}
}
}
let mut kept = Vec::new();
for tool in ["claude-code", "codex", "gemini", "antigravity"] {
if let Ok(Some((stamp, _))) = store.load_backup(tool) {
kept.push(format!("{tool} (newest {})", age_line(stamp)));
}
}
if kept.is_empty() {
report(
"backups",
true,
"none yet (one is taken on every `use`; `swapdex restore` brings it back)".into(),
);
} else {
report("backups", true, format!("intact - {}", kept.join(", ")));
}
let mut found = Vec::new();
for cli in ["claude", "codex", "gemini", "agy"] {
if command_exists(cli) {
found.push(cli);
}
}
report(
"tools",
true,
if found.is_empty() {
"none of `claude`, `codex`, `gemini`, `agy` found on PATH".into()
} else {
format!("on PATH: {}", found.join(", "))
},
);
if problems > 0 {
println!(
"\n{problems} problem{} found - each line above ends with its fix.",
if problems == 1 { "" } else { "s" }
);
return Ok(9);
}
println!("\neverything looks healthy.");
Ok(0)
}
pub fn rm(paths: &Paths, name: &str, yes: bool) -> Result<i32> {
if let Some(c) = reject_bad_name(name) {
return Ok(c);
}
let store = Store::open(paths)?;
if !store.list().iter().any(|p| p.name == name) {
eprintln!("swapdex: no profile named '{name}'");
return Ok(5);
}
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 mid-switch; try again");
return Ok(4);
}
Err(crate::store::LockError::Unwritable(e)) => {
eprintln!(
"swapdex: the store is not writable ({e}) - check permissions/mount of \
the store directory"
);
return Ok(4);
}
};
if !store.remove(name)? {
eprintln!("swapdex: no profile named '{name}'");
return Ok(5);
}
println!("removed profile '{name}' (any live login it matched keeps running, now unsaved)");
Ok(0)
}
pub fn rename(paths: &Paths, old: &str, new: &str) -> Result<i32> {
if let Some(c) = reject_bad_name(old) {
return Ok(c);
}
if let Some(c) = reject_bad_name(new).or_else(|| reject_reserved_name(new)) {
return Ok(c);
}
let store = Store::open(paths)?;
let _lock = match store.lock() {
Ok(g) => g,
Err(crate::store::LockError::Busy) => {
eprintln!("swapdex: another swapdex is mid-switch; try again");
return Ok(4);
}
Err(crate::store::LockError::Unwritable(e)) => {
eprintln!(
"swapdex: the store is not writable ({e}) - check permissions/mount of \
the store directory"
);
return Ok(4);
}
};
if !store.list().iter().any(|p| p.name == old) {
eprintln!("swapdex: no profile named '{old}'");
return Ok(5);
}
if store.profile_dir_exists(new) {
eprintln!("swapdex: a profile named '{new}' already exists");
return Ok(6);
}
if store.rename(old, new)? {
println!("renamed profile '{old}' -> '{new}'");
Ok(0)
} else {
eprintln!("swapdex: no profile named '{old}'");
Ok(5)
}
}
pub fn login(paths: &Paths, name: &str, sel: Option<ToolSel>) -> Result<i32> {
crate::atomic::ensure_not_root()?;
if let Some(c) = reject_bad_name(name).or_else(|| reject_reserved_name(name)) {
return Ok(c);
}
let tool = match sel {
Some(ToolSel::Claude) => "claude-code",
Some(ToolSel::Codex) => "codex",
Some(ToolSel::Gemini) => "gemini",
Some(ToolSel::Antigravity) => "antigravity",
_ => {
use std::io::IsTerminal;
let tty =
std::io::stdin().is_terminal() || std::env::var_os("SWAPDEX_ASSUME_TTY").is_some();
if !tty {
eprintln!("swapdex: say which tool: swapdex login {name} --tool <claude|codex|gemini|antigravity>");
return Ok(2);
}
println!("Which tool do you want to log '{name}' into?");
println!(" 1) Claude Code 2) Codex 3) Gemini CLI 4) Antigravity");
loop {
match prompt("pick [1-4] (Enter cancels): ", "").as_deref() {
Some("1") => break "claude-code",
Some("2") => break "codex",
Some("3") => break "gemini",
Some("4") => break "antigravity",
Some("") | None => {
println!("cancelled.");
return Ok(0);
}
_ => println!("pick a number between 1 and 4 (Enter cancels)"),
}
}
}
};
let bin = match tool {
"claude-code" => "claude",
"codex" => "codex",
"gemini" => "gemini",
_ => "agy",
};
if !command_exists(bin) {
eprintln!("swapdex: `{bin}` isn't on your PATH. Install it, then retry.");
return Ok(3);
}
let adapter = adapters::by_name(tool).expect("known tool");
let flag = pretty_tool_flag(tool);
let Some(cur) = adapter.identity(paths).ok().flatten() else {
println!(
"Opening {} to sign in. Complete the login{}",
pretty_tool(tool),
if tool == "codex" {
" in your browser.".to_string()
} else {
", then exit it.".to_string()
}
);
spawn_tool_login(bin, tool)?;
if adapter.identity(paths).ok().flatten().is_none() {
eprintln!(
"swapdex: no {} login was completed - nothing saved.",
pretty_tool(tool)
);
return Ok(8);
}
println!();
return add(paths, Some(name), sel_for_tool(tool), true);
};
use std::io::IsTerminal;
let tty = std::io::stdin().is_terminal() || std::env::var_os("SWAPDEX_ASSUME_TTY").is_some();
if !tty {
println!(
"You're already logged into {} ({}).",
pretty_tool(tool),
identity_line(&cur)
);
println!(" save the current account: swapdex add {name} --tool {flag}");
println!(
" add a DIFFERENT account: swapdex login {name} --tool {flag} (on a terminal)"
);
return Ok(3);
}
println!("Currently logged in as {}.", identity_line(&cur));
if !yes_no(
&format!(
"Sign in to a DIFFERENT account as '{name}'? swapdex will save the \
current login, sign you out locally, and open {} for the \
new sign-in. [Y/n]: ",
pretty_tool(tool)
),
true,
) {
println!("cancelled - nothing changed.");
return Ok(0);
}
let store = Store::open(paths)?;
let _lock = match store.lock() {
Ok(g) => g,
Err(crate::store::LockError::Busy) => {
eprintln!("swapdex: another swapdex is mid-switch; try again");
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 stash = adapter.capture(paths)?;
store.backup(&stash)?;
for pname in matching_profile_names(&store, tool, &cur.account_id) {
store.save(&pname, &stash)?;
}
if matched_profile_name(&store, tool, &cur.account_id).is_none() {
let suggestion = match &cur.email {
Some(e) => suggest_name(e),
None => "main".to_string(),
};
while let Some(keep) = ask_name(
&store,
&format!("name to keep the CURRENT account under [{suggestion}]: "),
&suggestion,
) {
if keep == name {
println!("'{name}' is the name for the NEW account - pick another.");
continue;
}
store.save(&keep, &stash)?;
println!("saved current login as '{keep}'.");
break;
}
}
sign_out_locally(paths, tool);
println!(
"Opening {} - sign in with the OTHER account{}",
pretty_tool(tool),
if tool == "codex" {
" in your browser.".to_string()
} else {
", then exit it.".to_string()
}
);
println!(" tip: {}", same_account_hint(tool));
let spawn = spawn_tool_login(bin, tool);
let new_id = adapter.identity(paths).ok().flatten();
match (spawn, new_id) {
(Ok(status), Some(new)) if !new.account_id.is_empty() => {
if new.account_id == cur.account_id {
adapter.apply(paths, &stash)?;
eprintln!(
"swapdex: you were signed back into the SAME account ({}), so \
nothing was saved as '{name}'.",
identity_line(&new)
);
eprintln!(" {}", same_account_hint(tool));
eprintln!(
" (to just save THIS account under a name, use `swapdex add {name} \
--tool {}`.)",
pretty_tool_flag(tool)
);
return Ok(0);
}
let has_tool_snapshot = store
.list()
.iter()
.any(|p| p.name == name && p.tools.iter().any(|t| t == tool));
let same_account = profile_account_id(&store, name, tool)
.filter(|s| !s.is_empty())
.as_deref()
== Some(new.account_id.as_str());
if has_tool_snapshot
&& !same_account
&& !yes_no(
&format!(
"profile '{name}' already holds a different (or unreadable) \
{tool} account. Repoint it to this new login? [y/N]: "
),
false,
)
{
if let Some(rescue) = ask_name(
&store,
"save the NEW account under a different name instead (Enter discards it): ",
"",
) {
if rescue != name {
let snap = adapter.capture(paths)?;
store.save(&rescue, &snap)?;
println!(
"saved profile '{rescue}' ({}). '{name}' is untouched.",
identity_line(&new)
);
println!("switch back any time: swapdex use <name> (or `swapdex ui`)");
return Ok(0);
}
}
adapter.apply(paths, &stash)?;
println!(
"the new sign-in was DISCARDED and your previous login restored - \
'{name}' is untouched. Re-run `swapdex login <other-name>` to \
redo it under another name."
);
return Ok(0);
}
let snap = adapter.capture(paths)?;
store.save(name, &snap)?;
println!("saved profile '{name}' ({}).", identity_line(&new));
if tool == "antigravity" {
println!(
"note: Antigravity stores no account identity on disk - swapdex \
cannot confirm WHICH Google account this is; verify inside agy."
);
}
if !status.success() {
println!(
"note: {} exited with an error after signing in - if anything \
looks off, `swapdex restore --tool {flag}` undoes this.",
pretty_tool(tool)
);
}
println!("switch back any time: swapdex use <name> (or `swapdex ui`)");
Ok(0)
}
_ => {
adapter.apply(paths, &stash)?;
eprintln!(
"swapdex: no new {} login was completed - your previous \
login ({}) was restored.",
pretty_tool(tool),
identity_line(&cur)
);
Ok(8)
}
}
}
fn sel_for_tool(tool: &str) -> Option<ToolSel> {
match tool {
"claude-code" => Some(ToolSel::Claude),
"codex" => Some(ToolSel::Codex),
"gemini" => Some(ToolSel::Gemini),
"antigravity" => Some(ToolSel::Antigravity),
_ => None,
}
}
fn spawn_tool_login(bin: &str, tool: &str) -> Result<std::process::ExitStatus> {
unsafe extern "C" fn ride_out(_: libc::c_int) {}
#[allow(function_casts_as_integer)]
let prev_int = unsafe { libc::signal(libc::SIGINT, ride_out as libc::sighandler_t) };
#[allow(function_casts_as_integer)]
let prev_quit = unsafe { libc::signal(libc::SIGQUIT, ride_out as libc::sighandler_t) };
let mut cmd = Command::new(bin);
if tool == "codex" {
cmd.arg("login");
}
let status = cmd.status();
unsafe {
libc::signal(libc::SIGINT, prev_int);
libc::signal(libc::SIGQUIT, prev_quit);
}
status.map_err(|e| anyhow::anyhow!("could not run {bin}: {e}"))
}
fn sign_out_locally(paths: &Paths, tool: &str) {
match tool {
"claude-code" => {
std::fs::remove_file(paths.claude_credentials()).ok();
if let Ok(bytes) = std::fs::read(paths.claude_config_json()) {
if let Ok(mut cfg) = serde_json::from_slice::<Value>(&bytes) {
if let Some(obj) = cfg.as_object_mut() {
obj.remove("oauthAccount");
if let Ok(out) = serde_json::to_vec(&cfg) {
let _ = crate::atomic::write_secret(&paths.claude_config_json(), &out);
}
}
}
}
}
"codex" => {
std::fs::remove_file(paths.codex_auth()).ok();
}
"gemini" => {
std::fs::remove_file(paths.gemini_oauth()).ok();
std::fs::remove_file(paths.gemini_accounts()).ok();
}
_ => {
std::fs::remove_file(paths.antigravity_token()).ok();
}
}
}
fn prompt(question: &str, default: &str) -> Option<String> {
use std::io::Write;
print!("{question}");
let _ = std::io::stdout().flush();
let mut line = String::new();
match std::io::stdin().read_line(&mut line) {
Ok(0) | Err(_) => return None, Ok(_) => {}
}
let t = line.trim();
Some(if t.is_empty() {
default.to_string()
} else {
t.to_string()
})
}
fn suggest_name(who: &str) -> String {
let base = who.split('@').next().unwrap_or(who);
let clean: String = base
.chars()
.filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_')
.collect();
if crate::store::valid_profile_name(&clean) {
clean
} else {
"account".to_string()
}
}
fn same_account_hint(tool: &str) -> String {
match tool {
"claude-code" => "To add a different account: sign out at claude.ai in your browser \
first (or use /logout then /login inside Claude Code and pick the other \
account), then run this again."
.to_string(),
"codex" => "Codex re-used your ChatGPT browser session. Sign out at chatgpt.com \
(or open the login in a different browser / private window), then run this \
again."
.to_string(),
_ => "The tool re-used your signed-in Google account. Choose the OTHER account at \
Google's account picker (or sign the first one out in your browser), then \
run this again."
.to_string(),
}
}
fn pretty_tool(tool: &str) -> &str {
match tool {
"claude-code" => "Claude Code",
"codex" => "Codex",
"gemini" => "Gemini CLI",
"antigravity" => "Antigravity",
other => other,
}
}
fn yes_no(question: &str, default_yes: bool) -> bool {
let Some(a) = prompt(question, if default_yes { "y" } else { "n" }) else {
return false;
};
matches!(a.to_ascii_lowercase().as_str(), "y" | "yes")
}
fn ask_name(store: &Store, question: &str, default: &str) -> Option<String> {
loop {
let ans = prompt(question, default)?; if ans.eq_ignore_ascii_case("skip") || ans.is_empty() {
return None;
}
if !crate::store::valid_profile_name(&ans) {
println!(" '{ans}' can't be a name (1-64 bytes, not all spaces; no '/', '\\\\', leading '.', or control chars). Try again.");
continue;
}
if 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)? {
Some(id) => id,
None => {
println!("{}: not logged in - skipping.\n", pretty_tool(tool));
continue;
}
};
if let Some(existing) = matched_profile_name(&store, tool, &id.account_id) {
println!("{}: already saved as '{existing}'.\n", pretty_tool(tool));
continue;
}
let who = id.email.clone().unwrap_or_else(|| id.display.clone());
let default = suggest_name(&who);
println!("{}: you're logged in as {who}.", pretty_tool(tool));
if let Some(p) = store.list().into_iter().find(|p| p.name == default) {
if !p.tools.iter().any(|t| t == tool) {
match adapter.capture(paths) {
Ok(snap) => {
store.save(&default, &snap)?;
println!(" attached {} to '{default}'.\n", pretty_tool(tool));
}
Err(e) => {
eprintln!(" could not read this login ({e:#}) - skipped.\n");
}
}
continue;
}
}
match ask_name(
&store,
&format!(" save it as [{default}] (Enter to accept, 'skip' to skip): "),
&default,
) {
Some(name) => match adapter.capture(paths) {
Ok(snap) => {
store.save(&name, &snap)?;
println!(" saved as '{name}'.\n");
}
Err(e) => {
eprintln!(" could not read this login ({e:#}) - skipped.\n");
}
},
None => println!(" skipped.\n"),
}
}
println!("You can keep several accounts per tool (e.g. work and personal).");
loop {
if !yes_no(" add another account now? [y/N]: ", false) {
break;
}
println!(" which tool? 1) Claude Code 2) Codex 3) Gemini CLI 4) Antigravity");
let sel = loop {
match prompt(" pick [1-4] (Enter cancels): ", "").as_deref() {
Some("1") => break Some(ToolSel::Claude),
Some("2") => break Some(ToolSel::Codex),
Some("3") => break Some(ToolSel::Gemini),
Some("4") => break Some(ToolSel::Antigravity),
Some("") | None => break None,
_ => println!(" pick a number between 1 and 4 (Enter cancels)"),
}
};
let Some(sel) = sel else {
println!(" skipped.\n");
continue;
};
let name = match ask_name(&store, " name for it (e.g. personal): ", "") {
Some(n) => n,
None => {
println!(" skipped.\n");
continue;
}
};
let _ = login(paths, &name, Some(sel))?;
println!();
}
let names: Vec<String> = store.list().into_iter().map(|p| p.name).collect();
println!();
if names.is_empty() {
println!(
"No accounts saved yet. Log into Claude Code or Codex, then run `swapdex setup` again."
);
} else {
println!("You're set - saved: {}.", names.join(", "));
println!(" switch: swapdex use <name>");
println!(" see all: swapdex ls");
if names.len() > 1 {
println!("Switching takes effect on your next message - no restart needed.");
}
}
Ok(0)
}
fn now_ms() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0)
}
fn now_secs() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
pub fn usage(paths: &Paths, json: bool) -> Result<i32> {
let rows = crate::usage::tool_usage(paths);
if json {
let out: Vec<Value> = rows
.iter()
.map(|r| {
let accounts: serde_json::Map<String, Value> = r
.accounts
.iter()
.map(|(name, (t5, t7))| {
(
name.clone(),
serde_json::json!({"last_5h_tokens": t5, "last_7d_tokens": t7}),
)
})
.collect();
serde_json::json!({
"tool": r.tool,
"last_5h": {"sessions": r.w5h.sessions, "tokens": r.w5h.tokens},
"last_7d": {"sessions": r.w7d.sessions, "tokens": r.w7d.tokens},
"accounts": accounts,
})
})
.collect();
println!("{}", serde_json::to_string(&out)?);
return Ok(0);
}
if rows.iter().all(|r| r.w7d.sessions == 0) {
println!("No recent session activity found (reads ~/.claude and ~/.codex, locally).");
return Ok(0);
}
println!("Local usage - this machine, approximate (not the billed quota):");
for r in &rows {
if r.w7d.sessions == 0 {
continue;
}
println!(
" {:<12} 5h: {:>7} tok / {} sess 7d: {:>8} tok / {} sess",
r.tool,
crate::usage::human(r.w5h.tokens),
r.w5h.sessions,
crate::usage::human(r.w7d.tokens),
r.w7d.sessions,
);
for (name, (t5, t7)) in &r.accounts {
println!(
" @{:<11} 5h: {:>7} tok 7d: {:>8} tok",
name,
crate::usage::human(*t5),
crate::usage::human(*t7),
);
}
let attributed7: u64 = r.accounts.values().map(|(_, t7)| *t7).sum();
let rest = r.w7d.tokens.saturating_sub(attributed7);
if !r.accounts.is_empty() && rest > 0 {
println!(
" {:<12} 5h: 7d: {:>8} tok (before your first switch)",
"(untagged)",
crate::usage::human(rest),
);
}
}
let uncovered: Vec<&str> = ["gemini", "antigravity"]
.into_iter()
.filter(|t| {
adapters::by_name(t)
.map(|a| a.present(paths))
.unwrap_or(false)
})
.collect();
if !uncovered.is_empty() {
println!(
"note: {} not shown - those CLIs keep no local token transcripts to read",
uncovered.join(" and ")
);
}
println!("(summed locally from session transcripts; accounts via the switch timeline)");
Ok(0)
}
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 see sessions grouped by account)"
);
}
Some(counts) if counts.is_empty() => {
println!("no sessions found (sessionwiki index empty - run `sessionwiki sync` once)");
}
Some(counts) => {
for (account, n) in &counts {
println!("{:<20} {n}", account);
}
}
}
Ok(0)
}
fn identity_line(id: &Account) -> String {
let who = id.email.clone().unwrap_or_else(|| id.display.clone());
match &id.tier {
Some(t) => format!("{who} [{t}]"),
None => who,
}
}
fn expiry_note(expires_at: Option<i64>) -> String {
match expires_at {
Some(ms) if ms < now_ms() => " - access token expired, may re-prompt".to_string(),
_ => String::new(),
}
}
fn warn_if_expired(target: &crate::adapters::Snapshot, tool: &str) {
if tool != "claude-code" {
return;
}
if let Some(cred) = target.part("credentials") {
if let Ok(v) = serde_json::from_slice::<Value>(cred.expose()) {
if let Some(ms) = v["claudeAiOauth"]["expiresAt"].as_i64() {
if ms < now_ms() {
eprintln!("swapdex: note - this saved login's access token expired; the tool may re-prompt for login");
}
}
}
}
}