claude-usage-tray 1.0.0

A simple tray application for Linux to show Claude's usage
use std::path::Path;
use std::time::SystemTime;

use serde::Deserialize;
use time::OffsetDateTime;
use time::format_description::well_known::Rfc3339;
use tokio::process::Command;
use tokio::sync::watch;
use tokio::time::sleep;
use tracing::{debug, info, warn};

use crate::config::Config;
use crate::state::{Money, UsageState, UsageStatus};

const USAGE_URL: &str = "https://api.anthropic.com/api/oauth/usage";
const OAUTH_BETA: &str = "oauth-2025-04-20";

#[derive(Deserialize)]
struct CredentialsFile {
    #[serde(rename = "claudeAiOauth")]
    oauth: OauthCredentials,
}

#[derive(Deserialize)]
struct OauthCredentials {
    #[serde(rename = "accessToken")]
    access_token: String,
    #[serde(rename = "expiresAt")]
    expires_at: i64,
    #[serde(rename = "subscriptionType")]
    subscription_type: Option<String>,
}

#[derive(Deserialize)]
struct AccountConfigFile {
    #[serde(rename = "oauthAccount")]
    oauth_account: Option<OauthAccount>,
}

#[derive(Deserialize)]
struct OauthAccount {
    #[serde(rename = "emailAddress")]
    email_address: Option<String>,
}

#[derive(Deserialize)]
struct UsageResponse {
    five_hour: Option<Window>,
    seven_day: Option<Window>,
    spend: Option<serde_json::Value>,
}

#[derive(Deserialize)]
struct Window {
    utilization: Option<f64>,
    resets_at: Option<String>,
}

enum Outcome {
    Ok {
        usage: UsageResponse,
        plan: Option<String>,
        email: Option<String>,
    },
    NeedsLogin,
    Transient {
        message: String,
        plan: Option<String>,
        email: Option<String>,
    },
}

fn load_credentials(path: &Path) -> Option<OauthCredentials> {
    let bytes = match std::fs::read(path) {
        Ok(b) => b,
        Err(e) => {
            debug!(path = %path.display(), error = %e, "credentials file not readable");
            return None;
        }
    };

    let file: CredentialsFile = match serde_json::from_slice(&bytes) {
        Ok(f) => f,
        Err(e) => {
            warn!(error = %e, "credentials file present but failed to parse");
            return None;
        }
    };

    let now_millis = SystemTime::now()
        .duration_since(SystemTime::UNIX_EPOCH)
        .ok()?
        .as_millis() as i64;

    if file.oauth.expires_at <= now_millis {
        debug!("credentials expired");
        return None;
    }

    Some(file.oauth)
}

fn load_account_email(path: &Path) -> Option<String> {
    let bytes = match std::fs::read(path) {
        Ok(b) => b,
        Err(e) => {
            debug!(path = %path.display(), error = %e, "account config file not readable");
            return None;
        }
    };

    let file: AccountConfigFile = match serde_json::from_slice(&bytes) {
        Ok(f) => f,
        Err(e) => {
            warn!(error = %e, "account config file present but failed to parse");
            return None;
        }
    };

    file.oauth_account.and_then(|a| a.email_address)
}

async fn detect_claude_version(fallback: &str) -> String {
    let output = Command::new("claude").arg("--version").output().await;
    let version = output.ok().and_then(|o| {
        if !o.status.success() {
            return None;
        }

        String::from_utf8(o.stdout)
            .ok()?
            .split_whitespace()
            .next()
            .map(str::to_owned)
    });

    match version {
        Some(v) => {
            info!(version = %v, "detected installed claude code version");
            v
        }
        None => {
            warn!(
                fallback,
                "could not detect claude code version, using fallback"
            );
            fallback.into()
        }
    }
}

async fn poll_once(
    client: &reqwest::Client,
    user_agent: &str,
    credentials_path: &Path,
    account_config_path: &Path,
) -> Outcome {
    let Some(creds) = load_credentials(credentials_path) else {
        return Outcome::NeedsLogin;
    };

    let plan = creds.subscription_type;
    let email = load_account_email(account_config_path);

    let response = client
        .get(USAGE_URL)
        .bearer_auth(creds.access_token)
        .header("anthropic-beta", OAUTH_BETA)
        .header("User-Agent", user_agent)
        .header("Content-Type", "application/json")
        .send()
        .await;

    let response = match response {
        Ok(r) => r,
        Err(e) => {
            warn!(error = %e, "usage request failed");
            return Outcome::Transient {
                message: e.to_string(),
                plan,
                email,
            };
        }
    };

    if response.status() == reqwest::StatusCode::UNAUTHORIZED {
        info!("access token rejected (401), needs login");
        return Outcome::NeedsLogin;
    }
    if !response.status().is_success() {
        warn!(status = %response.status(), "usage endpoint returned non-success status");
        return Outcome::Transient {
            message: format!("HTTP {}", response.status()),
            plan,
            email,
        };
    }

    match response.json::<UsageResponse>().await {
        Ok(usage) => Outcome::Ok { usage, plan, email },
        Err(e) => {
            warn!(error = %e, "failed to parse usage response");
            Outcome::Transient {
                message: e.to_string(),
                plan,
                email,
            }
        }
    }
}

fn parse_reset(s: &str) -> Option<SystemTime> {
    OffsetDateTime::parse(s, &Rfc3339)
        .ok()
        .map(SystemTime::from)
}

fn extract_money(v: &serde_json::Value) -> Option<Money> {
    let amount_minor = v.get("amount_minor")?.as_i64()?;
    let exponent = v.get("exponent")?.as_u64()? as i32;
    let currency = v.get("currency")?.as_str()?.to_string();
    Some(Money {
        amount: amount_minor as f64 / 10f64.powi(exponent),
        currency,
    })
}

fn extract_spend_field(spend: &serde_json::Value, key: &str) -> Option<Money> {
    let field = spend.get(key)?;
    if field.is_null() {
        debug!(field = key, "spend field is null");
        return None;
    }
    match extract_money(field) {
        Some(money) => Some(money),
        None => {
            debug!(
                field = key,
                "spend field present but not in expected money shape"
            );
            None
        }
    }
}

pub async fn run(tx: watch::Sender<UsageState>, config: Config) {
    let client = reqwest::Client::builder()
        .timeout(config.request_timeout())
        .build()
        .expect("failed to build HTTP client");

    let version = detect_claude_version(&config.claude_fallback_version).await;
    let user_agent = format!("claude-code/{}", version);
    let credentials_path = config.credentials_path();
    let account_config_path = config.account_config_path();
    let base_interval = config.poll_interval();
    let max_interval = config.max_poll_interval();

    let mut delay = base_interval;

    loop {
        let outcome = poll_once(
            &client,
            &user_agent,
            &credentials_path,
            &account_config_path,
        )
        .await;

        let next_state = match outcome {
            Outcome::Ok { usage, plan, email } => {
                delay = base_interval;
                let five_hour = usage.five_hour.as_ref().and_then(|w| w.utilization);
                let weekly = usage.seven_day.as_ref().and_then(|w| w.utilization);
                let credits_spent = usage
                    .spend
                    .as_ref()
                    .and_then(|s| extract_spend_field(s, "used"));
                let credits_limit = usage
                    .spend
                    .as_ref()
                    .and_then(|s| extract_spend_field(s, "limit"));
                let credits_total = usage
                    .spend
                    .as_ref()
                    .and_then(|s| extract_spend_field(s, "balance"));
                debug!(
                    five_hour = ?five_hour,
                    weekly = ?weekly,
                    plan = ?plan,
                    email = ?email,
                    credits_spent = ?credits_spent,
                    credits_limit = ?credits_limit,
                    credits_total = ?credits_total,
                    "usage updated"
                );
                UsageState {
                    five_hour_usage: five_hour,
                    five_hour_resets_at: usage
                        .five_hour
                        .as_ref()
                        .and_then(|w| w.resets_at.as_deref())
                        .and_then(parse_reset),
                    weekly_usage: weekly,
                    weekly_resets_at: usage
                        .seven_day
                        .as_ref()
                        .and_then(|w| w.resets_at.as_deref())
                        .and_then(parse_reset),
                    plan,
                    account_email: email,
                    credits_spent,
                    credits_limit,
                    credits_total,
                    status: UsageStatus::Ok,
                }
            }
            Outcome::Transient {
                message,
                plan,
                email,
            } => {
                delay = (delay * 2).min(max_interval);
                warn!(
                    error = %message,
                    next_retry_secs = delay.as_secs(),
                    "transient error polling usage, backing off"
                );
                let mut state = tx.borrow().clone();
                state.status = UsageStatus::Error(message);
                state.plan = plan;
                state.account_email = email;
                state
            }
            Outcome::NeedsLogin => {
                delay = base_interval;
                UsageState {
                    status: UsageStatus::NeedsLogin,
                    ..Default::default()
                }
            }
        };

        if tx.send(next_state).is_err() {
            debug!("state receiver dropped, stopping fetch loop");
            return;
        }

        sleep(delay).await;
    }
}