Option.Argument.GetDefaultValue() and Argument.AllowedValues should be public members
In both cases these are set by the configuration of the command line, but are then only available internally. If you access the ParseResult they are hidden.
For instance, I want to implement --interactive on a large command line app, I don't want to edit every existing handler to do it's own interactions, I want to access the ParseResult of the command to find out what the prompts should be.
Here is an example use-case (using Spectre.Console for the prompt formatting, but you can use any console input):
static class InteractiveParsePrompt
{
/// <summary>Limit of error depth - users can CTRL+C cancel out but this is a saftey check.</summary>
const int MaxDepth = 20;
/// <summary>Interative option, set globally and available on all commands.</summary>
public static readonly Option<bool> OptInteractive = new(["--interactive", "--i", "-i"], "Run in interactive mode, any missing arguments or options will be requested") { Arity = ArgumentArity.Zero };
/// <summary>Add interactive prompts when there are parse errors and the --interactive option is set</summary>
public static CommandLineBuilder UseInteractive(this CommandLineBuilder builder, IServiceProvider services)
{
// Add the --interactive option to the root command
builder.Command.AddGlobalOption(OptInteractive);
builder.AddMiddleware(async (context, next) => await MiddlewareInteractive(services, context, next), MiddlewareOrder.Configuration);
return builder;
}
/// <summary>Get the allowed values for an argument.</summary>
static string[] GetAllowedValues(Argument argument) {
// System.CommandLine makes all this info about the allowed values private, so we need to use reflection to get it
var prop = typeof(Argument).GetProperty("AllowedValues", BindingFlags.NonPublic | BindingFlags.Instance);
if(prop is null) return [];
var getter = prop.GetGetMethod(nonPublic: true);
if (getter is null) return [];
var allowedValues = (HashSet<string>?) getter.Invoke(argument, null);
if (allowedValues is null) return [];
return [..allowedValues];
}
/// <summary>Get the underlying Argument implementation for an option.</summary>
static Argument? GetArgument(Option option)
{
// System.CommandLine makes all this info about the allowed values private, so we need to use reflection to get it
var prop = typeof(Option).GetProperty("Argument", BindingFlags.NonPublic | BindingFlags.Instance);
if (prop is null) return null;
var getter = prop.GetGetMethod(nonPublic: true);
if (getter is null) return null;
return (Argument?)getter.Invoke(option, null);
}
/// <summary>Get the markup text for the option or argument description.</summary>
static string PromptText(Symbol symbol) {
if (symbol.Description is not null)
return $"[bold yellow]{symbol.Name}[/] [italic]{symbol.Description.EscapeMarkup().TrimEnd(' ', '.')}[/]" ;
return $"[bold yellow]{symbol.Name}[/]";
}
/// <summary>Prompt the user to provide the value for an argument</summary>
static string PromptArgument(Argument argument, IAnsiConsole console)
{
string[] allowedValues = GetAllowedValues(argument);
IPrompt<string> prompt;
if (allowedValues.Length > 0)
prompt = new SelectionPrompt<string>().
Title(PromptText(argument)).
PageSize(20).
AddChoices(allowedValues.Order());
else
prompt = new TextPrompt<string>(PromptText(argument));
string argResponse = console.Prompt(prompt);
console.MarkupLine($"Argument [bold yellow]{argument.Name}[/] = [green]{argResponse}[/]");
return argResponse;
}
/// <summary>Prompt the user to provide the value for an option</summary>
static IEnumerable<string> PromptOption(Option option, IAnsiConsole console)
{
if (option.ValueType == typeof(bool)) {
// Boolean, give them a y/n confirmation prompt
bool optConfirm = AnsiConsole.Prompt(
new TextPrompt<bool>(PromptText(option)).
AddChoice(true).
AddChoice(false).
DefaultValue(false).
WithConverter(choice => choice ? "y" : "n"));
if (optConfirm)
{
console.MarkupLine($"Option set [bold green]{option.Name}[/]");
yield return $"--{option.Name}";
}
yield break;
}
TextPrompt<string> prompt = new(PromptText(option));
// Get the underlying argument to get the default value
var argument = GetArgument(option);
if(argument is not null && argument.HasDefaultValue)
{
string? defaultValue = argument.GetDefaultValue()?.ToString();
if (defaultValue is not null)
prompt.DefaultValue(defaultValue);
}
string optResponse = console.Prompt(prompt);
console.MarkupLine($"Option [bold yellow]{option.Name}[/] = [green]{optResponse}[/]");
yield return $"--{option.Name}";
yield return optResponse;
}
/// <summary>Prompt the user to choose a subcommand, if that has arguments or options prompt for them too, return a new set of arguments to parse from the prompts</summary>
static IEnumerable<string> PromptCommand(Command command, IAnsiConsole console) {
int maxL = command.Subcommands.Select(c => c.Name.Length).Max() + 1;
string subCommand = console.Prompt(
new SelectionPrompt<string>().
Title("Choose command?").
PageSize(20).
AddChoices(command.Subcommands.Select(c => $"{c.Name.PadRight(maxL)}: {c.Description}").Order()));
string commandName = subCommand.Split(":")[0].Trim();
console.MarkupLine($"Command [green]{commandName}[/] selected");
yield return commandName;
var subCommandFound = command.Subcommands.FirstOrDefault(c => c.Name == commandName);
if(subCommandFound is null) yield break;
if(subCommandFound.Arguments.Count > 0)
foreach (var argument in subCommandFound.Arguments)
yield return PromptArgument(argument, console);
if (subCommandFound.Options.Count > 0)
foreach (var option in subCommandFound.Options)
foreach(string optionValue in PromptOption(option, console))
yield return optionValue;
if (subCommandFound.Subcommands.Count > 0)
foreach (string sub in PromptCommand(subCommandFound, console))
yield return sub;
}
/// <summary>Intercept the command line parse on failure if --interactive option is set to prompt the user for the missing commands, arguments and options.</summary>
static async Task MiddlewareInteractive(IServiceProvider services, InvocationContext context, Func<InvocationContext, Task> next)
{
// If no errors or not in interactive mode, continue
if (!context.ParseResult.GetValueForOption(OptInteractive) ||
context.ParseResult.Errors.Count == 0)
{
await next(context);
return;
}
var cancellationToken = context.GetCancellationToken();
// Use Spectre.Console for interactive prompts, set up in the DI
var console = services.GetRequiredService<IAnsiConsole>();
console.WriteLine("Interactive mode");
int retry = 0;
while(retry++ < MaxDepth &&
context.ParseResult.Errors.Count != 0 &&
!cancellationToken.IsCancellationRequested)
{
var command = context.ParseResult.CommandResult.Command;
List<string> interactiveArgs = [..context.ParseResult.Tokens.Select(t => t.Value)];
foreach (var error in context.ParseResult.Errors)
{
if (cancellationToken.IsCancellationRequested) break;
if (error.Message == "Required command was not provided.")
{
foreach (string arg in PromptCommand(command, console))
{
if (cancellationToken.IsCancellationRequested) break;
interactiveArgs.Add(arg);
}
}
else if (error.Message.StartsWith("Required argument missing for command:"))
{
string argumentName = error.Message.Split(":")[1].Trim(' ', '\'', '.');
var argument = command.Arguments.FirstOrDefault(a => a.Name == argumentName);
if (argument is null)
{
console.MarkupLine($"[red]{error.Message.EscapeMarkup()}[/]");
break;
}
interactiveArgs.Add(PromptArgument(argument, console));
}
else
{
console.MarkupLine($"[red]{error.Message.EscapeMarkup()}[/]");
break;
}
}
context.ParseResult = context.Parser.Parse(interactiveArgs);
}
if (cancellationToken.IsCancellationRequested)
console.MarkupLine("[red]Cancelled[/]");
else if (context.ParseResult.Errors.Count == 0)
{
string newArgs = string.Join(' ', context.ParseResult.Tokens.Select(t => t.Value));
console.MarkupLine($"New arguments set: [green]{newArgs.EscapeMarkup()}[/]");
}
else
console.MarkupLine("[red]Failed[/]");
await next(context);
}
}
I'm happy to be told if there's a better way to do this.
Note that I can't get the Argument.AllowedValues or Option.Argument.GetDefaultValue(), both of which I'd need for this to be interactive, without using reflection.
In both cases these are properties of the arguments and options configured in my code, why are they internal in ParseResult? Could we just have Argument.AllowedValues as public and a new public GetDefaultValue() = Argument.GetDefaultValue(); on Option?
The Argument.GetDefaultValue() method has been public ever since it was added in https://github.com/dotnet/command-line-api/pull/264.
The Argument.AllowedValues property was deleted in https://github.com/dotnet/command-line-api/pull/1959. It was replaced with the Argument.Validators property, which is not suitable for enumerating the allowed values or for telling the user how the argument will be validated; but I suppose you can enumerate the allowed values from Argument.CompletionSources.
As of commit 599faf215f4c0efe952d0251ab8fba4761f14a19, the Option.Argument property is still not public.
I feel the OpenAPI support that was added to ASP.NET Core may give some ideas for APIs that could be added to System.CommandLine for providing metadata about arguments and options.
The Argument.GetDefaultValue() method has been public ever since it was added in #264.
It's public on the argument but the argument is not public on the option, meaning there's no way to enumerate possible option values.
Sorry, I should have been more clear.
This is our workaround: https://github.com/Azure/azure-mcp/blob/main/src/Extensions/SystemCommandLineExtensions.cs