Tutorial: Get started with System.CommandLine

Important

System.CommandLine is currently in PREVIEW, and this documentation is for version 2.0 beta 4. Some information relates to prerelease product that may be substantially modified before it's released. Microsoft makes no warranties, express or implied, with respect to the information provided here.

This tutorial shows how to create a .NET command-line app that uses the System.CommandLine library. You'll begin by creating a simple root command that has one option. Then you'll add to that base, creating a more complex app that contains multiple subcommands and different options for each command.

In this tutorial, you learn how to:

  • Create commands, options, and arguments.
  • Specify default values for options.
  • Assign options and arguments to commands.
  • Assign an option recursively to all subcommands under a command.
  • Work with multiple levels of nested subcommands.
  • Create aliases for commands and options.
  • Work with string, string[], int, bool, FileInfo and enum option types.
  • Bind option values to command handler code.
  • Use custom code for parsing and validating options.

Prerequisites

Or

Create the app

Create a .NET 6 console app project named "scl".

  1. Create a folder named scl for the project, and then open a command prompt in the new folder.

  2. Run the following command:

    dotnet new console --framework net6.0
    

Install the System.CommandLine package

  • Run the following command:

    dotnet add package System.CommandLine --prerelease
    

    The --prerelease option is necessary because the library is still in beta.

  1. Replace the contents of Program.cs with the following code:

    using System.CommandLine;
    
    namespace scl;
    
    class Program
    {
        static async Task<int> Main(string[] args)
        {
            var fileOption = new Option<FileInfo?>(
                name: "--file",
                description: "The file to read and display on the console.");
    
            var rootCommand = new RootCommand("Sample app for System.CommandLine");
            rootCommand.AddOption(fileOption);
    
            rootCommand.SetHandler((file) => 
                { 
                    ReadFile(file!); 
                },
                fileOption);
    
            return await rootCommand.InvokeAsync(args);
        }
    
        static void ReadFile(FileInfo file)
        {
            File.ReadLines(file.FullName).ToList()
                .ForEach(line => Console.WriteLine(line));
        }
    }
    

The preceding code:

  • Creates an option named --file of type FileInfo and assigns it to the root command:

    var fileOption = new Option<FileInfo?>(
        name: "--file",
        description: "The file to read and display on the console.");
    
    var rootCommand = new RootCommand("Sample app for System.CommandLine");
    rootCommand.AddOption(fileOption);
    
  • Specifies that ReadFile is the method that will be called when the root command is invoked:

    rootCommand.SetHandler((file) => 
        { 
            ReadFile(file!); 
        },
        fileOption);
    
  • Displays the contents of the specified file when the root command is invoked:

    static void ReadFile(FileInfo file)
    {
        File.ReadLines(file.FullName).ToList()
            .ForEach(line => Console.WriteLine(line));
    }
    

Test the app

You can use any of the following ways to test while developing a command-line app:

  • Run the dotnet build command, and then open a command prompt in the scl/bin/Debug/net6.0 folder to run the executable:

    dotnet build
    cd bin/Debug/net6.0
    scl --file scl.runtimeconfig.json
    
  • Use dotnet run and pass option values to the app instead of to the run command by including them after --, as in the following example:

    dotnet run -- --file scl.runtimeconfig.json
    

    In .NET 7.0.100 SDK Preview, you can use the commandLineArgs of a launchSettings.json file by running the command dotnet run --launch-profile <profilename>.

  • Publish the project to a folder, open a command prompt to that folder, and run the executable:

    dotnet publish -o publish
    cd ./publish
    scl --file scl.runtimeconfig.json
    
  • In Visual Studio 2022, select Debug > Debug Properties from the menu, and enter the options and arguments in the Command line arguments box. For example:

    Command line arguments in Visual Studio 2022

    Then run the app, for example by pressing Ctrl+F5.

This tutorial assumes you're using the first of these options.

When you run the app, it displays the contents of the file specified by the --file option.

{
  "runtimeOptions": {
    "tfm": "net6.0",
    "framework": {
      "name": "Microsoft.NETCore.App",
      "version": "6.0.0"
    }
  }
}

Help output

System.CommandLine automatically provides help output:

scl --help
Description:
  Sample app for System.CommandLine

Usage:
  scl [options]

Options:
  --file <file>   The file to read and display on the console.
  --version       Show version information
  -?, -h, --help  Show help and usage information

Version output

System.CommandLine automatically provides version output:

scl --version
1.0.0

Add a subcommand and options

In this section, you:

  • Create more options.
  • Create a subcommand.
  • Assign the new options to the new subcommand.

The new options will let you configure the foreground and background text colors and the readout speed. These features will be used to read a collection of quotes that comes from the Teleprompter console app tutorial.

  1. Copy the sampleQuotes.txt file from the GitHub repository for this sample into your project directory. For information on how to download files, see the instructions in Samples and Tutorials.

  2. Open the project file and add an <ItemGroup> element just before the closing </Project> tag:

    <ItemGroup>
      <Content Include="sampleQuotes.txt">
        <CopyToOutputDirectory>Always</CopyToOutputDirectory>
      </Content>
    </ItemGroup>
    

    Adding this markup causes the text file to be copied to the bin/debug/net6.0 folder when you build the app. So when you run the executable in that folder, you can access the file by name without specifying a folder path.

  3. In Program.cs, after the code that creates the --file option, create options to control the readout speed and text colors:

    var delayOption = new Option<int>(
        name: "--delay",
        description: "Delay between lines, specified as milliseconds per character in a line.",
        getDefaultValue: () => 42);
    
    var fgcolorOption = new Option<ConsoleColor>(
        name: "--fgcolor",
        description: "Foreground color of text displayed on the console.",
        getDefaultValue: () => ConsoleColor.White);
    
    var lightModeOption = new Option<bool>(
        name: "--light-mode",
        description: "Background color of text displayed on the console: default is black, light mode is white.");
    
  4. After the line that creates the root command, delete the line that adds the --file option to it. You're removing it here because you'll add it to a new subcommand.

    var rootCommand = new RootCommand("Sample app for System.CommandLine");
    //rootCommand.AddOption(fileOption);
    
  5. After the line that creates the root command, create a read subcommand. Add the options to this subcommand, and add the subcommand to the root command.

    var readCommand = new Command("read", "Read and display the file.")
        {
            fileOption,
            delayOption,
            fgcolorOption,
            lightModeOption
        };
    rootCommand.AddCommand(readCommand);
    
  6. Replace the SetHandler code with the following SetHandler code for the new subcommand:

    readCommand.SetHandler(async (file, delay, fgcolor, lightMode) =>
        {
            await ReadFile(file!, delay, fgcolor, lightMode);
        },
        fileOption, delayOption, fgcolorOption, lightModeOption);
    

    You're no longer calling SetHandler on the root command because the root command no longer needs a handler. When a command has subcommands, you typically have to specify one of the subcommands when invoking a command-line app.

  7. Replace the ReadFile handler method with the following code:

    internal static async Task ReadFile(
            FileInfo file, int delay, ConsoleColor fgColor, bool lightMode)
    {
        Console.BackgroundColor = lightMode ? ConsoleColor.White : ConsoleColor.Black;
        Console.ForegroundColor = fgColor;
        List<string> lines = File.ReadLines(file.FullName).ToList();
        foreach (string line in lines)
        {
            Console.WriteLine(line);
            await Task.Delay(delay * line.Length);
        };
    }
    

The app now looks like this:

using System.CommandLine;

namespace scl;

class Program
{
    static int Main(string[] args)
    {
        var fileOption = new Option<FileInfo?>(
            name: "--file",
            description: "The file to read and display on the console.");

        var delayOption = new Option<int>(
            name: "--delay",
            description: "Delay between lines, specified as milliseconds per character in a line.",
            getDefaultValue: () => 42);

        var fgcolorOption = new Option<ConsoleColor>(
            name: "--fgcolor",
            description: "Foreground color of text displayed on the console.",
            getDefaultValue: () => ConsoleColor.White);

        var lightModeOption = new Option<bool>(
            name: "--light-mode",
            description: "Background color of text displayed on the console: default is black, light mode is white.");

        var rootCommand = new RootCommand("Sample app for System.CommandLine");
        //rootCommand.AddOption(fileOption);

        var readCommand = new Command("read", "Read and display the file.")
            {
                fileOption,
                delayOption,
                fgcolorOption,
                lightModeOption
            };
        rootCommand.AddCommand(readCommand);

        readCommand.SetHandler(async (file, delay, fgcolor, lightMode) =>
            {
                await ReadFile(file!, delay, fgcolor, lightMode);
            },
            fileOption, delayOption, fgcolorOption, lightModeOption);

        return rootCommand.InvokeAsync(args).Result;
    }

