Skip to content

Latest commit

 

History

History
1025 lines (763 loc) · 40.5 KB

Tutorial.md

File metadata and controls

1025 lines (763 loc) · 40.5 KB

Tutorial: getting started with Ookii.CommandLine

This tutorial will show you the basics of how to use Ookii.CommandLine. It will demonstrate how to create an application that parses the command line and shows usage help, how to customize some of the options—including using POSIX conventions—and how to use subcommands.

Refer to the documentation for more detailed information.

Creating a project

Create a directory called "tutorial" for the project, and run the following command in that directory:

dotnet new console --framework net8.0

Next, we will add a reference to Ookii.CommandLine's NuGet package:

dotnet add package Ookii.CommandLine

Defining and parsing command line arguments

Add a file to your project called Arguments.cs, and insert the following code:

using Ookii.CommandLine;
using System.ComponentModel;

namespace Tutorial;

[GeneratedParser]
[Description("Reads a file and displays the contents on the command line.")]
partial class Arguments
{
    [CommandLineArgument(IsPositional = true)]
    [Description("The path of the file to read.")]
    public required string Path { get; set; }
}

If you are targeting a .Net version before .Net 7.0, the required keyword is not available. In that case, use the following code instead for the Path property:

[CommandLineArgument(IsPositional = true, IsRequired = true)]
[Description("The path of the file to read.")]
public string? Path { get; set; }

In Ookii.CommandLine, you define arguments by making a class that holds them, and adding properties to that class. Every public property that has the CommandLineArgumentAttribute defines an argument.

The code above defines a single argument called "Path", indicates it's the a positional argument, and makes it required.

You can use the CommandLineArgumentAttribute to specify a custom name for your argument. If you don't, the property name is used.

The class above uses the GeneratedParserAttribute, which is not required, but is recommended unless you are using an SDK older than .Net 6.0, or a language other than C# (find out more).

Now replace the contents of Program.cs with the following:

using Tutorial;

var arguments = Arguments.Parse();
if (arguments == null)
{
    return 1;
}

foreach (var line in File.ReadLines(arguments.Path))
{
    Console.WriteLine(line);
}

return 0;

This code parses the arguments we defined, returns an error code if it was unsuccessful, and writes the contents of the file specified by the path argument to the console.

The important part is the call to Arguments.Parse(). This static method was created by the GeneratedParserAttribute, and will parse your arguments, handle and print any errors, and print usage help if required.

If you cannot use the GeneratedParserAttribute, call CommandLineParser.Parse<Arguments>() instead.

But wait, we didn't pass any arguments to this method? Actually, the method will call Environment.GetCommandLineArgs() to get the arguments. There are also overloads that take an explicit string[] array with the arguments, if you want to pass them manually.

So, let's run our application. Build the application using dotnet build, and then, from the bin/Debug/net8.0 directory, run the following:

./tutorial ../../../tutorial.csproj

Which will give print the contents of the tutorial.csproj file:

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Ookii.CommandLine" Version="4.0.0" />
  </ItemGroup>

</Project>

So far, so good. But what happens if we invoke the application without arguments? After all, we made the -Path argument required. To try this, run the following command:

./tutorial

This gives the following output:

The required argument 'Path' was not supplied.

Usage: tutorial [-Path] <String> [-Help] [-Version]

Run 'tutorial -Help' for more information.

As you can see, the generated Parse() method lets us know what's wrong (we didn't supply the required argument), and shows some basic help, with an instruction on how to get more help.

Let's follow that instruction:

./tutorial -Help

Now we get this output:

Reads a file and displays the contents on the command line.

Usage: tutorial [-Path] <String> [-Help] [-Version]

    -Path <String>
        The path of the file to read.

    -Help [<Boolean>] (-?, -h)
        Displays this help message.

    -Version [<Boolean>]
        Displays version information.

The actual usage help uses color if your console supports it. See here for an example.

The generated Parse() method also took care of handling that -Help argument, and showed the usage help.

This usage help includes the description we applied to the class (this is the application description), and the -Path argument using the DescriptionAttribute. This is how you can provide detailed information about your arguments to your users. It's strongly recommended to always add a description to your arguments.

You can also see that there are two more arguments that we didn't define: -Help and -Version. These arguments are automatically added by Ookii.CommandLine.

We've already seen what -Help does: it shows the usage help. Even if you supply other arguments along with -Help, it will still show the help and exit; it doesn't run the application. Basically, the presence of -Help will override anything else.

