use crate::adapters::{self, Account, AuthTool};
use crate::paths::Paths;
use crate::store::Store;
use anyhow::Result;
use serde_json::Value;
#[derive(Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
pub enum ToolSel {
#[value(alias = "claude-code")]
Claude,
Codex,
Both,
}
impl ToolSel {
fn wants(self, tool: &str) -> bool {
match self {
ToolSel::Claude => tool == "claude-code",
ToolSel::Codex => tool == "codex",
ToolSel::Both => 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))
}
fn profile_account_id(store: &Store, name: &str, tool: &str) -> Option<String> {
let snap = store.load(name, tool).ok()??;
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())
}
_ => None,
}
}
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 reject_bad_name(name: &str) -> Option<i32> {
if crate::store::valid_profile_name(name) {
None
} else {
eprintln!("swapdex: invalid profile name '{name}' (no '/', '\\', '..', leading '.', or control chars)");
Some(2)
}
}
pub fn add(paths: &Paths, name: &str, sel: Option<ToolSel>, update: bool) -> Result<i32> {
crate::atomic::ensure_not_root()?;
if let Some(c) = reject_bad_name(name) {
return Ok(c);
}
let store = Store::open(paths)?;
let _lock = match store.lock() {
Ok(g) => g,
Err(_) => {
eprintln!("swapdex: another swapdex is mid-switch; try again");
return Ok(4);
}
};
let mut saved = Vec::new();
let mut skipped = 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}");
return Ok(3);
}
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 = adapter.capture(paths)?;
store.save(name, &snap)?;
saved.push(tool);
}
if saved.is_empty() {
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(", "));
Ok(0)
}
pub fn use_account(paths: &Paths, name: &str, sel: Option<ToolSel>, dry_run: bool) -> Result<i32> {
crate::atomic::ensure_not_root()?;
if let Some(c) = reject_bad_name(name) {
return Ok(c);
}
let store = Store::open(paths)?;
let _lock = match store.lock() {
Ok(g) => g,
Err(_) => {
eprintln!("swapdex: another swapdex is mid-switch; try again");
return Ok(4);
}
};
let mut matched = 0; let mut changed = 0; for adapter in selected_adapters(sel) {
let tool = adapter.name();
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);
}
continue;
}
};
matched += 1;
let live_id = adapter
.identity(paths)?
.map(|i| i.account_id)
.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");
continue;
}
warn_if_expired(&target, tool);
if dry_run {
println!("would switch {tool} -> {name}");
continue;
}
if adapter.present(paths) {
let live = adapter.capture(paths)?;
store.backup(&live)?;
}
adapter.apply(paths, &target)?;
store.append_timeline(tool, name, "use")?;
if let Some(id) = adapter.identity(paths)? {
println!("switched {tool} -> {}", identity_line(&id));
}
changed += 1;
}
if matched == 0 {
eprintln!("swapdex: no profile named '{name}'");
return Ok(5);
}
if changed > 0 {
println!("(takes effect on your next message)");
}
Ok(0)
}
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()??;
match tool {
"claude-code" => {
let creds: Value = serde_json::from_slice(snap.part("credentials")?.expose()).ok()?;
let oauth: Value = serde_json::from_slice(snap.part("oauth_account")?.expose()).ok()?;
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 auth: Value = serde_json::from_slice(snap.part("auth")?.expose()).ok()?;
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))
}
_ => 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 t in tools {
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 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) -> 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 = 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 saved profiles yet - run `swapdex add <name>` while logged in");
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| r.name.len())
.max()
.unwrap_or(4)
.clamp(4, 24);
let ident_w = rows
.iter()
.map(|r| r.ident.len())
.max()
.unwrap_or(0)
.clamp(0, 40);
let mut saw_marker = false;
for r in &rows {
let mark = if r.active { "* " } else { " " };
let warn = r.warn.map(|m| format!(" ({m})")).unwrap_or_default();
saw_marker |= r.warn.is_some();
println!(
"{mark}{:<name_w$} {:<ident_w$} [{}]{warn}",
r.name, r.ident, r.tools
);
}
if saw_marker {
println!(
" (expired/stale: re-run `swapdex add --update <name>` while logged in to refresh)"
);
}
if active
.iter()
.map(|(_, n)| n)
.collect::<std::collections::HashSet<_>>()
.len()
> 1
{
println!(" (* marks the active account per tool)");
}
Ok(0)
}
pub fn status(paths: &Paths, json: bool) -> Result<i32> {
let store = Store::open(paths)?;
if json {
let rows: Vec<Value> = adapters::all()
.iter()
.map(|adapter| {
let tool = adapter.name();
match adapter.identity(paths).ok().flatten() {
None => serde_json::json!({"tool": tool, "logged_in": false}),
Some(id) => serde_json::json!({
"tool": tool,
"logged_in": true,
"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)? {
None => println!("{tool}: not logged in"),
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 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 !yes {
eprintln!("swapdex: `rm {name}` deletes the saved profile. Re-run with --yes to confirm.");
return Ok(7);
}
let _lock = match store.lock() {
Ok(g) => g,
Err(_) => {
eprintln!("swapdex: another swapdex is mid-switch; try again");
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) {
return Ok(c);
}
let store = Store::open(paths)?;
if store.rename(old, new)? {
println!("renamed profile '{old}' -> '{new}'");
Ok(0)
} else {
eprintln!("swapdex: no profile named '{old}'");
Ok(5)
}
}
fn now_ms() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0)
}
pub fn sessions(paths: &Paths) -> Result<i32> {
match crate::session_link::sessions_by_account(paths) {
None => {
println!("session data unavailable (install sessionwiki for `sessions --by-account`)");
}
Some(counts) if counts.is_empty() => {
println!("no sessions found");
}
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");
}
}
}
}
}