diff --git a/src/Aspire.Cli/Commands/GroupedHelpWriter.cs b/src/Aspire.Cli/Commands/GroupedHelpWriter.cs index a3a74cfada7..aa7a79fb0e0 100644 --- a/src/Aspire.Cli/Commands/GroupedHelpWriter.cs +++ b/src/Aspire.Cli/Commands/GroupedHelpWriter.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.CommandLine; +using System.CommandLine.Help; using Aspire.Cli.Resources; namespace Aspire.Cli.Commands; @@ -42,9 +43,7 @@ public static void WriteHelp(Command command, TextWriter writer, int? maxWidth = } // Usage - writer.WriteLine(HelpGroupStrings.Usage); - writer.WriteLine(GetIndent() + HelpGroupStrings.UsageSyntax); - writer.WriteLine(); + WriteUsage(writer, HelpGroupStrings.UsageSyntax); // Collect visible subcommands and organize by group. var grouped = new Dictionary>(); @@ -143,32 +142,48 @@ public static void WriteHelp(Command command, TextWriter writer, int? maxWidth = var visibleOptions = command.Options.Where(o => !o.Hidden).ToList(); if (visibleOptions.Count > 0) { - writer.WriteLine(HelpGroupStrings.Options); + WriteTwoColumnSection( + writer, + HelpGroupStrings.Options, + visibleOptions.Select(static opt => (FormatOptionLabel(opt), opt.Description ?? string.Empty)), + width); + } - var optionColumnWidth = 0; - foreach (var opt in visibleOptions) - { - var label = FormatOptionLabel(opt); - if (label.Length > optionColumnWidth) - { - optionColumnWidth = label.Length; - } - } + // Help hint + writer.WriteLine(HelpGroupStrings.HelpHint); + } - optionColumnWidth += 4; + internal static void WriteUsage(TextWriter writer, params string[] usages) + { + writer.WriteLine(HelpGroupStrings.Usage); + foreach (var usage in usages) + { + writer.WriteLine(GetIndent() + usage); + } - foreach (var opt in visibleOptions) - { - var label = FormatOptionLabel(opt); - var desc = opt.Description ?? string.Empty; - WriteTwoColumnRow(writer, label, desc, optionColumnWidth, width); - } + writer.WriteLine(); + } - writer.WriteLine(); + internal static void WriteTwoColumnSection(TextWriter writer, string heading, IEnumerable<(string Label, string Description)> rows, int maxWidth, bool trailingBlankLine = true) + { + var rowArray = rows.ToArray(); + if (rowArray.Length == 0) + { + return; } - // Help hint - writer.WriteLine(HelpGroupStrings.HelpHint); + writer.WriteLine(heading); + + var columnWidth = rowArray.Max(static row => row.Label.Length) + 4; + foreach (var (label, description) in rowArray) + { + WriteTwoColumnRow(writer, label, description, columnWidth, maxWidth); + } + + if (trailingBlankLine) + { + writer.WriteLine(); + } } private static void WriteGroup(TextWriter writer, string heading, List commands, int columnWidth, int width) @@ -287,7 +302,7 @@ private static string GetArgumentSyntax(Command cmd) return string.Join(" ", parts); } - private static string FormatOptionLabel(Option option) + internal static string FormatOptionLabel(Option option, bool includeValueName = false) { // Collect all identifiers: Name may not be in Aliases in System.CommandLine 2.0. var allNames = new HashSet(option.Aliases, StringComparer.Ordinal); @@ -296,9 +311,23 @@ private static string FormatOptionLabel(Option option) allNames.Add(option.Name); } - var sorted = allNames.OrderBy(a => a.Length).ToList(); - return sorted.Count > 1 - ? $"{sorted[0]}, {sorted[1]}" - : sorted.Count > 0 ? sorted[0] : option.Name; + var label = string.Join(", ", allNames.OrderBy(a => a.Length).ThenBy(a => a, StringComparer.Ordinal)); + return includeValueName && !IsBooleanOption(option) + ? $"{label} <{GetOptionValueName(option)}>" + : label; + } + + private static bool IsBooleanOption(Option option) + { + return option is Option or HelpOption; + } + + private static string GetOptionValueName(Option option) + { + var longName = option.Name.StartsWith("--", StringComparison.Ordinal) + ? option.Name + : option.Aliases.FirstOrDefault(static alias => alias.StartsWith("--", StringComparison.Ordinal)) ?? option.Name; + + return longName.TrimStart('-'); } } diff --git a/src/Aspire.Cli/Commands/ResourceCommand.cs b/src/Aspire.Cli/Commands/ResourceCommand.cs index 8bd9db51e72..3531260a2f7 100644 --- a/src/Aspire.Cli/Commands/ResourceCommand.cs +++ b/src/Aspire.Cli/Commands/ResourceCommand.cs @@ -2,7 +2,11 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.CommandLine; +using System.CommandLine.Help; +using System.CommandLine.Invocation; +using System.CommandLine.Parsing; using System.Globalization; +using System.Text; using System.Text.Json.Nodes; using Aspire.Cli.Backchannel; using Aspire.Cli.Configuration; @@ -25,12 +29,16 @@ internal sealed class ResourceCommand : BaseCommand private static readonly Argument s_resourceArgument = new("resource") { - Description = ResourceCommandStrings.CommandResourceArgumentDescription + Description = ResourceCommandStrings.CommandResourceArgumentDescription, + Arity = ArgumentArity.ExactlyOne, + DefaultValueFactory = _ => string.Empty }; private static readonly Argument s_commandArgument = new("command") { - Description = ResourceCommandStrings.CommandNameArgumentDescription + Description = ResourceCommandStrings.CommandNameArgumentDescription, + Arity = ArgumentArity.ExactlyOne, + DefaultValueFactory = _ => string.Empty }; private static readonly OptionWithLegacy s_appHostOption = new("--apphost", "--project", SharedCommandStrings.AppHostOptionDescription); @@ -39,7 +47,7 @@ internal sealed class ResourceCommand : BaseCommand /// Well-known commands with their display metadata. /// The command name is used directly (no mapping needed since the user-facing names match the actual command names). /// - private static readonly Dictionary s_wellKnownCommands = new(StringComparer.OrdinalIgnoreCase) + private static readonly Dictionary s_wellKnownCommands = new(StringComparers.CommandName) { ["start"] = ("Starting", "start", "started"), ["stop"] = ("Stopping", "stop", "stopped"), @@ -64,7 +72,24 @@ public ResourceCommand( Arguments.Add(s_resourceArgument); Arguments.Add(s_commandArgument); Options.Add(s_appHostOption); + Options.Add(new HelpOption { Action = new ResourceCommandHelpAction(this) }); TreatUnmatchedTokensAsErrors = false; + + Validators.Add(result => + { + var resourceName = result.GetValue(s_resourceArgument); + if (string.IsNullOrEmpty(resourceName) || IsOptionLikeToken(resourceName)) + { + result.AddError(string.Format(CultureInfo.CurrentCulture, ResourceCommandStrings.ArgumentRequired, s_resourceArgument.Name)); + return; + } + + var commandName = result.GetValue(s_commandArgument); + if (string.IsNullOrEmpty(commandName) || IsOptionLikeToken(commandName)) + { + result.AddError(string.Format(CultureInfo.CurrentCulture, ResourceCommandStrings.ArgumentRequired, s_commandArgument.Name)); + } + }); } protected override async Task ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken) @@ -87,9 +112,15 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell } var connection = result.Connection!; - var commandArguments = capturedArguments.Length > 0 - ? new JsonArray(capturedArguments.Select(static argument => (JsonNode?)JsonValue.Create(argument)).ToArray()) - : null; + var command = await GetCommandMetadataAsync(connection, resourceName, commandName, includeHidden: false, cancellationToken).ConfigureAwait(false); + var commandArgumentsResult = CreateCommandArguments(command, capturedArguments); + if (commandArgumentsResult.ErrorMessage is { } errorMessage) + { + _interactionService.DisplayError(errorMessage); + return ExitCodeConstants.InvalidCommand; + } + + var commandArguments = commandArgumentsResult.Arguments; // Map well-known friendly names (start/stop/restart) to their display metadata if (s_wellKnownCommands.TryGetValue(commandName, out var knownCommand)) @@ -117,4 +148,479 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell cancellationToken); } + private static async Task GetCommandMetadataAsync(IAppHostAuxiliaryBackchannel connection, string resourceName, string commandName, bool includeHidden, CancellationToken cancellationToken) + { + var snapshots = await connection.GetResourceSnapshotsAsync(includeHidden, cancellationToken).ConfigureAwait(false); + var resources = ResourceSnapshotMapper.ResolveResources(resourceName, snapshots); + + return resources + .SelectMany(static resource => resource.Commands) + .FirstOrDefault(command => string.Equals(command.Name, commandName, StringComparisons.CommandName)); + } + + private static (JsonNode? Arguments, string? ErrorMessage) CreateCommandArguments(ResourceSnapshotCommand? command, string[] capturedArguments) + { + capturedArguments = RemoveDelimiter(capturedArguments); + + if (capturedArguments.Length == 0) + { + if (command?.ArgumentInputs is { Length: > 0 } inputs) + { + return CreateCommandArguments(inputs, capturedArguments); + } + + return (null, null); + } + + if (command?.ArgumentInputs is not { Length: > 0 } argumentInputs) + { + // Without command metadata there are no options to give System.CommandLine, so do not infer any values. + // Forward tokens as unknown names and let hosting-side validation reject them. + return (CreateUnknownArguments(capturedArguments), null); + } + + return CreateCommandArguments(argumentInputs, capturedArguments); + } + + private static (JsonObject Arguments, string? ErrorMessage) CreateCommandArguments(ResourceSnapshotCommandArgument[] argumentInputs, string[] capturedArguments) + { + var arguments = new JsonObject(); + var options = new Dictionary(); + var parserCommand = new Command("resource-command") + { + TreatUnmatchedTokensAsErrors = true + }; + + foreach (var argument in argumentInputs) + { + var option = CreateCommandArgumentOption(argument); + options.Add(argument, option); + parserCommand.Options.Add(option); + } + + parserCommand.Validators.Add(result => + { + var missingRequiredOptions = argumentInputs + .Where(argument => argument.Required && string.IsNullOrEmpty(argument.Value) && result.GetResult(options[argument]) is not { Implicit: false }) + .Select(argument => $"--{ToKebabCase(argument.Name)}") + .ToArray(); + + if (missingRequiredOptions.Length == 1) + { + result.AddError($"Required option '{missingRequiredOptions[0]}' was not provided."); + } + else if (missingRequiredOptions.Length > 1) + { + result.AddError($"Required options were not provided: {string.Join(", ", missingRequiredOptions.Select(static optionName => $"'{optionName}'"))}."); + } + }); + + // Parse the resource command tail with System.CommandLine as a second pass. The first pass parses Aspire CLI + // options and leaves resource command tokens in ParseResult.UnmatchedTokens; this pass parses those remaining + // tokens against options generated from ResourceSnapshotCommand.ArgumentInputs. + var parseResult = parserCommand.Parse(capturedArguments); + if (parseResult.Errors.Count > 0) + { + var unrecognizedCommandOptions = GroupUnrecognizedCommandOptions(parseResult.UnmatchedTokens); + if (unrecognizedCommandOptions.Length > 0) + { + return (arguments, FormatUnrecognizedCommandOptions(unrecognizedCommandOptions)); + } + + return (arguments, string.Join(Environment.NewLine, parseResult.Errors.Select(static error => error.Message))); + } + + foreach (var argument in argumentInputs) + { + var option = options[argument]; + if (parseResult.GetResult(option) is not { Implicit: false }) + { + continue; + } + + if (option is Option boolOption) + { + arguments[argument.Name] = parseResult.GetValue(boolOption).ToString().ToLowerInvariant(); + } + else if (option is Option numberOption) + { + var value = parseResult.GetValue(numberOption); + arguments[argument.Name] = value?.ToString(CultureInfo.InvariantCulture); + } + else if (option is Option stringOption) + { + arguments[argument.Name] = parseResult.GetValue(stringOption); + } + } + + foreach (var unmatchedToken in parseResult.UnmatchedTokens) + { + // Metadata-backed command inputs are options only. Any leftover token is forwarded as an unknown argument + // name so hosting-side validation reports it instead of binding it positionally. + // Example: `#name` becomes `{ "#name": null }`, which is not a declared command input. + arguments[unmatchedToken] = null; + } + + return (arguments, null); + } + + private static string[] RemoveDelimiter(string[] capturedArguments) + { + if (capturedArguments.Length == 0 || capturedArguments[0] is not "--") + { + return capturedArguments; + } + + return capturedArguments[1..]; + } + + private static JsonObject CreateUnknownArguments(string[] capturedArguments) + { + var arguments = new JsonObject(); + foreach (var token in GroupOptionLikeArguments(capturedArguments)) + { + arguments[token] = null; + } + + return arguments; + } + + private static Option CreateCommandArgumentOption(ResourceSnapshotCommandArgument argument) + { + // Resource command input names are exposed as both exact-name and kebab-case System.CommandLine options: + // - "timeoutMilliseconds" accepts "--timeoutMilliseconds" and "--timeout-milliseconds" + // - "LogLevel" accepts "--LogLevel" and "--log-level" + // - "url" accepts "--url" + var optionName = ToKebabCase(argument.Name); + Option option = (IsBooleanInput(argument), IsNumberInput(argument)) switch + { + (true, _) => new Option($"--{optionName}") + { + DefaultValueFactory = _ => bool.TryParse(argument.Value, out var value) && value + }, + (_, true) => new Option($"--{optionName}") + { + Arity = ArgumentArity.ExactlyOne, + AllowMultipleArgumentsPerToken = false, + DefaultValueFactory = _ => double.TryParse(argument.Value, CultureInfo.InvariantCulture, out var value) ? value : null + }, + _ => new Option($"--{optionName}") + { + Arity = ArgumentArity.ExactlyOne, + AllowMultipleArgumentsPerToken = false, + DefaultValueFactory = _ => argument.Value + } + }; + + if (option is Option boolOption) + { + boolOption.Arity = ArgumentArity.ZeroOrOne; + boolOption.AllowMultipleArgumentsPerToken = false; + } + + option.Description = argument.Description ?? argument.Label; + option.Required = argument.Required && string.IsNullOrEmpty(argument.Value); + + if (!argument.AllowCustomChoice && argument.Options is { Count: > 0 } options) + { + option.Validators.Add(result => + { + var value = result.GetValueOrDefault(); + if (value is not null && !options.ContainsKey(value)) + { + result.AddError($"Option '--{optionName}' only accepts the following values: {string.Join(", ", options.Keys)}."); + } + }); + } + + var exactName = $"--{argument.Name}"; + if (!string.Equals(exactName, $"--{optionName}", StringComparison.Ordinal)) + { + option.Aliases.Add(exactName); + } + + return option; + } + + private static bool IsBooleanInput(ResourceSnapshotCommandArgument argument) + { + return string.Equals(argument.InputType, "Boolean", StringComparison.OrdinalIgnoreCase); + } + + private static bool IsNumberInput(ResourceSnapshotCommandArgument argument) + { + return string.Equals(argument.InputType, "Number", StringComparison.OrdinalIgnoreCase); + } + + private static bool IsOptionLikeToken(string value) + { + return value is not "--" && value.StartsWith("-", StringComparison.Ordinal); + } + + private static string[] GroupOptionLikeArguments(IReadOnlyList arguments) + { + var groupedArguments = new List(); + for (var i = 0; i < arguments.Count; i++) + { + var argument = arguments[i]; + if (IsOptionLikeToken(argument) && + !argument.Contains('=') && + i + 1 < arguments.Count && + !IsOptionLikeToken(arguments[i + 1])) + { + groupedArguments.Add($"{argument} {arguments[i + 1]}"); + i++; + } + else + { + groupedArguments.Add(argument); + } + } + + return [.. groupedArguments]; + } + + private static string[] GroupUnrecognizedCommandOptions(IReadOnlyList arguments) + { + var groupedArguments = new List(); + for (var i = 0; i < arguments.Count; i++) + { + var argument = arguments[i]; + if (!IsOptionLikeToken(argument)) + { + continue; + } + + if (!argument.Contains('=') && + i + 1 < arguments.Count && + !IsOptionLikeToken(arguments[i + 1])) + { + groupedArguments.Add($"{argument} {arguments[i + 1]}"); + i++; + } + else + { + groupedArguments.Add(argument); + } + } + + return [.. groupedArguments]; + } + + private static string FormatUnrecognizedCommandOptions(string[] optionNames) + { + return optionNames.Length == 1 + ? $"Unrecognized command option '{optionNames[0]}'." + : $"Unrecognized command options: {string.Join(", ", optionNames.Select(static optionName => $"'{optionName}'"))}."; + } + + private static string ToKebabCase(string value) + { + if (string.IsNullOrEmpty(value)) + { + return value; + } + + var builder = new StringBuilder(value.Length + 4); + for (var i = 0; i < value.Length; i++) + { + var ch = value[i]; + if (char.IsUpper(ch)) + { + if (i > 0 && builder[^1] != '-') + { + builder.Append('-'); + } + + builder.Append(char.ToLowerInvariant(ch)); + } + else + { + builder.Append(ch); + } + } + + return builder.ToString(); + } + + private sealed class ResourceCommandHelpAction(ResourceCommand command) : AsynchronousCommandLineAction + { + private readonly HelpAction _defaultHelpAction = new(); + + public override async Task InvokeAsync(ParseResult parseResult, CancellationToken cancellationToken) + { + var request = ResourceCommandHelpParser.Parse(parseResult, s_resourceArgument, s_commandArgument, s_appHostOption); + if (request is null) + { + return _defaultHelpAction.Invoke(parseResult); + } + + var result = await command._connectionResolver.ResolveConnectionAsync( + request.AppHostProjectFile, + SharedCommandStrings.ScanningForRunningAppHosts, + string.Format(CultureInfo.CurrentCulture, SharedCommandStrings.SelectAppHost, ResourceCommandStrings.SelectAppHostAction), + SharedCommandStrings.AppHostNotRunning, + cancellationToken).ConfigureAwait(false); + + if (!result.Success) + { + return _defaultHelpAction.Invoke(parseResult); + } + + var resourceCommand = await GetCommandMetadataAsync(result.Connection!, request.ResourceName, request.CommandName, includeHidden: false, cancellationToken).ConfigureAwait(false); + if (resourceCommand is null) + { + return _defaultHelpAction.Invoke(parseResult); + } + + WriteResourceCommandHelp(parseResult.InvocationConfiguration.Output, parseResult.CommandResult, request.ResourceName, resourceCommand); + return ExitCodeConstants.Success; + } + + private static void WriteResourceCommandHelp(TextWriter writer, CommandResult commandResult, string resourceName, ResourceSnapshotCommand command) + { + var cliOptionNames = GetCliOptionNames(commandResult); + + writer.WriteLine(command.Description is { Length: > 0 } ? command.Description : command.DisplayName ?? command.Name); + writer.WriteLine(); + GroupedHelpWriter.WriteUsage( + writer, + string.Format(CultureInfo.CurrentCulture, ResourceCommandStrings.CommandSpecificHelpUsageSyntax, resourceName, command.Name)); + + if (command.ArgumentInputs.Length > 0) + { + GroupedHelpWriter.WriteTwoColumnSection( + writer, + ResourceCommandStrings.CommandSpecificHelpCommandOptions, + command.ArgumentInputs.Select(argument => (GetCommandOptionLabel(argument), GetArgumentDescription(argument, cliOptionNames))), + maxWidth: 120); + } + + GroupedHelpWriter.WriteTwoColumnSection( + writer, + HelpGroupStrings.Options, + GetVisibleCliOptions(commandResult).Select(static option => (GroupedHelpWriter.FormatOptionLabel(option, includeValueName: true), option.Description ?? string.Empty)), + maxWidth: 120, + trailingBlankLine: false); + } + + private static IEnumerable