The -Version argument shows version information about your application:

$ ./tutorial -Version
tutorial 1.0.0

By default, it shows the assembly's name and informational version. It'll also show the assembly's copyright information, if there is any (there's not in this case). You can use the AssemblyTitleAttribute or ApplicationFriendlyNameAttribute attribute to specify a custom name instead of the assembly name.

If you define your own argument called "Help" or "Version", the automatic arguments won't be added. Also, you can disable the automatic arguments using the ParseOptionsAttribute attribute or ParseOptions class.

Note that the positional "Path" argument still has its name shown as -Path. That's because every argument, even positional ones, can still be supplied by name. So if you run this:

./tutorial -path ../../../tutorial.csproj

The output is the same as above.

Argument names are case insensitive by default, so -path will work instead of -Path, as does -PATH or any other capitalization.

Arguments with other types

Arguments don't have to be strings. In fact, they can have any type as long as there's a way to convert to that type from a string. All of the basic .Net types are supported (like int, float, bool, etc.), as well as many more that can be converted from a string (like enumerations, or classes like FileInfo or Uri, and many other types).

Let's try this out by adding more arguments in the Arguments class. First, add this to the top of Arguments.cs:

using Ookii.CommandLine.Validation;

And then add the following properties to the Arguments class:

[CommandLineArgument]
[Description("The maximum number of lines to output.")]
[ValueDescription("Number")]
[ValidateRange(1, null)]
[Alias("Lines")]
public int? MaxLines { get; set; }

[CommandLineArgument]
[Description("Use black text on a white background.")]
public bool Inverted { get; set; }

This defines two new arguments. The first, -MaxLines, uses int? as its type, so it will only accept integers, and be null if not supplied. This argument is not positional (you must use the name), and it's optional. We've also added a validator to ensure the value is positive, and since -MaxLines might be a bit verbose, we've given it an alias -Lines, which can be used as an alternative name to supply the argument.

An argument can have any number of aliases; just repeat the AliasAttribute attribute.

The second argument, -Inverted, is a boolean, which means it's a switch argument. Switch arguments don't need values, you either supply them or you don't.

Now, let's update Program.cs to use the new arguments:

using Tutorial;

var arguments = Arguments.Parse();
if (arguments == null)
{
    return 1;
}

if (arguments.Inverted)
{
    Console.BackgroundColor = ConsoleColor.White;
    Console.ForegroundColor = ConsoleColor.Black;
}

var lines = File.ReadLines(arguments.Path);
if (arguments.MaxLines is int maxLines)
{
    lines = lines.Take(maxLines);
}

foreach (var line in lines)
{
    Console.WriteLine(line);
}

if (arguments.Inverted)
{
    Console.ResetColor();
}

return 0;

Now we can run the application like this:

./tutorial ../../../tutorial.csproj -lines 5 -inverted

And it'll only show the first five lines of the file, using black-on-white text.

If you supply a value for -MaxLines that's not a valid integer, it shows an error message again:

./tutorial ../../../tutorial.csproj -lines hello
The value 'hello' provided for argument 'MaxLines' could not be interpreted as a 'Number'.

Usage: tutorial [-Path] <String> [-Help] [-Inverted] [-MaxLines <Number>] [-Version]

Run 'tutorial -Help' for more information.

And because of the ValidateRangeAttribute, we can't specify a value less than 1 either.

./tutorial ../../../tutorial.csproj -lines 0
The argument 'MaxLines' must be at least 1.

Usage: tutorial [-Path] <String> [-Help] [-Inverted] [-MaxLines <Number>] [-Version]

Run 'tutorial -Help' for more information.

Now, what do you think will happen if we run this command?

./tutorial ../../../tutorial.csproj -m 5 -i

You might expect that to fail, as there are no arguments named -m or -i. However, if you tried it, you can see that it worked. By default, Ookii.CommandLine will treat any unique prefix of a command line argument's name or aliases as an alias for that argument. So, -m is automatically an alias for -MaxLines. As is -ma, and -max, etc. And -l is as well, as it's a prefix of the alias -Lines.

This only works if the prefix matches exactly one argument. And if you don't like this behavior, it can be disabled using the ParseOptionsAttribute.AutoPrefixAliases property.

Let's take a look at the usage help for our updated application, by running ./tutorial -help:

Reads a file and displays the contents on the command line.

