use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use crate::lock::with_state_lock;
use crate::profile::{
AppConfig, ClaudeCredentials, Profile, atomic_write, home_dir, profile_dir, save_profile,
};
fn claude_credentials_path() -> Result<PathBuf> {
Ok(home_dir()?.join(".claude").join(".credentials.json"))
}
fn claude_settings_path() -> Result<PathBuf> {
Ok(home_dir()?.join(".claude").join("settings.json"))
}
pub(crate) fn read_claude_credentials() -> Result<Option<ClaudeCredentials>> {
let path = claude_credentials_path()?;
if !path.exists() {
return Ok(None);
}
let content = std::fs::read_to_string(&path).context("Failed to read .credentials.json")?;
serde_json::from_str(&content)
.context("Failed to parse .credentials.json")
.map(Some)
}
#[cfg(unix)]
pub(crate) fn create_symlink(target: &Path, link: &Path) -> Result<()> {
std::os::unix::fs::symlink(target, link).context("Failed to create credential symlink")
}
#[cfg(windows)]
pub(crate) fn create_symlink(target: &Path, link: &Path) -> Result<()> {
match std::os::windows::fs::symlink_file(target, link) {
Ok(()) => Ok(()),
Err(_) => std::fs::copy(target, link)
.map(|_| ())
.context("Failed to copy credentials"),
}
}
#[cfg(not(any(unix, windows)))]
pub(crate) fn create_symlink(target: &Path, link: &Path) -> Result<()> {
std::fs::copy(target, link)
.map(|_| ())
.context("Failed to copy credentials")
}
pub(crate) fn link_profile_credentials(name: &str) -> Result<()> {
with_state_lock(|| {
let link = claude_credentials_path()?;
if link.symlink_metadata().is_ok() {
std::fs::remove_file(&link).context("Failed to remove old .credentials.json")?;
}
let target = profile_dir(name)?.join("credentials.json");
if target.exists() {
if let Some(parent) = link.parent() {
std::fs::create_dir_all(parent)?;
}
create_symlink(&target, &link)?;
}
Ok(())
})
}
pub(crate) fn clear_claude_credentials() -> Result<()> {
with_state_lock(|| {
let link = claude_credentials_path()?;
if link.symlink_metadata().is_ok() {
std::fs::remove_file(&link).context("Failed to remove .credentials.json")?;
}
Ok(())
})
}
pub(crate) struct ClaudeEndpoint {
pub(crate) base_url: Option<String>,
pub(crate) api_key: Option<String>,
}
pub(crate) fn read_claude_endpoint_config() -> Result<ClaudeEndpoint> {
let path = claude_settings_path()?;
if !path.exists() {
return Ok(ClaudeEndpoint {
base_url: None,
api_key: None,
});
}
let content = std::fs::read_to_string(&path).context("Failed to read settings.json")?;
let settings: serde_json::Value =
serde_json::from_str(&content).context("Failed to parse settings.json")?;
Ok(ClaudeEndpoint {
base_url: settings["env"]["ANTHROPIC_BASE_URL"]
.as_str()
.map(str::to_owned),
api_key: settings["env"]["ANTHROPIC_AUTH_TOKEN"]
.as_str()
.map(str::to_owned),
})
}
pub(crate) fn apply_profile_to_claude_settings(
profile: &Profile,
prev_env_keys: &[String],
) -> Result<()> {
with_state_lock(|| apply_profile_to_claude_settings_inner(profile, prev_env_keys))
}
fn apply_profile_to_claude_settings_inner(
profile: &Profile,
prev_env_keys: &[String],
) -> Result<()> {
let path = claude_settings_path()?;
let has_anything = profile.base_url.is_some()
|| profile.api_key.is_some()
|| !profile.env.is_empty()
|| !prev_env_keys.is_empty();
if !has_anything && !path.exists() {
return Ok(());
}
let content = build_claude_settings_json(&path, profile, prev_env_keys)?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
atomic_write(&path, content).context("Failed to write settings.json")
}
pub(crate) fn build_claude_settings_json(
base_path: &Path,
profile: &Profile,
prev_env_keys: &[String],
) -> Result<String> {
let mut settings: serde_json::Value = if base_path.exists() {
let content = std::fs::read_to_string(base_path).context("Failed to read settings.json")?;
serde_json::from_str(&content).context("Failed to parse settings.json")?
} else {
serde_json::json!({})
};
if settings.get("env").is_none() {
settings["env"] = serde_json::json!({});
}
let env = settings["env"]
.as_object_mut()
.context("settings.json `env` is not an object")?;
for key in prev_env_keys {
if !profile.env.contains_key(key) {
env.remove(key);
}
}
match profile.base_url.as_deref() {
Some(url) => {
env.insert("ANTHROPIC_BASE_URL".into(), url.into());
}
None => {
env.remove("ANTHROPIC_BASE_URL");
}
}
match profile.api_key.as_deref() {
Some(key) => {
env.insert("ANTHROPIC_AUTH_TOKEN".into(), key.into());
}
None => {
env.remove("ANTHROPIC_AUTH_TOKEN");
}
}
for (k, v) in &profile.env {
env.insert(k.clone(), v.clone().into());
}
serde_json::to_string_pretty(&settings).context("Failed to serialize settings.json")
}
pub(crate) fn snapshot_active_credentials(config: &mut AppConfig) -> Result<()> {
with_state_lock(|| {
let Some(active) = config.state.active_profile.clone() else {
return Ok(());
};
let credentials = read_claude_credentials()?;
if let Some(profile) = config.find_mut(&active) {
profile.credentials = credentials;
save_profile(profile)?;
}
Ok(())
})
}
pub(crate) fn credentials_diverged(
stored: Option<&ClaudeCredentials>,
live: Option<&ClaudeCredentials>,
) -> bool {
let Some(stored) = stored.and_then(|c| c.claude_ai_oauth.as_ref()) else {
return false;
};
let Some(live) = live.and_then(|c| c.claude_ai_oauth.as_ref()) else {
return false;
};
stored.access_token != live.access_token || stored.refresh_token != live.refresh_token
}
pub(crate) fn detach_credentials_link() -> Result<()> {
with_state_lock(|| {
let path = claude_credentials_path()?;
let Ok(meta) = path.symlink_metadata() else {
return Ok(());
};
if !meta.file_type().is_symlink() {
return Ok(());
}
let content =
std::fs::read(&path).context("Failed to read .credentials.json before detach")?;
std::fs::remove_file(&path).context("Failed to remove .credentials.json symlink")?;
atomic_write(&path, content).context("Failed to write detached .credentials.json")?;
Ok(())
})
}
#[cfg(test)]
#[path = "../tests/inline/claude.rs"]
mod tests;