Ookii.CommandLine allows you to create applications that have multiple commands, each with their own
arguments. This is a common pattern used by many applications; for example, the dotnet
binary
uses it with commands like dotnet build
and dotnet run
, as does git
with commands like
git pull
and git cherry-pick
.
Ookii.CommandLine makes it trivial to define and use subcommands, using the same techniques we've
already seen for defining and parsing arguments. Subcommand specific functionality is all in the
Ookii.CommandLine.Commands
namespace.
In an application using subcommands, the first argument to the application is the name of the command. The remaining arguments are arguments to that command. You cannot have arguments that are not associated with a command using the subcommand functionality in Ookii.CommandLine, though you can still easily define common arguments.
For example, the subcommand sample can be invoked as follows:
./Subcommand read file.txt -Encoding utf-16
This command line invokes the command named read
, and passes the remaining arguments to that
command.
A subcommand class is essentially the same as a regular arguments class. Arguments can be defined using its constructor parameters, properties, and methods, exactly as was shown before.
Subcommand classes have the following differences from regular arguments classes:
- They must implement the
ICommand
interface. - They must use the
CommandAttribute
attribute. - The
DescriptionAttribute
sets the description for the command, not the application. - You can't apply the
ApplicationFriendlyNameAttribute
to a command class (apply it to the assembly instead). - An automatic
-Version
argument will not be created for subcommands, regardless of the value of theParseOptions.AutoVersionArgument
property.
It's therefore trivial to take any arguments class, and convert it into a subcommand:
[Command("sample")]
[Description("This is a sample command.")]
class SampleCommand : ICommand
{
[CommandLineArgument(Position = 0, IsRequired = true)]
[Description("A sample argument for the sample command.")]
public string? SampleArgument { get; set; }
public int Run()
{
// Command functionality goes here.
return 0;
}
}
This code creates a subcommand which can be invoked with the name sample
, and which has a single
positional required argument.
The ICommand
interface defines a single method, ICommand.Run()
, which all subcommands must
implement. This function is invoked to run your command. The return value is typically used as the
exit code for the application, after the command finishes running.
When using the CommandManager
class as shown below, the class will be
created using the CommandLineParser
as usual, using all the arguments except for the command name.
Then, the ICommand.Run()
method will be called.
All of the functionality and options available with regular arguments types are available with commands too, including usage help generation, long/short mode, all kinds of arguments, validators, etc.
The sample above used the CommandAttribute
attribute to set an explicit name for the command. If
no name is specified, the name is derived from the type name.
[Command]
class ReadDirectoryCommand : ICommand
{
/* omitted */
}
This creates a command with the name ReadDirectoryCommand
.
Just like with argument names and value descriptions, it's possible to apply a name transformation
to command names. This is done by setting the CommandOptions.CommandNameTransform
property. The
same transformations are available as for argument
names.
In addition to just transforming the case and separators, command name transformation can also strip
a suffix from the end of the type name. This is set with the CommandOptions.StripCommandNameSuffix
property, and defaults to "Command". This is only used if the CommandNameTransform
is not
NameTransform.None
.
So, if you use the NameTransform.DashCase
transform, with the default StripCommandNameSuffix
value, the ReadDirectoryCommand
class above will create a command named read-directory
.
Like argument names, a command can have one or more aliases, alternative names that can be used
to invoke the command. Simply apply the AliasAttribute
to the command class.
[Command]
[Alias("ls")]
class ReadDirectoryCommand : ICommand
{
/* omitted */
}
It's possible to use asynchronous code with subcommands. To do this, implement the IAsyncCommand
interface, which derives from ICommand
, and use the CommandManager.RunCommandAsync()
method (see
below).
The IAsyncCommand
interface adds a new IAsyncCommand.RunAsync()
method, but because
IAsyncCommand
derives from ICommand
, it's still necessary to implement the ICommand.Run()
method. If you use RunCommandAsync()
, the ICommand.Run()
method is guaranteed to never be called
on a command that implements IAsyncCommand
, so you can just leave this empty.
However, a better option is to use the AsyncCommandBase
class, which is provided for convenience,
and provides an implementation of ICommand.Run()
which invokes IAsyncCommand.RunAsync()
and
waits for it. That way, your command is compatible with both RunCommand()
and RunCommandAsync()
.
[Command]
[Description("Sleeps for a specified amount of time.")]
class AsyncSleepCommand : AsyncCommandBase
{
[CommandLineArgument(Position = 0, DefaultValue = 1000)]
[Description("The sleep time in milliseconds.")]
public int SleepTime { get; set; };
public override async Task<int> RunAsync()
{
await Task.Delay(SleepTime);
return 0;
}
}
You may have multiple commands that have one or more arguments in common. For example, you may have
a database application where every command needs the connection string as an argument. Because
CommandLineParser
considers base class members when defining arguments, this can be accomplished
by having a common base class for each command that needs the common arguments.
abstract class DatabaseCommand : ICommand
{
[CommandLineArgument(Position = 0, IsRequired = true)]
public string? ConnectionString { get; set; }
public abstract int Run();
}
[Command]
class AddCommand : DatabaseCommand
{
[CommandLineArgument(Position = 1, IsRequired = true)]
public string? NewValue { get; set; }
public override int Run()
{
/* omitted */
}
}
[Command]
class DeleteCommand : DatabaseCommand
{
[CommandLineArgument(Position = 1, IsRequired = true)]
public int Id { get; set; }
[CommandLineArgument]
public bool Force { get; set; }
public override int Run()
{
/* omitted */
}
}
The two commands, AddCommand
and DeleteCommand
both inherit the -ConnectionString
argument, and
add their own additional arguments.
The DatabaseCommand
class is not considered a subcommand by the CommandManager
, because it
does not have the CommandAttribute
attribute, and because it is abstract.
In some cases, you may want to create commands that do not use the CommandLineParser
class to parse
their arguments. For this purpose, you can implement the ICommandWithCustomParsing
method instead.
You must still use the CommandAttribute
.
Your type must have a constructor with no parameters, and implement the
ICommandWithCustomParsing.Parse()
method, which will be called before ICommand.Run()
to allow
you to parse the command line arguments. You can combine ICommandWithCustomParsing
with
IAsyncCommand
if you wish.
In this case, it is up to the command to handle argument parsing, and handle errors and display usage help if appropriate.
For example, you may have a command that launches an external executable, and wants to pass the arguments to that executable.
[Command]
class LaunchCommand : AsyncCommandBase, ICommandWithCustomParsing
{
private string[]? _args;
public void Parse(string[] args, int index, CommandOptions options)
{
_args = args[index..];
}
public override async Task<int> RunAsync()
{
var info = new ProcessStartInfo("executable");
if (_args != null)
{
foreach (var arg in _args)
{
info.ArgumentList.Add(arg);
}
}
var process = Process.Start(info);
if (process != null)
{
await process.WaitForExitAsync();
return process.ExitCode;
}
return 1;
}
}
To write an application that uses subcommands, you use the CommandManager
class in the Main()
method of your application.
In the majority of cases, it's sufficient to write code like the following.
public static int Main()
{
var manager = new CommandManager();
return manager.RunCommand() ?? 1;
}
This code does the following:
- Creates a command manager with default options, which looks for command classes in the assembly
that called the constructor (the assembly containing
Main()
, in this case). - Calls the
RunCommand()
method, which:- Gets the arguments using
Environment.GetCommandLineArgs()
(you can also pass astring[]
array to theRunCommand
method). - Uses the first argument to determine the command name.
- Creates the command, invokes the
ICommand.Run()
method, and returns its return value. - If the command could not be created, for example because no command name was supplied, an
unknown command name was supplied, or an error occurred parsing the command's arguments, it
will print the error message and usage help, similar to the static
CommandLineParser.Parse<T>()
method, and return null.
- Gets the arguments using
- If
RunCommand()
returned null, returns an error exit code.
Note: the
CommandManager
does not check if command names and aliases are unique. If you have multiple commands with the same names, the first matching one will be used, and there is no guarantee on the order in which command classes are checked.
If you use the IAsyncCommand
interface or AsyncCommandBase
class, use the following code instead.
public static async Task<int> Main()
{
var manager = new CommandManager();
return await manager.RunCommandAsync() ?? 1;
}
Note that the RunCommandAsync()
method can still run commands that only implement ICommand
, and
not IAsyncCommand
, so you can freely mix both types of command.
If you use RunCommand()
with asynchronous commands, it will call the ICommand.Run()
method, so
whether this works depends on the command's implementation of that method. If you used
AsyncCommandBase
, this will call the RunAsync()
method, so the command will work correctly.
However, in all cases, it's strongly recommended to use RunCommandAsync()
if you use any
asynchronous commands.
Check out the tutorial and the subcommand sample for more detailed examples of how to create and use commands.
The default constructor for the CommandManager
class will look for command classes only in the
calling assembly. If your command classes are all in the same assembly as your main method, this
will be sufficient. However, you may want to have your commands in a separate assembly, or split
amongst several assemblies. You could even want to dynamically load plugins with additional commands.
The CommandManager
constructor has overloads that take a single assembly, or an array of
assemblies. This allows you to load commands from one or more sources. You can even filter which
commands you actually want to use from those assemblies using the CommandOptions.CommandFilter
property.
public static int Main()
{
var assemblies = new[] { Assembly.GetExecutingAssembly() };
assemblies = assemblies.Concat(LoadPlugins()).ToArray();
var manager = new CommandManager(assemblies);
return manager.RunCommand() ?? 1;
}
The omitted LoadPlugins()
method would presumably load some list of assemblies from the
application's configuration.
Just like when you use CommandLineParser
directly, there are many options available to
customize the parsing behavior. When using CommandManager
, you use the CommandOptions
class to provide options. This class derives from ParseOptions
, so all the same options are
available, in addition to several options that apply only to subcommands.
While you can use the
ParseOptionsAttribute
to customize the behavior of a subcommand class, this will only apply to the class using the attribute. For a consistent experience, it's preferred to useCommandOptions
.
For example, the following code enables some options:
public static int Main()
{
var options = new CommandOptions()
{
CommandNameComparer = StringComparer.InvariantCulture,
CommandNameTransform = NameTransform.DashCase,
UsageWriter = new UsageWriter()
{
IncludeCommandHelpInstruction = true,
IncludeApplicationDescriptionBeforeCommandList = true,
}
};
var manager = new CommandManager(options);
return manager.RunCommand() ?? 1;
}
This code makes command names case sensitive by using the invariant string comparer (the default is
StringComparer.OrdinalIgnoreCase
, which is case insensitive), enables a name transformation,
and also sets some usage help options.
As with the static CommandLineParser.Parse<T>()
method, RunCommand()
and
RunCommandAsync()
handle errors and display usage help. If for any reason you want to do this
manually, CommandManager
provides the tools to do so.
If you only want more information about the error, but still want the CommandManager
class to
handle and display errors and usage help, you can check the CommandManager.ParseResult
property to
get information if RunCommand()
or RunCommandAsync()
returned null. The value of the
ParseResult.Status
property of the returned structure will indicate whether the command was not
found, if an error occurred parsing the command's arguments, or if parsing was canceled by one of
the command's arguments.
If you want to handle errors entirely manually, the CommandManager.GetCommand()
method returns
information about a command, if one with the specified name exists. From there, you can manually
create a CommandLineParser
for the command, instantiate the class, and invoke its run method.
When doing this, it's your responsibility to handle things such as IAsyncCommand
or
ICommandWithCustomParsing
. Of course, you can omit those parts if you do not have any commands
using those interfaces.
Because of the complexity of this approach, it's probably easier to just redirect the error and
output of the regular RunCommand(Async)
methods, as shown below:
var writer = LineWrappingTextWriter.ForStringWriter();
var options = new CommandOptions()
{
Error = writer,
UsageWriter = new UsageWriter(writer),
};
var manager = new CommandManager(options);
var exitCode = await manager.RunCommandAsync();
if (exitCode is int value)
{
return value;
}
// For demonstration purposes only; probably not the best way to show this.
MessageBox.Show(writer.ToString());
// Return an error code only if the failure was not caused by an argument that canceled parsing.
return manager.ParseResult.Status == ParseStatus.Canceled ? 0 : 1;
This, combined with a custom UsageWriter
to format the usage help as you like, is probably
sufficient for most scenarios. You can also use separate writers for errors and usage help, so you
can display them separately.
However, if you do want to manually handle everything, the below is an example of what this would look like.
public static async Task<int> Main(string[] args)
{
var options = new CommandOptions() { /* omitted */ };
var manager = new CommandManager(options);
var info = args.Length > 0 ? manager.GetCommand(args[0]) : null;
if (info is not CommandInfo commandInfo)
{
// No command or unknown command.
manager.WriteUsage();
return 1;
}
ICommand? command = null;
if (commandInfo.UseCustomArgumentParsing)
{
// CreateInstance handles parsing errors and displays usage when it uses the
// CommandLineParser, so don't use it in that case. However, it must be used for commands
// with custom parsing. How errors are handled here depends on the command.
command = commandInfo.CreateInstance(args, 1);
}
else
{
var parser = commandInfo.CreateParser();
try
{
// Skip the command name in the arguments.
command = (ICommand?)parser.Parse(args, 1);
}
catch (CommandLineArgumentException ex)
{
Console.Error.WriteLine(ex.Message);
}
if (parser.HelpRequested)
{
parser.WriteUsage();
}
}
// Run the command if successfully created, asynchronous if supported.
if (command != null)
{
if (command is IAsyncCommand asyncCommand)
{
return await asyncCommand.RunAsync();
}
return command.Run();
}
return 1;
}
The CommandManager
class also offers the CreateCommand()
method, which instantiates the
command class but does not call the Run(Async)
method. This method also handles errors and shows
usage help automatically.
Since subcommands are created using the CommandLineParser
, they support showing usage help
when parsing errors occur, or the -Help
argument is used. For example, with the subcommand sample
you could run the following to get help on the read
command:
./Subcommand read -help
In addition, the CommandManager
also prints usage help if no command name was supplied, or the
supplied command name did not match any command defined in the application. In this case, it prints
a list of commands, with their descriptions. This is what that looks like for the sample:
Subcommand sample for Ookii.CommandLine.
Usage: Subcommand <command> [arguments]
The following commands are available:
read
Reads and displays data from a file using the specified encoding, wrapping the text to fit
the console.
version
Displays version information.
write
Writes lines to a file, wrapping them to the specified width.
Run 'Subcommand <command> -Help' for more information about a command.
Usage help for a CommandManager
is also created using the UsageWriter
, and can be
customized by setting the subcommand-specific properties of that class. The sample above uses two of
them: IncludeApplicationDescriptionBeforeCommandList
, which causes the assembly description of
the first assembly used by the CommandManager
to be printed before the command list, and
IncludeCommandHelpInstruction
, which prints the line at the bottom telling the user to use
-Help
.
For the IncludeCommandHelpInstruction
option, the text will use the name of the automatic help
argument, after applying the ParseOptions.ArgumentNameTransform
if one is set. If using
long/short mode, the long argument prefix is used. Note that the
CommandManager
won't check if every command actually has an argument with that name, so only
enable it if this is true (it's recommended to enable it if possible).
Other properties let you configure indentation and colors, among others.
The actual help is created using a number of protected virtual methods on the UsageWriter
, so
this can be further customized by deriving your own class from the UsageWriter
class. Creating
command list usage help is driven by the WriteCommandListUsageCore()
method. You can also
override other methods to customize parts of the usage help, such as
WriteCommandListUsageSyntax()
, WriteCommandDescription()
, and
WriteCommandHelpInstruction()
, to name just a few.
As mentioned above, subcommand classes will not get an automatic -Version
argument. Instead, there
is an automatic version
command that gets added, which displays the same information.
Important: The version
command takes the name, version information, and copyright text from
the entry-point assembly of the application, regardless of what assembly or assemblies were passed
to the CommandManager
. If this is not correct for your application, you should create your own
version
command.
If you create a command named version
, the automatic version
command will not be added. You can
also disable the command with the CommandOptions.AutoVersionCommand
property. The name and
description of the command can be customized using the LocalizedStringProvider
.
Ookii.CommandLine does not natively support nested subcommands. However, with the
CommandOptions.CommandFilter
property and the ICommandWithCustomParsing
interface, it provides
the tools needed to implement support for this fairly easily.
The nested commands sample shows a complete implementation of this functionality.
Providing native support for nested subcommands is planned for a future release.
The next page will take a look at several utility classes provided, and used, by Ookii.CommandLine.