Usage: tutorial [-Path] <String> [-Help] [-Inverted] [-MaxLines <Number>] [-Version]

    -Path <String>
        The path of the file to read.

    -Help [<Boolean>] (-?, -h)
        Displays this help message.

    -Inverted [<Boolean>]
        Use black text on a white background.

    -MaxLines <Number> (-Lines)
        The maximum number of lines to output. Must be at least 1.

    -Version [<Boolean>]
        Displays version information.

There's a few interesting things here. The MaxLines property has the ValueDescriptionAttribute applied, and we can see that the value, "Number", is used inside the angle brackets after -MaxLines. This is the value description, which is a short, typically one-word description of the type of values the argument accepts. It defaults to the type name, but "Int32" might not be very meaningful to people who aren't programmers, so we've changed it to "Number" instead.

You may have noticed above that the value description was also used in the error message when we provided an invalid value.

You can also see that the ValidateRangeAttribute doesn't just validate its condition, it also adds that condition to the description of the argument (this can be disabled either globally or on a per-validator basis if you want). So you don't have to worry about keeping the description and the actual requirements in sync.

The -MaxLines argument also has its alias listed, just like the -Help argument.

Don't like the way the usage help looks? It can be fully customized! Check out the custom usage sample for an example of that.

Default values

Above, we used a nullable value type (Nullable<int>, or int?) so we could tell whether the argument was supplied. Instead, we could also set a default value. This can easily be done by initializing the property with that value:

[CommandLineArgument]
[ValidateRange(1, null)]
[Alias("Lines")]
public int MaxLines { get; set; } = 10;

Instead of initializing the property, you can also use the CommandLineArgumentAttribute.DefaultValue property, which can be useful if e.g. you're not using an automatic property (so you can't have a direct initializer like that). And, that property accepts not just the argument's actual type, but also any string that can be converted to it. For example, both [CommandLineArgument(DefaultValue = 10)] and [CommandLineArgument(DefaultValue = "10")] are equivalent to the above. Handy if your argument's type doesn't have literals.

This default value would be shown in the usage help as well, similar to the validator:

    -MaxLines <Number> (-Lines)
        The maximum number of lines to output. Must be at least 1. Default value: 10.

POSIX conventions and other options

Ookii.CommandLine offers many options to customize the way it parses the command line. For example, you can disable the use of white space as a separator between argument names and values, and specify custom separators. You can specify custom argument name prefixes, instead of - which is the default (on Windows only, / is also accepted by default). You can make the argument names case sensitive. And there's more.

One thing you may want to do is use POSIX-like conventions, instead of the default PowerShell-like parsing behavior. With POSIX conventions, arguments can have a short, one-character name, and a separate long name, which uses a different prefix (typically -- is used for long names, and - for short). Argument names are typically lowercase, with dashes between words, and are case sensitive. These are the same conventions followed by tools such as dotnet or git, and many others. For a cross-platform application, you may prefer these conventions over the default, but it's up to you of course.

A convenient way to change these options is to use the ParseOptionsAttribute, which you can apply to your class. Let's use it to enable POSIX mode:

[GeneratedParser]
[Description("Reads a file and displays the contents on the command line.")]
[ParseOptions(IsPosix = true)]
partial class Arguments
{
    [CommandLineArgument(IsPositional = true)]
    [Description("The path of the file to read.")]
    public required string Path { get; set; }

    [CommandLineArgument(IsShort = true)]
    [Description("The maximum number of lines to output.")]
    [ValueDescription("number")]
    [ValidateRange(1, null)]
    [Alias("lines")]
    public int? MaxLines { get; set; }

    [CommandLineArgument(IsShort = true)]
    [Description("Use black text on a white background.")]
    public bool Inverted { get; set; }
}

The ParseOptionsAttribute.IsPosix property is actually a shorthand way to set several related properties. The above attribute is identical to this:

[ParseOptions(Mode = ParsingMode.LongShort,
    CaseSensitive = true,
    ArgumentNameTransform = NameTransform.DashCase,
    ValueDescriptionTransform = NameTransform.DashCase)]

We've done a few things here: we've turned on an alternative set of parsing rules by setting the Mode property to ParsingMode.LongShort, we've made argument names case sensitive, and we've applied a name transformation to both argument names and value descriptions, which will make them lower case with dashes between words (e.g. "max-lines").

Long/short mode is the key to getting POSIX-like behavior. It allows every argument to have two separate names: a long name, using the -- prefix by default, and a single-character short name using the - prefix (and / on Windows).

When using long/short mode, all arguments have long names by default, but you'll need to indicate which arguments have short names. We've done that here with the MaxLines and Inverted properties, by specifying IsShort = true. This gives them a short name using the first character of their long name (after the name transformation is applied), so -m and -i in this case. You can also specify a custom short name using the CommandLineArgumentAttribute.ShortName property.

If you want an argument to only have a short name, you can disable the long name using the CommandLineArgumentAttribute.IsLong property.

With all these changes, the MaxLines property now creates an argument with the long name --max-lines, and the short name -m. We also have an argument with the long name --inverted, and the short name -i. Finally, --path only has a long name, and is still positional. All of these names are now case sensitive.

Name transformations don't apply to names or value descriptions that are explicitly specified, so we had to change "number" and the alias "lines" manually to match.

Now, the usage help looks like this:

Reads a file and displays the contents on the command line.

Usage: tutorial [--path] <string> [--help] [--inverted] [--max-lines <number>] [--version]

        --path <string>
            The path of the file to read.

    -?, --help [<boolean>] (-h)
            Displays this help message.

    -i, --inverted [<boolean>]
            Use black text on a white background.

    -m, --max-lines <number> (--lines)
            The maximum number of lines to output. Must be at least 1.

        --version [<boolean>]
            Displays version information.

As you can see, the format is slightly different, giving more prominence to the short names. You can see the result of the name transformation on all the arguments and value descriptions, including the automatic --help and --version arguments, which are now also lower case.

In addition to the ParseOptionsAttribute attribute, you can also use the ParseOptions class to specify these and many other options. ParseOptions can also be used to customize where to write errors and help, and to customize the usage help. You can pass an instance of the ParseOptions class to the generated Parse() method.

Using subcommands

Many applications have multiple functions, which are invoked through subcommands. Think for example of the dotnet application, which has commands like dotnet build and dotnet run, or something like git with commands like git pull or git cherry-pick. Each command does something different, and needs its own command line arguments.

Creating subcommands with Ookii.CommandLine is very similar to what we've been doing already. A subcommand is a class that defines arguments, same as before; the class will just have to implement the ICommand interface, and use the CommandAttribute attribute. And, instead of using Parse() directly, we'll use the command manager.

Let's change the example we've built so far to use subcommands. I'm going to continue with the POSIX-like long/short mode settings, but if you prefer the defaults, you can go back to that version too.

First, we'll add another using statement to Arguments.cs:

using Ookii.CommandLine.Commands;

Then, we'll rename our Arguments class to ReadCommand (we'll use the class name to derive the command name), and change it into a subcommand:

[GeneratedParser]
[Command]
[Description("Reads a file and displays the contents on the command line.")]
partial class ReadCommand : ICommand

We've added the CommandAttribute, which indicates the class is a command, and can also be used to set an explicit name if you don't want to use the class name. We've also added the ICommand interface, which all commands must implement.

Note that we've removed the ParseOptionsAttribute. Options set with the attribute would apply only to the command with the attribute, and usually you want to use the same options for all commands. So, we'll set our options a different way further down.

We don't have to change anything about the properties defining the arguments. However, we do have to implement the ICommand interface, which has a single method called Run(). To implement it, we take the code from Program.cs and move it into this method:

public int Run()
{
    if (Inverted)
    {
        Console.BackgroundColor = ConsoleColor.White;
        Console.ForegroundColor = ConsoleColor.Black;
    }

    var lines = File.ReadLines(Path);
    if (MaxLines is int maxLines)
    {
        lines = lines.Take(maxLines);
    }

    foreach (var line in lines)
    {
        Console.WriteLine(line);
    }

    if (Inverted)
    {
        Console.ResetColor();
    }

    return 0;
}

The Run() method is like the Main() method for your command, and its return value should be treated like the exit code returned from Main(), because typically, you will return the executed command's return value from Main().

And that's it: we've now defined a command. However, we still need to change the application to use commands instead of just parsing arguments from a single class. To do this, we'll use the CommandManager class.

First, we'll add a file named GeneratedManager.cs, with these contents:

using Ookii.CommandLine.Commands;

namespace Tutorial;

[GeneratedCommandManager]
partial class GeneratedManager
{
}

The GeneratedCommandManagerAttribute is similar to the GeneratedParserAttribute, except it turns the target class into a command manager. The GeneratedCommandManagerAttribute will make your class derive from CommandManager, and generates code to find and instantiate the commands in this assembly.

You can also use CommandManager directly, without a generated class, in which case reflection is used to find the commands. Do this if you can't use source generation.

Now replace the code in Program.cs with the following.

using Ookii.CommandLine.Commands;
using Tutorial;

var options = new CommandOptions()
{
    IsPosix = true,
};

var manager = new GeneratedManager(options);
return manager.RunCommand() ?? 1;

That's all you need to do to find, parse arguments for, and run any command in your application.

Here, we use the CommandOptions to set the same options as before, so they'll apply to every command (even if currently we have only one command). The CommandOptions class derives from the ParseOptions class, so it can be used to specify all the same options, in addition to some that are specific to commands.

Actually, for CommandOptions the meaning of IsPosix is slightly different. It sets the same options as before, but also sets two additional ones. It's actually equivalent to the following:

var options = new CommandOptions()
{
    Mode = ParsingMode.LongShort,
    ArgumentNameComparison = StringComparison.InvariantCulture,
    ArgumentNameTransform = NameTransform.DashCase,
    ValueDescriptionTransform = NameTransform.DashCase,
    CommandNameComparison = StringComparison.InvariantCulture,
    CommandNameTransform = NameTransform.DashCase,
};

So in addition to enabling what it did before, it also made command names case sensitive (they are case insensitive by default, just like argument names) and transforms their names to lowercase separated by dashes as well.

Note that ParseOptions, and by extension CommandOptions, use a StringComparison value instead of just a CaseSensitive property.

The RunCommand() method will take the arguments from Environment.GetCommandLineArgs() (as before, you can also pass them explicitly), and uses the first argument as the command name. If a command with that name exists, it uses CommandLineParser to parse the arguments for that command, and finally invokes the ICommand.Run() method. If anything goes wrong, it will either display a list of commands, or if a command has been found, the help for that command. The return value is the value returned from ICommand.Run(), or null if parsing failed, in which case we return a non-zero exit code to indicate failure.

If you want to customize any of these steps, there are methods like GetCommand() and CreateCommand() that you can call to do this manually.

If we build our application, and run it without arguments (./tutorial), we see the following:

Usage: tutorial <command> [arguments]

The following commands are available:

    read
        Reads a file and displays the contents on the command line.

    version
        Displays version information.

Run 'tutorial <command> --help' for more information about a command.

When no command, or an unknown command, is supplied, a list of commands is printed. The DescriptionAttribute for our class, which was the application description before, is now the description of the command.

But why is the command called read, and not read-command, if it's based on the class name ReadCommand? If you use a name transformation for command names, it will strip the suffix "Command" from the name by default. Use the CommandOptions.StripCommandNameSuffix property to customize that behavior.

There is a second command, version, which is automatically added unless there already is a command with that name. It does the same thing as the -Version argument before.

Let's see the usage help for our command:

./tutorial read --help

Which gives the following output:

Reads a file and displays the contents on the command line.

Usage: tutorial read [--path] <string> [--help] [--inverted] [--max-lines <number>]

        --path <string>
            The path of the file to read.

    -?, --help [<boolean>] (-h)
            Displays this help message.

    -i, --inverted [<boolean>]
            Use black text on a white background.

    -m, --max-lines <number> (--lines)
            The maximum number of lines to output. Must be at least 1.

There are two differences to spot from the earlier version: the usage syntax now says tutorial read before the arguments, indicating you have to use the command, and there is no automatic --version argument, since that would be redundant with the version command.

Adding an application description

The usage help for the single arguments class would print an application description at the top, but the command list doesn't have anything like that. We can, however, add it.

To do this, make the following change to the CommandOptions (and add using Ookii.CommandLine at the top of the file):

var options = new CommandOptions()
{
    IsPosix = true,
    UsageWriter = new UsageWriter()
    {
        IncludeApplicationDescriptionBeforeCommandList = true,
    }
};

We've set the IncludeApplicationDescriptionBeforeCommandList option, which prints the assembly description before the command list. So to set a description, we'll add one in the tutorial.csproj file.

<PropertyGroup>
  <Description>An application to read and write files.</Description>
</PropertyGroup>

Now, if you run the application without arguments, you'll see this:

An application to read and write files.

Usage: tutorial <command> [arguments]

The following commands are available:

    read
        Reads a file and displays the contents on the command line.

    version
        Displays version information.

Run 'tutorial <command> --help' for more information about a command.

Multiple commands

An application with only one subcommand doesn't really need to use subcommands, so let's add a second one. Create a new file in your project called WriteCommand.cs, and add the following code:

using Ookii.CommandLine;
using Ookii.CommandLine.Commands;
using System.ComponentModel;

