This commit is contained in:
2026-06-25 09:59:54 +02:00
commit bd2ecd0176
16 changed files with 2069 additions and 0 deletions

140
Services/AppConfig.cs Normal file
View File

@@ -0,0 +1,140 @@
using System.IO;
using System.Text.Json;
using System.Text.RegularExpressions;
namespace ClaudeOverview.Services;
/// <summary>Settings loaded from config.json next to the executable.</summary>
public sealed class AppConfig
{
public string WslDistro { get; set; } = "Ubuntu";
public string LinuxHome { get; set; } = "/home/philipp";
public string ProjectsPathOverride { get; set; } = "";
public int ActiveMinutes { get; set; } = 5;
/// <summary>
/// Fallback window (minutes) for showing "cold" (inactive) chats when the session-limit reset
/// time isn't available. Normally the cold cutoff is the start of the current session-limit
/// window (its reset time minus the 5-hour block); this is only used when that's unknown.
/// </summary>
public int ColdMinutes { get; set; } = 1440;
public int RefreshSeconds { get; set; } = 4;
/// <summary>Seconds a pending tool may sit before we treat it as a permission prompt (vs still running).</summary>
public int PendingPermissionSeconds { get; set; } = 8;
public int MonitorIndex { get; set; } = -1;
public int EdgeMargin { get; set; } = 12;
public bool OnlyVsCode { get; set; } = true;
public bool StartMinimized { get; set; } = true;
public bool FlashOnAttention { get; set; } = true;
public bool SoundOnAttention { get; set; } = false;
public bool ShowSessionLimit { get; set; } = true;
/// <summary>How often to poll the subscription usage endpoint (changes slowly).</summary>
public int UsageRefreshSeconds { get; set; } = 60;
/// <summary>Path to .credentials.json (sibling of the projects dir, i.e. inside .claude).</summary>
public string CredentialsPath
{
get
{
var claudeDir = Path.GetDirectoryName(ProjectsPath);
return claudeDir is null
? ""
: Path.Combine(claudeDir, ".credentials.json");
}
}
/// <summary>Resolves the Windows path to the .claude/projects directory.</summary>
public string ProjectsPath
{
get
{
if (!string.IsNullOrWhiteSpace(ProjectsPathOverride))
return ProjectsPathOverride;
// \\wsl.localhost\<Distro>\<linuxHome>\.claude\projects
var home = LinuxHome.Replace('/', '\\').TrimStart('\\');
return $@"\\wsl.localhost\{WslDistro}\{home}\.claude\projects";
}
}
/// <summary>
/// Translates a Linux path from a transcript (e.g. <c>/home/philipp/git/foo</c>) to the Windows
/// UNC path that reaches it over the WSL share (<c>\\wsl.localhost\Ubuntu\home\philipp\git\foo</c>).
/// </summary>
public string ToWindowsPath(string linuxPath)
{
var rel = linuxPath.Replace('/', '\\').TrimStart('\\');
return $@"\\wsl.localhost\{WslDistro}\{rel}";
}
public static AppConfig Load()
{
try
{
var path = Path.Combine(AppContext.BaseDirectory, "config.json");
if (!File.Exists(path)) return new AppConfig();
var json = File.ReadAllText(path);
var opts = new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true,
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true
};
return JsonSerializer.Deserialize<AppConfig>(json, opts) ?? new AppConfig();
}
catch
{
return new AppConfig();
}
}
private static string ConfigFilePath => Path.Combine(AppContext.BaseDirectory, "config.json");
/// <summary>
/// Persists the runtime-editable settings back to config.json, updating values in place
/// so the file's comments and other keys are preserved.
/// </summary>
public void Save()
{
string text = File.Exists(ConfigFilePath)
? File.ReadAllText(ConfigFilePath)
: "{\n}\n";
text = UpsertInt(text, "monitorIndex", MonitorIndex);
text = UpsertInt(text, "activeMinutes", ActiveMinutes);
text = UpsertInt(text, "refreshSeconds", RefreshSeconds);
text = UpsertBool(text, "flashOnAttention", FlashOnAttention);
text = UpsertBool(text, "soundOnAttention", SoundOnAttention);
File.WriteAllText(ConfigFilePath, text);
}
private static string UpsertBool(string json, string key, bool value)
{
var rx = new Regex($"(?<!//\\s)(\"{Regex.Escape(key)}\"\\s*:\\s*)(?:true|false)");
var literal = value ? "true" : "false";
if (rx.IsMatch(json))
return rx.Replace(json, m => m.Groups[1].Value + literal, 1);
int brace = json.IndexOf('{');
if (brace < 0) return json;
return json.Insert(brace + 1, $"\n \"{key}\": {literal},");
}
/// <summary>Replaces the integer value of a JSON key, or inserts the key if missing.</summary>
private static string UpsertInt(string json, string key, int value)
{
// Match the *real* key, not a "// key" comment line: require it isn't preceded by "// ".
var rx = new Regex($"(?<!//\\s)(\"{Regex.Escape(key)}\"\\s*:\\s*)-?\\d+");
if (rx.IsMatch(json))
return rx.Replace(json, m => m.Groups[1].Value + value, 1);
// Not present — insert right after the opening brace.
int brace = json.IndexOf('{');
if (brace < 0) return json;
return json.Insert(brace + 1, $"\n \"{key}\": {value},");
}
}

