use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::time::SystemTime;
use anyhow::{Context, Result};
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use crate::lock::with_state_lock;
use crate::providers::{Provider, ThirdPartyStats};
use crate::usage::{FetchStatus, UsageInfo};
#[derive(Debug, Clone, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub(crate) struct ProfileName(String);
impl ProfileName {
pub(crate) fn as_str(&self) -> &str {
&self.0
}
}
impl std::ops::Deref for ProfileName {
type Target = str;
fn deref(&self) -> &str {
&self.0
}
}
impl std::borrow::Borrow<str> for ProfileName {
fn borrow(&self) -> &str {
&self.0
}
}
impl AsRef<str> for ProfileName {
fn as_ref(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for ProfileName {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl From<String> for ProfileName {
fn from(s: String) -> Self {
Self(s)
}
}
impl From<&str> for ProfileName {
fn from(s: &str) -> Self {
Self(s.to_string())
}
}
impl PartialEq<str> for ProfileName {
fn eq(&self, other: &str) -> bool {
self.0 == other
}
}
impl PartialEq<&str> for ProfileName {
fn eq(&self, other: &&str) -> bool {
self.0 == *other
}
}
impl PartialEq<ProfileName> for str {
fn eq(&self, other: &ProfileName) -> bool {
self == other.0
}
}
impl PartialEq<String> for ProfileName {
fn eq(&self, other: &String) -> bool {
&self.0 == other
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ClaudeCredentials {
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) claude_ai_oauth: Option<OAuthToken>,
}
impl ClaudeCredentials {
pub(crate) fn refresh_token(&self) -> Option<&str> {
self.claude_ai_oauth.as_ref()?.refresh_token.as_deref()
}
pub(crate) fn access_token(&self) -> Option<&str> {
Some(self.claude_ai_oauth.as_ref()?.access_token.as_str())
}
pub(crate) fn access_token_expires_at(&self) -> Option<i64> {
self.claude_ai_oauth.as_ref()?.expires_at
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct OAuthToken {
pub(crate) access_token: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) refresh_token: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) expires_at: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) scopes: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) subscription_type: Option<String>,
}
#[derive(Debug, Clone)]
pub(crate) struct Profile {
pub(crate) name: ProfileName,
pub(crate) base_url: Option<String>,
pub(crate) api_key: Option<String>,
pub(crate) auto_start: bool,
pub(crate) env: BTreeMap<String, String>,
pub(crate) fallback_threshold: Option<f64>,
pub(crate) credentials: Option<ClaudeCredentials>,
pub(crate) usage: Option<UsageInfo>,
pub(crate) fetch_status: Option<FetchStatus>,
pub(crate) provider: Option<Provider>,
pub(crate) third_party_usage: Option<ThirdPartyStats>,
}
impl Profile {
pub(crate) fn new(name: String, base_url: Option<String>, api_key: Option<String>) -> Self {
let provider = base_url.as_deref().and_then(Provider::from_base_url);
Self {
name: name.into(),
base_url,
api_key,
auto_start: false,
env: BTreeMap::new(),
fallback_threshold: None,
credentials: None,
usage: None,
fetch_status: None,
provider,
third_party_usage: None,
}
}
pub(crate) fn is_oauth(&self) -> bool {
self.base_url.is_none()
}
pub(crate) fn is_third_party(&self) -> bool {
self.provider.is_some()
}
pub(crate) fn refresh_token(&self) -> Option<&str> {
self.credentials.as_ref()?.refresh_token()
}
pub(crate) fn access_token(&self) -> Option<&str> {
self.credentials.as_ref()?.access_token()
}
pub(crate) fn access_token_expires_at(&self) -> Option<i64> {
self.credentials.as_ref()?.access_token_expires_at()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub(crate) enum ThemeName {
Full,
Compatible,
}
#[derive(Debug, Serialize, Deserialize, Default)]
pub(crate) struct AppState {
pub(crate) active_profile: Option<ProfileName>,
pub(crate) profiles: Vec<ProfileName>,
#[serde(default)]
pub(crate) fallback_chain: Vec<ProfileName>,
#[serde(default)]
pub(crate) wrap_off: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) theme: Option<ThemeName>,
}
pub(crate) struct AppConfig {
pub(crate) state: AppState,
pub(crate) profiles: Vec<Profile>,
}
pub(crate) type ConfigHandle =
std::sync::Arc<crate::lockorder::RankedMutex<AppConfig, crate::lockorder::rank::Config>>;
impl AppConfig {
pub(crate) fn is_active(&self, name: &str) -> bool {
self.state.active_profile.as_deref() == Some(name)
}
pub(crate) fn find(&self, name: &str) -> Option<&Profile> {
self.profiles.iter().find(|p| p.name == name)
}
pub(crate) fn find_mut(&mut self, name: &str) -> Option<&mut Profile> {
self.profiles.iter_mut().find(|p| p.name == name)
}
pub(crate) fn names(&self) -> Vec<&str> {
self.profiles.iter().map(|p| p.name.as_str()).collect()
}
pub(crate) fn canonical_name(&self, query: &str) -> Option<String> {
self.names()
.into_iter()
.find(|n| n.eq_ignore_ascii_case(query))
.map(str::to_string)
}
pub(crate) fn add(&mut self, profile: Profile) {
self.state.profiles.push(profile.name.clone());
self.profiles.push(profile);
}
pub(crate) fn remove(&mut self, name: &str) {
self.profiles.retain(|p| p.name != name);
self.state.profiles.retain(|n| n.as_str() != name);
self.state.fallback_chain.retain(|n| n.as_str() != name);
if self.is_active(name) {
self.state.active_profile = None;
}
}
pub(crate) fn sync_state_profiles(&mut self) {
self.state.profiles = self.profiles.iter().map(|p| p.name.clone()).collect();
}
pub(crate) fn rename_all_occurrences(&mut self, old: &str, new: &str) {
if let Some(profile) = self.find_mut(old) {
profile.name = new.into();
}
if let Some(slot) = self.state.profiles.iter_mut().find(|n| n.as_str() == old) {
*slot = new.into();
}
if let Some(slot) = self
.state
.fallback_chain
.iter_mut()
.find(|n| n.as_str() == old)
{
*slot = new.into();
}
if self.is_active(old) {
self.state.active_profile = Some(new.into());
}
}
}
#[derive(Debug, Serialize, Deserialize, Default, PartialEq)]
struct ProfileConfig {
base_url: Option<String>,
api_key: Option<String>,
#[serde(default, alias = "kick_timer")]
auto_start: bool,
#[serde(default)]
env: BTreeMap<String, String>,
#[serde(default)]
fallback_threshold: Option<f64>,
}
#[cfg(test)]
static HOME_OVERRIDE: std::sync::Mutex<Option<PathBuf>> = std::sync::Mutex::new(None);
#[cfg(test)]
pub(crate) static HOME_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[cfg(test)]
pub(crate) fn set_home_override(path: PathBuf) {
if let Ok(mut guard) = HOME_OVERRIDE.lock() {
*guard = Some(path);
}
}
#[cfg(test)]
pub(crate) fn clear_home_override() {
if let Ok(mut guard) = HOME_OVERRIDE.lock() {
*guard = None;
}
}
pub(crate) fn home_dir() -> Result<PathBuf> {
#[cfg(test)]
if let Some(path) = HOME_OVERRIDE.lock().ok().and_then(|g| g.clone()) {
return Ok(path);
}
dirs::home_dir().context("Cannot determine home directory")
}
pub(crate) fn clauth_dir() -> Result<PathBuf> {
Ok(home_dir()?.join(".clauth"))
}
pub(crate) fn claude_dir() -> Result<PathBuf> {
Ok(home_dir()?.join(".claude"))
}
pub(crate) fn app_state_mtime() -> Option<SystemTime> {
let path = app_state_path().ok()?;
std::fs::metadata(&path).ok()?.modified().ok()
}
fn profiles_root() -> Result<PathBuf> {
Ok(clauth_dir()?.join("profiles"))
}
fn app_state_path() -> Result<PathBuf> {
Ok(clauth_dir()?.join("profiles.toml"))
}
pub(crate) fn profile_dir(name: &str) -> Result<PathBuf> {
Ok(profiles_root()?.join(name))
}
pub(crate) fn profile_subpath(name: &str, sub: &str) -> Result<PathBuf> {
Ok(profile_dir(name)?.join(sub))
}
fn profile_config_path(name: &str) -> Result<PathBuf> {
profile_subpath(name, "config.toml")
}
fn profile_credentials_path(name: &str) -> Result<PathBuf> {
profile_subpath(name, "credentials.json")
}
fn profile_credentials_pending_path(name: &str) -> Result<PathBuf> {
profile_subpath(name, "credentials.json.pending")
}
pub(crate) fn atomic_write(path: &Path, content: impl AsRef<[u8]>) -> std::io::Result<()> {
let dir = path.parent().unwrap_or_else(|| Path::new("."));
if !dir.exists() {
std::fs::create_dir_all(dir)?;
}
let file_name = path
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "file".to_string());
let tmp = dir.join(format!(".{file_name}.tmp.{}", std::process::id()));
std::fs::write(&tmp, content)?;
match std::fs::rename(&tmp, path) {
Ok(()) => Ok(()),
Err(e) => {
let _ = std::fs::remove_file(&tmp);
Err(e)
}
}
}
pub(crate) fn read_json_file<T: DeserializeOwned>(path: &Path) -> Result<T> {
let content = std::fs::read_to_string(path)
.with_context(|| format!("Failed to read {}", path.display()))?;
serde_json::from_str(&content).with_context(|| format!("Failed to parse {}", path.display()))
}
pub(crate) fn read_toml_file<T: DeserializeOwned>(path: &Path) -> Result<T> {
let content = std::fs::read_to_string(path)
.with_context(|| format!("Failed to read {}", path.display()))?;
toml::from_str(&content).with_context(|| format!("Failed to parse {}", path.display()))
}
fn load_app_state() -> Result<AppState> {
let path = app_state_path()?;
if !path.exists() {
return Ok(AppState::default());
}
read_toml_file(&path)
}
pub(crate) fn save_app_state(state: &AppState) -> Result<()> {
with_state_lock(|| {
std::fs::create_dir_all(clauth_dir()?)?;
atomic_write(&app_state_path()?, toml::to_string_pretty(state)?)
.context("Failed to write profiles.toml")
})
}
fn load_profile(name: &str) -> Result<Profile> {
let config_path = profile_config_path(name)?;
let raw_config = match std::fs::read_to_string(&config_path) {
Ok(s) => s,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
Err(e) => return Err(e).with_context(|| format!("Failed to read {name}/config.toml")),
};
let config: ProfileConfig = if raw_config.trim().is_empty() {
ProfileConfig::default()
} else {
toml::from_str(&raw_config)
.with_context(|| format!("Failed to parse {name}/config.toml"))?
};
let cred_path = profile_credentials_path(name)?;
let credentials = if cred_path.exists() {
Some(read_json_file(&cred_path)?)
} else {
None
};
let credentials = recover_pending_credentials(name, credentials);
let provider = config.base_url.as_deref().and_then(Provider::from_base_url);
let third_party_usage = provider
.as_ref()
.and_then(|_| crate::providers::load_third_party_disk_cache(name));
let profile = Profile {
name: name.into(),
base_url: config.base_url,
api_key: config.api_key,
auto_start: config.auto_start,
env: config.env,
fallback_threshold: config.fallback_threshold,
credentials,
usage: None,
fetch_status: None,
provider,
third_party_usage,
};
maybe_rewrite_config_toml(&config_path, &raw_config, &profile);
Ok(profile)
}
fn maybe_rewrite_config_toml(config_path: &Path, raw_config: &str, profile: &Profile) {
let rendered = render_config_toml(profile);
let needs_rewrite = match toml::from_str::<ProfileConfig>(&rendered) {
Ok(canonical) => {
let on_disk = ProfileConfig {
base_url: profile.base_url.clone(),
api_key: profile.api_key.clone(),
auto_start: profile.auto_start,
env: profile.env.clone(),
fallback_threshold: profile.fallback_threshold,
};
canonical != on_disk
}
Err(_) => raw_config != rendered,
};
if needs_rewrite {
let _ = with_state_lock(|| {
let _ = atomic_write(config_path, &rendered);
Ok(())
});
}
}
pub(crate) fn save_profile(profile: &Profile) -> Result<()> {
with_state_lock(|| {
std::fs::create_dir_all(profile_dir(&profile.name)?)?;
let cred_path = profile_credentials_path(&profile.name)?;
match &profile.credentials {
Some(creds) => atomic_write(&cred_path, serde_json::to_string_pretty(creds)?)
.context("Failed to write credentials.json")?,
None if cred_path.exists() => {
std::fs::remove_file(&cred_path).context("Failed to remove credentials.json")?
}
None => {}
}
atomic_write(
&profile_config_path(&profile.name)?,
render_config_toml(profile),
)
.context("Failed to write config.toml")?;
Ok(())
})
}
pub(crate) fn stage_rotated_credentials(name: &str, creds: &ClaudeCredentials) -> Result<()> {
with_state_lock(|| {
std::fs::create_dir_all(profile_dir(name)?)?;
atomic_write(
&profile_credentials_pending_path(name)?,
serde_json::to_string_pretty(creds)?,
)
.context("Failed to stage rotated credentials")
})
}
pub(crate) fn clear_staged_credentials(name: &str) {
if let Ok(path) = profile_credentials_pending_path(name) {
let _ = std::fs::remove_file(path);
}
}
fn recover_pending_credentials(
name: &str,
loaded: Option<ClaudeCredentials>,
) -> Option<ClaudeCredentials> {
let Ok(pending_path) = profile_credentials_pending_path(name) else {
return loaded;
};
let Ok(pending_meta) = pending_path.symlink_metadata() else {
return loaded; };
let recovered = (|| -> Option<ClaudeCredentials> {
let bytes = std::fs::read(&pending_path).ok()?;
let pending: ClaudeCredentials = serde_json::from_slice(&bytes).ok()?;
pending.claude_ai_oauth.as_ref()?; let cred_path = profile_credentials_path(name).ok()?;
let adopt = match cred_path.metadata().and_then(|m| m.modified()) {
Ok(cred_mtime) => pending_meta
.modified()
.map(|p| p >= cred_mtime)
.unwrap_or(true),
Err(_) => true,
};
if !adopt {
return None;
}
let _ = with_state_lock(|| atomic_write(&cred_path, &bytes).map_err(Into::into));
Some(pending)
})();
let _ = std::fs::remove_file(&pending_path);
recovered.or(loaded)
}
pub(crate) fn load_config() -> Result<AppConfig> {
std::fs::create_dir_all(profiles_root()?)?;
let state = load_app_state()?;
let profiles = state
.profiles
.iter()
.map(|n| load_profile(n))
.collect::<Result<Vec<_>>>()?;
Ok(AppConfig { state, profiles })
}
fn render_config_toml(profile: &Profile) -> String {
fn toml_str(s: &str) -> String {
toml::Value::String(s.to_string()).to_string()
}
let mut out = String::from("# clauth profile configuration\n\n");
out.push_str("# Base URL for an API-endpoint profile. Leave commented for an OAuth\n");
out.push_str("# (Pro / Max / Team / Enterprise) profile.\n");
match profile.base_url.as_deref() {
Some(v) => out.push_str(&format!("base_url = {}\n", toml_str(v))),
None => out.push_str("# base_url = \"https://api.anthropic.com\"\n"),
}
out.push('\n');
out.push_str("# API key for the endpoint. Only used when base_url is set.\n");
match profile.api_key.as_deref() {
Some(v) => out.push_str(&format!("api_key = {}\n", toml_str(v))),
None => out.push_str("# api_key = \"sk-ant-...\"\n"),
}
out.push('\n');
out.push_str("# Auto-start the 5-hour usage window for this profile. clauth fires a\n");
out.push_str("# 1-token Haiku ping at launch and on every 30s refresh while there's\n");
out.push_str("# no running window. ~0.001¢ per ping. OAuth profiles only.\n");
out.push_str("# Old name `kick_timer = true` is still accepted.\n");
if profile.auto_start {
out.push_str("auto_start = true\n");
} else {
out.push_str("# auto_start = true\n");
}
out.push('\n');
out.push_str("# 5-hour utilization percentage at/above which clauth will auto-switch\n");
out.push_str("# off this profile, provided the profile is also a member of the\n");
out.push_str("# fallback chain configured in ~/.clauth/profiles.toml. Range 0..=100.\n");
match profile.fallback_threshold {
Some(v) => out.push_str(&format!("fallback_threshold = {v}\n")),
None => out.push_str("# fallback_threshold = 95.0\n"),
}
out.push('\n');
out.push_str("# Extra env vars merged into ~/.claude/settings.json's env block while\n");
out.push_str("# this profile is active. Cleared on switch to another profile.\n");
if profile.env.is_empty() {
out.push_str("# [env]\n");
out.push_str("# HTTP_PROXY = \"http://localhost:8080\"\n");
} else {
out.push_str("[env]\n");
for (k, v) in &profile.env {
out.push_str(&format!("{k} = {}\n", toml_str(v)));
}
}
out
}
#[cfg(test)]
#[path = "../tests/inline/profile.rs"]
mod tests;