namespace Tutorial;

[GeneratedParser]
[Command]
[Description("Writes text to a file.")]
partial class WriteCommand : ICommand
{
    [CommandLineArgument(IsPositional = true)]
    [Description("The path of the file to write.")]
    public required string Path { get; set; }

    [CommandLineArgument(IsPositional = true)]
    [Description("The text to write to the file.")]
    public required string[] Text { get; set; }

    [CommandLineArgument(IsShort = true)]
    [Description("Append to the file instead of overwriting it.")]
    public bool Append { get; set; }

    public int Run()
    {
        if (Append)
        {
            File.AppendAllLines(Path, Text);
        }
        else
        {
            File.WriteAllLines(Path, Text);
        }

        return 0;
    }
}

There's one thing here that we haven't seen before, and that's a multi-value argument. The --text argument has an array type (string[]), which means it can have multiple values by supplying it multiple times. We could, for example, use --text foo --text bar to assign the values "foo" and "bar" to it. Because it's also a positional argument, we can simply use foo bar to do the same.

A positional multi-value argument must always be the last positional argument.

This command will take the values from the --text argument and write them as lines to the specified file, optionally appending to the file.

Let's build and run our application again, without arguments:

./tutorial

Which now gives the following output:

An application to read and write files.

Usage: tutorial <command> [arguments]

The following commands are available:

    read
        Reads a file and displays the contents on the command line.

    version
        Displays version information.

    write
        Writes text to a file.

Run 'tutorial <command> --help' for more information about a command.

As you can see, our application picked up the new command without us needing to do anything. That's because CommandManager automatically looks for all command classes in the assembly.

If you run ./tutorial write --help, you'll see the usage help for your new command:

Writes text to a file.

Usage: tutorial write [--path] <string> [--text] <string>... [--append] [--help]

        --path <string>
            The path of the file to write.

        --text <string>
            The text to write to the file.

    -a, --append [<boolean>]
            Append to the file instead of overwriting it.

    -?, --help [<boolean>] (-h)
            Displays this help message.

We can test out our new command like this:

./tutorial write test.txt "Hello!" "Ookii.CommandLine is pretty neat." "At least I think so."
./tutorial write test.txt "Thanks for using it!" -a
./tutorial read test.txt

Here, we wrote three lines of text to a file, then appended one more line, and read them back using the "read" command.

Asynchronous commands

If you want to use asynchronous code in your application, subcommands provide a way to do that too.

To make a command asynchronous, we have to implement the IAsyncCommand interface. This interface derives from the ICommand interface, and adds a RunAsync() method for you to implement. Then, you can invoke your command using the CommandManager.RunCommandAsync() method.

Because you still have to implement Run() when you use the IAsyncCommand interface, Ookii.CommandLine also provides the AsyncCommandBase class for convenience, which provides a default implementation of the Run() method that will invoke RunAsync() and wait for it to finish.

So, we'll make the following changes to WriteCommand:

[GeneratedParser]
[Command]
[Description("Writes text to a file.")]
partial class WriteCommand : AsyncCommandBase
{
    /* Properties are unchanged */

    public override async Task<int> RunAsync()
    {
        if (Append)
        {
            await File.AppendAllLinesAsync(Path, Text);
        }
        else
        {
            await File.WriteAllLinesAsync(Path, Text);
        }

        return 0;
    }
}

If you build and run your application now, you'll find that it works, because of the AsyncCommandBase.Run() method.

However, to fully take advantage of asynchronous tasks, you'll want to replace the RunCommand() method call with RunCommandAsync() in Program.cs:

return await manager.RunCommandAsync() ?? 1;

You'll notice that even with this change, the "read" command still works, despite not being asynchronous. That's because the RunCommandAsync() supports both synchronous and asynchronous commands, so you can mix and match them as you please.

Converting ReadCommand to use asynchronous code is left as an exercise to the reader (hint: you'll need the System.Linq.Async package to be able to use the Take() extension method on the IAsyncEnumerable<T> returned by File.ReadLinesAsync()).

Common arguments for commands

Sometimes, you'll want some arguments to be available to all commands. With Ookii.CommandLine, the way to do this is to make a common base class. CommandLineParser will consider base class members when determining what arguments are available. For example, here we could move the --path argument to a common base class.

For more information on how to do this, see the documentation on subcommand base classes.

More information

I hope this tutorial helped you get started with Ookii.CommandLine. To learn more, check out the following resources: