Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
- `watch` takes a sub-view (`limits|today|week|models|sessions|hours`, default `limits`) and refreshes it on an interval
- `watch` (default `--view limits`) continuously refreshes "current session" (5h) / "this week" usage with reset times (`--interval`, `--session-reset <HH:mm>`, `--week-reset <Ddd HH:mm>`; both persist to `%LOCALAPPDATA%\aimon\limits-settings.json`, and `r` re-prompts for both reset times while watching; the session reset is auto-computed at startup and never prompted for there, only the week reset is)
- `export` supports `--format json|csv` and `--output <path>` (defaults to stdout, JSON)
- Report commands (`today|week|month|models|hours|sessions`) take `--json` (machine-readable stdout, no progress bar, update check skipped). `today` takes `--date yyyy-MM-dd`; `week`/`month` take `--from [--to]` or `--last <days>` (`month` also `--month yyyy-MM`); `models` takes `--model <text>`. Date arguments are validated by `Commands/DateRangeResolver` (max 3650 days, years 2000-2999) and a bad one prints to stderr and exits 1; `Commands/DateRangeOptions` only defines/reads the options, and `Commands/JsonOutput` holds the shared `--json` plumbing (JSON shapes are in `Rendering/CliJsonContext`, durations as seconds).
- Run WPF app (Windows-only): `dotnet run --project src/AIUsageMonitor.WPF`
- Run tests: `dotnet test AIUsageMonitor.slnx`
- Single test: `dotnet test tests/AIUsageMonitor.Core.Tests --filter "FullyQualifiedName~MethodName"`
Expand Down
18 changes: 16 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,13 @@ Commands:
- `hours` — usage broken down by hour of day
- `export` — export raw analytics; supports `--format json|csv` and `--output <path>` (defaults to stdout, JSON)

Report options (`today`, `week`, `month`, `models`, `hours`, `sessions`):

- `--json` — print the report as JSON (no progress bar or table; the update check is skipped) for scripts
- `today --date yyyy-MM-dd` — show another day
- `week` / `month`: `--from yyyy-MM-dd [--to yyyy-MM-dd]` or `--last <days>`; `month` also takes `--month yyyy-MM`. Ranges are limited to 3650 days and years 2000-2999; invalid values print an error to stderr and exit with code 1
- `models --model <text>` — only models whose name contains the text (percentages stay relative to all models). If nothing matches, the table view prints `No model matches '<text>'.` to stderr and exits 0, and `--json` prints `[]`

`watch`'s default view, `limits`, approximates the "Current Session" (rolling 5-hour window) and "This Week" panels from Claude's own account UI, each with a **Time Progress** bar (how far the window has elapsed, colored green/orange when token usage is pacing notably behind/ahead of it) and a **Token Progress** bar (how close it is to its budget). For the session window, a pace hint also appears below the table when token usage is running noticeably ahead of or behind elapsed time, suggesting you slow down or use more freely.

