use std::collections::BTreeMap;
use std::sync::Arc;
use anyhow::{Context, Result, bail};
use crate::claude::{
ClaudeEndpoint, LinkState, apply_profile_to_claude_settings, classify_credentials_link,
clear_claude_credentials, force_link_profile_credentials, force_snapshot_active_credentials,
is_first_login, link_profile_credentials, managed_env_key_label, read_claude_credentials,
read_claude_endpoint_config, snapshot_active_credentials,
};
use crate::lock::with_state_lock;
use crate::lockorder::RankedMutex;
use crate::oauth;
use crate::profile::{
AppConfig, ClaudeCredentials, DivergenceChoice, ModelSettings, Profile, profile_dir,
save_app_state, save_profile,
};
use crate::providers::Provider;
use crate::spinner::Spinner;
pub(crate) fn validate_profile_name(
name: &str,
existing: &[&str],
exclude: Option<&str>,
) -> Result<()> {
let trimmed = name.trim();
if trimmed.is_empty() {
bail!("Name cannot be empty.");
}
let valid_chars = trimmed
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '@' | '+'));
if !valid_chars || trimmed.starts_with('.') {
bail!(
"Name must contain only letters, digits, '-', '_', '.', '@', or '+', and cannot start with '.'."
);
}
if existing
.iter()
.any(|&n| n.eq_ignore_ascii_case(trimmed) && Some(n) != exclude)
{
bail!("A profile named '{trimmed}' already exists.");
}
Ok(())
}
pub(crate) fn switch_profile(config: &mut AppConfig, name: &str) -> Result<()> {
with_state_lock(|| {
if config.is_active(name) {
return Ok(());
}
let uncaptured_relogin = match config.state.active_profile.as_deref() {
Some(active) => {
matches!(classify_credentials_link(active)?, LinkState::Diverged)
&& !is_first_login(active)?
}
None => false,
};
snapshot_active_credentials(config)?;
if uncaptured_relogin {
link_profile_credentials(name)?;
} else {
force_link_profile_credentials(name)?;
}
finish_switch(config, name)
})
}
pub(crate) fn switch_profile_discard(config: &mut AppConfig, target: &str) -> Result<()> {
with_state_lock(|| {
if config.is_active(target) {
return Ok(());
}
force_link_profile_credentials(target)?;
finish_switch(config, target)
})
}
pub(crate) fn switch_profile_reconciled(config: &mut AppConfig, name: &str) -> Result<()> {
with_state_lock(|| {
if config.is_active(name) {
return Ok(());
}
force_snapshot_active_credentials(config)?;
force_link_profile_credentials(name)?;
finish_switch(config, name)
})
}
pub(crate) fn switch_profile_cli(config: AppConfig, canonical: &str) -> Result<()> {
let outgoing = config.state.active_profile.clone();
let reconciled = match outgoing.as_deref() {
Some(active) => {
matches!(classify_credentials_link(active)?, LinkState::Diverged)
&& !is_first_login(active)?
}
None => false,
};
let config = Arc::new(RankedMutex::new(config));
if reconciled {
let active = {
#[allow(clippy::expect_used, reason = "mutex poisoning is unrecoverable")]
let cfg = config.lock().expect("config mutex poisoned");
cfg.state
.active_profile
.as_deref()
.unwrap_or("")
.to_string()
};
print!(
"active profile '{active}' has uncaptured credentials in ~/.claude \
(a re-login or token rotation). capture them into '{active}' and \
switch to '{canonical}'? [Y/n] "
);
use std::io::Write;
std::io::stdout().flush()?;
let mut answer = String::new();
std::io::stdin().read_line(&mut answer)?;
let answer = answer.trim().to_ascii_lowercase();
if answer.is_empty() || answer == "y" || answer == "yes" {
#[allow(clippy::expect_used, reason = "mutex poisoning is unrecoverable")]
let mut cfg = config.lock().expect("config mutex poisoned");
switch_profile_reconciled(&mut cfg, canonical)?;
} else {
println!("aborted — no changes made");
return Ok(());
}
} else {
#[allow(clippy::expect_used, reason = "mutex poisoning is unrecoverable")]
let mut cfg = config.lock().expect("config mutex poisoned");
switch_profile(&mut cfg, canonical)?;
}
{
let _spinner = Spinner::start("clauth: priming usage window");
let _ = oauth::prime_window(&config, canonical);
}
println!("switched to '{canonical}'");
Ok(())
}
pub(crate) fn switch_profile_noninteractive(
config: &mut AppConfig,
target: &str,
on_divergence: Option<DivergenceChoice>,
) -> Result<(Option<String>, String)> {
let previous = config.state.active_profile.as_deref().map(str::to_string);
let diverged = match previous.as_deref() {
Some(active) => {
matches!(classify_credentials_link(active)?, LinkState::Diverged)
&& !is_first_login(active)?
}
None => false,
};
if diverged {
match on_divergence {
Some(DivergenceChoice::Overwrite) => switch_profile_reconciled(config, target)?,
Some(DivergenceChoice::Discard) => switch_profile_discard(config, target)?,
Some(DivergenceChoice::NewProfile) => bail!(
"active profile has a divergent login and the divergence default is \
'save as new profile', which needs an interactive name prompt; \
resolve it in the clauth TUI"
),
None => bail!(
"active profile has uncaptured credentials in ~/.claude and no \
divergence default is set; resolve it in the clauth TUI"
),
}
} else {
switch_profile(config, target)?;
}
Ok((previous, target.to_string()))
}
pub(crate) fn switch_off(config: &mut AppConfig) -> Result<()> {
with_state_lock(|| {
if config.state.active_profile.is_none() {
return Ok(());
}
snapshot_active_credentials(config)?;
clear_claude_credentials()?;
crate::claude_json::strip_home_oauth_account()?;
config.state.active_profile = None;
save_app_state(&config.state)
})
}
fn finish_switch(config: &mut AppConfig, name: &str) -> Result<()> {
let prev_env_keys: Vec<String> = config
.state
.active_profile
.as_deref()
.and_then(|n| config.find(n))
.map(|p| p.env.keys().cloned().collect())
.unwrap_or_default();
let profile = config.find(name).context("Profile not found")?;
apply_profile_to_claude_settings(profile, &prev_env_keys)?;
crate::claude_json::strip_home_oauth_account()?;
config.state.active_profile = Some(name.into());
save_app_state(&config.state)
}
pub(crate) fn edit_profile_endpoint(
config: &mut AppConfig,
name: &str,
base_url: Option<String>,
api_key: Option<String>,
) -> Result<()> {
with_state_lock(|| {
let profile = config.find_mut(name).context("Profile not found")?;
let old_api_key = profile.api_key.clone();
profile.base_url = base_url;
profile.api_key = api_key;
let provider = profile
.base_url
.as_deref()
.and_then(crate::providers::Provider::from_base_url);
if provider != profile.provider || (provider.is_some() && profile.api_key != old_api_key) {
profile.third_party_usage = None;
}
profile.provider = provider;
save_profile(profile)?;
if config.is_active(name) {
let profile = config.find(name).context("Profile not found")?;
let prev_env_keys: Vec<String> = profile.env.keys().cloned().collect();
apply_profile_to_claude_settings(profile, &prev_env_keys)?;
}
Ok(())
})
}
pub(crate) fn edit_profile_model(
config: &mut AppConfig,
name: &str,
models: ModelSettings,
) -> Result<()> {
with_state_lock(|| {
let profile = config.find_mut(name).context("Profile not found")?;
profile.models = models;
save_profile(profile)?;
if config.is_active(name) {
let profile = config.find(name).context("Profile not found")?;
let prev_env_keys: Vec<String> = profile.env.keys().cloned().collect();
apply_profile_to_claude_settings(profile, &prev_env_keys)?;
}
Ok(())
})
}
pub(crate) fn edit_profile_env(
config: &mut AppConfig,
name: &str,
env: BTreeMap<String, String>,
) -> Result<()> {
with_state_lock(|| {
let profile = config.find_mut(name).context("Profile not found")?;
let old_env_keys: Vec<String> = profile.env.keys().cloned().collect();
profile.env = env;
save_profile(profile)?;
if config.is_active(name) {
let profile = config.find(name).context("Profile not found")?;
apply_profile_to_claude_settings(profile, &old_env_keys)?;
}
Ok(())
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum EnvKeyCollision {
Managed(&'static str),
ProfileField(usize),
BaseSettings,
}
pub(crate) fn classify_env_key(
profile: &Profile,
base_env_keys: &[String],
candidate: &str,
) -> Option<EnvKeyCollision> {
if let Some(label) = managed_env_key_label(candidate) {
return Some(EnvKeyCollision::Managed(label));
}
if let Some(idx) = profile.env.keys().position(|k| k == candidate) {
return Some(EnvKeyCollision::ProfileField(idx));
}
base_env_keys
.iter()
.any(|k| k == candidate)
.then_some(EnvKeyCollision::BaseSettings)
}
pub(crate) fn rename_profile(config: &mut AppConfig, old: &str, new: &str) -> Result<()> {
with_state_lock(|| {
let old_dir = profile_dir(old)?;
if old_dir.exists() {
std::fs::rename(&old_dir, profile_dir(new)?)
.with_context(|| format!("Failed to rename profile directory to '{new}'"))?;
}
let was_active = config.is_active(old);
config.rename_all_occurrences(old, new);
save_app_state(&config.state)?;
if was_active {
link_profile_credentials(new)?;
}
Ok(())
})
}
pub(crate) fn delete_profile(config: &mut AppConfig, name: &str) -> Result<()> {
with_state_lock(|| {
let was_active = config.is_active(name);
let dir = profile_dir(name)?;
if dir.exists() {
std::fs::remove_dir_all(&dir)
.with_context(|| format!("Failed to delete profile directory for '{name}'"))?;
}
config.remove(name);
save_app_state(&config.state)?;
if was_active {
clear_claude_credentials()?;
}
Ok(())
})
}
pub(crate) fn create_blank_profile(
config: &mut AppConfig,
name: String,
base_url: Option<String>,
api_key: Option<String>,
model: Option<String>,
) -> Result<()> {
with_state_lock(|| {
let mut profile = Profile::new(name, base_url, api_key);
profile.models.default = model
.as_deref()
.map(str::trim)
.filter(|m| !m.is_empty())
.map(str::to_string);
save_profile(&profile)?;
config.add(profile);
save_app_state(&config.state)
})
}
pub(crate) fn ensure_login_profile(
config: &mut AppConfig,
name: &str,
model: Option<&str>,
) -> Result<bool> {
if config.find(name).is_some() {
return Ok(false);
}
let existing: Vec<&str> = config.names();
validate_profile_name(name, &existing, None)?;
create_blank_profile(
config,
name.trim().to_string(),
None,
None,
model.map(str::to_string),
)?;
Ok(true)
}
pub(crate) fn set_profile_default_model(
config: &mut AppConfig,
name: &str,
raw_model: &str,
) -> Result<()> {
let mut models = config
.find(name)
.map(|p| p.models.clone())
.unwrap_or_default();
let trimmed = raw_model.trim();
models.default = (!trimmed.is_empty()).then(|| trimmed.to_string());
edit_profile_model(config, name, models)
}
pub(crate) fn find_matching_oauth_profile<'a>(
config: &'a AppConfig,
live: Option<&ClaudeCredentials>,
) -> Option<&'a str> {
let live_refresh = live?.refresh_token().filter(|t| !t.is_empty())?;
config
.profiles
.iter()
.find(|p| p.refresh_token() == Some(live_refresh))
.map(|p| p.name.as_str())
}
#[derive(Debug, Clone)]
pub(crate) struct CaptureSnapshot {
pub(crate) credentials: Option<ClaudeCredentials>,
pub(crate) base_url: Option<String>,
pub(crate) api_key: Option<String>,
}
pub(crate) fn capture_snapshot() -> Result<CaptureSnapshot> {
let credentials = read_claude_credentials()?;
let ClaudeEndpoint { base_url, api_key } = read_claude_endpoint_config()?;
Ok(CaptureSnapshot {
credentials,
base_url,
api_key,
})
}
pub(crate) fn capture_into_profile(
config: &mut AppConfig,
name: String,
snapshot: CaptureSnapshot,
) -> Result<()> {
with_state_lock(|| {
let CaptureSnapshot {
credentials,
base_url,
api_key,
} = snapshot;
let mut profile = Profile::new(name.clone(), base_url, api_key);
profile.credentials = credentials;
save_profile(&profile)?;
config.add(profile);
if config.state.active_profile.is_none() {
link_profile_credentials(&name)?;
config.state.active_profile = Some(name.into());
}
save_app_state(&config.state)
})
}
pub(crate) fn overwrite_captured_profile(
config: &mut AppConfig,
name: &str,
snapshot: CaptureSnapshot,
) -> Result<()> {
with_state_lock(|| {
let CaptureSnapshot {
credentials,
base_url,
api_key,
} = snapshot;
let provider = base_url.as_deref().and_then(Provider::from_base_url);
let was_active = config.is_active(name);
let profile = config
.find_mut(name)
.with_context(|| format!("profile '{name}' vanished before overwrite"))?;
profile.base_url = base_url;
profile.api_key = api_key;
profile.credentials = credentials;
profile.provider = provider;
profile.usage = None;
profile.fetch_status = None;
profile.third_party_usage = None;
save_profile(profile)?;
for file in [
crate::profile_cache::USAGE_CACHE_FILE,
crate::profile_cache::THIRD_PARTY_CACHE_FILE,
crate::throughput::THROUGHPUT_CACHE_FILE,
] {
if let Some(path) = crate::profile_cache::profile_cache_path(name, file) {
let _ = std::fs::remove_file(path);
}
}
if config.state.active_profile.is_none() {
link_profile_credentials(name)?;
config.state.active_profile = Some(name.into());
} else if was_active {
link_profile_credentials(name)?;
let profile = config.find(name).context("Profile not found")?;
let prev_env_keys: Vec<String> = profile.env.keys().cloned().collect();
apply_profile_to_claude_settings(profile, &prev_env_keys)?;
}
save_app_state(&config.state)
})
}
pub(crate) fn reorder_profile(config: &mut AppConfig, from: usize, to: usize) -> Result<()> {
if from == to || from >= config.profiles.len() || to >= config.profiles.len() {
return Ok(());
}
with_state_lock(|| {
config.sync_state_profiles();
let profile = config.profiles.remove(from);
config.profiles.insert(to, profile);
let name = config.state.profiles.remove(from);
config.state.profiles.insert(to, name);
save_app_state(&config.state)
})
}
#[cfg(test)]
#[path = "../tests/inline/actions.rs"]
mod tests;
#[cfg(test)]
#[path = "../tests/inline/mcp_switch.rs"]
mod tests_mcp_switch;