use std::collections::{BTreeMap, HashMap};
use std::path::{Path, PathBuf};
use std::time::SystemTime;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use crate::lock::with_state_lock;
use crate::usage::{FetchStatus, UsageInfo};
#[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()
}
}
#[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: String,
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>,
}
impl Profile {
pub(crate) fn new(name: String, base_url: Option<String>, api_key: Option<String>) -> Self {
Self {
name,
base_url,
api_key,
auto_start: false,
env: BTreeMap::new(),
fallback_threshold: None,
credentials: None,
usage: None,
fetch_status: None,
}
}
pub(crate) fn is_oauth(&self) -> bool {
self.base_url.is_none()
}
pub(crate) fn refresh_token(&self) -> Option<&str> {
self.credentials.as_ref()?.refresh_token()
}
}
#[derive(Debug, Serialize, Deserialize, Default)]
pub(crate) struct AppState {
pub(crate) active_profile: Option<String>,
pub(crate) profiles: Vec<String>,
#[serde(default, alias = "last_kick_at", rename = "last_kick_at")]
pub(crate) last_auto_start_at: HashMap<String, u64>,
#[serde(default)]
pub(crate) fallback_chain: Vec<String>,
}
pub(crate) struct AppConfig {
pub(crate) state: AppState,
pub(crate) profiles: Vec<Profile>,
}
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 != name);
self.state.fallback_chain.retain(|n| n != name);
if self.is_active(name) {
self.state.active_profile = None;
}
}
}
#[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>,
}
pub(crate) fn home_dir() -> Result<PathBuf> {
dirs::home_dir().context("Cannot determine home directory")
}
pub(crate) fn clauth_dir() -> Result<PathBuf> {
Ok(home_dir()?.join(".clauth"))
}
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))
}
fn profile_config_path(name: &str) -> Result<PathBuf> {
Ok(profile_dir(name)?.join("config.toml"))
}
fn profile_credentials_path(name: &str) -> Result<PathBuf> {
Ok(profile_dir(name)?.join("credentials.json"))
}
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)
}
}
}
fn load_app_state() -> Result<AppState> {
let path = app_state_path()?;
if !path.exists() {
return Ok(AppState::default());
}
let content = std::fs::read_to_string(&path).context("Failed to read profiles.toml")?;
toml::from_str(&content).context("Failed to parse profiles.toml")
}
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() {
let content = std::fs::read_to_string(&cred_path)
.with_context(|| format!("Failed to read {name}/credentials.json"))?;
Some(
serde_json::from_str(&content)
.with_context(|| format!("Failed to parse {name}/credentials.json"))?,
)
} else {
None
};
let profile = Profile {
name: name.to_string(),
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,
};
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(())
});
}
Ok(profile)
}
pub(crate) fn save_profile(profile: &Profile) -> Result<()> {
with_state_lock(|| {
std::fs::create_dir_all(profile_dir(&profile.name)?)?;
atomic_write(
&profile_config_path(&profile.name)?,
render_config_toml(profile),
)
.context("Failed to write config.toml")?;
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 => {}
}
Ok(())
})
}
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;