kai-tool 0.1.6

CLI helpers for AI coding, Codex credentials, and git worktree management.
use std::env;
use std::fs;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use std::path::Path;

use assert_cmd::Command;
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use predicates::prelude::*;
use serde_json::json;
use tempfile::tempdir;

fn command(credentials_home: &Path, codex_home: &Path, runtime_dir: &Path) -> Command {
    let mut command = Command::cargo_bin("kai").unwrap();
    command
        .env("KAI_CREDENTIALS_HOME", credentials_home)
        .env("CODEX_HOME", codex_home)
        .env("XDG_RUNTIME_DIR", runtime_dir);
    command
}

fn auth_json(email: &str, account_id: &str, refresh_token: &str) -> Vec<u8> {
    let jwt = |claims: serde_json::Value| {
        let header = URL_SAFE_NO_PAD.encode(br#"{"alg":"none","typ":"JWT"}"#);
        let payload = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&claims).unwrap());
        format!("{header}.{payload}.signature")
    };
    serde_json::to_vec_pretty(&json!({
        "auth_mode": "chatgpt",
        "tokens": {
            "id_token": jwt(json!({
                "email": email,
                "https://api.openai.com/auth": {
                    "chatgpt_account_id": account_id,
                    "chatgpt_plan_type": "pro"
                }
            })),
            "access_token": jwt(json!({"exp": 2_000_000_000_i64})),
            "account_id": account_id,
            "refresh_token": refresh_token
        },
        "last_refresh": "2026-07-29T00:00:00Z"
    }))
    .unwrap()
}

#[test]
fn help_orders_commands_logically_and_exposes_the_account_workflow() {
    let output = Command::cargo_bin("kai")
        .unwrap()
        .arg("help")
        .output()
        .unwrap();
    assert!(output.status.success());
    let help = String::from_utf8(output.stdout).unwrap();
    let commands = help
        .split_once("Commands:")
        .map(|(_, commands)| commands)
        .unwrap();
    let mut previous = 0;
    for command in [
        "agent", "worktree", "cred", "next", "llm-get", "init", "bump",
    ] {
        let position = commands
            .find(&format!("\n  {command}"))
            .unwrap_or_else(|| panic!("{command} missing from help:\n{help}"));
        assert!(
            position >= previous,
            "{command} is out of order in help:\n{help}"
        );
        previous = position;
    }

    Command::cargo_bin("kai")
        .unwrap()
        .args(["cred", "--help"])
        .assert()
        .success()
        .stdout(predicate::str::contains("add"))
        .stdout(predicate::str::contains("next"))
        .stdout(predicate::str::contains("activate"));
}

#[test]
fn empty_list_has_human_and_json_output() {
    let root = tempdir().unwrap();
    let credentials_home = root.path().join("credentials");
    let codex_home = root.path().join("codex");
    let runtime_dir = root.path().join("runtime");

    command(&credentials_home, &codex_home, &runtime_dir)
        .args(["cred", "list"])
        .assert()
        .success()
        .stdout(predicate::str::contains("No Codex accounts enrolled"));

    command(&credentials_home, &codex_home, &runtime_dir)
        .args(["cred", "list", "--json"])
        .assert()
        .success()
        .stdout(predicate::str::contains("\"accounts\": []"))
        .stdout(predicate::str::contains("refresh_token").not());
}

#[cfg(unix)]
#[test]
fn complete_rotation_preserves_a_live_refreshed_credential() {
    let root = tempdir().unwrap();
    let credentials_home = root.path().join("credentials");
    let codex_home = root.path().join("codex");
    let runtime_dir = root.path().join("runtime");
    fs::create_dir_all(&codex_home).unwrap();

    let alice_original = auth_json("alice@example.com", "alice-id", "alice-original");
    let alice_refreshed = auth_json("alice@example.com", "alice-id", "alice-refreshed");
    let bob = auth_json("bob@example.com", "bob-id", "bob-refresh");
    fs::write(codex_home.join("auth.json"), &alice_original).unwrap();

    command(&credentials_home, &codex_home, &runtime_dir)
        .args(["cred", "add", "alice@example.com"])
        .assert()
        .success()
        .stderr(predicate::str::contains(
            "Imported the active Codex account",
        ));

    fs::write(codex_home.join("auth.json"), &alice_refreshed).unwrap();
    let fake_bin = root.path().join("bin");
    fs::create_dir_all(&fake_bin).unwrap();
    let enrolled_path = root.path().join("bob.json");
    fs::write(&enrolled_path, &bob).unwrap();
    let fake_codex = fake_bin.join("codex");
    fs::write(
        &fake_codex,
        "#!/bin/sh\ncp \"$KAI_TEST_CREDENTIAL\" \"$CODEX_HOME/auth.json\"\n",
    )
    .unwrap();
    fs::set_permissions(&fake_codex, fs::Permissions::from_mode(0o700)).unwrap();
    let path = env::join_paths(
        std::iter::once(fake_bin.clone()).chain(env::split_paths(&env::var_os("PATH").unwrap())),
    )
    .unwrap();

    command(&credentials_home, &codex_home, &runtime_dir)
        .env("PATH", path)
        .env("KAI_TEST_CREDENTIAL", &enrolled_path)
        .args(["cred", "add", "bob@example.com"])
        .assert()
        .success()
        .stderr(predicate::str::contains("Enrolled bob@example.com"));
    assert_eq!(
        fs::read(codex_home.join("auth.json")).unwrap(),
        alice_refreshed
    );

    command(&credentials_home, &codex_home, &runtime_dir)
        .arg("next")
        .assert()
        .success();
    assert_eq!(fs::read(codex_home.join("auth.json")).unwrap(), bob);

    command(&credentials_home, &codex_home, &runtime_dir)
        .args(["cred", "next"])
        .assert()
        .success();
    assert_eq!(
        fs::read(codex_home.join("auth.json")).unwrap(),
        alice_refreshed
    );

    command(&credentials_home, &codex_home, &runtime_dir)
        .args(["cred", "list", "--json"])
        .assert()
        .success()
        .stdout(predicate::str::contains(
            "\"active\": \"alice@example.com\"",
        ))
        .stdout(predicate::str::contains("alice-refreshed").not())
        .stdout(predicate::str::contains("bob-refresh").not());

    command(&credentials_home, &codex_home, &runtime_dir)
        .args(["cred", "remove", "bob@example.com", "--yes"])
        .assert()
        .success();
    command(&credentials_home, &codex_home, &runtime_dir)
        .args(["cred", "list", "--json"])
        .assert()
        .success()
        .stdout(predicate::str::contains("\"email\": \"alice@example.com\""))
        .stdout(predicate::str::contains("bob@example.com").not());
}