Skip to content

Proposal: CLI Actions  #2071

Open
Open
@jonsequitur

Description

@jonsequitur

This proposal describes a rethinking of the approach to command invocation that's been in place in System.CommandLine from the beginning. The convention- and reflection-based approach (CommandHandler.Create(Func<...>) and overloads) was removed in past previews in favor of something suited to better performance, trimming, and NativeAOT compilation (Command.SetHandler(Func<...>) and overloads). This change reduced the usability of the API by increasing its verbosity. The current proposal is for a design to replace these APIs with something quite different. (For more background and discussion, see #1776.)

A few examples

I'll start with a few code examples before discussing the various specific goals of the design.

The core concept adds a new abstraction, roughly similar to the existing ICommandHandler, called a CliAction. The core of the interface looks like this:

public abstract class CliAction
{
    public abstract Task<int> RunAsync(
        ParseResult parseResult, 
        CancellationToken cancellationToken); 
}

(The parameters to RunAsync assume that the InvocationContext class will be combined into ParseResult, a change currently under discussion.)

A CliAction can be attached to a command (or other symbol--more on that below) very much like a CommandHandler today:

var rootCommand = new RootCommand();
rootCommand.SetAction(new MyRootCommandAction())

For convenience, the action can be set up using a delegate, much like today:

rootCommand.SetAction((parseResult, cancellationToken) => 
{
    /* custom logic */ 
});

After parsing, you can run the action designated by the command line input like this:

ParseResult result = rootCommand.Parse(args);
await result.Action.RunAsync();

Just like the existing InvokeAsync APIs, this will run the code associated with the command the end user specified.

While there are a number of similarities to the current command handler API, it's intended to be used somewhat differently, using different CliAction types to represent outcomes that you can inspect and alter prior to invocation.

The key difference can be summed up as:

The ParseResult.Action property, set during parsing, makes the behavior designated by the parsed command line explicit, inspectable, and configurable prior to invocation.

Goals:

Clearly separate parsing from invocation.

The Parse and Invoke methods currently appear as sibling methods on Parser and Command, which, while convenient, has led many people to misunderstand that Parse is a prerequisite to Invoke. The Invoke methods internally call Parse. It seems helpful to separate these.

Reduce the number of overloads associated with setting up handlers.

By associating command line options and arguments directly with handler method parameters, the existing API makes it looks like you need one handler parameter for each command line parameter. For example, a command line like --one 1 --two 2 --three 3 should map to a handler method such as DoSomething(int one, int two, int three). This led to a huge number of overloads accepting from 1 to 8 parameters with variations returning void, int, Task, or Task<int>. It's confusing, especially when you need more than 8 parameters. (The answer we provided, using BinderBase<T>, isn't discoverable or intuitive.)

We'll be pulling out most of these overloads in favor of just the ones that accept ParseResult (replacing InvocationContext in the handler APIs). The result is that the API usage will be the same whether you need one parameter or fifty. In combination with slightly reducing verbosity by reintroducing name-based lookups, the API should be simpler to use. Here’s a comparison:

Current:

var fileOption = new Option<FileInfo>("--from");
var destinationOption = new Option<DirectoryInfo>("--to");
var rootCommand = new RootCommand()
{
    fileOption,
    destinationOption   
};
rootCommand.SetHandler((from, to) => 
{
    // logic to move the file
}, fileOption, destinationOption);

Proposed:

var rootCommand = new RootCommand()
{
    new Option<FileInfo>("--from"),
    new Option<DirectoryInfo>("--to")
};
rootCommand.SetAction(parseResult => 
{
    var from = parseResult.GetValue<FileInfo>("--from");
    var to = parseResult.GetValue<DirectoryInfo>("--to");
    // logic to move the file
});

Provide better support for building source generators.

Working on a source generator for System.CommandLine exposed a number of places where the current configuration API is difficult to use. The middleware pipeline, while flexible, is also not inspectable, making it hard for API users to see what's already been configured. Configurations of unrelated subsystems are unnecessarily coupled via the CommandLineBuilder.

We plan to make the CommandLineConfiguration class mutable and hopefully remove the CommandLineBuilder altogether (since the motivation for it was to support building immutable configuration objects). (See below for how behaviors will be configurable under this proposal.)

Broaden the concept of which symbol types support invocation to include Option and Directive symbols.

Commands have always supported invocation. They are the most common indicators of different command line behaviors, while options usually represent parameters to those behaviors. But that's not always the case. Sometimes options have behaviors. The help option (-h) is the most familiar example. Then there are directives, a System.CommandLine concept, which also have behaviors that can override the parsed command. We realized that allowing other symbol types to have behaviors, and using a common abstraction for all of these, is clearer than having to implement the option and directive behaviors through middleware.

