diff --git a/Cargo.lock b/Cargo.lock index 51c5981..b68edaa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -142,6 +142,7 @@ dependencies = [ "log", "notify", "reqwest", + "security-framework", "serde", "serde_json", "serde_yaml", diff --git a/packages/ui/src/modules/usage/index.tsx b/packages/ui/src/modules/usage/index.tsx index dcbbdaa..b13a761 100644 --- a/packages/ui/src/modules/usage/index.tsx +++ b/packages/ui/src/modules/usage/index.tsx @@ -1,6 +1,6 @@ "use client"; -import { Clock, RefreshCw } from "lucide-react"; +import { Clock, Crown, RefreshCw } from "lucide-react"; import { CardError } from "@/components/card-error"; import { PageHeader } from "@/components/page-header"; import { Button } from "@/components/ui/button"; @@ -13,6 +13,24 @@ import { } from "./components"; import { useService } from "./use-service"; +const TIER_CONFIG: Record = { + MAX: { + label: "Claude Max", + color: + "from-violet-500/15 to-fuchsia-500/15 dark:from-violet-500/25 dark:to-fuchsia-500/25", + }, + PRO: { + label: "Claude Pro", + color: + "from-sky-500/15 to-blue-500/15 dark:from-sky-500/25 dark:to-blue-500/25", + }, + API: { + label: "API Usage", + color: + "from-emerald-500/15 to-teal-500/15 dark:from-emerald-500/25 dark:to-teal-500/25", + }, +} as const; + function formatRelativeTime(fetchedAt: number, now: number): string { if (!fetchedAt) return "never"; const diff = Math.floor((now - fetchedAt) / 1000); @@ -26,6 +44,7 @@ export function Usage() { useService(); const usage = data?.usage; + const subscriptionType = data?.subscriptionType; return (
@@ -61,6 +80,11 @@ export function Usage() { {status === "success" && usage && (
+ {/* Subscription tier banner */} + {subscriptionType && ( + + )} + {/* Session limit (5-hour) */} {usage.fiveHour && (
@@ -140,3 +164,27 @@ export function Usage() {
); } + +function SubscriptionBanner({ type }: { type: string }) { + const config = TIER_CONFIG[type] ?? { + label: type, + color: "from-primary/10 to-primary/5", + }; + + return ( +
+
+ +
+
+

{config.label}

+

Active subscription

+
+ + {type} + +
+ ); +} diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 048186a..e441e8d 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -50,5 +50,8 @@ serde_yaml = "0.9" which = "8" tauri-plugin-opener = "2" +[target.'cfg(target_os = "macos")'.dependencies] +security-framework = "3" + [dev-dependencies] tempfile = "3" diff --git a/src-tauri/src/services/claude_credentials.rs b/src-tauri/src/services/claude_credentials.rs index 88ca309..79cdbc4 100644 --- a/src-tauri/src/services/claude_credentials.rs +++ b/src-tauri/src/services/claude_credentials.rs @@ -1,81 +1,317 @@ //! Claude OAuth credential loader //! -//! Reads Claude Code's OAuth access token from Keychain, file, or environment. -//! Does NOT implement token refresh — Claude Code handles that itself. +//! Reads Claude Code's OAuth credentials from file, Keychain, or environment. +//! Includes in-memory cache with 5-minute TTL to avoid repeated Keychain/file reads. use std::path::PathBuf; +use std::sync::Mutex; +use std::time::Instant; +#[cfg(target_os = "macos")] const KEYCHAIN_SERVICE: &str = "Claude Code-credentials"; const CREDENTIALS_FILE: &str = ".claude/.credentials.json"; const ENV_TOKEN: &str = "CLAUDE_CODE_OAUTH_TOKEN"; -/// Load the Claude OAuth access token from available sources. +/// Cache TTL: 5 minutes. Forces reload from file to detect external changes. +const CACHE_TTL_SECS: u64 = 5 * 60; + +/// Full OAuth credentials from Claude Code. +#[derive(Debug, Clone)] +pub struct ClaudeCredentials { + pub access_token: String, + pub refresh_token: Option, + pub expires_at: Option, // milliseconds since epoch + pub subscription_type: Option, + pub source: CredentialSource, + /// Raw JSON data for persisting updates back to the source. + pub full_data: serde_json::Value, + /// macOS Keychain account name (preserved for writes to avoid creating duplicate entries). + #[cfg_attr(not(target_os = "macos"), allow(dead_code))] + pub keychain_account: Option, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum CredentialSource { + File, + #[cfg_attr(not(target_os = "macos"), allow(dead_code))] + Keychain, + Environment, +} + +// --- In-memory cache --- + +struct CacheEntry { + credentials: ClaudeCredentials, + cached_at: Instant, +} + +static CACHE: Mutex> = Mutex::new(None); + +fn cache_get() -> Option { + let lock = CACHE.lock().ok()?; + let entry = lock.as_ref()?; + if entry.cached_at.elapsed().as_secs() > CACHE_TTL_SECS { + return None; + } + Some(entry.credentials.clone()) +} + +fn cache_set(creds: &ClaudeCredentials) { + if let Ok(mut lock) = CACHE.lock() { + *lock = Some(CacheEntry { + credentials: creds.clone(), + cached_at: Instant::now(), + }); + } +} + +/// Clear the credential cache. Call this after auth failures so next +/// load re-reads from file/keychain (Claude Code may have refreshed externally). +pub fn clear_cache() { + if let Ok(mut lock) = CACHE.lock() { + *lock = None; + } +} + +/// Load Claude OAuth credentials from cache or available sources. /// -/// Priority: -/// 1. File: `~/.claude/.credentials.json` -/// 2. macOS Keychain: service "Claude Code-credentials" -/// 3. Environment variable: `CLAUDE_CODE_OAUTH_TOKEN` -pub fn load_access_token() -> Option { - load_from_file() +/// Priority: cache → file → keychain → environment variable. +pub fn load_credentials() -> Option { + // Check cache first + if let Some(cached) = cache_get() { + log::debug!("Loaded Claude credentials from cache"); + return Some(cached); + } + + let creds = load_from_file() .or_else(load_from_keychain) - .or_else(load_from_env) + .or_else(load_from_env); + + if let Some(ref c) = creds { + cache_set(c); + } else { + log::warn!( + "No Claude credentials found (checked file, keychain, env var '{}')", + ENV_TOKEN + ); + } + + creds } -fn load_from_file() -> Option { - let home = dirs::home_dir()?; - let path: PathBuf = home.join(CREDENTIALS_FILE); +/// Force-reload credentials from file/keychain, bypassing cache. +pub fn reload_credentials() -> Option { + clear_cache(); + let creds = load_from_file() + .or_else(load_from_keychain) + .or_else(load_from_env); - let data = std::fs::read_to_string(&path).ok()?; - let json: serde_json::Value = serde_json::from_str(&data).ok()?; + if let Some(ref c) = creds { + cache_set(c); + } + + creds +} - let token = json - .get("claudeAiOauth")? +/// Save updated credentials back to the original source and update cache. +pub fn save_credentials(creds: &ClaudeCredentials) { + match creds.source { + CredentialSource::File => save_to_file(creds), + CredentialSource::Keychain => save_to_keychain(creds), + CredentialSource::Environment => {} + } + cache_set(creds); +} + +// --- Extract credentials from JSON --- + +fn extract_credentials( + json: &serde_json::Value, + source: CredentialSource, + keychain_account: Option, +) -> Option { + let oauth = json.get("claudeAiOauth")?; + + let access_token = oauth .get("accessToken")? .as_str()? .trim() .to_string(); - if token.is_empty() { + if access_token.is_empty() { return None; } - log::debug!("Loaded Claude credentials from file"); - Some(token) + let refresh_token = oauth + .get("refreshToken") + .and_then(|v| v.as_str()) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()); + + let expires_at = oauth.get("expiresAt").and_then(|v| v.as_f64()); + + let subscription_type = oauth + .get("subscriptionType") + .and_then(|v| v.as_str()) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()); + + Some(ClaudeCredentials { + access_token, + refresh_token, + expires_at, + subscription_type, + source, + full_data: json.clone(), + keychain_account, + }) +} + +// --- Source loaders --- + +fn load_from_file() -> Option { + let home = dirs::home_dir()?; + let path: PathBuf = home.join(CREDENTIALS_FILE); + + let data = match std::fs::read_to_string(&path) { + Ok(d) => d, + Err(e) => { + log::debug!("Credentials file not found at {}: {}", path.display(), e); + return None; + } + }; + + let json: serde_json::Value = match serde_json::from_str(&data) { + Ok(v) => v, + Err(e) => { + log::warn!( + "Failed to parse credentials file {}: {}", + path.display(), + e + ); + return None; + } + }; + + let creds = extract_credentials(&json, CredentialSource::File, None); + if creds.is_some() { + log::debug!("Loaded Claude credentials from file"); + } else { + log::warn!("Credentials file exists but missing accessToken"); + } + creds +} + +#[cfg(target_os = "macos")] +fn load_from_keychain() -> Option { + // Try native API first (fast, triggers proper auth dialog), then CLI fallback + // (service-only search, compatible with any account name). + load_from_keychain_native().or_else(load_from_keychain_cli) } -fn load_from_keychain() -> Option { +/// Native Keychain API: requires exact service + account match. +#[cfg(target_os = "macos")] +fn load_from_keychain_native() -> Option { + let username = dirs::home_dir() + .and_then(|h| h.file_name().map(|n| n.to_string_lossy().into_owned()))?; + + let password_bytes = + match security_framework::passwords::get_generic_password(KEYCHAIN_SERVICE, &username) { + Ok(bytes) => bytes, + Err(e) => { + log::debug!( + "Keychain native lookup failed for service='{}' account='{}': {}", + KEYCHAIN_SERVICE, + username, + e + ); + return None; + } + }; + + parse_keychain_bytes(password_bytes, "Keychain (native)", Some(username)) +} + +/// CLI fallback: searches by service name only, matches any account. +/// Handles cases where the keychain entry's account differs from the local username. +#[cfg(target_os = "macos")] +fn load_from_keychain_cli() -> Option { + // First, discover the actual account name for this service entry. + let acct_output = std::process::Command::new("security") + .args(["find-generic-password", "-s", KEYCHAIN_SERVICE]) + .output() + .ok()?; + + let acct_name = if acct_output.status.success() { + let stderr = String::from_utf8_lossy(&acct_output.stdout); + // Parse "acct"="username" from output + stderr + .lines() + .find(|l| l.contains("\"acct\"")) + .and_then(|l| l.split('=').nth(1)) + .map(|s| s.trim().trim_matches('"').to_string()) + } else { + None + }; + + // Now get the password let output = std::process::Command::new("security") .args(["find-generic-password", "-s", KEYCHAIN_SERVICE, "-w"]) .output() .ok()?; if !output.status.success() { + log::debug!("Keychain CLI lookup failed (exit {})", output.status); return None; } - let json_str = String::from_utf8(output.stdout).ok()?.trim().to_string(); - if json_str.is_empty() { + let raw = String::from_utf8(output.stdout).ok()?; + let trimmed = raw.trim(); + if trimmed.is_empty() { return None; } - let json: serde_json::Value = serde_json::from_str(&json_str).ok()?; + parse_keychain_bytes(trimmed.as_bytes().to_vec(), "Keychain (CLI)", acct_name) +} - let token = json - .get("claudeAiOauth")? - .get("accessToken")? - .as_str()? - .trim() - .to_string(); +/// Shared parser for keychain data (used by both native and CLI paths). +#[cfg(target_os = "macos")] +fn parse_keychain_bytes( + bytes: Vec, + source_label: &str, + keychain_account: Option, +) -> Option { + let json_str = match String::from_utf8(bytes) { + Ok(s) => s, + Err(e) => { + log::warn!("{} value is not valid UTF-8: {}", source_label, e); + return None; + } + }; - if token.is_empty() { - return None; + let json: serde_json::Value = match serde_json::from_str(json_str.trim()) { + Ok(v) => v, + Err(e) => { + log::warn!("{} value is not valid JSON: {}", source_label, e); + return None; + } + }; + + let creds = extract_credentials(&json, CredentialSource::Keychain, keychain_account); + if creds.is_some() { + log::debug!("Loaded Claude credentials from {}", source_label); + } else { + log::warn!("{} entry exists but missing accessToken", source_label); } + creds +} - log::debug!("Loaded Claude credentials from Keychain"); - Some(token) +#[cfg(not(target_os = "macos"))] +fn load_from_keychain() -> Option { + None } -fn load_from_env() -> Option { +fn load_from_env() -> Option { let token = std::env::var(ENV_TOKEN).ok()?.trim().to_string(); if token.is_empty() { @@ -83,5 +319,97 @@ fn load_from_env() -> Option { } log::debug!("Loaded Claude credentials from environment"); - Some(token) + Some(ClaudeCredentials { + access_token: token, + refresh_token: None, + expires_at: None, + subscription_type: None, + source: CredentialSource::Environment, + full_data: serde_json::Value::Object(serde_json::Map::new()), + keychain_account: None, + }) } + +// --- Save helpers --- + +fn build_updated_json(creds: &ClaudeCredentials) -> serde_json::Value { + let mut data = creds.full_data.clone(); + let oauth = data + .as_object_mut() + .and_then(|obj| obj.get_mut("claudeAiOauth")) + .and_then(|v| v.as_object_mut()); + + if let Some(oauth) = oauth { + oauth.insert( + "accessToken".to_string(), + serde_json::Value::String(creds.access_token.clone()), + ); + if let Some(ref rt) = creds.refresh_token { + oauth.insert( + "refreshToken".to_string(), + serde_json::Value::String(rt.clone()), + ); + } + if let Some(exp) = creds.expires_at { + oauth.insert("expiresAt".to_string(), serde_json::json!(exp)); + } + } + + data +} + +fn save_to_file(creds: &ClaudeCredentials) { + let Some(home) = dirs::home_dir() else { + return; + }; + let path = home.join(CREDENTIALS_FILE); + let data = build_updated_json(creds); + + match serde_json::to_string_pretty(&data) { + Ok(json_str) => { + if let Err(e) = std::fs::write(&path, json_str) { + log::warn!("Failed to save credentials to file: {}", e); + } else { + log::debug!("Saved updated credentials to file"); + } + } + Err(e) => log::warn!("Failed to serialize credentials: {}", e), + } +} + +#[cfg(target_os = "macos")] +fn save_to_keychain(creds: &ClaudeCredentials) { + // Use the original account name from when we loaded the entry, + // falling back to the local username if unknown. + let account = creds + .keychain_account + .clone() + .or_else(|| { + dirs::home_dir() + .and_then(|h| h.file_name().map(|n| n.to_string_lossy().into_owned())) + }); + + let Some(account) = account else { return }; + + let data = build_updated_json(creds); + let json_str = match serde_json::to_string(&data) { + Ok(s) => s, + Err(e) => { + log::warn!("Failed to serialize credentials for keychain: {}", e); + return; + } + }; + + if let Err(e) = security_framework::passwords::set_generic_password( + KEYCHAIN_SERVICE, + &account, + json_str.as_bytes(), + ) { + log::warn!("Failed to save credentials to keychain: {}", e); + } else { + log::debug!("Saved updated credentials to keychain"); + } +} + +#[cfg(not(target_os = "macos"))] +fn save_to_keychain(_creds: &ClaudeCredentials) {} diff --git a/src-tauri/src/services/subscription_usage_service.rs b/src-tauri/src/services/subscription_usage_service.rs index 57c953f..dc701c5 100644 --- a/src-tauri/src/services/subscription_usage_service.rs +++ b/src-tauri/src/services/subscription_usage_service.rs @@ -1,16 +1,24 @@ //! Subscription usage service //! -//! Fetches Claude Pro/Max subscription usage from Anthropic's OAuth API -//! using credentials managed by Claude Code. +//! Fetches Claude Pro/Max subscription usage via: +//! 1. OAuth API (primary) with automatic token refresh +//! 2. CLI fallback (`claude /usage`) if API fails use anyhow::{Context, Result}; -use super::claude_credentials; +use super::claude_credentials::{self, ClaudeCredentials}; use crate::types::{SubscriptionUsageResponse, SubscriptionUsageResult}; const USAGE_URL: &str = "https://api.anthropic.com/api/oauth/usage"; +const REFRESH_URL: &str = "https://platform.claude.com/v1/oauth/token"; +const CLIENT_ID: &str = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"; +const SCOPES: &str = "user:profile user:inference user:sessions:claude_code"; + +/// 5-minute buffer before expiry to trigger refresh (in milliseconds). +const REFRESH_BUFFER_MS: f64 = 5.0 * 60.0 * 1000.0; + +// --- API response structures --- -/// API response structure (snake_case from the API, converted to camelCase by our types) #[derive(serde::Deserialize)] struct ApiUsageBucket { utilization: Option, @@ -34,39 +42,275 @@ struct ApiUsageResponse { extra_usage: Option, } +#[derive(serde::Deserialize)] +struct TokenRefreshResponse { + access_token: Option, + refresh_token: Option, + expires_in: Option, +} + +/// Internal error type to distinguish auth failures from other errors. +enum AuthOrError { + NeedsAuth, + Other(anyhow::Error), +} + pub struct SubscriptionUsageService; impl SubscriptionUsageService { pub async fn fetch_usage() -> Result { - let access_token = match claude_credentials::load_access_token() { - Some(token) => token, + // Derive subscription badge once — applies to all result paths (API + CLI). + let subscription_badge = claude_credentials::load_credentials() + .as_ref() + .and_then(Self::parse_subscription_badge); + + let mut result = match Self::fetch_via_api().await { + Ok(r) if !r.needs_login => r, + Ok(api_result) => { + // needs_login from API — try CLI fallback before giving up + log::debug!("API probe requires login, trying CLI fallback..."); + match Self::fetch_via_cli().await { + Ok(cli_result) => cli_result, + Err(e) => { + log::debug!("CLI fallback also failed: {}", e); + api_result + } + } + } + Err(api_err) => { + // API error — try CLI fallback + log::warn!("API probe failed: {}, trying CLI fallback...", api_err); + match Self::fetch_via_cli().await { + Ok(cli_result) => cli_result, + Err(cli_err) => { + log::warn!("CLI fallback also failed: {}", cli_err); + return Err(api_err); + } + } + } + }; + + // Ensure subscription badge is present on all paths + if result.subscription_type.is_none() { + result.subscription_type = subscription_badge; + } + + Ok(result) + } + + // --------------------------------------------------------------- + // API probe + // --------------------------------------------------------------- + + async fn fetch_via_api() -> Result { + let mut creds = match claude_credentials::load_credentials() { + Some(c) => c, None => { - return Ok(SubscriptionUsageResult { - needs_login: true, - usage: None, - error: None, - }); + return Ok(Self::login_result(None)); + } + }; + + let subscription_badge = Self::parse_subscription_badge(&creds); + + // Refresh token if expired or about to expire + if Self::needs_refresh(&creds) { + match Self::refresh_token(&creds).await { + Ok(refreshed) => creds = refreshed, + Err(e) => { + log::warn!("Token refresh failed: {}", e); + // Try reloading from file — CLI may have refreshed externally + claude_credentials::clear_cache(); + if let Some(fresh) = claude_credentials::reload_credentials() { + if fresh.access_token != creds.access_token { + log::debug!( + "Found updated credentials from file/keychain, retrying..." + ); + creds = fresh; + // If fresh creds also expired, try refreshing once more + if Self::needs_refresh(&creds) { + match Self::refresh_token(&creds).await { + Ok(refreshed) => creds = refreshed, + Err(e2) => { + log::warn!("Retry refresh also failed: {}", e2); + } + } + } + } + } + } + } + } + + // Fetch usage data + let client = reqwest::Client::new(); + match Self::call_usage_api(&client, &creds.access_token).await { + Ok(mut result) => { + result.subscription_type = subscription_badge; + Ok(result) + } + Err(AuthOrError::NeedsAuth) => { + // Token rejected — try refreshing once if we have a refresh token + if creds.refresh_token.is_some() { + log::debug!("Usage API returned 401/403, attempting token refresh..."); + claude_credentials::clear_cache(); + + // First try reloading from file (CLI may have refreshed) + if let Some(fresh) = claude_credentials::reload_credentials() { + if fresh.access_token != creds.access_token { + log::debug!("Found externally updated credentials, retrying..."); + if let Ok(mut r) = Self::call_usage_api(&client, &fresh.access_token).await { + r.subscription_type = subscription_badge; + return Ok(r); + } + } + } + + // Then try refreshing + match Self::refresh_token(&creds).await { + Ok(refreshed) => { + match Self::call_usage_api(&client, &refreshed.access_token).await { + Ok(mut r) => { + r.subscription_type = subscription_badge; + Ok(r) + } + Err(AuthOrError::NeedsAuth) => Ok(Self::login_result(subscription_badge)), + Err(AuthOrError::Other(e)) => Err(e), + } + } + Err(e) => { + log::warn!("Token refresh after 401/403 failed: {}", e); + Ok(Self::login_result(subscription_badge)) + } + } + } else { + Ok(Self::login_result(subscription_badge)) + } } + Err(AuthOrError::Other(e)) => Err(e), + } + } + + fn login_result(subscription_type: Option) -> SubscriptionUsageResult { + SubscriptionUsageResult { + needs_login: true, + usage: None, + error: None, + subscription_type, + } + } + + fn parse_subscription_badge(creds: &ClaudeCredentials) -> Option { + let raw = creds.subscription_type.as_deref()?; + let badge = match raw.to_lowercase().as_str() { + "claude_max" | "max" => "MAX", + "claude_pro" | "pro" => "PRO", + "api" | "claude_api" => "API", + _ => raw, }; + Some(badge.to_string()) + } + + fn needs_refresh(creds: &ClaudeCredentials) -> bool { + match creds.expires_at { + Some(expires_at) => { + let now_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs_f64() + * 1000.0; + now_ms + REFRESH_BUFFER_MS >= expires_at + } + // No expiry info — assume refresh needed if we have a refresh token + None => creds.refresh_token.is_some(), + } + } + + async fn refresh_token(creds: &ClaudeCredentials) -> Result { + let refresh_token = creds + .refresh_token + .as_ref() + .context("No refresh token available")?; + + log::debug!("Refreshing Claude OAuth token..."); + + let body = serde_json::json!({ + "grant_type": "refresh_token", + "refresh_token": refresh_token, + "client_id": CLIENT_ID, + "scope": SCOPES, + }); let client = reqwest::Client::new(); + let response = client + .post(REFRESH_URL) + .header("Content-Type", "application/json") + .json(&body) + .timeout(std::time::Duration::from_secs(15)) + .send() + .await + .context("Failed to connect to token refresh endpoint")?; + + let status = response.status(); + if !status.is_success() { + let body_text = response.text().await.unwrap_or_default(); + log::warn!( + "Token refresh failed with HTTP {}: {}", + status.as_u16(), + body_text + ); + anyhow::bail!("Token refresh failed: HTTP {}", status.as_u16()); + } + + let refresh_resp: TokenRefreshResponse = response + .json() + .await + .context("Failed to parse token refresh response")?; + + let new_access_token = refresh_resp + .access_token + .filter(|s| !s.is_empty()) + .context("No access token in refresh response")?; + + let now_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs_f64() + * 1000.0; + + let mut updated = creds.clone(); + updated.access_token = new_access_token; + if let Some(new_rt) = refresh_resp.refresh_token.filter(|s| !s.is_empty()) { + updated.refresh_token = Some(new_rt); + } + if let Some(expires_in) = refresh_resp.expires_in { + updated.expires_at = Some(now_ms + (expires_in as f64) * 1000.0); + } + + claude_credentials::save_credentials(&updated); + log::debug!("Claude OAuth token refreshed successfully"); + Ok(updated) + } + + async fn call_usage_api( + client: &reqwest::Client, + access_token: &str, + ) -> std::result::Result { let response = client .get(USAGE_URL) .header("Authorization", format!("Bearer {}", access_token)) .header("anthropic-beta", "oauth-2025-04-20") + .header("Accept", "application/json") .timeout(std::time::Duration::from_secs(15)) .send() .await - .context("Failed to connect to Anthropic API")?; + .map_err(|e| AuthOrError::Other(e.into()))?; let status = response.status(); if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN { - return Ok(SubscriptionUsageResult { - needs_login: true, - usage: None, - error: None, - }); + let body = response.text().await.unwrap_or_default(); + log::warn!("Usage API returned {} — body: {}", status.as_u16(), body); + return Err(AuthOrError::NeedsAuth); } if status == reqwest::StatusCode::TOO_MANY_REQUESTS { @@ -82,6 +326,7 @@ impl SubscriptionUsageService { "Rate limited. Please wait {} seconds before retrying.", retry_after )), + subscription_type: None, }); } @@ -90,6 +335,7 @@ impl SubscriptionUsageService { needs_login: false, usage: None, error: Some(format!("API returned HTTP {}", status.as_u16())), + subscription_type: None, }); } @@ -100,19 +346,146 @@ impl SubscriptionUsageService { needs_login: false, usage: None, error: Some(format!("Failed to parse usage response: {}", e)), + subscription_type: None, }); } }; - let usage = Self::convert_response(api_response); - Ok(SubscriptionUsageResult { needs_login: false, - usage: Some(usage), + usage: Some(Self::convert_response(api_response)), error: None, + subscription_type: None, // filled in by caller }) } + // --------------------------------------------------------------- + // CLI fallback + // --------------------------------------------------------------- + + async fn fetch_via_cli() -> Result { + let claude_path = which::which("claude") + .context("Claude CLI binary not found in PATH")?; + + log::debug!("CLI fallback: using {}", claude_path.display()); + + // Strip CLAUDE_CODE_OAUTH_TOKEN from env to force stored credentials + // (setup-tokens only have inference scope, not usage scope) + let env_vars: Vec<(String, String)> = std::env::vars() + .filter(|(k, _)| k != "CLAUDE_CODE_OAUTH_TOKEN") + .collect(); + + let output = tokio::process::Command::new(&claude_path) + .args(["/usage", "--output", "json", "--allowed-tools", ""]) + .env_clear() + .envs(env_vars) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .output() + .await + .context("Failed to execute claude /usage")?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + anyhow::bail!("claude /usage failed (exit {}): {}", output.status, stderr); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + + // Try parsing as JSON first (--output json may work) + if let Ok(api_resp) = serde_json::from_str::(&stdout) { + return Ok(SubscriptionUsageResult { + needs_login: false, + usage: Some(Self::convert_response(api_resp)), + error: None, + subscription_type: None, + }); + } + + // Try parsing as text output (fallback) + match Self::parse_cli_text_output(&stdout) { + Some(usage) => Ok(SubscriptionUsageResult { + needs_login: false, + usage: Some(usage), + error: None, + subscription_type: None, + }), + None => { + log::warn!("CLI fallback: could not parse output: {}", &stdout[..stdout.len().min(500)]); + anyhow::bail!("Failed to parse claude /usage output") + } + } + } + + /// Best-effort parser for `claude /usage` text output. + /// Looks for percentage patterns like "45.2% used" or "45%" and reset times. + fn parse_cli_text_output(text: &str) -> Option { + use crate::types::{ExtraUsage, UsageBucket}; + + // Strip ANSI escape codes + let clean = strip_ansi(text); + let lines: Vec<&str> = clean.lines().collect(); + + let mut five_hour: Option = None; + let mut seven_day: Option = None; + let mut seven_day_opus: Option = None; + let mut seven_day_sonnet: Option = None; + let mut extra_usage: Option = None; + + let mut i = 0; + while i < lines.len() { + let line = lines[i].trim().to_lowercase(); + + if line.contains("session") && line.contains("limit") || line.contains("5-hour") || line.contains("five") { + if let Some((util, resets)) = find_usage_in_nearby_lines(&lines, i) { + five_hour = Some(UsageBucket { utilization: Some(util), resets_at: resets }); + } + } else if line.contains("opus") { + if let Some((util, resets)) = find_usage_in_nearby_lines(&lines, i) { + seven_day_opus = Some(UsageBucket { utilization: Some(util), resets_at: resets }); + } + } else if line.contains("sonnet") { + if let Some((util, resets)) = find_usage_in_nearby_lines(&lines, i) { + seven_day_sonnet = Some(UsageBucket { utilization: Some(util), resets_at: resets }); + } + } else if (line.contains("weekly") || line.contains("7-day") || line.contains("seven")) + && !line.contains("opus") && !line.contains("sonnet") + { + if let Some((util, resets)) = find_usage_in_nearby_lines(&lines, i) { + seven_day = Some(UsageBucket { utilization: Some(util), resets_at: resets }); + } + } else if line.contains("extra") || line.contains("overage") || line.contains("pay") { + if let Some((util, _)) = find_usage_in_nearby_lines(&lines, i) { + extra_usage = Some(ExtraUsage { + is_enabled: true, + utilization: Some(util), + used_credits: None, + monthly_limit: None, + }); + } + } + + i += 1; + } + + // Only return if we found at least one bucket + if five_hour.is_some() || seven_day.is_some() || seven_day_opus.is_some() || seven_day_sonnet.is_some() { + Some(SubscriptionUsageResponse { + five_hour, + seven_day, + seven_day_opus, + seven_day_sonnet, + extra_usage, + }) + } else { + None + } + } + + // --------------------------------------------------------------- + // Response conversion + // --------------------------------------------------------------- + fn convert_response(api: ApiUsageResponse) -> SubscriptionUsageResponse { use crate::types::{ExtraUsage, UsageBucket}; @@ -135,3 +508,53 @@ impl SubscriptionUsageService { } } } + +// --- Helpers --- + +/// Strip ANSI escape codes from text. +fn strip_ansi(text: &str) -> String { + let mut result = String::with_capacity(text.len()); + let mut chars = text.chars().peekable(); + while let Some(ch) = chars.next() { + if ch == '\x1b' { + // Skip until we find the terminating letter + if chars.peek() == Some(&'[') { + chars.next(); + while let Some(&c) = chars.peek() { + chars.next(); + if c.is_ascii_alphabetic() { + break; + } + } + } + } else { + result.push(ch); + } + } + result +} + +/// Extract a percentage value from text (e.g. "45.2% used" → 45.2). +fn extract_percentage(text: &str) -> Option { + let text = text.trim(); + for word in text.split_whitespace() { + let word = word.trim_end_matches('%'); + if let Ok(val) = word.parse::() { + if (0.0..=100.0).contains(&val) { + return Some(val); + } + } + } + None +} + +/// Search nearby lines (current + next 5) for a percentage value. +fn find_usage_in_nearby_lines(lines: &[&str], start: usize) -> Option<(f64, Option)> { + let end = (start + 6).min(lines.len()); + for line in &lines[start..end] { + if let Some(pct) = extract_percentage(line) { + return Some((pct, None)); + } + } + None +} diff --git a/src-tauri/src/types/subscription_usage.rs b/src-tauri/src/types/subscription_usage.rs index 46ea237..41b20f5 100644 --- a/src-tauri/src/types/subscription_usage.rs +++ b/src-tauri/src/types/subscription_usage.rs @@ -50,4 +50,6 @@ pub struct SubscriptionUsageResult { pub needs_login: bool, pub usage: Option, pub error: Option, + /// Subscription tier badge (e.g. "MAX", "PRO", "API") + pub subscription_type: Option, }