.netcommand-linecommand-line-interfacecommand-line-argumentssystem.commandline

Implementing --interactive with System.CommandLine


I have a System.CommandLine .NET 9 command line application. It's mostly used for scheduled tasks and the like, but sometimes users need to run it manually.

When run manually, while the help is good, users find it quite difficult to build one command line.

I want to add an --interactive mode flag. When this is true any required missing commands or options should become prompts instead.

var rootCommand = new RootCommand("Command-line interface") {
    new Command(["example-a", "a"], "Do something"),
    new Command(["example-b", "b"], "Do something else")
}

If a user calls this without the commands they get something like:

C:\Users\me>myApp
Required command was not provided.

Description:
  Command-line interface

Usage:
  myApp [command] [options]

Options:
  -?, -h, --help     Show help and usage information

Commands:
  a, example-a      Do something
  b, example-b      Do something else

Which is helpful, and needs to be the default, but we have a lot of commands that go quite deep and some required many options (which can be things with their own validation, like file paths or URLs).

I want to add an opt-in interactive mode for manual users that reads the System.CommandLine implementation, so something like:

C:\Users\me>myApp --interactive

Choose a Command:
     example-a  Do something
   > example-b  Do something else

I could do this by going through every command and adding Console.Readline into every SetHandler implementation, but that is a lot of duplication.

It seems much cleaner to AddMiddleware that picks up the ParseResult.Errors collection and builds a set of prompts to ask for the missing options, but building this from scratch involves a lot of messy work and corner cases.

Is there a better way to do this? Am I missing a built in option on System.CommandLine? I can't be the only person trying to do this, even System.CommandLine's own documentation reserves --interactive and -i for this kind of opt-in prompts.


Solution

  • A middleware approach to this would be something like (using DI-injected Spectre.Console for nice prompts):

    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);
        }
    }
    
    // And then in your Program.cs entrypoint:
    var builder = Host.CreateDefaultBuilder(args);
    builder.ConfigureServices((host, s) =>
    {
        s.AddSingleton(AnsiConsole.Console); // Inject Spectre.Console.IAnsiConsole
    });
    // Build host, scope services, etc etc
    
    // Configure the rootCommand and all handlers normally...
    await new CommandLineBuilder(rootCommand).
        UseDefaults().
        UseInteractive(services). // Use this interactive middleware
        Build().
        InvokeAsync(args);
    

    However, it very much seems like System.CommandLine has been written specifically to stop you from doing this - all the properties that you'd want to access to make this easier are internal or private, you can't even fall back to the default HelpResult when this fails (it's also internal). I have to use reflection to get the default and allowed values, both of which are configured in my code and shouldn't need to be hidden.

    It very much feels like there should be a better way.