clauth 0.1.8

A simple Claude Code account switcher - swap OAuth and API profiles in an instant
use anyhow::{Context, Result};
use std::path::PathBuf;

use crate::profile::{AppConfig, ClaudeCredentials, Profile, 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)]
fn create_symlink(target: &std::path::Path, link: &std::path::Path) -> Result<()> {
    std::os::unix::fs::symlink(target, link).context("Failed to create credential symlink")
}

#[cfg(windows)]
fn create_symlink(target: &std::path::Path, link: &std::path::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)))]
fn create_symlink(target: &std::path::Path, link: &std::path::Path) -> Result<()> {
    std::fs::copy(target, link)
        .map(|_| ())
        .context("Failed to copy credentials")
}

/// Symlinks `~/.claude/.credentials.json` → profile's `credentials.json`; copies on Windows without symlink privilege.
pub(crate) fn link_profile_credentials(name: &str) -> Result<()> {
    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<()> {
    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),
    })
}

/// Patches the `env` object of settings.json with ANTHROPIC_BASE_URL,
/// ANTHROPIC_AUTH_TOKEN, and the profile's `env` map. Keys in `prev_env_keys`
/// that the new profile doesn't carry are removed first so stale entries from
/// the previously active profile don't linger. Every other field is untouched.
pub(crate) fn apply_profile_to_claude_settings(
    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 mut settings: serde_json::Value = if path.exists() {
        let content = std::fs::read_to_string(&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")?;

    // Drop prior-profile keys the new profile doesn't carry over. Keys still
    // present in `profile.env` get re-inserted below with the new value.
    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");
        }
    }

    // Apply profile env last so an explicit `ANTHROPIC_*` entry in the
    // profile's env map wins over the dedicated base_url / api_key fields.
    for (k, v) in &profile.env {
        env.insert(k.clone(), v.clone().into());
    }

    std::fs::write(&path, serde_json::to_string_pretty(&settings)?)
        .context("Failed to write settings.json")
}

/// Reads the live .credentials.json and saves it to the active profile.
pub(crate) fn snapshot_active_credentials(config: &mut AppConfig) -> Result<()> {
    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(())
}