codex-ops 0.1.8

A local operations CLI for Codex auth, usage, and cycle workflows.
Documentation
use std::env;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};

#[derive(Debug, Clone, Eq, PartialEq)]
pub struct StoragePaths {
    pub codex_home: PathBuf,
    pub helper_dir: PathBuf,
    pub auth_file: PathBuf,
    pub profile_store_dir: PathBuf,
    pub account_history_file: PathBuf,
    pub cycle_file: PathBuf,
    pub sessions_dir: PathBuf,
}

#[derive(Debug, Clone, Default, Eq, PartialEq)]
pub struct StorageOptions {
    pub codex_home: Option<PathBuf>,
    pub auth_file: Option<PathBuf>,
    pub profile_store_dir: Option<PathBuf>,
    pub account_history_file: Option<PathBuf>,
    pub cycle_file: Option<PathBuf>,
    pub sessions_dir: Option<PathBuf>,
}

pub fn default_codex_home() -> PathBuf {
    let home = env::var_os("HOME")
        .map(PathBuf::from)
        .unwrap_or_else(|| PathBuf::from("."));
    default_codex_home_from(env::var_os("CODEX_HOME").map(PathBuf::from), home)
}

pub fn default_codex_home_from(codex_home_env: Option<PathBuf>, home_dir: PathBuf) -> PathBuf {
    codex_home_env.unwrap_or_else(|| home_dir.join(".codex"))
}

pub fn resolve_codex_home(options: &StorageOptions) -> PathBuf {
    options
        .codex_home
        .clone()
        .unwrap_or_else(default_codex_home)
}

pub fn resolve_codex_ops_dir(codex_home: impl AsRef<Path>) -> PathBuf {
    codex_home.as_ref().join("codex-ops")
}

pub fn resolve_storage_paths(options: &StorageOptions) -> StoragePaths {
    let codex_home = resolve_codex_home(options);
    let helper_dir = resolve_codex_ops_dir(&codex_home);

    StoragePaths {
        auth_file: options
            .auth_file
            .clone()
            .unwrap_or_else(|| codex_home.join("auth.json")),
        profile_store_dir: options
            .profile_store_dir
            .clone()
            .unwrap_or_else(|| helper_dir.join("auth-profiles")),
        account_history_file: options
            .account_history_file
            .clone()
            .unwrap_or_else(|| helper_dir.join("auth-account-history.json")),
        cycle_file: options
            .cycle_file
            .clone()
            .unwrap_or_else(|| helper_dir.join("stat-cycles.json")),
        sessions_dir: options
            .sessions_dir
            .clone()
            .unwrap_or_else(|| codex_home.join("sessions")),
        codex_home,
        helper_dir,
    }
}

pub fn write_sensitive_file(file_path: impl AsRef<Path>, content: &str) -> io::Result<()> {
    let file_path = file_path.as_ref();
    let parent = file_path.parent().unwrap_or_else(|| Path::new("."));
    fs::create_dir_all(parent)?;
    set_permissions_best_effort(parent, 0o700);

    let temp_file = parent.join(format!(
        ".{}.{}.tmp",
        percent_encode(&temp_seed()),
        percent_encode(
            file_path
                .file_name()
                .and_then(|name| name.to_str())
                .unwrap_or("codex-ops")
        )
    ));

    let write_result = (|| {
        fs::write(&temp_file, content)?;
        set_permissions_best_effort(&temp_file, 0o600);
        fs::rename(&temp_file, file_path)?;
        set_permissions_best_effort(file_path, 0o600);
        Ok(())
    })();

    if write_result.is_err() {
        let _ = fs::remove_file(&temp_file);
    }

    write_result
}

pub fn percent_encode(value: &str) -> String {
    let mut output = String::new();
    for byte in value.bytes() {
        let char = byte as char;
        if char.is_ascii_alphanumeric()
            || matches!(char, '-' | '_' | '.' | '!' | '~' | '*' | '\'' | '(' | ')')
        {
            output.push(char);
        } else {
            output.push_str(&format!("%{byte:02X}"));
        }
    }
    output
}

pub fn path_to_string(path: impl AsRef<Path>) -> String {
    path.as_ref().to_string_lossy().to_string()
}

fn temp_seed() -> String {
    let millis = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|duration| duration.as_millis())
        .unwrap_or_default();
    format!("{millis}-{}", std::process::id())
}

#[cfg(unix)]
fn set_permissions_best_effort(path: &Path, mode: u32) {
    use std::os::unix::fs::PermissionsExt;

    let _ = fs::set_permissions(path, fs::Permissions::from_mode(mode));
}

#[cfg(not(unix))]
fn set_permissions_best_effort(_path: &Path, _mode: u32) {}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn default_codex_home_prefers_env_value() {
        let result = default_codex_home_from(
            Some(PathBuf::from("/tmp/codex")),
            PathBuf::from("/home/user"),
        );

        assert_eq!(result, PathBuf::from("/tmp/codex"));
    }

    #[test]
    fn default_codex_home_falls_back_to_home_dot_codex() {
        let result = default_codex_home_from(None, PathBuf::from("/home/user"));

        assert_eq!(result, PathBuf::from("/home/user/.codex"));
    }

    #[test]
    fn resolves_default_helper_paths_under_codex_home() {
        let paths = resolve_storage_paths(&StorageOptions {
            codex_home: Some(PathBuf::from("/tmp/codex-home")),
            ..StorageOptions::default()
        });

        assert_eq!(paths.auth_file, PathBuf::from("/tmp/codex-home/auth.json"));
        assert_eq!(
            paths.profile_store_dir,
            PathBuf::from("/tmp/codex-home/codex-ops/auth-profiles")
        );
        assert_eq!(
            paths.account_history_file,
            PathBuf::from("/tmp/codex-home/codex-ops/auth-account-history.json")
        );
        assert_eq!(
            paths.cycle_file,
            PathBuf::from("/tmp/codex-home/codex-ops/stat-cycles.json")
        );
        assert_eq!(
            paths.sessions_dir,
            PathBuf::from("/tmp/codex-home/sessions")
        );
    }

    #[test]
    fn explicit_files_override_default_paths() {
        let paths = resolve_storage_paths(&StorageOptions {
            codex_home: Some(PathBuf::from("/tmp/codex-home")),
            auth_file: Some(PathBuf::from("/tmp/auth.json")),
            profile_store_dir: Some(PathBuf::from("/tmp/profiles")),
            account_history_file: Some(PathBuf::from("/tmp/history.json")),
            cycle_file: Some(PathBuf::from("/tmp/cycles.json")),
            sessions_dir: Some(PathBuf::from("/tmp/sessions")),
        });

        assert_eq!(paths.auth_file, PathBuf::from("/tmp/auth.json"));
        assert_eq!(paths.profile_store_dir, PathBuf::from("/tmp/profiles"));
        assert_eq!(
            paths.account_history_file,
            PathBuf::from("/tmp/history.json")
        );
        assert_eq!(paths.cycle_file, PathBuf::from("/tmp/cycles.json"));
        assert_eq!(paths.sessions_dir, PathBuf::from("/tmp/sessions"));
    }

    #[test]
    fn percent_encodes_like_encode_uri_component_for_profile_names() {
        assert_eq!(percent_encode("account-a"), "account-a");
        assert_eq!(percent_encode("account a/b"), "account%20a%2Fb");
    }
}