mod actions;
mod claude;
mod completions;
mod fallback;
mod lock;
mod menu;
mod oauth;
mod platform;
mod profile;
mod ui;
mod update;
mod ureq_error;
mod usage;
use std::collections::HashMap;
use std::io;
use std::sync::{Arc, Mutex};
use std::time::SystemTime;
use anyhow::Result;
use crossterm::cursor::MoveTo;
use crossterm::execute;
use crossterm::terminal::{Clear, ClearType};
use inquire::{Confirm, InquireError};
use crate::actions::{capture_current_profile, create_blank_profile, is_cancelled, switch_profile};
use crate::claude::{
credentials_diverged, detach_credentials_link, link_profile_credentials,
read_claude_credentials, snapshot_active_credentials,
};
use crate::fallback::auto_switch_if_needed;
use crate::menu::{
MainAction, MainMenuResult, build_main_menu, fallback_chain_menu, main_menu_prompt,
profile_submenu,
};
use crate::profile::{AppConfig, Profile, app_state_mtime, load_config, save_app_state};
use crate::ui::build_render_config;
fn collect_tokens(profiles: &[Profile]) -> Vec<(String, String)> {
profiles
.iter()
.filter_map(|p| {
let token = p
.credentials
.as_ref()?
.claude_ai_oauth
.as_ref()?
.access_token
.clone();
Some((p.name.clone(), token))
})
.collect()
}
fn apply_usage(profiles: &mut [Profile], store: &usage::UsageStore, status: &usage::StatusStore) {
let info_map = store.lock().ok();
let status_map = status.lock().ok();
for p in profiles {
if let Some(s) = info_map.as_ref()
&& let Some(info) = s.get(&p.name)
{
p.usage = Some(info.clone());
}
p.fetch_status = status_map.as_ref().and_then(|s| s.get(&p.name).copied());
}
}
fn reconcile_startup_credentials(config: &mut AppConfig) -> Result<()> {
let Some(active) = config.state.active_profile.clone() else {
return Ok(());
};
let live = read_claude_credentials().ok().flatten();
let stored = config.find(&active).and_then(|p| p.credentials.as_ref());
if !credentials_diverged(stored, live.as_ref()) {
let _ = snapshot_active_credentials(config);
return Ok(());
}
let keep = match Confirm::new(&format!("Still logged in as '{active}'?"))
.with_default(true)
.with_help_message("~/.claude/.credentials.json differs from this profile's saved tokens.")
.prompt()
{
Ok(b) => b,
Err(InquireError::OperationCanceled | InquireError::OperationInterrupted) => true,
Err(e) => return Err(e.into()),
};
if keep {
let _ = snapshot_active_credentials(config);
return Ok(());
}
detach_credentials_link()?;
config.state.active_profile = None;
save_app_state(&config.state)?;
let capture = match Confirm::new("Capture current credentials as a new profile?")
.with_default(true)
.prompt()
{
Ok(b) => b,
Err(InquireError::OperationCanceled | InquireError::OperationInterrupted) => false,
Err(e) => return Err(e.into()),
};
if capture
&& let Err(e) = capture_current_profile(config)
&& !is_cancelled(&e)
{
return Err(e);
}
Ok(())
}
fn reload_if_state_changed(config: &mut AppConfig, last_seen: &mut Option<SystemTime>) -> bool {
let current = app_state_mtime();
if current == *last_seen {
return false;
}
match load_config() {
Ok(fresh) => {
*config = fresh;
*last_seen = current;
true
}
Err(_) => false,
}
}
fn main() -> Result<()> {
let args: Vec<String> = std::env::args().skip(1).collect();
match args.as_slice() {
[cmd, shell] if cmd == "completions" => return completions::print_script(shell),
[cmd] if cmd == "__complete" => {
completions::print_profile_names();
return Ok(());
}
[name] => {
platform::init();
let mut config = load_config()?;
let canonical = config
.names()
.into_iter()
.find(|n| n.eq_ignore_ascii_case(name))
.map(str::to_string);
let Some(canonical) = canonical else {
let available = config.names().join(", ");
anyhow::bail!("profile '{name}' not found\navailable: {available}");
};
let _ = oauth::refresh_all(&mut config);
switch_profile(&mut config, &canonical)?;
println!("switched to '{canonical}'");
return Ok(());
}
[] => {}
_ => anyhow::bail!("usage: clauth [profile] | clauth completions <bash|zsh|fish>"),
}
platform::init();
completions::auto_install_once();
update::spawn();
inquire::set_global_render_config(build_render_config());
let mut config = load_config()?;
reconcile_startup_credentials(&mut config)?;
if let Some(active) = config.state.active_profile.clone() {
let _ = link_profile_credentials(&active);
}
let _ = oauth::refresh_all(&mut config);
let usage_store: usage::UsageStore = Arc::new(Mutex::new(HashMap::new()));
let usage_status: usage::StatusStore = Arc::new(Mutex::new(HashMap::new()));
let usage_tokens: usage::TokenList = Arc::new(Mutex::new(collect_tokens(&config.profiles)));
{
let snapshot = usage_tokens
.lock()
.expect("usage_tokens mutex poisoned")
.clone();
usage::fetch_all_into(&snapshot, &usage_store, &usage_status);
}
let kicked = oauth::kick_missing_timers(&mut config, &usage_store);
if !kicked.is_empty() {
let retry: Vec<(String, String)> = collect_tokens(&config.profiles)
.into_iter()
.filter(|(name, _)| kicked.contains(name))
.collect();
usage::fetch_all_into(&retry, &usage_store, &usage_status);
*usage_tokens.lock().expect("usage_tokens mutex poisoned") =
collect_tokens(&config.profiles);
}
usage::spawn_refresher(
Arc::clone(&usage_tokens),
Arc::clone(&usage_store),
Arc::clone(&usage_status),
);
apply_usage(&mut config.profiles, &usage_store, &usage_status);
let _ = auto_switch_if_needed(&mut config);
let mut last_state_mtime = app_state_mtime();
loop {
apply_usage(&mut config.profiles, &usage_store, &usage_status);
let menu = build_main_menu(&config);
let labels: Vec<String> = menu.iter().map(|(l, _)| l.clone()).collect();
let idx = match main_menu_prompt(labels, || {
if reload_if_state_changed(&mut config, &mut last_state_mtime) {
*usage_tokens.lock().expect("usage_tokens mutex poisoned") =
collect_tokens(&config.profiles);
}
apply_usage(&mut config.profiles, &usage_store, &usage_status);
let _ = auto_switch_if_needed(&mut config);
build_main_menu(&config)
.into_iter()
.map(|(label, _)| label)
.collect()
})? {
MainMenuResult::Selected(idx) => idx,
MainMenuResult::Cancelled => break,
};
let menu_row = crossterm::cursor::position().map(|(_, r)| r).ok();
let result = match menu[idx].1 {
MainAction::Quit => break,
MainAction::NewBlank => create_blank_profile(&mut config),
MainAction::Capture => capture_current_profile(&mut config),
MainAction::FallbackChain => fallback_chain_menu(&mut config),
MainAction::Profile(i) => {
let name = config.profiles[i].name.clone();
profile_submenu(&mut config, &name)
}
};
if let Err(e) = result
&& !is_cancelled(&e)
{
return Err(e);
}
if let Some(row) = menu_row {
let _ = execute!(
io::stdout(),
MoveTo(0, row),
Clear(ClearType::FromCursorDown)
);
}
*usage_tokens.lock().expect("usage_tokens mutex poisoned") =
collect_tokens(&config.profiles);
}
let _ = snapshot_active_credentials(&mut config);
let _ = detach_credentials_link();
Ok(())
}