The real reset times and token limits live on your Anthropic account, not locally, so pin them once:
Expand All @@ -84,7 +91,7 @@ Press `r` while `watch` is running to re-enter any of the four (Enter keeps the

### Update checks

Every command checks GitHub for a newer release once after it finishes (`watch` checks once before entering its refresh loop and keeps the notice pinned to the bottom of the view for the whole session). If a newer version is available, a one-line notice with the new version and a link to the release is printed — this never blocks or fails the command, and no data is sent beyond the standard GitHub API request for the latest release.
Every command checks GitHub for a newer release once after it finishes (`watch` checks once before entering its refresh loop and keeps the notice pinned to the bottom of the view for the whole session). Runs with `--json` skip the check entirely, so scripted calls make no network request and never wait for one; the notice itself is written to stderr. If a newer version is available, a one-line notice with the new version and a link to the release is printed — this never blocks or fails the command, and no data is sent beyond the standard GitHub API request for the latest release.

### WPF dashboard (Windows only)

Expand Down Expand Up @@ -166,6 +173,13 @@ aimon <命令>
- `hours` — 按小时统计用量
- `export` — 导出原始分析数据;支持 `--format json|csv` 与 `--output <path>`(默认输出到标准输出,格式为 JSON)

报表选项(`today`、`week`、`month`、`models`、`hours`、`sessions`):

- `--json` — 以 JSON 输出(不显示进度条和表格,并跳过更新检查),便于脚本处理
- `today --date yyyy-MM-dd` — 查看指定某天
- `week` / `month`:`--from yyyy-MM-dd [--to yyyy-MM-dd]` 或 `--last <天数>`;`month` 另支持 `--month yyyy-MM`。范围最长 3650 天,年份限定在 2000–2999;值无效时向 stderr 输出错误并以退出码 1 结束
- `models --model <文本>` — 只显示名称包含该文本的模型(占比仍相对于全部模型)。若没有匹配项,表格模式会向 stderr 输出 `No model matches '<文本>'.` 并以退出码 0 结束,`--json` 则输出 `[]`

`watch` 的默认视图 `limits` 近似展示 Claude 官方账户界面中的 "Current Session"(滚动 5 小时窗口)和 "This Week" 面板,各自附带 **Time Progress**(窗口已过去的时间比例,当 token 消耗明显落后/领先于时间进度时会分别显示绿色/橙色)与 **Token Progress**(用量占预算的比例)两条进度条。针对当前会话窗口,当 token 消耗进度明显快于或慢于时间进度时,表格下方还会出现一条节奏提示,建议你放慢或可以放心多用。

真实的重置时间和 token 上限存储在 Anthropic 账号侧,本地无法读取,建议各录入一次:
Expand All @@ -182,7 +196,7 @@ aimon <命令>

### 更新检查

每个命令执行结束后都会检查一次 GitHub 上是否有新版本发布(`watch` 会在进入刷新循环前检查一次,并在整个运行期间将提示固定显示在视图底部)。如果有新版本,会打印一行提示,附带新版本号和发布页链接——这不会阻塞或影响命令本身的执行,除了标准的 GitHub 最新发布查询请求外不会发送任何其他数据。
每个命令执行结束后都会检查一次 GitHub 上是否有新版本发布(`watch` 会在进入刷新循环前检查一次,并在整个运行期间将提示固定显示在视图底部)。带 `--json` 的调用会完全跳过该检查,因此脚本调用不会发起网络请求,也不会为此等待;提示本身输出到 stderr。如果有新版本,会打印一行提示,附带新版本号和发布页链接——这不会阻塞或影响命令本身的执行,除了标准的 GitHub 最新发布查询请求外不会发送任何其他数据。

### WPF 仪表盘(仅 Windows)

Expand Down
88 changes: 88 additions & 0 deletions src/AIUsageMonitor.Cli/Commands/DateRangeOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
using System.CommandLine;

namespace AIUsageMonitor.Cli.Commands;

/// <summary>
/// The raw, unvalidated date-range arguments a report command received.
/// </summary>
/// <param name="Date">A single day (<c>yyyy-MM-dd</c>), or <see langword="null"/> when not given.</param>
/// <param name="From">The first day of a range (<c>yyyy-MM-dd</c>), or <see langword="null"/> when not given.</param>
/// <param name="To">The last day of a range (<c>yyyy-MM-dd</c>), or <see langword="null"/> when not given.</param>
/// <param name="Last">The number of days ending today, as typed, or <see langword="null"/> when not given.</param>
/// <param name="Month">A calendar month (<c>yyyy-MM</c>), or <see langword="null"/> when not given.</param>
internal sealed record DateRangeRequest(string? Date = null, string? From = null, string? To = null, int? Last = null, string? Month = null);

/// <summary>
/// Defines the date-range options shared by the report commands and turns them into a concrete date range.
/// </summary>
internal sealed class DateRangeOptions
{
/// <summary>
/// The <c>--date</c> option, or <see langword="null"/> when the command does not offer it.
/// </summary>
private readonly Option<string?>? _date;

/// <summary>
/// The <c>--from</c> option, or <see langword="null"/> when the command does not offer it.
/// </summary>
private readonly Option<string?>? _from;

/// <summary>
/// The <c>--to</c> option, or <see langword="null"/> when the command does not offer it.
/// </summary>
private readonly Option<string?>? _to;

/// <summary>
/// The <c>--last</c> option, or <see langword="null"/> when the command does not offer it.
/// </summary>
private readonly Option<int?>? _last;

/// <summary>
/// The <c>--month</c> option, or <see langword="null"/> when the command does not offer it.
/// </summary>
private readonly Option<string?>? _month;

/// <summary>
/// Creates the options and adds them to <paramref name="command"/>.
/// </summary>
/// <param name="command">The command that receives the options.</param>
/// <param name="singleDay">Whether to offer <c>--date</c> (a single day) instead of a range.</param>
/// <param name="month">Whether to also offer <c>--month</c> (a calendar month).</param>
public DateRangeOptions(Command command, bool singleDay, bool month = false)
{
if (singleDay)
{
_date = new Option<string?>("--date") { Description = "Day to show, as yyyy-MM-dd, years 2000-2999 (default: today)" };
command.Options.Add(_date);
return;
}

_from = new Option<string?>("--from") { Description = "First day of the range, as yyyy-MM-dd, years 2000-2999 (the range ends today unless --to is given; at most 3650 days)" };
_to = new Option<string?>("--to") { Description = "Last day of the range, as yyyy-MM-dd, years 2000-2999 (requires --from)" };
_last = new Option<int?>("--last") { Description = "Show the last N days, ending today (1-3650)" };
command.Options.Add(_from);
command.Options.Add(_to);
command.Options.Add(_last);

if (month)
{
_month = new Option<string?>("--month") { Description = "Calendar month to show, as yyyy-MM, years 2000-2999" };
command.Options.Add(_month);
}
}

/// <summary>
/// Reads the option values the user supplied.
/// </summary>
/// <param name="parseResult">The parse result of the invoked command.</param>
/// <returns>The raw date-range request.</returns>
public DateRangeRequest Read(ParseResult parseResult)
{
return new DateRangeRequest(
_date is null ? null : parseResult.GetValue(_date),
_from is null ? null : parseResult.GetValue(_from),
_to is null ? null : parseResult.GetValue(_to),
_last is null ? null : parseResult.GetValue(_last),
_month is null ? null : parseResult.GetValue(_month));
}
}
249 changes: 249 additions & 0 deletions src/AIUsageMonitor.Cli/Commands/DateRangeResolver.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,249 @@
using System.Globalization;

namespace AIUsageMonitor.Cli.Commands;

/// <summary>
/// An inclusive range of days.
/// </summary>
/// <param name="From">The first day.</param>
/// <param name="To">The last day.</param>
internal readonly record struct DateRange(DateOnly From, DateOnly To);

/// <summary>
/// The outcome of resolving a <see cref="DateRangeRequest"/>: either a range or a user-facing error.
/// Only <see cref="Ok"/> and <see cref="Fail"/> create one, so a result never carries both.
/// </summary>
internal readonly struct DateRangeResolution
{
/// <summary>
/// Initializes a new instance of the <see cref="DateRangeResolution"/> struct.
/// </summary>
/// <param name="range">The resolved range, or <see langword="null"/> when the request was invalid.</param>
/// <param name="error">A user-facing message when the request was invalid.</param>
private DateRangeResolution(DateRange? range, string? error)
{
Range = range;
Error = error;
}

/// <summary>
/// Gets the resolved range, or <see langword="null"/> when the request was invalid.
/// </summary>
public DateRange? Range { get; }

/// <summary>
/// Gets a user-facing message when the request was invalid.
/// </summary>
public string? Error { get; }

/// <summary>
/// Creates a successful resolution.
/// </summary>
/// <param name="from">The first day.</param>
/// <param name="to">The last day.</param>
/// <returns>A resolution carrying the range.</returns>
public static DateRangeResolution Ok(DateOnly from, DateOnly to)
{
return new DateRangeResolution(new DateRange(from, to), null);
}

/// <summary>
/// Creates a failed resolution.
/// </summary>
/// <param name="error">The user-facing message.</param>
/// <returns>A resolution carrying the error.</returns>
public static DateRangeResolution Fail(string error)
{
return new DateRangeResolution(null, error);
}
}

/// <summary>
/// Turns the raw date-range arguments of a report command into a concrete, validated date range.
/// </summary>
internal static class DateRangeResolver
{
/// <summary>
/// The most days a range may span, whether given as <c>--last</c> or as <c>--from</c>/<c>--to</c>.
/// </summary>
public const int MaxDays = 3650;

/// <summary>
/// The earliest year any date option accepts.
/// </summary>
private const int MinYear = 2000;

/// <summary>
/// The latest year any date option accepts; keeps day-by-day iteration clear of <see cref="DateOnly.MaxValue"/>.
/// </summary>
private const int MaxYear = 2999;

/// <summary>
/// Gets the current local date.
/// </summary>
/// <param name="clock">The clock to read.</param>
/// <returns>Today's date in the clock's local time zone.</returns>
public static DateOnly Today(TimeProvider clock)
{
return DateOnly.FromDateTime(clock.GetLocalNow().DateTime);
}

/// <summary>
/// Resolves a request into an inclusive date range.
/// </summary>
/// <param name="request">The raw arguments.</param>
/// <param name="today">The current local date.</param>
/// <param name="defaultRange">The length of the range used when no argument was given, ending today.</param>
/// <returns>The range, or a user-facing error.</returns>
public static DateRangeResolution Resolve(DateRangeRequest request, DateOnly today, TimeSpan defaultRange)
{
var groups = (request.Date is not null ? 1 : 0)
+ (request.From is not null || request.To is not null ? 1 : 0)
+ (request.Last is not null ? 1 : 0)
+ (request.Month is not null ? 1 : 0);
if (groups > 1)
{
return DateRangeResolution.Fail("Specify only one of --date, --from/--to, --last or --month.");
}

if (request.Date is not null)
{
return ResolveDate(request.Date);
}

if (request.From is not null || request.To is not null)
{
return ResolveFromTo(request.From, request.To, today);
}

if (request.Last is not null)
{
return ResolveLast(request.Last.Value, today);
}

if (request.Month is not null)
{
return ResolveMonth(request.Month);
}

return DateRangeResolution.Ok(StartOfLast(today, (int)defaultRange.TotalDays), today);
}

/// <summary>
/// Resolves a single <c>--date</c> day.
/// </summary>
/// <param name="date">The text given to <c>--date</c>.</param>
/// <returns>A one-day range, or an error.</returns>
private static DateRangeResolution ResolveDate(string date)
{
var error = TryParseDay(date, "--date", out var day);
return error is null ? DateRangeResolution.Ok(day, day) : DateRangeResolution.Fail(error);
}

/// <summary>
/// Resolves a <c>--from</c> / <c>--to</c> pair.
/// </summary>
/// <param name="fromText">The text given to <c>--from</c>, if any.</param>
/// <param name="toText">The text given to <c>--to</c>, if any.</param>
/// <param name="today">The current local date, used when <c>--to</c> is omitted.</param>
/// <returns>The range, or an error.</returns>
private static DateRangeResolution ResolveFromTo(string? fromText, string? toText, DateOnly today)
{
if (fromText is null)
{
return DateRangeResolution.Fail("--to requires --from.");
}

if (TryParseDay(fromText, "--from", out var from) is { } fromError)
{
return DateRangeResolution.Fail(fromError);
}

var to = today;
if (toText is not null && TryParseDay(toText, "--to", out to) is { } toError)
{
return DateRangeResolution.Fail(toError);
}

if (from > to)
{
return DateRangeResolution.Fail($"--from ({from:yyyy-MM-dd}) must not be after the end of the range ({to:yyyy-MM-dd}).");
}

var days = to.DayNumber - from.DayNumber + 1;
return days > MaxDays
? DateRangeResolution.Fail($"The range must not be longer than {MaxDays} days (got {days}).")
: DateRangeResolution.Ok(from, to);
}

/// <summary>
/// Resolves <c>--last</c>.
/// </summary>
/// <param name="last">The requested number of days, as typed.</param>
/// <param name="today">The current local date.</param>
/// <returns>The range ending today, or an error.</returns>
private static DateRangeResolution ResolveLast(int last, DateOnly today)
{
if (last < 1)
{
return DateRangeResolution.Fail($"--last must be at least 1 (got {last}).");
}

if (last > MaxDays)
{
return DateRangeResolution.Fail($"--last must be at most {MaxDays} (got {last}).");
}

return DateRangeResolution.Ok(StartOfLast(today, last), today);
}

/// <summary>
/// Resolves <c>--month</c>.
/// </summary>
/// <param name="month">The text given to <c>--month</c>.</param>
/// <returns>The whole calendar month, or an error.</returns>
private static DateRangeResolution ResolveMonth(string month)
{
if (!DateOnly.TryParseExact(month + "-01", "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var first))
{
return DateRangeResolution.Fail($"Invalid --month '{month}'; expected yyyy-MM.");
}

if (first.Year is < MinYear or > MaxYear)
{
return DateRangeResolution.Fail($"--month year must be between {MinYear} and {MaxYear}.");
}

return DateRangeResolution.Ok(first, first.AddMonths(1).AddDays(-1));
}

/// <summary>
/// Gets the first day of a range of the given length that ends today.
/// </summary>
/// <param name="today">The last day of the range.</param>
/// <param name="days">The length of the range, in days.</param>
/// <returns>The first day of the range.</returns>
private static DateOnly StartOfLast(DateOnly today, int days)
{
return today.AddDays(1 - days);
}

/// <summary>
/// Parses a <c>yyyy-MM-dd</c> day and checks that its year is in the supported range.
/// </summary>
/// <param name="text">The text to parse.</param>
/// <param name="optionName">The option the text came from, used in the error message.</param>
/// <param name="day">The parsed day.</param>
/// <returns>A user-facing message when the text is not a supported day; <see langword="null"/> otherwise.</returns>
private static string? TryParseDay(string text, string optionName, out DateOnly day)
{
if (!DateOnly.TryParseExact(text, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out day))
{
return $"Invalid {optionName} '{text}'; expected yyyy-MM-dd.";
}

return day.Year is < MinYear or > MaxYear
? $"{optionName} year must be between {MinYear} and {MaxYear}."
: null;
}
}
Loading
Loading