use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use crate::lock::with_state_lock;
use crate::profile::{
AppConfig, ClaudeCredentials, Profile, atomic_write, atomic_write_600, claude_dir, profile_dir,
read_json_file, save_profile,
};
fn claude_credentials_path() -> Result<PathBuf> {
Ok(claude_dir()?.join(".credentials.json"))
}
fn claude_settings_path() -> Result<PathBuf> {
Ok(claude_dir()?.join("settings.json"))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum LinkState {
LinkedTo,
Diverged,
Missing,
}
pub(crate) fn classify_credentials_link(active: &str) -> Result<LinkState> {
let link = claude_credentials_path()?;
let expected = profile_dir(active)?.join("credentials.json");
classify_link_at(&link, &expected)
}
pub(crate) fn classify_link_at(link: &Path, expected: &Path) -> Result<LinkState> {
let meta = match link.symlink_metadata() {
Ok(m) => m,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(LinkState::Missing),
Err(e) => return Err(e).context("Failed to stat .credentials.json"),
};
if !meta.file_type().is_symlink() {
return Ok(
match (
read_json_file::<ClaudeCredentials>(link),
read_json_file::<ClaudeCredentials>(expected),
) {
(Ok(live), Ok(stored))
if live.access_token().is_some_and(|t| !t.is_empty())
&& live.access_token() == stored.access_token() =>
{
LinkState::LinkedTo
}
_ => LinkState::Diverged,
},
);
}
let target = std::fs::read_link(link).context("Failed to read .credentials.json link")?;
if paths_equivalent(&target, expected) {
Ok(LinkState::LinkedTo)
} else {
Ok(LinkState::Diverged)
}
}
fn paths_equivalent(a: &Path, b: &Path) -> bool {
match (std::fs::canonicalize(a), std::fs::canonicalize(b)) {
(Ok(a), Ok(b)) => a == b,
_ => a == b,
}
}
pub(crate) fn is_first_login(active: &str) -> Result<bool> {
let link = claude_credentials_path()?;
let expected = profile_dir(active)?.join("credentials.json");
Ok(is_first_login_at(&link, &expected))
}
fn is_first_login_at(link: &Path, expected: &Path) -> bool {
if expected.exists() {
return false;
}
let Ok(meta) = link.symlink_metadata() else {
return false;
};
if meta.file_type().is_symlink() {
return false;
}
std::fs::read(link)
.ok()
.and_then(|bytes| serde_json::from_slice::<ClaudeCredentials>(&bytes).ok())
.is_some_and(|creds| creds.claude_ai_oauth.is_some())
}
pub(crate) fn read_claude_credentials() -> Result<Option<ClaudeCredentials>> {
let path = claude_credentials_path()?;
if !path.exists() {
return Ok(None);
}
read_json_file(&path).map(Some)
}
#[cfg(target_os = "macos")]
fn keychain_write_profile(name: &str) -> Result<()> {
let path = profile_dir(name)?.join("credentials.json");
if !path.exists() {
return Ok(());
}
let creds: ClaudeCredentials = read_json_file(&path)?;
crate::keychain::keychain_write(&creds)
}
#[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()?;
let target = profile_dir(name)?.join("credentials.json");
if let Ok(meta) = link.symlink_metadata() {
if !meta.file_type().is_symlink() {
let live_bytes = std::fs::read(&link).ok();
let target_bytes = std::fs::read(&target).ok();
if live_bytes != target_bytes {
anyhow::bail!(
"refusing to replace .credentials.json — live file differs from profile '{name}'; resolve divergence first"
);
}
}
std::fs::remove_file(&link).context("Failed to remove old .credentials.json")?;
}
if target.exists() {
if let Some(parent) = link.parent() {
std::fs::create_dir_all(parent)?;
}
create_symlink(&target, &link)?;
#[cfg(target_os = "macos")]
if crate::keychain::enabled() {
keychain_write_profile(name)?;
}
}
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")?;
}
#[cfg(target_os = "macos")]
if crate::keychain::enabled() {
crate::keychain::keychain_delete()?;
}
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 settings: serde_json::Value = read_json_file(&path)?;
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 managed_env_key_label(key: &str) -> Option<&'static str> {
Some(match key {
"ANTHROPIC_BASE_URL" => "the base url field",
"ANTHROPIC_AUTH_TOKEN" => "the api key field",
"ANTHROPIC_DEFAULT_OPUS_MODEL" => "the opus model field",
"ANTHROPIC_DEFAULT_SONNET_MODEL" => "the sonnet model field",
"ANTHROPIC_DEFAULT_HAIKU_MODEL" => "the haiku model field",
"CLAUDE_CODE_SUBAGENT_MODEL" => "the subagent model field",
_ => return None,
})
}
pub(crate) fn claude_settings_env_keys() -> Result<Vec<String>> {
let path = claude_settings_path()?;
if !path.exists() {
return Ok(Vec::new());
}
let settings: serde_json::Value = read_json_file(&path)?;
Ok(settings["env"]
.as_object()
.map(|env| env.keys().cloned().collect())
.unwrap_or_default())
}
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()
|| !profile.models.is_empty()
|| !prev_env_keys.is_empty();
if !has_anything && !path.exists() {
return Ok(());
}
let content = build_claude_settings_json(Some(&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: Option<&Path>,
profile: &Profile,
prev_env_keys: &[String],
) -> Result<String> {
let mut settings: serde_json::Value = match base {
Some(p) if p.exists() => read_json_file(p)?,
_ => 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");
}
}
let model_env = [
("ANTHROPIC_DEFAULT_OPUS_MODEL", &profile.models.opus),
("ANTHROPIC_DEFAULT_SONNET_MODEL", &profile.models.sonnet),
("ANTHROPIC_DEFAULT_HAIKU_MODEL", &profile.models.haiku),
("CLAUDE_CODE_SUBAGENT_MODEL", &profile.models.subagent),
];
for (key, value) in model_env {
match value {
Some(v) => {
env.insert(key.into(), v.clone().into());
}
None => {
env.remove(key);
}
}
}
for (k, v) in &profile.env {
env.insert(k.clone(), v.clone().into());
}
let obj = settings
.as_object_mut()
.context("settings.json is not an object")?;
match profile.models.default.as_deref() {
Some(model) => {
obj.insert("model".into(), model.into());
}
None => {
obj.remove("model");
}
}
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(());
};
if matches!(classify_credentials_link(&active)?, LinkState::Diverged) {
if is_first_login(&active)? {
adopt_first_login(config, &active)?;
}
return Ok(());
}
snapshot_active_credentials_unchecked(config, &active)
})
}
pub(crate) fn adopt_first_login(config: &mut AppConfig, active: &str) -> Result<()> {
with_state_lock(|| {
snapshot_active_credentials_unchecked(config, active)?;
force_link_profile_credentials(active)
})
}
fn snapshot_active_credentials_unchecked(config: &mut AppConfig, active: &str) -> Result<()> {
let credentials = read_claude_credentials()?;
if let Some(profile) = config.find_mut(active) {
profile.credentials = credentials;
save_profile(profile)?;
}
Ok(())
}
pub(crate) fn force_snapshot_active_credentials(config: &mut AppConfig) -> Result<()> {
with_state_lock(|| {
let Some(active) = config.state.active_profile.clone() else {
return Ok(());
};
snapshot_active_credentials_unchecked(config, &active)
})
}
pub(crate) fn force_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 .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)?;
#[cfg(target_os = "macos")]
if crate::keychain::enabled() {
keychain_write_profile(name)?;
}
}
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_600(&path, content).context("Failed to write detached .credentials.json")?;
Ok(())
})
}
#[cfg(test)]
#[path = "../tests/inline/claude.rs"]
mod tests;