Description
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
?