From 74faa870716cb95dec9254506c1a51ce31e673c4 Mon Sep 17 00:00:00 2001 From: Gaoyang Date: Fri, 9 Oct 2026 21:54:12 +0800 Subject: [PATCH 1/4] fix(cli): reject unsupported export formats Any --format value other than csv used to fall back to JSON silently, so a typo such as xml produced JSON. Validate the option and report an error for anything but json or csv (case-insensitive). --- .../Commands/ExportCommand.cs | 19 ++++++++++++++++ .../ExportCommandTests.cs | 22 +++++++++++++++++-- 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/src/AIUsageMonitor.Cli/Commands/ExportCommand.cs b/src/AIUsageMonitor.Cli/Commands/ExportCommand.cs index 415747b..875862e 100644 --- a/src/AIUsageMonitor.Cli/Commands/ExportCommand.cs +++ b/src/AIUsageMonitor.Cli/Commands/ExportCommand.cs @@ -30,6 +30,14 @@ public static Command Create(DataService dataService) var command = new Command("export", "Export usage data"); var formatOption = new Option("--format") { Description = "Output format (json or csv)", DefaultValueFactory = _ => "json" }; var outputOption = new Option("--output") { Description = "Output file path (defaults to stdout)" }; + formatOption.Validators.Add(result => + { + var value = result.GetValueOrDefault(); + if (!IsSupportedFormat(value)) + { + result.AddError($"Unsupported format '{value}'; expected json or csv."); + } + }); command.Options.Add(formatOption); command.Options.Add(outputOption); @@ -75,6 +83,17 @@ public static Command Create(DataService dataService) return command; } + /// + /// Determines whether an output format name is one the export command can produce. + /// + /// The format name given on the command line. + /// for json or csv (case-insensitive). + internal static bool IsSupportedFormat(string? format) + { + return string.Equals(format, "json", StringComparison.OrdinalIgnoreCase) + || string.Equals(format, "csv", StringComparison.OrdinalIgnoreCase); + } + /// /// Exports the usage data and model distribution to a CSV formatted string. The CSV includes daily activity statistics and model distribution details, with appropriate headers for each section. /// diff --git a/tests/AIUsageMonitor.Cli.Tests/ExportCommandTests.cs b/tests/AIUsageMonitor.Cli.Tests/ExportCommandTests.cs index 7c5e078..939eb93 100644 --- a/tests/AIUsageMonitor.Cli.Tests/ExportCommandTests.cs +++ b/tests/AIUsageMonitor.Cli.Tests/ExportCommandTests.cs @@ -1,13 +1,14 @@ -using AIUsageMonitor.Cli.Commands; +using AIUsageMonitor.Cli.Commands; using AIUsageMonitor.Core.Models; using AIUsageMonitor.Core.Providers.Claude.Models; +using System.CommandLine; using System.Globalization; using Xunit; namespace AIUsageMonitor.Cli.Tests; /// -/// Tests for the CSV output of . +/// Tests for the CSV output and the format validation of . /// public class ExportCommandTests { @@ -52,4 +53,21 @@ public void ExportCsv_ModelNameWithCommaAndQuote_QuotesTheField() Assert.Contains("\"odd,\"\"name\"\"\",1,2,3,4,10,100.0,0.00", csv); } + + /// + /// Verifies that the export command rejects an unknown format and accepts json and csv in any case. + /// + [Theory] + [InlineData("xml", false)] + [InlineData("", false)] + [InlineData("json", true)] + [InlineData("CSV", true)] + public void Create_FormatOption_ValidatesFormat(string format, bool valid) + { + var export = ExportCommand.Create(null!); + + var errors = export.Parse(["--format", format]).Errors; + + Assert.Equal(valid, errors.Count == 0); + } } From 79df579b04191c7985be88096347bf3dd3c57dea Mon Sep 17 00:00:00 2001 From: Gaoyang Date: Fri, 9 Oct 2026 21:54:13 +0800 Subject: [PATCH 2/4] fix(cli): render a note instead of an empty hourly chart A BarChart without items throws when rendered. Providers always emit the current hour, but guard the renderer so an empty trend shows a note instead of failing the command. --- .../Rendering/SpectreRenderer.cs | 8 +++++- .../SpectreRendererTests.cs | 26 ++++++++++++++++++- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/src/AIUsageMonitor.Cli/Rendering/SpectreRenderer.cs b/src/AIUsageMonitor.Cli/Rendering/SpectreRenderer.cs index 2a261e7..45f1e2f 100644 --- a/src/AIUsageMonitor.Cli/Rendering/SpectreRenderer.cs +++ b/src/AIUsageMonitor.Cli/Rendering/SpectreRenderer.cs @@ -1,4 +1,4 @@ -using AIUsageMonitor.Cli; +using AIUsageMonitor.Cli; using AIUsageMonitor.Core.Models; using Spectre.Console; using Spectre.Console.Rendering; @@ -79,6 +79,12 @@ public static IRenderable BuildHourlyActivity(List hours) /// An bar chart of tokens by hour. public static IRenderable BuildHourlyTokenChart(List buckets) { + // Defensive only: the providers always emit at least the current hour's bucket, but a BarChart with no items throws when rendered. + if (buckets.Count == 0) + { + return new Markup("[grey]No hourly activity yet.[/]"); + } + var chart = new BarChart().Label("[bold]Tokens by Hour[/]").Width(80).UseValueFormatter(v => FormatTokens((long)v)); var colorIndex = 0; foreach (var bucket in buckets) diff --git a/tests/AIUsageMonitor.Cli.Tests/SpectreRendererTests.cs b/tests/AIUsageMonitor.Cli.Tests/SpectreRendererTests.cs index 97057b7..c5991b9 100644 --- a/tests/AIUsageMonitor.Cli.Tests/SpectreRendererTests.cs +++ b/tests/AIUsageMonitor.Cli.Tests/SpectreRendererTests.cs @@ -1,4 +1,4 @@ -using AIUsageMonitor.Cli.Rendering; +using AIUsageMonitor.Cli.Rendering; using AIUsageMonitor.Core.Models; using System.Globalization; using Xunit; @@ -148,4 +148,28 @@ public void BuildSessionStats_SessionIdWithBrackets_RendersIdVerbatim() Assert.Contains("id[1]", rendered); } + + /// + /// Verifies that an empty hourly trend renders a note instead of an empty chart, which would throw when rendered. + /// + [Fact] + public void BuildHourlyTokenChart_NoBuckets_RendersNoteInsteadOfThrowing() + { + var rendered = ConsoleMarkupTests.RenderPlain(SpectreRenderer.BuildHourlyTokenChart([])); + + Assert.Contains("No hourly activity yet.", rendered); + } + + /// + /// Verifies that hourly buckets are rendered as a chart. + /// + [Fact] + public void BuildHourlyTokenChart_WithBuckets_RendersTheChart() + { + var rendered = ConsoleMarkupTests.RenderPlain( + SpectreRenderer.BuildHourlyTokenChart([new(new DateTimeOffset(2026, 10, 9, 9, 0, 0, TimeSpan.Zero), 3, 1500)])); + + Assert.Contains("Tokens by Hour", rendered); + Assert.Contains("09:00", rendered); + } } From 023f02dacd22f701cde35eac866d31d83959191b Mon Sep 17 00:00:00 2001 From: Gaoyang Date: Fri, 9 Oct 2026 21:54:14 +0800 Subject: [PATCH 3/4] feat(cli): add date ranges, model filter and JSON output to report commands today takes --date; week and month take --from/--to or --last (month also --month); models takes --model. The report commands (today, week, month, models, hours, sessions) take --json, which prints camelCase JSON without a progress bar and skips the update check so scripted calls make no network request. Date input is validated in DateRangeResolver (years 2000-2999, at most 3650 days) and invalid values exit with code 1 and a message on stderr. Session durations are emitted as seconds. --- .../Commands/DateRangeOptions.cs | 88 ++++ .../Commands/DateRangeResolver.cs | 249 +++++++++++ .../Commands/HoursCommand.cs | 20 +- src/AIUsageMonitor.Cli/Commands/JsonOutput.cs | 100 +++++ .../Commands/ModelsCommand.cs | 51 ++- .../Commands/MonthCommand.cs | 26 +- .../Commands/PeriodCommand.cs | 52 +++ .../Commands/SessionsCommand.cs | 20 +- .../Commands/TodayCommand.cs | 67 ++- .../Commands/WeekCommand.cs | 26 +- src/AIUsageMonitor.Cli/Program.cs | 3 +- .../Rendering/CliJsonContext.cs | 15 + .../Rendering/SessionStatsReport.cs | 34 ++ src/AIUsageMonitor.Cli/UpdateNotice.cs | 11 + .../CommandActionTests.cs | 405 ++++++++++++++++++ .../ReportOptionsTests.cs | 290 +++++++++++++ 16 files changed, 1386 insertions(+), 71 deletions(-) create mode 100644 src/AIUsageMonitor.Cli/Commands/DateRangeOptions.cs create mode 100644 src/AIUsageMonitor.Cli/Commands/DateRangeResolver.cs create mode 100644 src/AIUsageMonitor.Cli/Commands/JsonOutput.cs create mode 100644 src/AIUsageMonitor.Cli/Commands/PeriodCommand.cs create mode 100644 src/AIUsageMonitor.Cli/Rendering/CliJsonContext.cs create mode 100644 src/AIUsageMonitor.Cli/Rendering/SessionStatsReport.cs create mode 100644 tests/AIUsageMonitor.Cli.Tests/CommandActionTests.cs create mode 100644 tests/AIUsageMonitor.Cli.Tests/ReportOptionsTests.cs diff --git a/src/AIUsageMonitor.Cli/Commands/DateRangeOptions.cs b/src/AIUsageMonitor.Cli/Commands/DateRangeOptions.cs new file mode 100644 index 0000000..ae50a94 --- /dev/null +++ b/src/AIUsageMonitor.Cli/Commands/DateRangeOptions.cs @@ -0,0 +1,88 @@ +using System.CommandLine; + +namespace AIUsageMonitor.Cli.Commands; + +/// +/// The raw, unvalidated date-range arguments a report command received. +/// +/// A single day (yyyy-MM-dd), or when not given. +/// The first day of a range (yyyy-MM-dd), or when not given. +/// The last day of a range (yyyy-MM-dd), or when not given. +/// The number of days ending today, as typed, or when not given. +/// A calendar month (yyyy-MM), or when not given. +internal sealed record DateRangeRequest(string? Date = null, string? From = null, string? To = null, int? Last = null, string? Month = null); + +/// +/// Defines the date-range options shared by the report commands and turns them into a concrete date range. +/// +internal sealed class DateRangeOptions +{ + /// + /// The --date option, or when the command does not offer it. + /// + private readonly Option? _date; + + /// + /// The --from option, or when the command does not offer it. + /// + private readonly Option? _from; + + /// + /// The --to option, or when the command does not offer it. + /// + private readonly Option? _to; + + /// + /// The --last option, or when the command does not offer it. + /// + private readonly Option? _last; + + /// + /// The --month option, or when the command does not offer it. + /// + private readonly Option? _month; + + /// + /// Creates the options and adds them to . + /// + /// The command that receives the options. + /// Whether to offer --date (a single day) instead of a range. + /// Whether to also offer --month (a calendar month). + public DateRangeOptions(Command command, bool singleDay, bool month = false) + { + if (singleDay) + { + _date = new Option("--date") { Description = "Day to show, as yyyy-MM-dd, years 2000-2999 (default: today)" }; + command.Options.Add(_date); + return; + } + + _from = new Option("--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("--to") { Description = "Last day of the range, as yyyy-MM-dd, years 2000-2999 (requires --from)" }; + _last = new Option("--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("--month") { Description = "Calendar month to show, as yyyy-MM, years 2000-2999" }; + command.Options.Add(_month); + } + } + + /// + /// Reads the option values the user supplied. + /// + /// The parse result of the invoked command. + /// The raw date-range request. + 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)); + } +} diff --git a/src/AIUsageMonitor.Cli/Commands/DateRangeResolver.cs b/src/AIUsageMonitor.Cli/Commands/DateRangeResolver.cs new file mode 100644 index 0000000..83e6021 --- /dev/null +++ b/src/AIUsageMonitor.Cli/Commands/DateRangeResolver.cs @@ -0,0 +1,249 @@ +using System.Globalization; + +namespace AIUsageMonitor.Cli.Commands; + +/// +/// An inclusive range of days. +/// +/// The first day. +/// The last day. +internal readonly record struct DateRange(DateOnly From, DateOnly To); + +/// +/// The outcome of resolving a : either a range or a user-facing error. +/// Only and create one, so a result never carries both. +/// +internal readonly struct DateRangeResolution +{ + /// + /// Initializes a new instance of the struct. + /// + /// The resolved range, or when the request was invalid. + /// A user-facing message when the request was invalid. + private DateRangeResolution(DateRange? range, string? error) + { + Range = range; + Error = error; + } + + /// + /// Gets the resolved range, or when the request was invalid. + /// + public DateRange? Range { get; } + + /// + /// Gets a user-facing message when the request was invalid. + /// + public string? Error { get; } + + /// + /// Creates a successful resolution. + /// + /// The first day. + /// The last day. + /// A resolution carrying the range. + public static DateRangeResolution Ok(DateOnly from, DateOnly to) + { + return new DateRangeResolution(new DateRange(from, to), null); + } + + /// + /// Creates a failed resolution. + /// + /// The user-facing message. + /// A resolution carrying the error. + public static DateRangeResolution Fail(string error) + { + return new DateRangeResolution(null, error); + } +} + +/// +/// Turns the raw date-range arguments of a report command into a concrete, validated date range. +/// +internal static class DateRangeResolver +{ + /// + /// The most days a range may span, whether given as --last or as --from/--to. + /// + public const int MaxDays = 3650; + + /// + /// The earliest year any date option accepts. + /// + private const int MinYear = 2000; + + /// + /// The latest year any date option accepts; keeps day-by-day iteration clear of . + /// + private const int MaxYear = 2999; + + /// + /// Gets the current local date. + /// + /// The clock to read. + /// Today's date in the clock's local time zone. + public static DateOnly Today(TimeProvider clock) + { + return DateOnly.FromDateTime(clock.GetLocalNow().DateTime); + } + + /// + /// Resolves a request into an inclusive date range. + /// + /// The raw arguments. + /// The current local date. + /// The length of the range used when no argument was given, ending today. + /// The range, or a user-facing error. + 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); + } + + /// + /// Resolves a single --date day. + /// + /// The text given to --date. + /// A one-day range, or an error. + 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); + } + + /// + /// Resolves a --from / --to pair. + /// + /// The text given to --from, if any. + /// The text given to --to, if any. + /// The current local date, used when --to is omitted. + /// The range, or an error. + 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); + } + + /// + /// Resolves --last. + /// + /// The requested number of days, as typed. + /// The current local date. + /// The range ending today, or an error. + 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); + } + + /// + /// Resolves --month. + /// + /// The text given to --month. + /// The whole calendar month, or an error. + 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)); + } + + /// + /// Gets the first day of a range of the given length that ends today. + /// + /// The last day of the range. + /// The length of the range, in days. + /// The first day of the range. + private static DateOnly StartOfLast(DateOnly today, int days) + { + return today.AddDays(1 - days); + } + + /// + /// Parses a yyyy-MM-dd day and checks that its year is in the supported range. + /// + /// The text to parse. + /// The option the text came from, used in the error message. + /// The parsed day. + /// A user-facing message when the text is not a supported day; otherwise. + 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; + } +} diff --git a/src/AIUsageMonitor.Cli/Commands/HoursCommand.cs b/src/AIUsageMonitor.Cli/Commands/HoursCommand.cs index 707f3a3..52bcb26 100644 --- a/src/AIUsageMonitor.Cli/Commands/HoursCommand.cs +++ b/src/AIUsageMonitor.Cli/Commands/HoursCommand.cs @@ -5,24 +5,28 @@ namespace AIUsageMonitor.Cli.Commands; /// -/// Represents the "hours" command, which shows the hourly activity distribution of AI usage. This command retrieves hourly activity data from the provided and renders it using the . +/// Represents the "hours" command, which shows token usage by hour of day. /// public static class HoursCommand { /// - /// Creates a new instance of the "hours" command with the specified . The command retrieves hourly activity data and renders it using the . + /// Creates a new instance of the "hours" command with the specified data service. /// - /// The data service used to retrieve hourly activity data. - /// A configured instance for showing hourly activity distribution. + /// The data service used to retrieve the hourly activity. + /// A configured instance for showing the hourly distribution. public static Command Create(DataService dataService) { var command = new Command("hours", "Show hourly activity distribution"); - command.SetAction(_ => + var jsonOption = JsonOutput.CreateOption(); + command.Options.Add(jsonOption); + + command.SetAction(parseResult => { - var hours = ProgressReporter.Run("Loading usage data...", dataService.GetHourlyActivity); - SpectreRenderer.RenderHourlyActivity(hours); + var json = parseResult.GetValue(jsonOption); + var hours = JsonOutput.Load(json, "Loading usage data...", dataService.GetHourlyActivity); + JsonOutput.Emit(json, hours, CliJsonContext.Default.ListHourlyActivity, SpectreRenderer.RenderHourlyActivity); return 0; }); return command; } -} \ No newline at end of file +} diff --git a/src/AIUsageMonitor.Cli/Commands/JsonOutput.cs b/src/AIUsageMonitor.Cli/Commands/JsonOutput.cs new file mode 100644 index 0000000..d06744e --- /dev/null +++ b/src/AIUsageMonitor.Cli/Commands/JsonOutput.cs @@ -0,0 +1,100 @@ +using AIUsageMonitor.Cli.Rendering; +using System.CommandLine; +using System.Text.Json; +using System.Text.Json.Serialization.Metadata; + +namespace AIUsageMonitor.Cli.Commands; + +/// +/// Shared plumbing for the --json option of the report commands. +/// +internal static class JsonOutput +{ + /// + /// The name of the --json option. + /// + private const string OptionName = "--json"; + + /// + /// Creates the --json option. + /// + /// A new boolean option. + public static Option CreateOption() + { + return new Option(OptionName) { Description = "Print the report as JSON instead of a formatted table" }; + } + + /// + /// Determines whether the invoked command was given --json. + /// + /// The parse result of the invoked command. + /// when the command has a --json option and it was set. + public static bool IsRequested(ParseResult parseResult) + { + foreach (var option in parseResult.CommandResult.Command.Options) + { + if (option is Option { Name: OptionName } jsonOption) + { + return parseResult.GetValue(jsonOption); + } + } + + return false; + } + + /// + /// Loads data, showing a progress bar unless the output is JSON (which must stay machine-readable). + /// + /// The type of the loaded data. + /// Whether the output will be JSON. + /// The progress bar caption. + /// Loads the data, reporting progress to the supplied reporter. + /// The loaded data. + public static T Load(bool json, string description, Func?, T> body) + { + return json ? body(null) : ProgressReporter.Run(description, p => body(p)); + } + + /// + /// Serializes a report to indented camelCase JSON. + /// + /// The type of the report. + /// The report. + /// The source-generated type metadata for . + /// The JSON text. + public static string Serialize(T value, JsonTypeInfo typeInfo) + { + return JsonSerializer.Serialize(value, typeInfo); + } + + /// + /// Writes a report as JSON to standard output. + /// + /// The type of the report. + /// The report. + /// The source-generated type metadata for . + public static void Write(T value, JsonTypeInfo typeInfo) + { + Console.WriteLine(Serialize(value, typeInfo)); + } + + /// + /// Prints a report as JSON, or hands it to a renderer for the formatted view. + /// + /// The type of the report. + /// Whether JSON output was requested. + /// The report. + /// The source-generated type metadata for . + /// Renders the report as a formatted view. + public static void Emit(bool json, T value, JsonTypeInfo typeInfo, Action render) + { + if (json) + { + Write(value, typeInfo); + } + else + { + render(value); + } + } +} diff --git a/src/AIUsageMonitor.Cli/Commands/ModelsCommand.cs b/src/AIUsageMonitor.Cli/Commands/ModelsCommand.cs index 5c7ebf9..16047ca 100644 --- a/src/AIUsageMonitor.Cli/Commands/ModelsCommand.cs +++ b/src/AIUsageMonitor.Cli/Commands/ModelsCommand.cs @@ -1,28 +1,63 @@ using AIUsageMonitor.Cli.Rendering; +using AIUsageMonitor.Core.Models; using AIUsageMonitor.Core.Services; using System.CommandLine; namespace AIUsageMonitor.Cli.Commands; /// -/// Represents the command for displaying model usage distribution in the AI Usage Monitor CLI application. +/// Represents the "models" command, which shows the token usage distribution across models. /// public static class ModelsCommand { /// - /// Creates a new instance of the "models" command, which shows the model usage distribution. + /// Creates a new instance of the "models" command with the specified data service. /// - /// The data service used to retrieve model usage data. - /// A configured instance for showing model usage distribution. + /// The data service used to retrieve the model distribution. + /// A configured instance for showing the model distribution. public static Command Create(DataService dataService) { var command = new Command("models", "Show model usage distribution"); - command.SetAction(_ => + var modelOption = new Option("--model") { - var models = ProgressReporter.Run("Loading usage data...", dataService.GetModelDistribution); - SpectreRenderer.RenderModelDistribution(models); + Description = "Only show models whose name contains this text (case-insensitive); percentages stay relative to all models" + }; + var jsonOption = JsonOutput.CreateOption(); + command.Options.Add(modelOption); + command.Options.Add(jsonOption); + + command.SetAction(parseResult => + { + var json = parseResult.GetValue(jsonOption); + var filter = parseResult.GetValue(modelOption); + var models = FilterByModel(JsonOutput.Load(json, "Loading usage data...", dataService.GetModelDistribution), filter); + + if (!json && models.Count == 0 && !string.IsNullOrWhiteSpace(filter)) + { + Console.Error.WriteLine($"No model matches '{filter.Trim()}'."); + return 0; + } + + JsonOutput.Emit(json, models, CliJsonContext.Default.ListModelDistribution, SpectreRenderer.RenderModelDistribution); return 0; }); return command; } -} \ No newline at end of file + + /// + /// Keeps only the models whose name contains the given text. + /// + /// The model distribution to filter. + /// The case-insensitive text to look for, or or empty to keep every model. + /// The matching models, in their original order. + internal static List FilterByModel(List models, string? filter) + { + if (string.IsNullOrWhiteSpace(filter)) + { + return models; + } + + var needle = filter.Trim(); + return models.Where(m => m.ModelName.Contains(needle, StringComparison.OrdinalIgnoreCase)).ToList(); + } +} diff --git a/src/AIUsageMonitor.Cli/Commands/MonthCommand.cs b/src/AIUsageMonitor.Cli/Commands/MonthCommand.cs index ca489c7..51b97cd 100644 --- a/src/AIUsageMonitor.Cli/Commands/MonthCommand.cs +++ b/src/AIUsageMonitor.Cli/Commands/MonthCommand.cs @@ -1,30 +1,22 @@ -using AIUsageMonitor.Cli.Rendering; using AIUsageMonitor.Core.Services; using System.CommandLine; namespace AIUsageMonitor.Cli.Commands; /// -/// Represents the command to show the last 30 days summary of AI usage. +/// Represents the "month" command, which shows a usage summary for the last 30 days, a chosen date range or a calendar month. /// public static class MonthCommand { /// - /// Creates a new instance of the MonthCommand. + /// Creates a new instance of the "month" command with the specified data service. /// - /// The data service used to retrieve AI usage data. - /// A configured instance for showing the last 30 days summary of AI usage. - public static Command Create(DataService dataService) + /// The data service used to retrieve the period summary. + /// The clock that decides what "today" is; the system clock when . + /// A configured instance for showing a monthly summary. + public static Command Create(DataService dataService, TimeProvider? timeProvider = null) { - var command = new Command("month", "Show last 30 days summary"); - command.SetAction(_ => - { - var to = DateOnly.FromDateTime(DateTime.Today); - var from = to.AddDays(-29); - var summary = ProgressReporter.Run("Loading usage data...", p => dataService.GetPeriodSummary(from, to, p)); - SpectreRenderer.RenderPeriodSummary(summary); - return 0; - }); - return command; + return PeriodCommand.Create( + "month", "Show usage for a date range or calendar month (default: last 30 days)", TimeSpan.FromDays(30), allowMonth: true, dataService, timeProvider); } -} \ No newline at end of file +} diff --git a/src/AIUsageMonitor.Cli/Commands/PeriodCommand.cs b/src/AIUsageMonitor.Cli/Commands/PeriodCommand.cs new file mode 100644 index 0000000..62841fa --- /dev/null +++ b/src/AIUsageMonitor.Cli/Commands/PeriodCommand.cs @@ -0,0 +1,52 @@ +using AIUsageMonitor.Cli.Rendering; +using AIUsageMonitor.Core.Services; +using System.CommandLine; + +namespace AIUsageMonitor.Cli.Commands; + +/// +/// Builds the date-range report commands (week and month), which differ only in their default range. +/// +internal static class PeriodCommand +{ + /// + /// Creates a command that shows a usage summary for a date range. + /// + /// The command name. + /// The command description. + /// The length of the range used when no range option is given, ending today. + /// Whether the command also accepts --month. + /// The data service used to retrieve the period summary. + /// The clock that decides what "today" is; the system clock when . + /// A configured instance. + public static Command Create( + string name, + string description, + TimeSpan defaultRange, + bool allowMonth, + DataService dataService, + TimeProvider? timeProvider = null) + { + var clock = timeProvider ?? TimeProvider.System; + var command = new Command(name, description); + var rangeOptions = new DateRangeOptions(command, singleDay: false, month: allowMonth); + var jsonOption = JsonOutput.CreateOption(); + command.Options.Add(jsonOption); + + command.SetAction(parseResult => + { + var resolution = DateRangeResolver.Resolve(rangeOptions.Read(parseResult), DateRangeResolver.Today(clock), defaultRange); + if (resolution.Range is not { } range) + { + Console.Error.WriteLine(resolution.Error); + return 1; + } + + var json = parseResult.GetValue(jsonOption); + var summary = JsonOutput.Load(json, "Loading usage data...", p => dataService.GetPeriodSummary(range.From, range.To, p)); + JsonOutput.Emit(json, summary, CliJsonContext.Default.PeriodSummary, SpectreRenderer.RenderPeriodSummary); + return 0; + }); + return command; + } +} diff --git a/src/AIUsageMonitor.Cli/Commands/SessionsCommand.cs b/src/AIUsageMonitor.Cli/Commands/SessionsCommand.cs index 1eeb5fd..df70481 100644 --- a/src/AIUsageMonitor.Cli/Commands/SessionsCommand.cs +++ b/src/AIUsageMonitor.Cli/Commands/SessionsCommand.cs @@ -17,12 +17,24 @@ public static class SessionsCommand public static Command Create(DataService dataService) { var command = new Command("sessions", "Show session statistics"); - command.SetAction(_ => + var jsonOption = JsonOutput.CreateOption(); + command.Options.Add(jsonOption); + + command.SetAction(parseResult => { - var stats = ProgressReporter.Run("Loading usage data...", dataService.GetSessionStats); - SpectreRenderer.RenderSessionStats(stats); + var json = parseResult.GetValue(jsonOption); + var stats = JsonOutput.Load(json, "Loading usage data...", dataService.GetSessionStats); + if (json) + { + JsonOutput.Write(SessionStatsReport.From(stats), CliJsonContext.Default.SessionStatsReport); + } + else + { + SpectreRenderer.RenderSessionStats(stats); + } + return 0; }); return command; } -} \ No newline at end of file +} diff --git a/src/AIUsageMonitor.Cli/Commands/TodayCommand.cs b/src/AIUsageMonitor.Cli/Commands/TodayCommand.cs index a620cc4..405120a 100644 --- a/src/AIUsageMonitor.Cli/Commands/TodayCommand.cs +++ b/src/AIUsageMonitor.Cli/Commands/TodayCommand.cs @@ -7,34 +7,69 @@ namespace AIUsageMonitor.Cli.Commands; /// -/// Represents the "today" command in the CLI application, which shows today's usage summary. +/// Represents the "today" command in the CLI application, which shows a day's usage summary (today by default). /// public static class TodayCommand { /// /// Creates a new instance of the "today" command with the specified data service. /// - /// The data service used to retrieve today's usage summary and recent activity. - /// A configured instance for showing today's usage summary. - public static Command Create(DataService dataService) + /// The data service used to retrieve the usage summary and recent activity. + /// The clock that decides what "today" is; the system clock when . + /// A configured instance for showing a day's usage summary. + public static Command Create(DataService dataService, TimeProvider? timeProvider = null) { - var command = new Command("today", "Show today's usage summary"); + var clock = timeProvider ?? TimeProvider.System; + var command = new Command("today", "Show today's usage summary (or another day's with --date)"); + var rangeOptions = new DateRangeOptions(command, singleDay: true); + var jsonOption = JsonOutput.CreateOption(); + command.Options.Add(jsonOption); - command.SetAction(_ => + command.SetAction(parseResult => { - var summary = ProgressReporter.Run("Loading usage data...", - p => dataService.GetDailySummary(DateOnly.FromDateTime(DateTime.Today), p)) - ?? DailySummary.Empty(DateOnly.FromDateTime(DateTime.Today)); + var today = DateRangeResolver.Today(clock); + var resolution = DateRangeResolver.Resolve(rangeOptions.Read(parseResult), today, TimeSpan.FromDays(1)); + if (resolution.Range is not { } range) + { + Console.Error.WriteLine(resolution.Error); + return 1; + } - var recent = ProgressReporter.Run("Loading recent activity...", - p => dataService.GetRecentActivity(DateTimeOffset.Now - DateTimeOffset.Now.Date, p)); + var date = range.From; + var json = parseResult.GetValue(jsonOption); + var summary = JsonOutput.Load(json, "Loading usage data...", p => dataService.GetDailySummary(date, p)) + ?? DailySummary.Empty(date); - AnsiConsole.Write(new Rows( - SpectreRenderer.BuildDailySummary(summary), - new Rule().RuleStyle("grey"), - SpectreRenderer.BuildHourlyTokenChart(recent.HourlyTrend))); + JsonOutput.Emit(json, summary, CliJsonContext.Default.DailySummary, s => Render(dataService, s, date == today, clock)); return 0; }); return command; } -} \ No newline at end of file + + /// + /// Renders a day's summary, followed by the hourly chart when the day is today. + /// + /// The data service used to retrieve the recent activity. + /// The day's summary. + /// Whether the summarised day is today; the hourly chart covers the time since midnight, so it only applies then. + /// The clock used to measure the time since midnight. + private static void Render(DataService dataService, DailySummary summary, bool isToday, TimeProvider clock) + { + if (!isToday) + { + SpectreRenderer.RenderDailySummary(summary); + return; + } + + // Midnight is built in the clock's own zone; "now - now.Date" would convert it with the machine's zone instead. + var now = clock.GetLocalNow(); + var midnight = new DateTimeOffset(now.Date, clock.LocalTimeZone.GetUtcOffset(now.Date)); + var recent = ProgressReporter.Run("Loading recent activity...", + p => dataService.GetRecentActivity(now - midnight, p)); + + AnsiConsole.Write(new Rows( + SpectreRenderer.BuildDailySummary(summary), + new Rule().RuleStyle("grey"), + SpectreRenderer.BuildHourlyTokenChart(recent.HourlyTrend))); + } +} diff --git a/src/AIUsageMonitor.Cli/Commands/WeekCommand.cs b/src/AIUsageMonitor.Cli/Commands/WeekCommand.cs index 040cf1e..b8683a7 100644 --- a/src/AIUsageMonitor.Cli/Commands/WeekCommand.cs +++ b/src/AIUsageMonitor.Cli/Commands/WeekCommand.cs @@ -1,30 +1,22 @@ -using AIUsageMonitor.Cli.Rendering; using AIUsageMonitor.Core.Services; using System.CommandLine; namespace AIUsageMonitor.Cli.Commands; /// -/// Represents the "week" command that shows a summary of usage data for the last 7 days. +/// Represents the "week" command, which shows a usage summary for the last 7 days or a chosen date range. /// public static class WeekCommand { /// - /// Creates a new instance of the "week" command. + /// Creates a new instance of the "week" command with the specified data service. /// - /// The data service used to retrieve usage data for the last 7 days. - /// A configured instance for showing the last 7 days summary. - public static Command Create(DataService dataService) + /// The data service used to retrieve the period summary. + /// The clock that decides what "today" is; the system clock when . + /// A configured instance for showing a weekly summary. + public static Command Create(DataService dataService, TimeProvider? timeProvider = null) { - var command = new Command("week", "Show last 7 days summary"); - command.SetAction(_ => - { - var to = DateOnly.FromDateTime(DateTime.Today); - var from = to.AddDays(-6); - var summary = ProgressReporter.Run("Loading usage data...", p => dataService.GetPeriodSummary(from, to, p)); - SpectreRenderer.RenderPeriodSummary(summary); - return 0; - }); - return command; + return PeriodCommand.Create( + "week", "Show usage for a date range (default: last 7 days)", TimeSpan.FromDays(7), allowMonth: false, dataService, timeProvider); } -} \ No newline at end of file +} diff --git a/src/AIUsageMonitor.Cli/Program.cs b/src/AIUsageMonitor.Cli/Program.cs index d51e736..75e433a 100644 --- a/src/AIUsageMonitor.Cli/Program.cs +++ b/src/AIUsageMonitor.Cli/Program.cs @@ -56,7 +56,8 @@ // show while it's running, not after it exits) - every other command starts the check here so it // runs alongside the command, and prints the notice after the command's own output. using var updateCheckCts = new CancellationTokenSource(); - var updateCheck = isWatch ? null : UpdateNotice.StartCheck(updateCheckCts.Token, updateCheckLogger); + // --json is for scripts, which shouldn't pay a network request or the wait for it on every call. + var updateCheck = UpdateNotice.ShouldCheckAfterCommand(isWatch, JsonOutput.IsRequested(parseResult)) ? UpdateNotice.StartCheck(updateCheckCts.Token, updateCheckLogger) : null; var exitCode = await parseResult.InvokeAsync(); diff --git a/src/AIUsageMonitor.Cli/Rendering/CliJsonContext.cs b/src/AIUsageMonitor.Cli/Rendering/CliJsonContext.cs new file mode 100644 index 0000000..8117832 --- /dev/null +++ b/src/AIUsageMonitor.Cli/Rendering/CliJsonContext.cs @@ -0,0 +1,15 @@ +using AIUsageMonitor.Core.Models; +using System.Text.Json.Serialization; + +namespace AIUsageMonitor.Cli.Rendering; + +/// +/// The source-generated JSON serialization context for the reports the CLI prints with --json. +/// +[JsonSerializable(typeof(DailySummary))] +[JsonSerializable(typeof(PeriodSummary))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(SessionStatsReport))] +[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, WriteIndented = true)] +internal sealed partial class CliJsonContext : JsonSerializerContext; diff --git a/src/AIUsageMonitor.Cli/Rendering/SessionStatsReport.cs b/src/AIUsageMonitor.Cli/Rendering/SessionStatsReport.cs new file mode 100644 index 0000000..721893e --- /dev/null +++ b/src/AIUsageMonitor.Cli/Rendering/SessionStatsReport.cs @@ -0,0 +1,34 @@ +using AIUsageMonitor.Core.Models; + +namespace AIUsageMonitor.Cli.Rendering; + +/// +/// The JSON shape of , with durations as plain seconds so scripts need no duration parsing. +/// +/// The number of sessions. +/// The average session length, in seconds. +/// The average number of messages per session. +/// The longest session length, in seconds. +/// The id of the longest session, if known. +internal sealed record SessionStatsReport( + int Total, + double AvgDurationSeconds, + double AvgMessages, + double LongestDurationSeconds, + string? LongestSessionId) +{ + /// + /// Converts session statistics to their JSON shape. + /// + /// The statistics to convert. + /// The JSON-shaped report. + public static SessionStatsReport From(SessionStats stats) + { + return new SessionStatsReport( + stats.Total, + stats.AvgDuration.TotalSeconds, + stats.AvgMessages, + stats.LongestDuration.TotalSeconds, + stats.LongestSessionId); + } +} diff --git a/src/AIUsageMonitor.Cli/UpdateNotice.cs b/src/AIUsageMonitor.Cli/UpdateNotice.cs index 4d86685..d115b2b 100644 --- a/src/AIUsageMonitor.Cli/UpdateNotice.cs +++ b/src/AIUsageMonitor.Cli/UpdateNotice.cs @@ -10,6 +10,17 @@ namespace AIUsageMonitor.Cli; /// public static class UpdateNotice { + /// + /// Decides whether the update check should run alongside (and be reported after) the invoked command. + /// + /// Whether the command is watch, which runs its own check up front. + /// Whether the command was given --json, which is meant for scripts. + /// for watch and for --json runs. + internal static bool ShouldCheckAfterCommand(bool isWatch, bool jsonRequested) + { + return !isWatch && !jsonRequested; + } + /// /// Starts an update check on a background thread so it runs alongside the command rather than after it. /// diff --git a/tests/AIUsageMonitor.Cli.Tests/CommandActionTests.cs b/tests/AIUsageMonitor.Cli.Tests/CommandActionTests.cs new file mode 100644 index 0000000..e7ff126 --- /dev/null +++ b/tests/AIUsageMonitor.Cli.Tests/CommandActionTests.cs @@ -0,0 +1,405 @@ +using AIUsageMonitor.Cli.Commands; +using AIUsageMonitor.Core.Analytics; +using AIUsageMonitor.Core.Models; +using AIUsageMonitor.Core.Providers; +using AIUsageMonitor.Core.Providers.Claude; +using AIUsageMonitor.Core.Providers.Claude.Models; +using AIUsageMonitor.Core.Services; +using Microsoft.Extensions.Logging.Abstractions; +using Spectre.Console; +using System.CommandLine; +using System.Text.Json; +using Xunit; + +namespace AIUsageMonitor.Cli.Tests; + +/// +/// Defines the collection that keeps console-capturing tests from running in parallel with each other. +/// +[CollectionDefinition(Name, DisableParallelization = true)] +public sealed class ConsoleCollection +{ + /// + /// The name of the collection. + /// + public const string Name = "Console"; +} + +/// +/// Runs the report commands end to end against a fake provider and checks what they print and return. +/// +[Collection(ConsoleCollection.Name)] +public sealed class CommandActionTests : IDisposable +{ + /// + /// The date the fake clock reports as today. + /// + private static readonly DateOnly Today = new(2026, 10, 9); + + /// + /// The directory that stands in for the Claude data directory. + /// + private readonly string _dataDir = Path.Combine(Path.GetTempPath(), $"aimon-tests-{Guid.NewGuid():N}"); + + /// + /// The data service under test. + /// + private readonly DataService _dataService; + + /// + /// The provider behind , kept to inspect how it was used. + /// + private readonly FakeProvider _provider; + + /// + /// The clock reporting at noon, UTC. + /// + private readonly FixedTimeProvider _clock = new(new DateTimeOffset(2026, 10, 9, 12, 0, 0, TimeSpan.Zero)); + + /// + /// Creates the data service over a fake provider holding a few days of activity. + /// + public CommandActionTests() + { + Directory.CreateDirectory(_dataDir); + var cache = new StatsCache + { + TotalSessions = 4, + TotalMessages = 30, + DailyActivity = + [ + new() { Date = Today, MessageCount = 10, SessionCount = 2, ToolCallCount = 5 }, + new() { Date = Today.AddDays(-1), MessageCount = 20, SessionCount = 2, ToolCallCount = 7 }, + ], + DailyModelTokens = + [ + new() { Date = Today, TokensByModel = new() { ["claude-opus-5-5"] = 1000 } }, + new() { Date = Today.AddDays(-1), TokensByModel = new() { ["claude-sonnet-5-5"] = 3000 } }, + ], + ModelUsage = new() + { + ["claude-opus-5-5"] = new() { InputTokens = 1000 }, + ["claude-sonnet-5-5"] = new() { InputTokens = 3000 }, + }, + }; + + var locator = new ClaudeDataLocator(_dataDir); + _provider = new FakeProvider(cache); + _dataService = new DataService( + _provider, + new UsageAnalyzer(new CostCalculator()), + new SessionFileCache(new SessionParser(NullLogger.Instance), NullLogger.Instance), + new SessionActivityTracker(locator), + NullLogger.Instance); + } + + /// + /// Releases the data service and removes the temporary directory. + /// + public void Dispose() + { + _dataService.Dispose(); + Directory.Delete(_dataDir, recursive: true); + } + + /// + /// Verifies that today --json prints the summary for the clock's date and nothing else. + /// + [Fact] + public void Create_TodayJson_PrintsSummaryForClockDate() + { + var result = Run(TodayCommand.Create(_dataService, _clock), "--json"); + + Assert.True(result.ExitCode == 0, result.Error); + using var document = JsonDocument.Parse(result.Out); + Assert.Equal("2026-10-09", document.RootElement.GetProperty("date").GetString()); + Assert.Equal(10, document.RootElement.GetProperty("messages").GetInt32()); + Assert.Equal("", result.Error); + } + + /// + /// Verifies that --date picks another day, and that a day without activity yields an empty summary. + /// + [Fact] + public void Create_TodayJsonWithDate_PrintsThatDayOrAnEmptySummary() + { + var yesterday = Run(TodayCommand.Create(_dataService, _clock), "--date", "2026-10-08", "--json"); + var quiet = Run(TodayCommand.Create(_dataService, _clock), "--date", "2026-01-01", "--json"); + + using var yesterdayDocument = JsonDocument.Parse(yesterday.Out); + using var quietDocument = JsonDocument.Parse(quiet.Out); + Assert.Equal(20, yesterdayDocument.RootElement.GetProperty("messages").GetInt32()); + Assert.Equal(0, quietDocument.RootElement.GetProperty("messages").GetInt32()); + Assert.Equal("2026-01-01", quietDocument.RootElement.GetProperty("date").GetString()); + } + + /// + /// Verifies that an invalid date is reported on stderr with exit code 1 and nothing on stdout. + /// + [Fact] + public void Create_TodayInvalidDate_WritesErrorToStderrAndReturnsOne() + { + var result = Run(TodayCommand.Create(_dataService, _clock), "--date", "2026-13-01", "--json"); + + Assert.Equal(1, result.ExitCode); + Assert.Contains("--date", result.Error); + Assert.Equal("", result.Out); + } + + /// + /// Verifies that week --last counts back from the clock's date. + /// + [Fact] + public void Create_WeekJsonWithLast_CoversTheRequestedDays() + { + var result = Run(WeekCommand.Create(_dataService, _clock), "--last", "2", "--json"); + + Assert.True(result.ExitCode == 0, result.Error); + using var document = JsonDocument.Parse(result.Out); + Assert.Equal("2026-10-08", document.RootElement.GetProperty("from").GetString()); + Assert.Equal("2026-10-09", document.RootElement.GetProperty("to").GetString()); + Assert.Equal(30, document.RootElement.GetProperty("totalMessages").GetInt32()); + } + + /// + /// Verifies that the default ranges of week and month are 7 and 30 days. + /// + [Fact] + public void Create_WeekAndMonthJsonWithoutRange_UseTheirDefaultLength() + { + var week = Run(WeekCommand.Create(_dataService, _clock), "--json"); + var month = Run(MonthCommand.Create(_dataService, _clock), "--json"); + + using var weekDocument = JsonDocument.Parse(week.Out); + using var monthDocument = JsonDocument.Parse(month.Out); + Assert.Equal("2026-10-03", weekDocument.RootElement.GetProperty("from").GetString()); + Assert.Equal("2026-09-10", monthDocument.RootElement.GetProperty("from").GetString()); + } + + /// + /// Verifies that month --month covers the whole calendar month. + /// + [Fact] + public void Create_MonthJsonWithMonth_CoversTheCalendarMonth() + { + var result = Run(MonthCommand.Create(_dataService, _clock), "--month", "2026-09", "--json"); + + using var document = JsonDocument.Parse(result.Out); + Assert.Equal("2026-09-01", document.RootElement.GetProperty("from").GetString()); + Assert.Equal("2026-09-30", document.RootElement.GetProperty("to").GetString()); + } + + /// + /// Verifies that absurd ranges are rejected with exit code 1 rather than crashing. + /// + [Theory] + [InlineData("week", "--last 2147483647", "at most")] + [InlineData("week", "--last 0", "at least 1")] + [InlineData("month", "--month 9999-12", "between 2000 and 2999")] + [InlineData("week", "--from 2026-10-05 --to 2026-10-01", "must not be after")] + public void Create_PeriodCommandsInvalidRange_ReturnOneWithMessage(string commandName, string options, string expectedError) + { + var command = commandName == "week" ? WeekCommand.Create(_dataService, _clock) : MonthCommand.Create(_dataService, _clock); + + var result = Run(command, [.. options.Split(' '), "--json"]); + + Assert.Equal(1, result.ExitCode); + Assert.Contains(expectedError, result.Error); + Assert.Equal("", result.Out); + } + + /// + /// Verifies that models --json honors --model and returns an empty array when nothing matches. + /// + [Fact] + public void Create_ModelsJson_FiltersByModelName() + { + var all = Run(ModelsCommand.Create(_dataService), "--json"); + var sonnet = Run(ModelsCommand.Create(_dataService), "--model", "SONNET", "--json"); + var none = Run(ModelsCommand.Create(_dataService), "--model", "gpt", "--json"); + + using var allDocument = JsonDocument.Parse(all.Out); + using var sonnetDocument = JsonDocument.Parse(sonnet.Out); + using var noneDocument = JsonDocument.Parse(none.Out); + Assert.Equal(2, allDocument.RootElement.GetArrayLength()); + Assert.Equal("claude-sonnet-5-5", Assert.Single(sonnetDocument.RootElement.EnumerateArray()).GetProperty("modelName").GetString()); + Assert.Equal(0, noneDocument.RootElement.GetArrayLength()); + } + + /// + /// Verifies that a filter matching nothing prints a hint on stderr instead of an empty table. + /// + [Fact] + public void Create_ModelsFilterMatchesNothing_WritesHintToStderr() + { + var result = Run(ModelsCommand.Create(_dataService), "--model", "gpt"); + + Assert.True(result.ExitCode == 0, result.Error); + Assert.Contains("No model matches 'gpt'", result.Error); + Assert.Equal("", result.Out); + } + + /// + /// Verifies that sessions --json reports durations in seconds. + /// + [Fact] + public void Create_SessionsJson_PrintsSessionStatsInSeconds() + { + var result = Run(SessionsCommand.Create(_dataService), "--json"); + + Assert.True(result.ExitCode == 0, result.Error); + using var document = JsonDocument.Parse(result.Out); + Assert.Equal(4, document.RootElement.GetProperty("total").GetInt32()); + Assert.True(document.RootElement.TryGetProperty("longestDurationSeconds", out _)); + } + + /// + /// Verifies that hours --json prints the provider's hourly activity. + /// + [Fact] + public void Create_HoursJson_PrintsHourlyActivity() + { + var result = Run(HoursCommand.Create(_dataService), "--json"); + + Assert.True(result.ExitCode == 0, result.Error); + using var document = JsonDocument.Parse(result.Out); + Assert.Equal(14, Assert.Single(document.RootElement.EnumerateArray()).GetProperty("hour").GetInt32()); + } + + /// + /// Verifies that today renders the day's table and the hourly chart, which needs the recent activity. + /// + [Fact] + public void Create_TodayTable_RendersSummaryAndLoadsRecentActivity() + { + var result = Run(TodayCommand.Create(_dataService, _clock)); + + Assert.True(result.ExitCode == 0, result.Error); + Assert.Contains("2026-10-09 Summary", result.Out); + Assert.Contains("Tokens by Hour", result.Out); + Assert.Contains("09:00", result.Out); + Assert.Equal(1, _provider.RecentActivityCalls); + Assert.Equal(TimeSpan.FromHours(12), _provider.LastRecentActivityWindow); + } + + /// + /// Verifies that another day renders its table without the since-midnight chart or loading recent activity. + /// + [Fact] + public void Create_TodayTableWithOtherDate_RendersSummaryWithoutRecentActivity() + { + var result = Run(TodayCommand.Create(_dataService, _clock), "--date", "2026-10-08"); + + Assert.True(result.ExitCode == 0, result.Error); + Assert.Contains("2026-10-08 Summary", result.Out); + Assert.Equal(0, _provider.RecentActivityCalls); + } + + /// + /// Verifies that the other report commands render a table when --json is not given. + /// + [Fact] + public void Create_ReportCommandsTable_RenderFormattedOutput() + { + var week = Run(WeekCommand.Create(_dataService, _clock), "--last", "2"); + var models = Run(ModelsCommand.Create(_dataService)); + var hours = Run(HoursCommand.Create(_dataService)); + var sessions = Run(SessionsCommand.Create(_dataService)); + + Assert.Contains("2026-10-08 ~ 2026-10-09", week.Out); + Assert.Contains("Input", models.Out); + Assert.Contains("14", hours.Out); + Assert.Contains("Total Sessions", sessions.Out); + } + + /// + /// Invokes a command while capturing what it writes to standard output and standard error. + /// + /// The command to invoke. + /// The command-line arguments. + /// The exit code and the captured output. + private static (int ExitCode, string Out, string Error) Run(Command command, params string[] args) + { + var originalOut = Console.Out; + var originalError = Console.Error; + var originalConsole = AnsiConsole.Console; + using var stdout = new StringWriter(); + using var stderr = new StringWriter(); + try + { + Console.SetOut(stdout); + Console.SetError(stderr); + AnsiConsole.Console = AnsiConsole.Create(new AnsiConsoleSettings + { + Ansi = AnsiSupport.No, + ColorSystem = ColorSystemSupport.NoColors, + Interactive = InteractionSupport.No, + Out = new AnsiConsoleOutput(stdout), + }); + var exitCode = command.Parse(args).Invoke(); + return (exitCode, stdout.ToString(), stderr.ToString()); + } + finally + { + Console.SetOut(originalOut); + Console.SetError(originalError); + AnsiConsole.Console = originalConsole; + } + } + + /// + /// A clock that always reports the same instant, in UTC. + /// + /// The instant to report. + private sealed class FixedTimeProvider(DateTimeOffset now) : TimeProvider + { + /// + public override DateTimeOffset GetUtcNow() => now; + + /// + public override TimeZoneInfo LocalTimeZone => TimeZoneInfo.Utc; + } + + /// + /// A provider that serves a fixed stats cache, one hour of hourly activity and a single recent-activity bucket. + /// + /// The stats cache to serve. + private sealed class FakeProvider(StatsCache cache) : IUsageProvider + { + /// + public string Name => "fake"; + + /// + public List GetHourlyActivity(IProgress? progress = null) => [new(14, 500)]; + + /// + /// Gets how many times recent activity was requested. + /// + public int RecentActivityCalls { get; private set; } + + /// + /// Gets the window of the most recent recent-activity request. + /// + public TimeSpan? LastRecentActivityWindow { get; private set; } + + /// + public RecentActivitySummary GetRecentActivity(TimeSpan window, IProgress? progress = null) + { + RecentActivityCalls++; + LastRecentActivityWindow = window; + return new RecentActivitySummary( + window, 3, 1, 0, 1500, [], 0m, [new HourBucket(new DateTimeOffset(2026, 10, 9, 9, 0, 0, TimeSpan.Zero), 3, 1500)]); + } + + /// + public StatsCache GetStatsCache(IProgress? progress = null) => cache; + + /// + public UsageWindowSummary GetCurrentSessionWindow(DateTimeOffset? sessionResetAt, IProgress? progress = null) => + throw new NotSupportedException(); + + /// + public UsageWindowSummary GetWeekWindow((DayOfWeek Day, TimeSpan TimeOfDay)? anchor, IProgress? progress = null) => + throw new NotSupportedException(); + } +} diff --git a/tests/AIUsageMonitor.Cli.Tests/ReportOptionsTests.cs b/tests/AIUsageMonitor.Cli.Tests/ReportOptionsTests.cs new file mode 100644 index 0000000..80351c1 --- /dev/null +++ b/tests/AIUsageMonitor.Cli.Tests/ReportOptionsTests.cs @@ -0,0 +1,290 @@ +using AIUsageMonitor.Cli.Commands; +using AIUsageMonitor.Cli.Rendering; +using AIUsageMonitor.Core.Models; +using System.CommandLine; +using System.Text.Json; +using Xunit; + +namespace AIUsageMonitor.Cli.Tests; + +/// +/// Tests for the date-range resolver, option parsing, model filter, JSON shapes and export format validation of the report commands. +/// +public class ReportOptionsTests +{ + /// + /// The fixed "today" used by the date-range tests. + /// + private static readonly DateOnly Today = new(2026, 10, 9); + + /// + /// Resolves a request against with a 7-day default range. + /// + /// The raw arguments. + /// The resolution. + private static DateRangeResolution Resolve(DateRangeRequest request) + { + return DateRangeResolver.Resolve(request, Today, TimeSpan.FromDays(7)); + } + + /// + /// Verifies that with no arguments the range is the default length ending today. + /// + [Fact] + public void Resolve_NoArguments_ReturnsDefaultRangeEndingToday() + { + var resolution = Resolve(new DateRangeRequest()); + + Assert.Null(resolution.Error); + Assert.Equal(new DateRange(new DateOnly(2026, 10, 3), Today), resolution.Range); + } + + /// + /// Verifies that --date selects exactly that day. + /// + [Fact] + public void Resolve_Date_ReturnsThatSingleDay() + { + var resolution = Resolve(new DateRangeRequest(Date: "2026-09-30")); + + Assert.Equal(new DateRange(new DateOnly(2026, 9, 30), new DateOnly(2026, 9, 30)), resolution.Range); + } + + /// + /// Verifies that --from without --to runs through today. + /// + [Fact] + public void Resolve_FromOnly_EndsToday() + { + var resolution = Resolve(new DateRangeRequest(From: "2026-10-01")); + + Assert.Equal(new DateRange(new DateOnly(2026, 10, 1), Today), resolution.Range); + } + + /// + /// Verifies that --from and --to give an explicit inclusive range. + /// + [Fact] + public void Resolve_FromAndTo_ReturnsExplicitRange() + { + var resolution = Resolve(new DateRangeRequest(From: "2026-08-01", To: "2026-08-15")); + + Assert.Equal(new DateRange(new DateOnly(2026, 8, 1), new DateOnly(2026, 8, 15)), resolution.Range); + } + + /// + /// Verifies that --last N covers N days including today. + /// + [Fact] + public void Resolve_Last_ReturnsNDaysEndingToday() + { + var resolution = Resolve(new DateRangeRequest(Last: 3)); + + Assert.Equal(new DateRange(new DateOnly(2026, 10, 7), Today), resolution.Range); + } + + /// + /// Verifies that the longest allowed --last is accepted. + /// + [Fact] + public void Resolve_LastAtMaximum_IsAccepted() + { + var resolution = Resolve(new DateRangeRequest(Last: DateRangeResolver.MaxDays)); + + Assert.NotNull(resolution.Range); + } + + /// + /// Verifies that --month covers the whole calendar month, including a leap-year February. + /// + [Theory] + [InlineData("2026-09", "2026-09-01", "2026-09-30")] + [InlineData("2028-02", "2028-02-01", "2028-02-29")] + [InlineData("2999-12", "2999-12-01", "2999-12-31")] + public void Resolve_Month_ReturnsWholeCalendarMonth(string month, string expectedFrom, string expectedTo) + { + var resolution = Resolve(new DateRangeRequest(Month: month)); + + Assert.Equal(new DateRange(DateOnly.Parse(expectedFrom), DateOnly.Parse(expectedTo)), resolution.Range); + } + + /// + /// Verifies that invalid or contradictory arguments are rejected with a message instead of throwing. + /// + [Theory] + [InlineData("2026-10-01", null, null, 2, null, "only one")] + [InlineData(null, "2026-10-01", null, 3, null, "only one")] + [InlineData(null, null, null, 3, "2026-09", "only one")] + [InlineData("10/01/2026", null, null, null, null, "--date")] + [InlineData(null, "nope", null, null, null, "--from")] + [InlineData(null, "2026-10-01", "2026-13-40", null, null, "--to")] + [InlineData(null, null, "2026-10-01", null, null, "requires --from")] + [InlineData(null, "2026-10-05", "2026-10-01", null, null, "must not be after")] + [InlineData(null, null, null, 0, null, "at least 1")] + [InlineData(null, null, null, -5, null, "at least 1")] + [InlineData(null, null, null, 3651, null, "at most 3650")] + [InlineData(null, null, null, null, "2026-13", "--month")] + [InlineData(null, null, null, null, "9999-12", "between 2000 and 2999")] + [InlineData(null, null, null, null, "1999-12", "between 2000 and 2999")] + [InlineData(null, "2026-01-01", "9999-12-31", null, null, "between 2000 and 2999")] + [InlineData(null, "0001-01-01", null, null, null, "between 2000 and 2999")] + [InlineData("9999-12-31", null, null, null, null, "between 2000 and 2999")] + [InlineData(null, "2000-01-01", "2026-10-09", null, null, "longer than 3650 days")] + [InlineData(null, null, null, 2147483647, null, "got 2147483647")] + public void Resolve_InvalidRequest_ReturnsError(string? date, string? from, string? to, int? lastDays, string? month, string expectedError) + { + var resolution = Resolve(new DateRangeRequest(date, from, to, lastDays, month)); + + Assert.Null(resolution.Range); + Assert.Contains(expectedError, resolution.Error); + } + + /// + /// Verifies that a range spanning exactly the maximum number of days is accepted. + /// + [Fact] + public void Resolve_FromToAtMaximumSpan_IsAccepted() + { + var resolution = Resolve(new DateRangeRequest(From: "2016-10-12", To: "2026-10-09")); + + Assert.Equal(DateRangeResolver.MaxDays, resolution.Range!.Value.To.DayNumber - resolution.Range.Value.From.DayNumber + 1); + } + + /// + /// Verifies that the update check runs after ordinary commands but not for watch or --json runs. + /// + [Theory] + [InlineData(false, false, true)] + [InlineData(false, true, false)] + [InlineData(true, false, false)] + [InlineData(true, true, false)] + public void ShouldCheckAfterCommand_WatchAndJson_SkipsTheCheck(bool isWatch, bool jsonRequested, bool expected) + { + Assert.Equal(expected, UpdateNotice.ShouldCheckAfterCommand(isWatch, jsonRequested)); + } + + /// + /// Verifies that the week command parses the range options and that --month is only offered by the month command. + /// + [Fact] + public void Create_WeekAndMonthCommands_ExposeTheirOwnRangeOptions() + { + var week = WeekCommand.Create(null!); + var month = MonthCommand.Create(null!); + + Assert.Empty(week.Parse("--from 2026-10-01 --to 2026-10-05 --json").Errors); + Assert.Empty(week.Parse("--last 3").Errors); + Assert.NotEmpty(week.Parse("--month 2026-09").Errors); + Assert.Empty(month.Parse("--month 2026-09").Errors); + Assert.NotEmpty(week.Parse("--last abc").Errors); + } + + /// + /// Verifies that the today command offers --date but not a range. + /// + [Fact] + public void Create_TodayCommand_ExposesDateAndJsonOnly() + { + var today = TodayCommand.Create(null!); + + Assert.Empty(today.Parse("--date 2026-10-01 --json").Errors); + Assert.NotEmpty(today.Parse("--from 2026-10-01").Errors); + } + + /// + /// Verifies that --json is detected on the invoked command only when it was actually passed. + /// + [Fact] + public void IsRequested_JsonFlag_ReflectsWhetherItWasPassed() + { + var models = ModelsCommand.Create(null!); + var export = ExportCommand.Create(null!); + + Assert.True(JsonOutput.IsRequested(models.Parse("--json"))); + Assert.False(JsonOutput.IsRequested(models.Parse([]))); + Assert.False(JsonOutput.IsRequested(export.Parse([]))); + } + + /// + /// Verifies that the model filter matches case-insensitively on a substring and keeps the original order. + /// + [Fact] + public void FilterByModel_Substring_KeepsMatchingModelsOnly() + { + List models = + [ + new("claude-opus-5-5", 1, 1, 1, 1, 4, 40, 1m), + new("claude-sonnet-5-5", 1, 1, 1, 1, 4, 40, 1m), + new("claude-haiku-5-5", 1, 1, 1, 1, 2, 20, 1m), + ]; + + var filtered = ModelsCommand.FilterByModel(models, " SONNET "); + + Assert.Equal("claude-sonnet-5-5", Assert.Single(filtered).ModelName); + } + + /// + /// Verifies that a missing or blank filter leaves the list untouched. + /// + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void FilterByModel_NoFilter_ReturnsAllModels(string? filter) + { + List models = [new("a", 1, 1, 1, 1, 4, 100, 1m)]; + + Assert.Same(models, ModelsCommand.FilterByModel(models, filter)); + } + + /// + /// Verifies that JSON output uses camelCase names and the expected values. + /// + [Fact] + public void Serialize_DailySummary_UsesCamelCaseProperties() + { + var summary = new DailySummary(new DateOnly(2026, 10, 9), 5, 2, 3, 1234, new() { ["opus"] = 1234 }, 1.5m); + + var json = JsonOutput.Serialize(summary, CliJsonContext.Default.DailySummary); + + using var document = JsonDocument.Parse(json); + var root = document.RootElement; + Assert.Equal("2026-10-09", root.GetProperty("date").GetString()); + Assert.Equal(1234, root.GetProperty("totalTokens").GetInt64()); + Assert.Equal(1.5m, root.GetProperty("estimatedCost").GetDecimal()); + Assert.Equal(1234, root.GetProperty("tokensByModel").GetProperty("opus").GetInt64()); + } + + /// + /// Verifies that session durations are emitted as plain seconds, including past 24 hours. + /// + [Fact] + public void Serialize_SessionStats_EmitsDurationsAsSeconds() + { + var stats = new SessionStats(3, TimeSpan.FromMinutes(5), 2.5, TimeSpan.FromHours(30), "abc"); + + var json = JsonOutput.Serialize(SessionStatsReport.From(stats), CliJsonContext.Default.SessionStatsReport); + + using var document = JsonDocument.Parse(json); + var root = document.RootElement; + Assert.Equal(3, root.GetProperty("total").GetInt32()); + Assert.Equal(300, root.GetProperty("avgDurationSeconds").GetDouble()); + Assert.Equal(108000, root.GetProperty("longestDurationSeconds").GetDouble()); + Assert.Equal("abc", root.GetProperty("longestSessionId").GetString()); + } + + /// + /// Verifies that the model and hourly reports serialize with camelCase names. + /// + [Fact] + public void Serialize_ModelAndHourLists_ProduceJson() + { + var models = JsonOutput.Serialize>([new("m", 1, 2, 3, 4, 10, 100, 0.5m)], CliJsonContext.Default.ListModelDistribution); + var hours = JsonOutput.Serialize>([new(9, 100)], CliJsonContext.Default.ListHourlyActivity); + + using var modelDocument = JsonDocument.Parse(models); + using var hourDocument = JsonDocument.Parse(hours); + Assert.Equal("m", modelDocument.RootElement[0].GetProperty("modelName").GetString()); + Assert.Equal(9, hourDocument.RootElement[0].GetProperty("hour").GetInt32()); + } +} From 7f21da36e5a30ec9ab8378bc3b2815e39f921c03 Mon Sep 17 00:00:00 2001 From: Gaoyang Date: Fri, 9 Oct 2026 21:54:16 +0800 Subject: [PATCH 4/4] docs: document report command options and --json --- CLAUDE.md | 1 + README.md | 18 ++++++++++++++++-- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 8ac334d..1117fc3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 `, `--week-reset `; 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 ` (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 ` (`month` also `--month yyyy-MM`); `models` takes `--model `. 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"` diff --git a/README.md b/README.md index 1365fbe..3e1efb1 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,13 @@ Commands: - `hours` — usage broken down by hour of day - `export` — export raw analytics; supports `--format json|csv` and `--output ` (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 `; `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 ` — only models whose name contains the text (percentages stay relative to all models). If nothing matches, the table view prints `No model matches ''.` 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: @@ -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) @@ -166,6 +173,13 @@ aimon <命令> - `hours` — 按小时统计用量 - `export` — 导出原始分析数据;支持 `--format json|csv` 与 `--output `(默认输出到标准输出,格式为 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 账号侧,本地无法读取,建议各录入一次: @@ -182,7 +196,7 @@ aimon <命令> ### 更新检查 -每个命令执行结束后都会检查一次 GitHub 上是否有新版本发布(`watch` 会在进入刷新循环前检查一次,并在整个运行期间将提示固定显示在视图底部)。如果有新版本,会打印一行提示,附带新版本号和发布页链接——这不会阻塞或影响命令本身的执行,除了标准的 GitHub 最新发布查询请求外不会发送任何其他数据。 +每个命令执行结束后都会检查一次 GitHub 上是否有新版本发布(`watch` 会在进入刷新循环前检查一次,并在整个运行期间将提示固定显示在视图底部)。带 `--json` 的调用会完全跳过该检查,因此脚本调用不会发起网络请求,也不会为此等待;提示本身输出到 stderr。如果有新版本,会打印一行提示,附带新版本号和发布页链接——这不会阻塞或影响命令本身的执行,除了标准的 GitHub 最新发布查询请求外不会发送任何其他数据。 ### WPF 仪表盘(仅 Windows)