codex-switch 0.1.5

Local CLI account switcher for Codex
use std::fs;
use std::path::{Path, PathBuf};

use anyhow::{Context, Result};
use chrono::Utc;

use crate::store::write_private_file;
use crate::types::{
    AuthData, AuthDotJson, NewChatGptAccount, StoredAccount, TokenData,
    parse_chatgpt_id_token_claims,
};

pub fn codex_home() -> Result<PathBuf> {
    if let Ok(codex_home) = std::env::var("CODEX_HOME")
        && !codex_home.trim().is_empty()
    {
        return Ok(PathBuf::from(codex_home));
    }

    let home = dirs::home_dir().context("Could not find home directory")?;
    Ok(home.join(".codex"))
}

pub fn codex_auth_file() -> Result<PathBuf> {
    Ok(codex_home()?.join("auth.json"))
}

pub fn write_account_auth(account: &StoredAccount) -> Result<PathBuf> {
    let auth_path = codex_auth_file()?;
    let auth_json = create_auth_json(account);
    let mut content =
        serde_json::to_string_pretty(&auth_json).context("Failed to serialize auth.json")?;
    content.push('\n');
    write_private_file(&auth_path, &content)?;
    Ok(auth_path)
}

pub fn import_from_auth_json(path: &Path, account_name: String) -> Result<StoredAccount> {
    let content = fs::read_to_string(path)
        .with_context(|| format!("Failed to read auth.json: {}", path.display()))?;
    import_from_auth_json_contents(&content, account_name)
        .with_context(|| format!("Failed to parse auth.json: {}", path.display()))
}

fn import_from_auth_json_contents(content: &str, account_name: String) -> Result<StoredAccount> {
    let auth: AuthDotJson =
        serde_json::from_str(content).context("Failed to parse auth.json contents")?;
    let AuthDotJson {
        openai_api_key,
        tokens,
        last_refresh,
        ..
    } = auth;

    if let Some(api_key) = openai_api_key {
        return Ok(StoredAccount::new_api_key(account_name, api_key));
    }

    if let Some(tokens) = tokens {
        let claims = parse_chatgpt_id_token_claims(&tokens.id_token);
        return Ok(StoredAccount::new_chatgpt(NewChatGptAccount {
            name: account_name,
            email: claims.email,
            plan_type: claims.plan_type,
            chatgpt_user_id: claims.user_id,
            chatgpt_account_is_fedramp: claims.account_is_fedramp,
            token_last_refresh_at: last_refresh.unwrap_or_else(Utc::now),
            subscription_expires_at: claims.subscription_expires_at,
            id_token: tokens.id_token,
            access_token: tokens.access_token,
            refresh_token: tokens.refresh_token,
            account_id: claims.account_id.or(tokens.account_id),
        }));
    }

    anyhow::bail!("auth.json contains neither OPENAI_API_KEY nor tokens");
}

fn create_auth_json(account: &StoredAccount) -> AuthDotJson {
    match &account.auth_data {
        AuthData::ApiKey { key } => AuthDotJson {
            auth_mode: Some("apikey".to_string()),
            openai_api_key: Some(key.clone()),
            tokens: None,
            last_refresh: None,
        },
        AuthData::ChatGPT {
            id_token,
            access_token,
            refresh_token,
            account_id,
        } => AuthDotJson {
            auth_mode: Some("chatgpt".to_string()),
            openai_api_key: None,
            tokens: Some(TokenData {
                id_token: id_token.clone(),
                access_token: access_token.clone(),
                refresh_token: refresh_token.clone(),
                account_id: account_id.clone(),
            }),
            last_refresh: account.token_last_refresh_at,
        },
    }
}