169
Services/PermissionRules.cs Normal file
View File

@@ -0,0 +1,169 @@
using System.IO;
using System.Text.Json;
using System.Text.RegularExpressions;
namespace ClaudeOverview.Services;
/// <summary>
/// A merged view of the user's Claude Code permission settings, used to decide whether a given
/// tool call would run silently (auto-approved) or pop a permission prompt. This is an
/// approximation of Claude Code's own matcher — good enough to keep the overview widget from
/// mislabelling an allowlisted long-running tool as "needs permission".
/// </summary>
public sealed class PermissionRules
{
// Tools allowed for any input, e.g. an "Edit" rule with no parens.
private readonly HashSet<string> _allowAll = new(StringComparer.OrdinalIgnoreCase);
// Tool -> specifiers it's allowed for, e.g. Bash -> ["npm run build:*", "ls:*"].
private readonly Dictionary<string, List<string>> _allowSpec = new(StringComparer.OrdinalIgnoreCase);
private bool _bypassAll; // defaultMode = bypassPermissions
private bool _acceptEdits; // defaultMode = acceptEdits (file edits don't prompt)
private static readonly string[] EditTools =
{ "Edit", "Write", "MultiEdit", "NotebookEdit" };
/// <summary>True when <paramref name="tool"/> with <paramref name="arg"/> would run without prompting.</summary>
public bool Allows(string tool, string arg)
{
if (_bypassAll) return true;
if (_acceptEdits && Array.Exists(EditTools, t => t.Equals(tool, StringComparison.OrdinalIgnoreCase)))
return true;
if (_allowAll.Contains(tool)) return true;
if (_allowSpec.TryGetValue(tool, out var specs))
foreach (var spec in specs)
if (Matches(tool, spec, arg))
return true;
return false;
}
/// <summary>True only when every pending tool/arg pair is auto-approved.</summary>
public bool AllowsEvery(IReadOnlyList<string> tools, IReadOnlyList<string> args)
{
if (tools.Count == 0) return false;
for (int i = 0; i < tools.Count; i++)
{
var arg = i < args.Count ? args[i] : "";
if (!Allows(tools[i], arg)) return false;
}
return true;
}
private static bool Matches(string tool, string spec, string arg)
{
if (spec is "*" or "") return true;
// Bash specifiers are command prefixes; "git push:*" allows any command starting "git push".
if (tool.Equals("Bash", StringComparison.OrdinalIgnoreCase))
{
if (spec.EndsWith(":*"))
return arg.StartsWith(spec[..^2], StringComparison.Ordinal);
return arg.Equals(spec, StringComparison.Ordinal);
}
// Everything else (paths, urls): treat the specifier as a glob over the argument.
return GlobMatch(spec, arg);
}
private static bool GlobMatch(string pattern, string text)
{
// Translate a gitignore-ish glob to a regex: ** = any, * = any except '/'.
var rx = "^" + Regex.Escape(pattern)
.Replace(@"\*\*", ".*")
.Replace(@"\*", "[^/]*")
.Replace(@"\?", ".") + "$";
try { return Regex.IsMatch(text, rx); }
catch { return false; }
}
// ----- Loading -----
/// <summary>
/// Loads and merges permission rules for a chat with the given working directory: user-level
/// settings plus the project's settings.json and settings.local.json. Missing files are ignored.
/// </summary>
public static PermissionRules Load(AppConfig config, string cwd)
{
var rules = new PermissionRules();
// ~/.claude/settings.json
rules.MergeFile(config.ToWindowsPath($"{config.LinuxHome}/.claude/settings.json"));
if (!string.IsNullOrWhiteSpace(cwd))
{
var projectDir = config.ToWindowsPath($"{cwd.TrimEnd('/')}/.claude");
rules.MergeFile(Path.Combine(projectDir, "settings.json"));
rules.MergeFile(Path.Combine(projectDir, "settings.local.json"));
}
return rules;
}
private void MergeFile(string path)
{
JsonDocument doc;
try
{
if (!File.Exists(path)) return;
doc = JsonDocument.Parse(File.ReadAllText(path));
}
catch { return; }
using (doc)
{
var root = doc.RootElement;
if (root.ValueKind != JsonValueKind.Object) return;
if (root.TryGetProperty("permissions", out var perms) && perms.ValueKind == JsonValueKind.Object)
{
AddRules(perms, "allow");
if (perms.TryGetProperty("defaultMode", out var modeEl) &&
modeEl.ValueKind == JsonValueKind.String)
ApplyMode(modeEl.GetString());
}
// Legacy / top-level forms.
AddRules(root, "allowedTools");
if (root.TryGetProperty("defaultMode", out var topMode) &&
topMode.ValueKind == JsonValueKind.String)
ApplyMode(topMode.GetString());
}
}
private void ApplyMode(string? mode)
{
if (string.Equals(mode, "bypassPermissions", StringComparison.OrdinalIgnoreCase)) _bypassAll = true;
else if (string.Equals(mode, "acceptEdits", StringComparison.OrdinalIgnoreCase)) _acceptEdits = true;
}
private void AddRules(JsonElement parent, string key)
{
if (!parent.TryGetProperty(key, out var arr) || arr.ValueKind != JsonValueKind.Array) return;
foreach (var item in arr.EnumerateArray())
{
if (item.ValueKind != JsonValueKind.String) continue;
var rule = item.GetString();
if (string.IsNullOrWhiteSpace(rule)) continue;
// "Tool" or "Tool(specifier)".
int open = rule.IndexOf('(');
if (open < 0)
{
_allowAll.Add(rule.Trim());
continue;
}
var tool = rule[..open].Trim();
int close = rule.LastIndexOf(')');
var spec = close > open ? rule[(open + 1)..close].Trim() : "";
if (!_allowSpec.TryGetValue(tool, out var list))
_allowSpec[tool] = list = new List<string>();
list.Add(spec);
}
}
}