    internal static async Task ReadFile(
            FileInfo file, int delay, ConsoleColor fgColor, bool lightMode)
    {
        Console.BackgroundColor = lightMode ? ConsoleColor.White : ConsoleColor.Black;
        Console.ForegroundColor = fgColor;
        List<string> lines = File.ReadLines(file.FullName).ToList();
        foreach (string line in lines)
        {
            Console.WriteLine(line);
            await Task.Delay(delay * line.Length);
        };
    }
}

Test the new subcommand

Now if you try to run the app without specifying the subcommand, you get an error message followed by a help message that specifies the subcommand that is available.

scl --file sampleQuotes.txt
'--file' was not matched. Did you mean one of the following?
--help
Required command was not provided.
Unrecognized command or argument '--file'.
Unrecognized command or argument 'sampleQuotes.txt'.

Description:
  Sample app for System.CommandLine

Usage:
  scl [command] [options]

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

Commands:
  read  Read and display the file.

The help text for subcommand read shows that four options are available. It shows valid values for the enum.

scl read -h
Description:
  Read and display the file.

Usage:
  scl read [options]

Options:
  --file <file>                                               The file to read and display on the console.
  --delay <delay>                                             Delay between lines, specified as milliseconds per
                                                              character in a line. [default: 42]
  --fgcolor                                                   Foreground color of text displayed on the console.
  <Black|Blue|Cyan|DarkBlue|DarkCyan|DarkGray|DarkGreen|Dark  [default: White]
  Magenta|DarkRed|DarkYellow|Gray|Green|Magenta|Red|White|Ye
  llow>
  --light-mode                                                Background color of text displayed on the console:
                                                              default is black, light mode is white.
  -?, -h, --help                                              Show help and usage information

Run subcommand read specifying only the --file option, and you get the default values for the other three options.

scl read --file sampleQuotes.txt

The 42 milliseconds per character default delay causes a slow readout speed. You can speed it up by setting --delay to a lower number.

scl read --file sampleQuotes.txt --delay 0

You can use --fgcolor and --light-mode to set text colors:

scl read --file sampleQuotes.txt --fgcolor red --light-mode

Provide an invalid value for --delay and you get an error message:

scl read --file sampleQuotes.txt --delay forty-two
Cannot parse argument 'forty-two' for option '--int' as expected type 'System.Int32'.

Provide an invalid value for --file and you get an exception:

scl read --file nofile
Unhandled exception: System.IO.FileNotFoundException:
Could not find file 'C:\bin\Debug\net6.0\nofile'.

Add subcommands and custom validation

This section creates the final version of the app. When finished, the app will have the following commands and options:

  • root command with a global* option named --file
    • quotes command
      • read command with options named --delay, --fgcolor, and --light-mode
      • add command with arguments named quote and byline
      • delete command with option named --search-terms

* A global option is available to the command it's assigned to and recursively to all its subcommands.

Here's sample command line input that invokes each of the available commands with its options and arguments:

scl quotes read --file sampleQuotes.txt --delay 40 --fgcolor red --light-mode
scl quotes add "Hello world!" "Nancy Davolio"
scl quotes delete --search-terms David "You can do" Antoine "Perfection is achieved"
  1. In Program.cs, replace the code that creates the --file option with the following code:

    var fileOption = new Option<FileInfo?>(
        name: "--file",
        description: "An option whose argument is parsed as a FileInfo",
        isDefault: true,
        parseArgument: result =>
        {
            if (result.Tokens.Count == 0)
            {
                return new FileInfo("sampleQuotes.txt");
    
            }
            string? filePath = result.Tokens.Single().Value;
            if (!File.Exists(filePath))
            {
                result.ErrorMessage = "File does not exist";
                return null;
            }
            else
            {
                return new FileInfo(filePath);
            }
        });
    

    This code uses ParseArgument<T> to provide custom parsing, validation, and error handling.

    Without this code, missing files are reported with an exception and stack trace. With this code just the specified error message is displayed.

    This code also specifies a default value, which is why it sets isDefault to true. If you don't set isDefault to true, the parseArgument delegate doesn't get called when no input is provided for --file.

  2. After the code that creates lightModeOption, add options and arguments for the add and delete commands:

    var searchTermsOption = new Option<string[]>(
        name: "--search-terms",
        description: "Strings to search for when deleting entries.")
        { IsRequired = true, AllowMultipleArgumentsPerToken = true };
    
    var quoteArgument = new Argument<string>(
        name: "quote",
        description: "Text of quote.");
    
    var bylineArgument = new Argument<string>(
        name: "byline",
        description: "Byline of quote.");
    

    The AllowMultipleArgumentsPerToken setting lets you omit the --search-terms option name when specifying elements in the list after the first one. It makes the following examples of command-line input equivalent:

    scl quotes delete --search-terms David "You can do"
    scl quotes delete --search-terms David --search-terms "You can do"
    
  3. Replace the code that creates the root command and the read command with the following code:

    var rootCommand = new RootCommand("Sample app for System.CommandLine");
    rootCommand.AddGlobalOption(fileOption);
    
    var quotesCommand = new Command("quotes", "Work with a file that contains quotes.");
    rootCommand.AddCommand(quotesCommand);
    
    var readCommand = new Command("read", "Read and display the file.")
        {
            delayOption,
            fgcolorOption,
            lightModeOption
        };
    quotesCommand.AddCommand(readCommand);
    
    var deleteCommand = new Command("delete", "Delete lines from the file.");
    deleteCommand.AddOption(searchTermsOption);
    quotesCommand.AddCommand(deleteCommand);
    
    var addCommand = new Command("add", "Add an entry to the file.");
    addCommand.AddArgument(quoteArgument);
    addCommand.AddArgument(bylineArgument);
    addCommand.AddAlias("insert");
    quotesCommand.AddCommand(addCommand);
    

    This code makes the following changes:

    • Removes the --file option from the read command.

    • Adds the --file option as a global option to the root command.

    • Creates a quotes command and adds it to the root command.

    • Adds the read command to the quotes command instead of to the root command.

    • Creates add and delete commands and adds them to the quotes command.

    The result is the following command hierarchy:

    • Root command
      • quotes
        • read
        • add
        • delete

    The app now implements the recommended pattern where the parent command (quotes) specifies an area or group, and its children commands (read, add, delete) are actions.

    Global options are applied to the command and recursively to subcommands. Since --file is on the root command, it will be available automatically in all subcommands of the app.

  4. After the SetHandler code, add new SetHandler code for the new subcommands:

    deleteCommand.SetHandler((file, searchTerms) =>
        {
            DeleteFromFile(file!, searchTerms);
        },
        fileOption, searchTermsOption);
    
    addCommand.SetHandler((file, quote, byline) =>
        {
            AddToFile(file!, quote, byline);
        },
        fileOption, quoteArgument, bylineArgument);
    

    Subcommand quotes doesn't have a handler because it isn't a leaf command. Subcommands read, add, and delete are leaf commands under quotes, and SetHandler is called for each of them.

  5. Add the handlers for add and delete.

    internal static void DeleteFromFile(FileInfo file, string[] searchTerms)
    {
        Console.WriteLine("Deleting from file");
        File.WriteAllLines(
            file.FullName, File.ReadLines(file.FullName)
                .Where(line => searchTerms.All(s => !line.Contains(s))).ToList());
    }
    internal static void AddToFile(FileInfo file, string quote, string byline)
    {
        Console.WriteLine("Adding to file");
        using StreamWriter? writer = file.AppendText();
        writer.WriteLine($"{Environment.NewLine}{Environment.NewLine}{quote}");
        writer.WriteLine($"{Environment.NewLine}-{byline}");
        writer.Flush();
    }
    

The finished app looks like this:

using System.CommandLine;

namespace scl;

