cargo-ai 0.0.11

Build lightweight AI agents with Cargo. Powered by Rust. Declared in JSON.
//! Runtime behavior for `cargo ai auth`.
use clap::ArgMatches;
use serde::Serialize;
use std::io::{self, Write};
use std::process::Command;

use crate::config::loader::load_config;
use crate::config::schema::{
    default_profile_auth_mode, default_secret_store_mode, ProfileAuthMode,
};
use crate::config::settings as config_settings;
use crate::credentials::{openai_oauth, store};

#[derive(Debug, Serialize)]
struct AuthStatusJson {
    provider: &'static str,
    session_state: String,
    auth_mode_effective: String,
    has_refresh_token: bool,
    access_token_expires_at_unix: Option<i64>,
    secret_store_mode: String,
}

fn now_unix_seconds() -> i64 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .ok()
        .map(|duration| duration.as_secs() as i64)
        .unwrap_or(0)
}

fn confirm(message: &str) -> Result<bool, String> {
    print!("{message} [y/N]: ");
    io::stdout()
        .flush()
        .map_err(|error| format!("failed to flush stdout: {error}"))?;
    let mut input = String::new();
    io::stdin()
        .read_line(&mut input)
        .map_err(|error| format!("failed to read confirmation input: {error}"))?;

    Ok(matches!(
        input.trim().to_ascii_lowercase().as_str(),
        "y" | "yes"
    ))
}

fn validate_profile_target(profile_name: &str) -> Result<(), String> {
    let cfg = load_config().ok_or_else(|| {
        "No config file found. Create a profile first with `cargo ai profile add`.".to_string()
    })?;

    let profile = cfg
        .profile
        .iter()
        .find(|candidate| candidate.name == profile_name)
        .ok_or_else(|| format!("Profile '{profile_name}' not found."))?;

    if !profile.server.eq_ignore_ascii_case("openai") {
        return Err(format!(
            "Profile '{profile_name}' uses server '{}'. OpenAI login can only target profiles with `--server openai`.",
            profile.server
        ));
    }

    Ok(())
}

fn effective_auth_mode_for_status() -> String {
    let Some(cfg) = load_config() else {
        return default_profile_auth_mode().as_str().to_string();
    };

    if let Some(default_profile) = cfg.default_profile.as_deref() {
        if let Some(profile) = cfg.profile.iter().find(|profile| {
            profile.name == default_profile && profile.server.eq_ignore_ascii_case("openai")
        }) {
            return profile.auth_mode.as_str().to_string();
        }
    }

    let mut seen_modes: Vec<&'static str> = Vec::new();
    for profile in cfg
        .profile
        .iter()
        .filter(|profile| profile.server.eq_ignore_ascii_case("openai"))
    {
        let mode = profile.auth_mode.as_str();
        if !seen_modes.contains(&mode) {
            seen_modes.push(mode);
        }
    }

    match seen_modes.len() {
        0 => default_profile_auth_mode().as_str().to_string(),
        1 => seen_modes[0].to_string(),
        _ => "mixed".to_string(),
    }
}

fn local_session_state() -> Result<AuthStatusJson, String> {
    let session = openai_oauth::load_codex_session()?;
    let now = now_unix_seconds();
    let locally_disabled = openai_oauth::openai_account_locally_disabled();

    let access_token_expires_at_unix = if locally_disabled {
        None
    } else {
        session
            .as_ref()
            .and_then(|session| session.access_token_expires_at_unix)
    };

    let session_state = match session.as_ref() {
        _ if locally_disabled => "logged_out_local".to_string(),
        None => "logged_out".to_string(),
        Some(session)
            if openai_oauth::access_token_expired_or_near(
                session.access_token_expires_at_unix,
                now,
            ) =>
        {
            "expiring".to_string()
        }
        Some(session) if session.access_token_expires_at_unix.is_some() => "active".to_string(),
        Some(_) => "active_unknown_expiry".to_string(),
    };

    let has_refresh_token = if locally_disabled {
        false
    } else {
        session
            .as_ref()
            .and_then(|session| session.refresh_token.as_ref())
            .is_some()
    };

    let configured_store_mode = store::configured_secret_store_mode()
        .unwrap_or(default_secret_store_mode())
        .as_str()
        .to_string();

    Ok(AuthStatusJson {
        provider: "openai",
        session_state,
        auth_mode_effective: effective_auth_mode_for_status(),
        has_refresh_token,
        access_token_expires_at_unix,
        secret_store_mode: configured_store_mode,
    })
}

fn run_codex_login() -> Result<(), String> {
    let status = Command::new("codex")
        .arg("login")
        .status()
        .map_err(|error| {
            if error.kind() == std::io::ErrorKind::NotFound {
                "`codex` CLI was not found in PATH. Install Codex and run `codex login`, then retry `cargo ai auth login openai`.".to_string()
            } else {
                format!("failed to run `codex login`: {error}")
            }
        })?;

    if status.success() {
        Ok(())
    } else {
        Err(format!("`codex login` exited with status {status}"))
    }
}

fn run_codex_logout() -> Result<(), String> {
    let status = Command::new("codex")
        .arg("logout")
        .status()
        .map_err(|error| {
            if error.kind() == std::io::ErrorKind::NotFound {
                "`codex` CLI was not found in PATH. Install Codex to use `cargo ai auth logout`."
                    .to_string()
            } else {
                format!("failed to run `codex logout`: {error}")
            }
        })?;

    if status.success() {
        Ok(())
    } else {
        Err(format!("`codex logout` exited with status {status}"))
    }
}

