claude-code-status-line 1.2.2

A configurable status line for Claude Code with powerline arrows, context tracking, and quota monitoring
Documentation
use crate::colors::SectionColors;
use crate::config::Config;
use crate::render::get_details_and_fg_codes;
use crate::types::{CachedQuota, KeychainCredentials, QuotaData, QuotaResponse};
use chrono::{DateTime, Duration, Utc};
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::{SystemTime, UNIX_EPOCH};

fn get_oauth_token() -> Option<String> {
    if let Ok(token) = std::env::var("CLAUDE_OAUTH_TOKEN") {
        if token.contains('\r') || token.contains('\n') {
            return None;
        }
        return Some(token);
    }

    #[cfg(target_os = "macos")]
    {
        let output = Command::new("security")
            .args([
                "find-generic-password",
                "-s",
                "Claude Code-credentials",
                "-w",
            ])
            .output()
            .ok()?;

        if !output.status.success() {
            return None;
        }

        let creds_json = String::from_utf8(output.stdout).ok()?;
        let creds: KeychainCredentials = serde_json::from_str(&creds_json).ok()?;
        let token = creds.claude_ai_oauth?.access_token;
        if token.contains('\r') || token.contains('\n') {
            return None;
        }
        Some(token)
    }

    #[cfg(not(target_os = "macos"))]
    {
        // On Linux/Windows, try ~/.claude/.credentials.json first
        #[cfg(unix)]
        let home_dir = std::env::var_os("HOME");

        #[cfg(windows)]
        let home_dir = std::env::var_os("USERPROFILE");

        if let Some(home) = home_dir {
            let creds_path = std::path::Path::new(&home)
                .join(".claude")
                .join(".credentials.json");
            if let Ok(creds_json) = fs::read_to_string(creds_path) {
                if let Ok(creds) = serde_json::from_str::<KeychainCredentials>(&creds_json) {
                    if let Some(oauth) = creds.claude_ai_oauth {
                        let token = oauth.access_token;
                        if !token.contains('\r') && !token.contains('\n') {
                            return Some(token);
                        }
                    }
                }
            }
        }

        // Fallback: try keyring crate (for other credential storage systems)
        let username = std::env::var("USER")
            .or_else(|_| std::env::var("USERNAME"))
            .unwrap_or_else(|_| "default".to_string());

        let entry = keyring::Entry::new("Claude Code-credentials", &username).ok()?;
        let creds_json = entry.get_password().ok()?;
        let creds: KeychainCredentials = serde_json::from_str(&creds_json).ok()?;
        let token = creds.claude_ai_oauth?.access_token;
        if token.contains('\r') || token.contains('\n') {
            return None;
        }
        Some(token)
    }
}

fn fetch_quota_from_api_safe(token: &str) -> Option<QuotaData> {
    let output = match Command::new("curl")
        .args([
            "-s", // Silent
            "-f", // Fail on HTTP error (4xx/5xx) - CRITICAL for cache safety
            "-m",
            "1", // 1 second timeout
            "-H",
            "Accept: application/json",
            "-H",
            "Content-Type: application/json",
            "-H",
            "User-Agent: claude-code/2.0.31",
            "-H",
            &format!("Authorization: Bearer {}", token),
            "-H",
            "anthropic-beta: oauth-2025-04-20",
            "https://api.anthropic.com/api/oauth/usage",
        ])
        .output()
    {
        Ok(o) => o,
        Err(e) => {
            if std::env::var("STATUSLINE_DEBUG").is_ok() {
                eprintln!("statusline warning: curl not available: {}", e);
            }
            return None;
        }
    };

    if !output.status.success() {
        if std::env::var("STATUSLINE_DEBUG").is_ok() {
            eprintln!(
                "statusline warning: quota fetch failed with status {:?}",
                output.status
            );
        }
        return None;
    }

    let q: QuotaResponse = serde_json::from_slice(&output.stdout).ok()?;

    Some(QuotaData {
        five_hour_pct: q.five_hour.as_ref().map(|x| x.utilization),
        five_hour_resets_at: q.five_hour.and_then(|x| x.resets_at),
        seven_day_pct: q.seven_day.as_ref().map(|x| x.utilization),
        seven_day_resets_at: q.seven_day.and_then(|x| x.resets_at),
    })
}

