codex-profiles 0.3.0

Seamlessly switch between multiple Codex accounts
Documentation
use clap::{FromArgMatches, error::ErrorKind};
use std::process::Command as ProcessCommand;

use crate::cli::{Cli, Commands, command_with_examples};

pub fn run_cli() {
    let args: Vec<std::ffi::OsString> = std::env::args_os().collect();
    if let Err(message) = run_cli_with_args(args) {
        eprintln!("{message}");
        std::process::exit(1);
    }
}

fn run_cli_with_args(args: Vec<std::ffi::OsString>) -> Result<(), String> {
    if args.len() == 1 {
        print_version_header();
        let mut cmd = command_with_examples();
        let _ = cmd.print_help();
        println!();
        return Ok(());
    }
    let cmd = command_with_examples();
    let matches = match cmd.clone().try_get_matches_from(args) {
        Ok(matches) => matches,
        Err(err) => {
            if err.kind() == ErrorKind::DisplayHelp {
                print_version_header();
                let _ = err.print();
                println!();
                return Ok(());
            }
            return Err(err.to_string());
        }
    };
    let cli = Cli::from_arg_matches(&matches).map_err(|err| err.to_string())?;
    set_plain(cli.plain);
    if let Err(message) = run(cli) {
        if message == CANCELLED_MESSAGE {
            let message = format_cancel(use_color_stdout());
            print_output_block(&message);
            return Ok(());
        }
        return Err(message);
    }
    Ok(())
}

fn print_version_header() {
    let name = package_command_name();
    println!("{name} {}", env!("CARGO_PKG_VERSION"));
    println!();
}

fn run(cli: Cli) -> Result<(), String> {
    let paths = resolve_paths()?;
    let json = cli.json;
    let is_doctor = matches!(&cli.command, Commands::Doctor { .. });
    if !is_doctor {
        ensure_paths(&paths)?;
        let check_for_update_on_startup = std::env::var_os("CODEX_PROFILES_SKIP_UPDATE").is_none();
        let update_config = UpdateConfig {
            codex_home: paths.codex.clone(),
            check_for_update_on_startup,
        };
        match run_update_prompt_if_needed(&update_config)? {
            UpdatePromptOutcome::Continue => {}
            UpdatePromptOutcome::RunUpdate(action) => {
                return run_update_action(action);
            }
        }
    }

    match cli.command {
        Commands::Save { label } => save_profile(&paths, label, json),
        Commands::Load { label, id, force } => load_profile(&paths, label, id, force, json),
        Commands::List { show_id } => list_profiles(&paths, json, show_id),
        Commands::Export { label, id, output } => export_profiles(&paths, label, id, output, json),
        Commands::Import { input } => import_profiles(&paths, input, json),
        Commands::Doctor { fix } => doctor(&paths, fix, json),
        Commands::Label { command } => match command {
            crate::cli::LabelCommands::Set { selector, to } => {
                let (label, id) = require_saved_profile_selector(
                    selector,
                    crate::cli::label_set_usage(command_name()),
                )?;
                set_profile_label(&paths, label, id, to, json)
            }
            crate::cli::LabelCommands::Clear { selector } => {
                let (label, id) = require_saved_profile_selector(
                    selector,
                    crate::cli::label_clear_usage(command_name()),
                )?;
                clear_profile_label(&paths, label, id, json)
            }
            crate::cli::LabelCommands::Rename { label, to } => {
                rename_profile_label(&paths, label, to, json)
            }
        },
        Commands::Status { all, label, id } => status_profiles(&paths, all, label, id, json),
        Commands::Delete { yes, label, id } => delete_profile(&paths, yes, label, id, json),
    }
}

fn require_saved_profile_selector(
    selector: crate::cli::SavedProfileSelector,
    usage: String,
) -> Result<(Option<String>, Option<String>), String> {
    if selector.label.is_none() && selector.id.is_none() {
        return Err(format!(
            "error: exactly one of `--label <label>` or `--id <profile-id>` is required.\n\nUsage: {usage}\n\nFor more information, try '--help'."
        ));
    }
    Ok((selector.label, selector.id))
}

fn run_update_action(action: UpdateAction) -> Result<(), String> {
    let (command, args) = action.command_args();
    let status = ProcessCommand::new(command)
        .args(args)
        .status()
        .map_err(|err| crate::msg1(crate::CMD_ERR_UPDATE_RUN, err))?;
    if status.success() {
        Ok(())
    } else {
        Err(crate::msg1(
            crate::CMD_ERR_UPDATE_FAILED,
            action.command_str(),
        ))
    }
}
mod auth;
mod cli;
mod common;
mod doctor;
mod json_response;
mod messages;
mod profiles;
#[cfg(test)]
mod test_utils;
mod ui;
mod updates;
mod usage;

pub(crate) use auth::*;
pub(crate) use common::*;
pub(crate) use doctor::*;
pub(crate) use messages::*;
pub(crate) use profiles::*;
pub(crate) use ui::*;
pub(crate) use updates::*;
pub(crate) use usage::*;

pub use auth::{AuthFile, Tokens, extract_email_and_plan};
pub use updates::{
    InstallSource, detect_install_source_inner, extract_version_from_cask,
    extract_version_from_latest_tag, is_newer,
};

#[cfg(test)]
mod tests {
    use super::*;
    use crate::test_utils::{make_paths, set_env_guard};
    use std::ffi::OsString;
    use std::fs;
    use std::os::unix::fs::PermissionsExt;

    #[test]
    fn run_cli_with_args_help() {
        let args = vec![OsString::from("codex-profiles")];
        run_cli_with_args(args).unwrap();
    }

    #[test]
    fn run_cli_with_args_display_help() {
        let args = vec![OsString::from("codex-profiles"), OsString::from("--help")];
        run_cli_with_args(args).unwrap();
    }

    #[test]
    fn run_cli_with_args_errors() {
        let args = vec![OsString::from("codex-profiles"), OsString::from("nope")];
        let err = run_cli_with_args(args).unwrap_err();
        assert!(err.contains("error"));
    }

    #[test]
    fn run_update_action_paths() {
        let dir = tempfile::tempdir().expect("tempdir");
        let bin = dir.path().join("npm");
        fs::write(&bin, "#!/bin/sh\nexit 0\n").unwrap();
        let mut perms = fs::metadata(&bin).unwrap().permissions();
        perms.set_mode(0o755);
        fs::set_permissions(&bin, perms).unwrap();
        let path = dir.path().to_string_lossy().into_owned();
        {
            let _env = set_env_guard("PATH", Some(&path));
            run_update_action(UpdateAction::NpmGlobalLatest).unwrap();
        }
        {
            let _env = set_env_guard("PATH", Some(""));
            let err = run_update_action(UpdateAction::NpmGlobalLatest).unwrap_err();
            assert!(err.contains("Could not run update command"));
        }
    }

    #[test]
    fn run_cli_list_command() {
        let dir = tempfile::tempdir().expect("tempdir");
        let paths = make_paths(dir.path());
        fs::create_dir_all(&paths.profiles).unwrap();
        let home = dir.path().to_string_lossy().into_owned();
        let _home = set_env_guard("CODEX_PROFILES_HOME", Some(&home));
        let _skip = set_env_guard("CODEX_PROFILES_SKIP_UPDATE", Some("1"));
        let cli = Cli {
            plain: true,
            json: false,
            command: Commands::List { show_id: false },
        };
        run(cli).unwrap();
    }
}