using System.IO; using System.Net.Http; using System.Text.Json; using ClaudeOverview.Models; namespace ClaudeOverview.Services; /// /// Fetches subscription usage (session / weekly limits) from the same endpoint Claude /// Code's /usage command uses, authenticating with the OAuth token stored in /// ~/.claude/.credentials.json (read fresh each call so it tracks token refreshes). /// public sealed class UsageService { private const string Endpoint = "https://api.anthropic.com/api/oauth/usage"; private static readonly HttpClient Http = new() { Timeout = TimeSpan.FromSeconds(12) }; private readonly AppConfig _config; public UsageService(AppConfig config) => _config = config; public async Task FetchAsync() { try { var token = ReadAccessToken(out var tokenError); if (token is null) return new UsageInfo { Error = tokenError }; using var req = new HttpRequestMessage(HttpMethod.Get, Endpoint); req.Headers.TryAddWithoutValidation("Authorization", "Bearer " + token); req.Headers.TryAddWithoutValidation("anthropic-beta", "oauth-2025-04-20"); using var resp = await Http.SendAsync(req); if (!resp.IsSuccessStatusCode) { if (resp.StatusCode == System.Net.HttpStatusCode.TooManyRequests) { return new UsageInfo { Error = "rate limited (429 — backing off)", RateLimited = true, RetryAfter = resp.Headers.RetryAfter?.Delta ?? (resp.Headers.RetryAfter?.Date is { } retryAt ? retryAt.ToUniversalTime() - DateTimeOffset.UtcNow : null), }; } var hint = resp.StatusCode == System.Net.HttpStatusCode.Unauthorized ? " (token expired — open Claude Code to refresh)" : ""; return new UsageInfo { Error = $"usage HTTP {(int)resp.StatusCode}{hint}" }; } await using var stream = await resp.Content.ReadAsStreamAsync(); using var doc = await JsonDocument.ParseAsync(stream); var root = doc.RootElement; var info = new UsageInfo(); if (root.TryGetProperty("five_hour", out var fh) && fh.ValueKind == JsonValueKind.Object) { info.SessionPercent = GetDouble(fh, "utilization"); info.SessionResetsAt = GetDate(fh, "resets_at"); } if (root.TryGetProperty("seven_day", out var sd) && sd.ValueKind == JsonValueKind.Object) { info.WeeklyPercent = GetDouble(sd, "utilization"); info.WeeklyResetsAt = GetDate(sd, "resets_at"); } return info; } catch (Exception ex) { return new UsageInfo { Error = ex.Message }; } } private string? ReadAccessToken(out string? error) { error = null; var path = _config.CredentialsPath; if (!File.Exists(path)) { error = "not signed in (no credentials)"; return null; } try { using var fs = File.OpenRead(path); using var doc = JsonDocument.Parse(fs); if (!doc.RootElement.TryGetProperty("claudeAiOauth", out var oauth)) { error = "no OAuth session"; return null; } var token = oauth.TryGetProperty("accessToken", out var t) ? t.GetString() : null; if (string.IsNullOrEmpty(token)) { error = "no access token"; return null; } return token; } catch (Exception ex) { error = $"credentials read failed: {ex.Message}"; return null; } } private static double GetDouble(JsonElement e, string name) => e.TryGetProperty(name, out var p) && p.ValueKind == JsonValueKind.Number ? p.GetDouble() : 0; private static DateTime? GetDate(JsonElement e, string name) => e.TryGetProperty(name, out var p) && p.ValueKind == JsonValueKind.String && DateTime.TryParse(p.GetString(), null, System.Globalization.DateTimeStyles.AdjustToUniversal, out var dt) ? dt : null; }