fn get_cache_path() -> PathBuf {
    let temp_dir = std::env::temp_dir();
    #[cfg(unix)]
    let uid = unsafe { libc::getuid() };
    #[cfg(not(unix))]
    let uid = std::env::var("USERNAME")
        .or_else(|_| std::env::var("USER"))
        .unwrap_or_else(|_| "default".to_string());
    temp_dir.join(format!("claude-statusline-quota-{}.json", uid))
}

fn write_cache_atomic(path: &Path, content: &str) -> std::io::Result<()> {
    use std::io::Write;
    let dir = path.parent().unwrap_or_else(|| Path::new("."));
    let mut f = tempfile::NamedTempFile::new_in(dir)?;
    f.write_all(content.as_bytes())?;
    f.persist(path).map(|_| ()).map_err(|e| e.error)
}

#[allow(clippy::absurd_extreme_comparisons)]
pub fn get_quota(config: &Config) -> Option<QuotaData> {
    let cache_path = get_cache_path();
    let now = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap_or_default()
        .as_secs();

    let cached_full = fs::read_to_string(&cache_path)
        .ok()
        .and_then(|content| serde_json::from_str::<CachedQuota>(&content).ok());

    let is_fresh = if config.sections.quota.cache_ttl == 0 {
        false
    } else if let Some(cache) = &cached_full {
        now.saturating_sub(cache.timestamp) < config.sections.quota.cache_ttl
    } else {
        false
    };

    if is_fresh {
        return cached_full.map(|c| c.data);
    }

    if let Some(token) = get_oauth_token() {
        if let Some(fresh_data) = fetch_quota_from_api_safe(&token) {
            let cache = CachedQuota {
                timestamp: now,
                data: fresh_data.clone(),
            };
            if let Ok(json) = serde_json::to_string(&cache) {
                let _ = write_cache_atomic(&cache_path, &json);
            }
            return Some(fresh_data);
        }
    }

    cached_full.map(|c| c.data)
}

pub fn format_quota_display(
    label: &str,
    pct: Option<f64>,
    reset: &str,
    colors: &SectionColors,
    config: &Config,
) -> String {
    let pct_str = match pct {
        Some(p) => format!("{}%", p),
        None => "-".to_string(),
    };

    if reset.is_empty() {
        format!("{}: {}", label, pct_str)
    } else {
        let (details, fg) = get_details_and_fg_codes(colors, config);
        format!("{}: {} {}({}){}", label, pct_str, details, reset, fg)
    }
}

pub fn format_quota_compact(label: &str, pct: Option<f64>) -> String {
    match pct {
        Some(p) => format!("{}: {}%", label, p),
        None => format!("{}: -", label),
    }
}

pub fn format_time_remaining(resets_at: &str, now: DateTime<Utc>) -> String {
    let reset_time = match DateTime::parse_from_rfc3339(resets_at) {
        Ok(t) => t.with_timezone(&Utc),
        Err(_) => return String::new(),
    };

    let duration = reset_time.signed_duration_since(now);

    if duration <= Duration::zero() {
        return "now".to_string();
    }

    let total_minutes = duration.num_minutes();
    let total_hours = duration.num_hours();
    let total_days = duration.num_days();

    if total_days >= 1 {
        let hours = total_hours % 24;
        format!("{}d {}h", total_days, hours)
    } else if total_hours >= 1 {
        let minutes = total_minutes % 60;
        format!("{}h {}m", total_hours, minutes)
    } else {
        format!("{}m", total_minutes.max(1))
    }
}

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

    #[test]
    fn test_format_time_remaining_now() {
        let now = Utc::now();
        let past = now - Duration::minutes(5);
        assert_eq!(format_time_remaining(&past.to_rfc3339(), now), "now");
    }

    #[test]
    fn test_format_time_remaining_minutes() {
        let now = Utc::now();
        let future = now + Duration::minutes(45);
        let result = format_time_remaining(&future.to_rfc3339(), now);
        assert!(result.ends_with('m'));
        assert!(!result.contains('h'));
        assert!(!result.contains('d'));
    }

    #[test]
    fn test_format_time_remaining_hours() {
        let now = Utc::now();
        let future = now + Duration::hours(5) + Duration::minutes(30);
        let result = format_time_remaining(&future.to_rfc3339(), now);
        assert!(result.contains('h'));
        assert!(result.contains('m'));
        assert!(!result.contains('d'));
    }

    #[test]
    fn test_format_time_remaining_days() {
        let now = Utc::now();
        let future = now + Duration::days(3) + Duration::hours(12);
        let result = format_time_remaining(&future.to_rfc3339(), now);
        assert!(result.contains('d'));
        assert!(result.contains('h'));
    }
}