async fn run_login_openai(login_openai_m: &ArgMatches) -> bool {
    let profile_name = login_openai_m
        .get_one::<String>("profile")
        .map(String::as_str);
    let set_default = login_openai_m.get_flag("set_default");

    if let Some(profile_name) = profile_name {
        if let Err(error) = validate_profile_target(profile_name) {
            eprintln!("{error}");
            return false;
        }
    }

    println!("Starting OpenAI browser login via Codex...");
    if let Err(error) = run_codex_login() {
        eprintln!("{error}");
        return false;
    }

    match openai_oauth::load_codex_session() {
        Ok(Some(_)) => {}
        Ok(None) => {
            eprintln!(
                "❌ Codex login completed, but no local auth session was found. Verify Codex is configured and run `codex login` again."
            );
            return false;
        }
        Err(error) => {
            eprintln!("❌ Failed to read Codex auth session: {error}");
            return false;
        }
    }

    // Clean up any duplicated OpenAI session material from prior Cargo AI builds.
    openai_oauth::clear_legacy_openai_session_tokens();
    if let Err(error) = config_settings::set_openai_auth_locally_disabled(false) {
        eprintln!("❌ Login succeeded, but failed to clear local logout state: {error}");
        return false;
    }

    if let Some(profile_name) = profile_name {
        if let Err(error) =
            config_settings::set_profile_auth_mode(profile_name, ProfileAuthMode::OpenaiAccount)
        {
            eprintln!("❌ Login succeeded, but failed to update profile auth mode: {error}");
            return false;
        }

        if set_default {
            if let Err(error) = config_settings::set_default_profile(profile_name) {
                eprintln!("❌ Login succeeded, but failed to set default profile: {error}");
                return false;
            }
        }
    }

    println!("✅ OpenAI login complete (Codex session detected).");
    if let Some(profile_name) = profile_name {
        println!(
            "Profile '{profile_name}' auth mode set to '{}'.",
            ProfileAuthMode::OpenaiAccount.as_str()
        );
        if set_default {
            println!("Profile '{profile_name}' set as default.");
        }
    }

    true
}

fn render_status_text(status: &AuthStatusJson) {
    println!("Provider: {}", status.provider);
    println!("Session state: {}", status.session_state);
    println!("Effective auth mode: {}", status.auth_mode_effective);
    println!(
        "Refresh token present: {}",
        if status.has_refresh_token {
            "yes"
        } else {
            "no"
        }
    );
    println!(
        "Access token expires at (unix): {}",
        status
            .access_token_expires_at_unix
            .map(|value| value.to_string())
            .unwrap_or_else(|| "(unknown)".to_string())
    );
    println!("Secret-store mode: {}", status.secret_store_mode);
}

async fn run_status(status_m: &ArgMatches) -> bool {
    let status = match local_session_state() {
        Ok(status) => status,
        Err(error) => {
            eprintln!("{error}");
            return false;
        }
    };

    if status_m.get_flag("json") {
        match serde_json::to_string_pretty(&status) {
            Ok(serialized) => println!("{serialized}"),
            Err(error) => {
                eprintln!("❌ Failed to serialize auth status JSON: {error}");
                return false;
            }
        }
    } else {
        render_status_text(&status);
    }

    true
}

async fn run_logout(logout_m: &ArgMatches) -> bool {
    let global = logout_m.get_flag("global") || logout_m.get_flag("revoke");
    let yes = logout_m.get_flag("yes");

    if !yes {
        let prompt = if global {
            "Log out of OpenAI for Cargo AI and also log out Codex globally?"
        } else {
            "Log out of OpenAI for Cargo AI only? (Codex stays signed in)"
        };
        let confirmed = match confirm(prompt) {
            Ok(confirmed) => confirmed,
            Err(error) => {
                eprintln!("{error}");
                return false;
            }
        };
        if !confirmed {
            println!("Operation canceled.");
            return true;
        }
    }

    if global {
        if let Err(error) = run_codex_logout() {
            eprintln!("{error}");
            return false;
        }
    }

    if let Err(error) = openai_oauth::clear_local_session() {
        eprintln!("❌ Failed to clear local OpenAI metadata: {error}");
        return false;
    }
    if let Err(error) = config_settings::set_openai_auth_locally_disabled(true) {
        eprintln!("❌ Failed to persist local Cargo AI logout state: {error}");
        return false;
    }

    if global {
        println!("✅ OpenAI logged out globally via Codex and logged out for Cargo AI.");
    } else {
        println!("✅ OpenAI logged out for Cargo AI only. Codex session remains signed in.");
    }
    true
}

/// Routes `cargo ai auth ...` subcommands to runtime handlers.
pub async fn run(sub_m: &ArgMatches) -> bool {
    if let Some(login_m) = sub_m.subcommand_matches("login") {
        if let Some(login_openai_m) = login_m.subcommand_matches("openai") {
            run_login_openai(login_openai_m).await
        } else {
            eprintln!("No auth login provider found. Try 'cargo ai auth login openai'.");
            false
        }
    } else if let Some(status_m) = sub_m.subcommand_matches("status") {
        run_status(status_m).await
    } else if let Some(logout_m) = sub_m.subcommand_matches("logout") {
        run_logout(logout_m).await
    } else {
        eprintln!(
            "No auth subcommand found. Try 'cargo ai auth login openai', 'cargo ai auth status [--json]', or 'cargo ai auth logout [--global] [--yes]'."
        );
        false
    }
}