class Program
{
    static async Task<int> Main(string[] args)
    {
        var fileOption = new Option<FileInfo?>(
            name: "--file",
            description: "An option whose argument is parsed as a FileInfo",
            isDefault: true,
            parseArgument: result =>
            {
                if (result.Tokens.Count == 0)
                {
                    return new FileInfo("sampleQuotes.txt");

                }
                string? filePath = result.Tokens.Single().Value;
                if (!File.Exists(filePath))
                {
                    result.ErrorMessage = "File does not exist";
                    return null;
                }
                else
                {
                    return new FileInfo(filePath);
                }
            });

        var delayOption = new Option<int>(
            name: "--delay",
            description: "Delay between lines, specified as milliseconds per character in a line.",
            getDefaultValue: () => 42);

        var fgcolorOption = new Option<ConsoleColor>(
            name: "--fgcolor",
            description: "Foreground color of text displayed on the console.",
            getDefaultValue: () => ConsoleColor.White);

        var lightModeOption = new Option<bool>(
            name: "--light-mode",
            description: "Background color of text displayed on the console: default is black, light mode is white.");

        var searchTermsOption = new Option<string[]>(
            name: "--search-terms",
            description: "Strings to search for when deleting entries.")
            { IsRequired = true, AllowMultipleArgumentsPerToken = true };

        var quoteArgument = new Argument<string>(
            name: "quote",
            description: "Text of quote.");

        var bylineArgument = new Argument<string>(
            name: "byline",
            description: "Byline of quote.");

        var rootCommand = new RootCommand("Sample app for System.CommandLine");
        rootCommand.AddGlobalOption(fileOption);

        var quotesCommand = new Command("quotes", "Work with a file that contains quotes.");
        rootCommand.AddCommand(quotesCommand);

        var readCommand = new Command("read", "Read and display the file.")
            {
                delayOption,
                fgcolorOption,
                lightModeOption
            };
        quotesCommand.AddCommand(readCommand);

        var deleteCommand = new Command("delete", "Delete lines from the file.");
        deleteCommand.AddOption(searchTermsOption);
        quotesCommand.AddCommand(deleteCommand);

        var addCommand = new Command("add", "Add an entry to the file.");
        addCommand.AddArgument(quoteArgument);
        addCommand.AddArgument(bylineArgument);
        addCommand.AddAlias("insert");
        quotesCommand.AddCommand(addCommand);

        readCommand.SetHandler(async (file, delay, fgcolor, lightMode) =>
            {
                await ReadFile(file!, delay, fgcolor, lightMode);
            },
            fileOption, delayOption, fgcolorOption, lightModeOption);

        deleteCommand.SetHandler((file, searchTerms) =>
            {
                DeleteFromFile(file!, searchTerms);
            },
            fileOption, searchTermsOption);

        addCommand.SetHandler((file, quote, byline) =>
            {
                AddToFile(file!, quote, byline);
            },
            fileOption, quoteArgument, bylineArgument);

        return await rootCommand.InvokeAsync(args);
    }

    internal static async Task ReadFile(
                FileInfo file, int delay, ConsoleColor fgColor, bool lightMode)
    {
        Console.BackgroundColor = lightMode ? ConsoleColor.White : ConsoleColor.Black;
        Console.ForegroundColor = fgColor;
        var lines = File.ReadLines(file.FullName).ToList();
        foreach (string line in lines)
        {
            Console.WriteLine(line);
            await Task.Delay(delay * line.Length);
        };

    }
    internal static void DeleteFromFile(FileInfo file, string[] searchTerms)
    {
        Console.WriteLine("Deleting from file");
        File.WriteAllLines(
            file.FullName, File.ReadLines(file.FullName)
                .Where(line => searchTerms.All(s => !line.Contains(s))).ToList());
    }
    internal static void AddToFile(FileInfo file, string quote, string byline)
    {
        Console.WriteLine("Adding to file");
        using StreamWriter? writer = file.AppendText();
        writer.WriteLine($"{Environment.NewLine}{Environment.NewLine}{quote}");
        writer.WriteLine($"{Environment.NewLine}-{byline}");
        writer.Flush();
    }
}

Build the project, and then try the following commands.

Submit a nonexistent file to --file with the read command, and you get an error message instead of an exception and stack trace:

scl quotes read --file nofile
File does not exist

Try to run subcommand quotes and you get a message directing you to use read, add, or delete:

scl quotes
Required command was not provided.

Description:
  Work with a file that contains quotes.

Usage:
  scl quotes [command] [options]

Options:
  --file <file>   An option whose argument is parsed as a FileInfo [default: sampleQuotes.txt]
  -?, -h, --help  Show help and usage information

Commands:
  read                          Read and display the file.
  delete                        Delete lines from the file.
  add, insert <quote> <byline>  Add an entry to the file.

Run subcommand add, and then look at the end of the text file to see the added text:

scl quotes add "Hello world!" "Nancy Davolio"

Run subcommand delete with search strings from the beginning of the file, and then look at the beginning of the text file to see where text was removed:

scl quotes delete --search-terms David "You can do" Antoine "Perfection is achieved"

Note

If you're running in the bin/debug/net6.0 folder, that folder is where you'll find the file with changes from the add and delete commands. The copy of the file in the project folder remains unchanged.

Next steps

In this tutorial, you created a simple command-line app that uses System.CommandLine. To learn more about the library, see System.CommandLine overview.