kai-tool 0.1.6

CLI helpers for AI coding, Codex credentials, and git worktree management.
use std::time::{SystemTime, UNIX_EPOCH};

use anyhow::Result;
use capulus::ui::{Color, RenderTarget, stdout_render_target};
use serde::Serialize;

const FIELD_SEPARATOR: &str = " · ";

#[derive(Debug, Serialize)]
pub struct ListView {
    pub active: Option<String>,
    pub next: Option<String>,
    pub accounts: Vec<AccountView>,
}

#[derive(Debug, Serialize)]
pub struct AccountView {
    pub email: String,
    pub active: bool,
    pub plan: Option<String>,
    pub access_expires_at: Option<i64>,
    pub last_refresh: Option<String>,
    pub status: AccountStatus,
}

#[derive(Debug, Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum AccountStatus {
    Ready,
    Invalid { error: String },
}

pub fn print_list(view: &ListView, json: bool) -> Result<()> {
    if json {
        println!("{}", serde_json::to_string_pretty(view)?);
        return Ok(());
    }
    if view.accounts.is_empty() {
        println!("No Codex accounts enrolled.");
        println!("Run `kai cred add <email>` to import the current account or enroll a new one.");
        return Ok(());
    }

    let target = stdout_render_target();
    let email_width = view
        .accounts
        .iter()
        .map(|account| account.email.chars().count())
        .max()
        .unwrap_or_default();
    for account in &view.accounts {
        let (bullet, color) = match (&account.status, account.active) {
            (AccountStatus::Invalid { .. }, _) => ("×", Color::Red),
            (_, true) => ("", Color::Green),
            _ => ("", Color::Cyan),
        };
        let mut fields = Vec::new();
        if account.active {
            fields.push(target.paint("active", Color::Green));
        }
        if let Some(plan) = &account.plan {
            fields.push(plan.to_ascii_uppercase());
        }
        match &account.status {
            AccountStatus::Ready => {
                if let Some(expires_at) = account.access_expires_at {
                    fields.push(render_access_expiry(expires_at, &target));
                }
            }
            AccountStatus::Invalid { error } => {
                fields.push(target.paint(&format!("invalid: {error}"), Color::Red));
            }
        }
        println!(
            "{} {:email_width$}{}{}",
            target.paint(bullet, color),
            account.email,
            if fields.is_empty() {
                ""
            } else {
                FIELD_SEPARATOR
            },
            fields.join(FIELD_SEPARATOR),
        );
    }

    println!();
    let noun = if view.accounts.len() == 1 {
        "account"
    } else {
        "accounts"
    };
    match &view.next {
        Some(next) => println!("{} {noun} enrolled · next: {next}", view.accounts.len()),
        None => println!("{} {noun} enrolled", view.accounts.len()),
    }
    Ok(())
}

fn render_access_expiry(expires_at: i64, target: &impl RenderTarget) -> String {
    let now = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap_or_default()
        .as_secs() as i64;
    let remaining = expires_at - now;
    if remaining <= 0 {
        return target.paint("access refresh due", Color::Yellow);
    }
    if remaining < 120 {
        return "access <2m".to_owned();
    }
    if remaining < 7200 {
        return format!("access {}m", remaining / 60);
    }
    format!("access {}h", remaining / 3600)
}