The result is that in the proposed API, a CliAction can be attached to any of these types.

(An alternative proposal that's been raised is to handle help by making -h a command. But -h really does act grammatically as an option, not a command, and enabling this would require other fundamental and likely confusing changes to syntax concepts that have been stable and well-understood so far.)

Remove execution concepts from configuration.

The CommandLineConfiguration type actually configures two different aspects of your CLI app:

  • the parser rules that define the CLI's syntax and grammar (e.g. Command, Option<T>, Argument<T>, POSIX rules, etc), and

  • the behaviors of the CLI (exception handling, help formats, completion behaviors, typo correction rules).

This proposal would remove the latter entirely from CommandLineConfiguration. Behavioral configuration would be available on specific CliAction-derived types.

Make configuration of CLI actions lazy and make associated configuration APIs more cohesive with the handler code.

A common style of CLI app performs one of a set of mutually exclusive actions (subcommands or verbs) and then exits. We've recommended configuring the behaviors of those actions lazily because, since most won't be needed most of the time, it will generally give better startup performance. (We've often made the recommendation not to configure DI globally for CLI apps for this same reason.)

The CommandLineBuilder API, though, lends itself to configuring all of these behaviors centrally. This reduces the cohesiveness of the code associated with a specific behavior.

Help is a good example to illustrate this.

In the current help API, you might do something like this using the CommandLineBuilder and HelpBuilder to add some text after the default help output:

var rootCommand = new RootCommand();
Parser parser = new CommandLineBuilder(rootCommand)
    .UseDefaults()
    .UseHelpBuilder(helpContext => 
    {
        helpContext.HelpBuilder.CustomizeLayout(CustomLayout);

        IEnumerable<Action<HelpContext>> CustomLayout(HelpContext _)
        {
            foreach (var section in HelpBuilder.Default.GetLayout())
            {
                yield return section;
            }

            yield return ctx => ctx.Output.WriteLine("For more help see https://github.com/dotnet/command-line-api");
        }
    })
    .Build();

Under this proposal, the code would now look like this:

// setup:
var rootCommand = new RootCommand();
var helpOption = new HelpOption();
rootCommand.Add(helpOption);
helpOption.SetAction(new HelpAction());

// usage:
var result = rootCommand.Parse(args);

switch (result.Action)
{
    case HelpAction helpAction:
        await helpAction.RunAsync();

        Console.WriteLine("For more help see https://github.com/dotnet/command-line-api");
        break;

    default:
        await result.Action.RunAsync();
        break; 
}

Other built-in CliAction types for existing behavior might include ParseErrorAction, CompletionAction, and DisplayVersionAction. Commands could opt to use custom action types or use a default CommandAction.

Replacing existing functionality outright with custom implementations would also be much simpler under the proposed API:

// setup:
var helpOption = new HelpOption();
helpOption.SetAction(new SpectreHelpAction());

This approach removes abstractions and reduces the concept count relative to the existing builder pattern. The use of pattern matching allows a clearer and more cohesive way to intercept, augment, or replace existing functionality.

Make "pass-through" middleware behaviors simple.

In today's API, command handlers are sometimes overridden at runtime. The --help and --version options do this, as do certain directives like [suggest] and [parse]. This is implemented in the middleware pipeline. The middleware checks the parse result for the presence of a given symbol and if it's found, it performs some action and then short circuits the command handler by not calling the pipeline continuation. This is flexible but hard to understand.

The CliAction design simplifies things by making these kinds of option actions peers to command actions, and then allowing interception to happen via pattern matching (a familiar language construct) rather than middleware (an often unfamiliar abstraction with a special delegate signature). A default order of precedence will be used if you simply call parseResult.Action.RunAsync(), but by optionally expanding the different CliAction types in a switch, you can change the order of precedence by just reordering the case statements. Or you can use if patterns. The code flow uses language concepts and is no longer hidden in the middleware.

There's one additional middleware use case that this doesn't cover in an obvious way, though, so I'll provide an illustration. This is the case when a middleware performs some action and then calls the continuation delegate. This enables behaviors that happen before and/or after the designated command's behavior.

Under this proposal, you would simply call the other action directly.

var parseResult = root.Parse("one");

switch (parseResult.Action)
{
    ParseErrorAction errorAction:
        // NOTE: The API to look up an invoke another action should be made simpler
        await new HelpAction(new HelpOption()).RunAsync();

        await parseResult.Action.RunAsync();

        break;
}

Additional implications

  • ICommandHandler will be replaced by CliAction, since the concepts are largely redundant and the name ICommandHandler won't make sense when applied to options or directives.

  • There will no longer be middleware APIs associated with CommandLineConfiguration.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions