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 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,
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 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())
}
_ => 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 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}");
if let Some(note) = macos_keychain_note(paths, tool) {
eprintln!("swapdex: note - {note}");
}
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(", "));
if name.contains(char::is_whitespace) {
println!(
"note: the name has spaces - quote it in later commands (`swapdex use \"{name}\"`)"
);
}
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;
let running = if dry_run {
Vec::new()
} else {
crate::proc::running_process_names()
};
let switch_ts = now_secs();
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);
}
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");
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 {
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"
),
}
}
adapter.apply(paths, &target).map_err(|e| {
e.context(format!(
"profile '{name}' has a bad {tool} snapshot - log in to that account \
and re-save it with `swapdex add {name} --tool {tool} --update`"
))
})?;
store.append_timeline_at(tool, name, "use", switch_ts)?;
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)");
}
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(_) => {
eprintln!("swapdex: another swapdex is mid-switch; try again");
return Ok(4);
}
};
let running = if dry_run {
Vec::new()
} else {
crate::proc::running_process_names()
};
let last_switch = last_switch_tools(paths);
let restore_ts = now_secs();
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;
}
}
}
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) => store.backup(&live)?,
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_at(tool, &event_name, "restore", restore_ts)?;
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 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)> = 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()) {
events.push((ts, tool.to_string()));
}
}
let max_ts = events.iter().map(|(ts, _)| *ts).max()?;
let mut tools: Vec<String> = events
.into_iter()
.filter(|(ts, _)| *ts == max_ts)
.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))
}
_ => 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 fit(s: &str, w: usize) -> String {
let n = s.chars().count();
if n <= w {
let mut out = String::from(s);
out.extend(std::iter::repeat_n(' ', w - n));
out
} else {
let mut out: String = s.chars().take(w.saturating_sub(1)).collect();
out.push('…');
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) -> 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 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| r.name.chars().count())
.max()
.unwrap_or(4)
.clamp(4, 24);
let ident_w = rows
.iter()
.map(|r| r.ident.chars().count())
.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 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) {
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 doctor(paths: &Paths) -> Result<i32> {
use std::os::unix::fs::PermissionsExt;
let store = Store::open(paths)?;
let mut problems = 0u32;
let mut report = |label: &str, ok: bool, msg: String| {
println!(
"{:<13} {} - {msg}",
label,
if ok { "ok" } else { "problem" }
);
if !ok {
problems += 1;
}
};
let sd = paths.store_dir();
let profiles = store.list();
match std::fs::metadata(&sd) {
Ok(m) if m.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, {} profile{}",
profiles.len(),
if profiles.len() == 1 { "" } else { "s" }
),
),
Err(e) => report("store", false, format!("cannot stat store dir: {e}")),
}
for adapter in adapters::all() {
let tool = adapter.name();
match adapter.identity(paths) {
Ok(Some(id)) => {
let saved = matched_profile_name(&store, tool, &id.account_id)
.map(|n| format!("profile '{n}'"))
.unwrap_or_else(|| "not saved - `swapdex add <name>` keeps it".into());
report(
tool,
true,
format!("live login {} ({saved})", identity_line(&id)),
);
}
Ok(None) => match macos_keychain_note(paths, tool) {
Some(note) => report(tool, true, format!("not manageable - {note}")),
None => report(tool, true, "not logged in".into()),
},
Err(_) => report(
tool,
false,
"live login file unreadable; `swapdex use <profile>` can replace it, \
or log in again in the tool"
.into(),
),
}
}
if let 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} - `swapdex add {} --update` refreshes it",
p.name
),
),
_ => {}
}
}
}
let mut kept = Vec::new();
for tool in ["claude-code", "codex"] {
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"] {
if command_exists(cli) {
found.push(cli);
}
}
report(
"tools",
true,
if found.is_empty() {
"neither `claude` nor `codex` 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 !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)?;
let _lock = match store.lock() {
Ok(g) => g,
Err(_) => {
eprintln!("swapdex: another swapdex is mid-switch; try again");
return Ok(4);
}
};
if store.list().iter().any(|p| p.name == 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) {
return Ok(c);
}
let tool = match sel {
Some(ToolSel::Claude) => "claude-code",
Some(ToolSel::Codex) => "codex",
_ if command_exists("codex") => "codex",
_ => "claude-code",
};
if tool == "claude-code" {
if !command_exists("claude") {
eprintln!("swapdex: Claude Code isn't on your PATH. Install it, then:");
eprintln!(" 1) run `claude` and complete the login");
eprintln!(" 2) then: swapdex add {name} --tool claude");
return Ok(3);
}
let claude = adapters::by_name("claude-code");
let already = claude
.as_ref()
.and_then(|c| c.identity(paths).ok().flatten())
.is_some();
if already {
println!("You're already logged into Claude Code.");
println!(" save the current account: swapdex add {name} --tool claude");
println!(" or switch to another account first: run `claude`, use /logout then");
println!(
" /login with the other account, exit, then `swapdex add {name} --tool claude`."
);
return Ok(0);
}
println!(
"Opening Claude Code to sign in. Complete the login, then exit it (Ctrl-D or /exit)."
);
Command::new("claude")
.status()
.map_err(|e| anyhow::anyhow!("could not run claude: {e}"))?;
let logged_in = adapters::by_name("claude-code")
.and_then(|c| c.identity(paths).ok().flatten())
.is_some();
if !logged_in {
eprintln!("swapdex: no Claude login was completed - nothing saved.");
return Ok(8);
}
println!();
return add(paths, name, Some(ToolSel::Claude), true);
}
if !command_exists("codex") {
eprintln!("swapdex: the `codex` CLI is not on your PATH - install it, then retry.");
return Ok(3);
}
if let Some(codex) = adapters::by_name("codex") {
if codex.present(paths) {
if let Ok(snap) = codex.capture(paths) {
let _ = Store::open(paths).and_then(|s| s.backup(&snap));
}
}
}
println!("Opening `codex login` - sign in with the account to save as '{name}'.");
println!("(If it says you're already logged in, run `codex logout` first, then retry.)");
let status = Command::new("codex")
.arg("login")
.status()
.map_err(|e| anyhow::anyhow!("could not run codex login: {e}"))?;
if !status.success() {
eprintln!("swapdex: codex login did not complete - nothing saved.");
return Ok(8);
}
println!();
add(paths, name, Some(ToolSel::Codex), true)
}
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 pretty_tool(tool: &str) -> &str {
match tool {
"claude-code" => "Claude Code",
"codex" => "Codex",
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 (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 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));
match ask_name(
&store,
&format!(" save it as [{default}] (Enter to accept, 'skip' to skip): "),
&default,
) {
Some(name) => {
let snap = adapter.capture(paths)?;
store.save(&name, &snap)?;
println!(" saved as '{name}'.\n");
}
None => println!(" skipped.\n"),
}
}
if command_exists("codex") {
println!("You can keep several Codex accounts (e.g. work and personal).");
while yes_no(" add another Codex account now? [y/N]: ", false) {
let name = match ask_name(&store, " name for it (e.g. personal): ", "") {
Some(n) => n,
None => {
println!(" skipped.\n");
continue;
}
};
println!(
" This logs out of the current Codex account and opens a fresh browser login."
);
println!(" (Your current login is backed up first, so nothing is lost.)");
if !yes_no(" continue? [y/N]: ", false) {
println!(" cancelled.\n");
continue;
}
if let Some(codex) = adapters::by_name("codex") {
if codex.present(paths) {
if let Ok(s) = codex.capture(paths) {
let _ = store.backup(&s);
}
}
}
let _ = Command::new("codex").arg("logout").status();
println!(" opening codex login - complete the sign-in in your browser...");
let ok = Command::new("codex")
.arg("login")
.status()
.map(|s| s.success())
.unwrap_or(false);
if !ok {
println!(" login didn't finish; nothing saved.\n");
continue;
}
if let Some(codex) = adapters::by_name("codex") {
if codex.present(paths) {
let snap = codex.capture(paths)?;
store.save(&name, &snap)?;
println!(" saved '{name}'.\n");
}
}
}
}
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| {
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},
})
})
.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,
);
}
println!("(tokens are summed from local session transcripts; not tagged by account)");
Ok(0)
}
pub fn sessions(paths: &Paths) -> Result<i32> {
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");
}
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");
}
}
}
}
}