335
Services/SessionScanner.cs Normal file
View File

@@ -0,0 +1,335 @@
using System.IO;
using System.Text.Json;
using ClaudeOverview.Models;
namespace ClaudeOverview.Services;
/// <summary>
/// Scans the .claude/projects directory for transcript (.jsonl) files that were
/// written recently, and parses each into a <see cref="ClaudeSession"/> with usage totals.
/// Only "active" files (modified within the configured window) are fully parsed, so the
/// scan stays cheap even with a large history.
/// </summary>
public sealed class SessionScanner
{
private readonly AppConfig _config;
public SessionScanner(AppConfig config) => _config = config;
public string ProjectsPath => _config.ProjectsPath;
/// <summary>
/// Start of the current session-limit window (set from usage data). Cold chats are shown back
/// to this point, and per-chat session-token shares are measured from here; when null we fall
/// back to <see cref="AppConfig.ColdMinutes"/> for the cutoff and count all tokens for shares.
/// </summary>
public DateTime? ColdCutoffUtc { get; set; }
/// <summary>Returns the active VS Code chats, most recently active first.</summary>
public IReadOnlyList<ClaudeSession> Scan(out string? error)
{
error = null;
var results = new List<ClaudeSession>();
var root = _config.ProjectsPath;
if (!Directory.Exists(root))
{
error = $"Projects path not found:\n{root}\n\nIs WSL running? Check config.json.";
return results;
}
var now = DateTime.UtcNow;
var activeCutoff = now - TimeSpan.FromMinutes(_config.ActiveMinutes);
// Cold chats are listed (dimmed) back to the start of the current session-limit window;
// if that's unknown, fall back to the configured cold window.
var coldCutoff = ColdCutoffUtc ?? (now - TimeSpan.FromMinutes(_config.ColdMinutes));
if (coldCutoff > activeCutoff) coldCutoff = activeCutoff; // never narrower than the active window
// Permission rules are loaded lazily and cached per working directory for this scan, so we
// read each project's settings files at most once even when several chats share a cwd.
var rulesByCwd = new Dictionary<string, PermissionRules>(StringComparer.OrdinalIgnoreCase);
IEnumerable<string> files;
try
{
files = Directory.EnumerateFiles(root, "*.jsonl", SearchOption.AllDirectories);
}
catch (Exception ex)
{
error = $"Could not read transcripts: {ex.Message}";
return results;
}
foreach (var file in files)
{
DateTime mtimeUtc;
try { mtimeUtc = File.GetLastWriteTimeUtc(file); }
catch { continue; }
if (mtimeUtc < coldCutoff) continue; // older than the cold window — skip the expensive parse
var session = TryParse(file, mtimeUtc, ColdCutoffUtc);
if (session is null) continue;
if (_config.OnlyVsCode && !session.IsVsCode) continue;
session.IsActive = session.LastActivityUtc >= activeCutoff;
ResolvePendingPermission(session, rulesByCwd);
results.Add(session);
}
// Each chat's slice of the total tokens consumed across all listed chats this window.
long sessionSum = 0;
foreach (var s in results) sessionSum += s.SessionTokens;
if (sessionSum > 0)
foreach (var s in results)
s.SessionSharePercent = 100.0 * s.SessionTokens / sessionSum;
// Active chats first, then cold ones. Among active, those waiting on the user lead;
// within each group, most recently active first.
results.Sort((a, b) =>
{
if (a.IsActive != b.IsActive) return a.IsActive ? -1 : 1;
if (a.IsActive && a.IsWaiting != b.IsWaiting) return a.IsWaiting ? -1 : 1;
return b.LastActivityUtc.CompareTo(a.LastActivityUtc);
});
return results;
}
private static ClaudeSession? TryParse(string file, DateTime mtimeUtc, DateTime? sessionWindowStartUtc)
{
var session = new ClaudeSession
{
SessionId = Path.GetFileNameWithoutExtension(file),
FilePath = file,
LastActivityUtc = mtimeUtc
};
var sawAnything = false;
try
{
// FileShare.ReadWrite so we can read while Claude Code is still appending.
using var stream = new FileStream(file, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
using var reader = new StreamReader(stream);
string? line;
while ((line = reader.ReadLine()) is not null)
{
if (line.Length == 0) continue;
JsonDocument doc;
try { doc = JsonDocument.Parse(line); }
catch { continue; } // tolerate a half-written trailing line
using (doc)
{
ApplyLine(session, doc.RootElement, sessionWindowStartUtc);
sawAnything = true;
}
}
}
catch
{
return sawAnything ? session : null;
}
return sawAnything ? session : null;
}
private static void ApplyLine(ClaudeSession s, JsonElement root, DateTime? sessionWindowStartUtc)
{
if (root.ValueKind != JsonValueKind.Object) return;
// VS Code writes the generated chat name as its own "ai-title" entry (no message body).
if (TryStr(root, "type", out var lineType) && lineType == "ai-title" &&
TryStr(root, "aiTitle", out var aiTitle) && aiTitle.Length > 0)
{
s.AiTitle = aiTitle;
return;
}
if (TryStr(root, "entrypoint", out var ep) && ep.Length > 0) s.Entrypoint = ep;
if (TryStr(root, "cwd", out var cwd) && cwd.Length > 0) s.Cwd = cwd;
if (TryStr(root, "gitBranch", out var gb) && gb.Length > 0) s.GitBranch = gb;
DateTime? lineTimeUtc = null;
if (TryStr(root, "timestamp", out var ts) &&
DateTime.TryParse(ts, null, System.Globalization.DateTimeStyles.AdjustToUniversal |
System.Globalization.DateTimeStyles.AssumeUniversal, out var dt))
{
lineTimeUtc = dt;
if (dt > s.LastActivityUtc) s.LastActivityUtc = dt;
}
if (!root.TryGetProperty("message", out var msg) || msg.ValueKind != JsonValueKind.Object)
return;
var role = TryStr(msg, "role", out var r) ? r : "";
// Sub-agent (sidechain) turns consume tokens but don't reflect the main thread's
// wait state — count their usage, but don't let them drive the status.
var isSidechain = root.TryGetProperty("isSidechain", out var sc) &&
sc.ValueKind == JsonValueKind.True;
if (role == "assistant")
{
if (TryStr(msg, "model", out var model) && model.Length > 0) s.Model = model;
if (msg.TryGetProperty("usage", out var usage) && usage.ValueKind == JsonValueKind.Object)
{
var input = GetLong(usage, "input_tokens");
var output = GetLong(usage, "output_tokens");
var cacheRead = GetLong(usage, "cache_read_input_tokens");
var cacheCreate = GetLong(usage, "cache_creation_input_tokens");
s.InputTokens += input;
s.OutputTokens += output;
s.CacheReadTokens += cacheRead;
s.CacheCreationTokens += cacheCreate;
s.MessageCount++;
// Count toward this session's window only for messages sent after it started
// (or everything when the window start is unknown).
if (sessionWindowStartUtc is null ||
(lineTimeUtc is { } lt && lt >= sessionWindowStartUtc))
s.SessionTokens += input + output + cacheRead + cacheCreate;
}
if (!isSidechain)
{
s.LastRole = "assistant";
s.LastStopReason = TryStr(msg, "stop_reason", out var sr) ? sr : "";
(s.LastToolNames, s.LastToolArgs, s.LastAssistantText) = ReadAssistantContent(msg);
// A turn streams as several lines (think → text → tool_use); only the tool_use line
// leaves a call awaiting a result. Any later result/human line clears this below.
s.AwaitingToolResult = s.LastToolNames.Count > 0;
}
}
else if (role == "user")
{
if (!isSidechain)
{
// Whether it's a tool_result or genuine human input, the prior tool_use is now
// resolved — so long thinking after a quick command isn't read as a permission wait.
s.AwaitingToolResult = false;
if (IsHumanTurn(msg))
s.LastRole = "user";
}
if (s.Title.Length == 0)
{
var text = ExtractUserText(msg);
if (!string.IsNullOrWhiteSpace(text) && !text.StartsWith("<") && !text.StartsWith("/"))
s.Title = Truncate(text.Trim(), 90);
}
}
}
/// <summary>Extracts tool-call names, their primary arguments, and the trailing text from an assistant message.</summary>
private static (List<string> tools, List<string> args, string text) ReadAssistantContent(JsonElement msg)
{
var tools = new List<string>();
var args = new List<string>();
var text = "";
if (msg.TryGetProperty("content", out var content) && content.ValueKind == JsonValueKind.Array)
{
foreach (var part in content.EnumerateArray())
{
if (part.ValueKind != JsonValueKind.Object) continue;
var type = TryStr(part, "type", out var t) ? t : "";
if (type == "tool_use" && TryStr(part, "name", out var name))
{
tools.Add(name);
args.Add(part.TryGetProperty("input", out var input) ? PrimaryArg(input) : "");
}
else if (type == "text" && TryStr(part, "text", out var txt))
text = txt;
}
}
return (tools, args, text);
}
/// <summary>Picks the field of a tool's input that permission rules match on (command / path / url / pattern).</summary>
private static string PrimaryArg(JsonElement input)
{
if (input.ValueKind != JsonValueKind.Object) return "";
foreach (var key in new[] { "command", "file_path", "path", "url", "pattern", "notebook_path" })
if (TryStr(input, key, out var v) && v.Length > 0)
return v;
return "";
}
/// <summary>
/// Decides whether a chat sitting on a pending tool_use is auto-approved by the user's permission
/// settings (so a stale file means a long-running tool, not a prompt awaiting approval).
/// </summary>
private void ResolvePendingPermission(ClaudeSession s, Dictionary<string, PermissionRules> cache)
{
if (!s.AwaitingToolResult || s.LastToolNames.Count == 0)
return;
if (!cache.TryGetValue(s.Cwd, out var rules))
cache[s.Cwd] = rules = PermissionRules.Load(_config, s.Cwd);
s.PendingToolsAutoApproved = rules.AllowsEvery(s.LastToolNames, s.LastToolArgs);
}
/// <summary>
/// True when a user message is genuine human input (not a tool_result fed back to Claude).
/// </summary>
private static bool IsHumanTurn(JsonElement msg)
{
if (!msg.TryGetProperty("content", out var content)) return true;
if (content.ValueKind == JsonValueKind.String) return true;
if (content.ValueKind == JsonValueKind.Array)
{
foreach (var part in content.EnumerateArray())
{
if (part.ValueKind == JsonValueKind.Object &&
TryStr(part, "type", out var t) && t == "tool_result")
return false; // tool output, Claude will continue
}
}
return true;
}
private static string ExtractUserText(JsonElement msg)
{
if (!msg.TryGetProperty("content", out var content)) return "";
if (content.ValueKind == JsonValueKind.String)
return content.GetString() ?? "";
if (content.ValueKind == JsonValueKind.Array)
{
foreach (var part in content.EnumerateArray())
{
if (part.ValueKind == JsonValueKind.Object &&
TryStr(part, "type", out var t) && t == "text" &&
TryStr(part, "text", out var txt))
return txt;
}
}
return "";
}
private static bool TryStr(JsonElement e, string name, out string value)
{
value = "";
if (e.TryGetProperty(name, out var p) && p.ValueKind == JsonValueKind.String)
{
value = p.GetString() ?? "";
return true;
}
return false;
}
private static long GetLong(JsonElement e, string name) =>
e.TryGetProperty(name, out var p) && p.ValueKind == JsonValueKind.Number && p.TryGetInt64(out var v)
? v : 0;
private static string Truncate(string s, int max) =>
s.Length <= max ? s : s[..max].TrimEnd() + "…";
}

113
Services/UsageService.cs Normal file
View File

@@ -0,0 +1,113 @@
using System.IO;
using System.Net.Http;
using System.Text.Json;
using ClaudeOverview.Models;
namespace ClaudeOverview.Services;
/// <summary>
/// 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).
/// </summary>
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<UsageInfo> 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;
}