System.CommandLine

The official command-line parsing library for .NET developed by Microsoft. Provides a modern and flexible API.

csharpdotnetclicommand-line

GitHub Overview

dotnet/command-line-api

Command line parsing, invocation, and rendering of terminal output.

Stars3,566
Watchers254
Forks402
Created:April 17, 2018
Language:C#
License:MIT License

Topics

command-linecommandlineparsercompletionsdotnet-coredotnet-standarddotnet-suggesthacktoberfestparserparsingposixsystem-commandlineterminalvt100

Star History

dotnet/command-line-api Star History
Data as of: 7/25/2025, 02:06 AM

Framework

System.CommandLine

Overview

System.CommandLine is the official .NET library for developing command-line applications. It provides functionality commonly needed by command-line apps, such as parsing command-line input and displaying help text. Used by the .NET CLI, additional tools, and many global and local tools, it's a highly reliable library with official Microsoft support. With trimming and AOT (Ahead-of-Time) compilation support, it enables the development of fast and lightweight CLI applications.

Details

The System.CommandLine library automates routine tasks like parsing command-line input and generating help pages, allowing you to focus on writing your app code. It also improves testability by enabling you to test app code independently from input parsing code.

Current Status and Future Outlook

The System.CommandLine project is currently undergoing a reset to better align with the current ecosystem, isolate parsing behavior for sharing with other parser libraries, and clarify long-term maintenance. This work is intended to bring System.CommandLine out of preview mode into a stable release.

Key Features

  • Automatic Parsing: Consistent command-line input parsing according to POSIX or Windows conventions
  • Automatic Help Generation: Automatic help page generation from commands and options
  • Tab Completion: Automatic tab completion functionality support
  • Response Files: Ability to read arguments from files
  • Trimming Support: Development of fast and lightweight AOT-compatible CLI apps
  • Powerful Type System: Support for various option types like string, string[], int, bool, FileInfo, enum
  • Multi-level Subcommands: Support for nested subcommand structures
  • Aliases: Aliasing functionality for commands and options
  • Custom Parsing & Validation: Custom parsing and validation logic for options
  • Dependency Injection: Support for .NET standard dependency injection patterns

Supported Platforms

  • .NET 9: Requires --prerelease option (due to beta status)
  • Cross-platform: Works on Windows, Linux, macOS
  • AOT Support: Performance optimization with Native AOT compilation

Ecosystem Integration

Adopted by many official and unofficial tools including the .NET CLI, establishing its position as the standard CLI library in the .NET ecosystem.

Pros and Cons

Pros

  • Official Microsoft Support: Official development and maintenance by the .NET team
  • Automation Features: Automation of parsing and help generation for code focus
  • Testability: Testing of app logic independent from input parsing
  • High Performance: Fast operation through trimming and AOT support
  • Powerful Type System: Safe option processing leveraging C#'s type system
  • Rich Features: Comprehensive functionality including subcommands, aliases, completion
  • Standards Compliance: Adherence to POSIX and Windows conventions
  • Ecosystem Integration: Consistency with .NET CLI and other tools

Cons

  • Preview Status: Still in beta, requiring caution for production use
  • Learning Curve: Initial learning time required due to rich functionality
  • .NET Dependency: Cannot be used outside .NET projects
  • API Change Risk: Potential future API changes due to preview status
  • Documentation: Some community documentation gaps for certain features

Key Links

Code Examples

Basic Command Creation

using System.CommandLine;

class Program
{
    static async Task<int> Main(string[] args)
    {
        // Create root command
        var rootCommand = new RootCommand("Simple CLI application");

        // Define options
        var nameOption = new Option<string>(
            name: "--name",
            description: "Name to display")
        {
            IsRequired = true
        };

        var verboseOption = new Option<bool>(
            name: "--verbose",
            description: "Display verbose output");

        // Add options to command
        rootCommand.AddOption(nameOption);
        rootCommand.AddOption(verboseOption);

        // Set command handler
        rootCommand.SetHandler((string name, bool verbose) =>
        {
            if (verbose)
            {
                Console.WriteLine($"Verbose mode: Displaying greeting for {name}");
            }
            Console.WriteLine($"Hello, {name}!");
        }, nameOption, verboseOption);

        // Execute command
        return await rootCommand.InvokeAsync(args);
    }
}

Subcommand Implementation

using System.CommandLine;
using System.IO;

class Program
{
    static async Task<int> Main(string[] args)
    {
        // Root command
        var rootCommand = new RootCommand("File management tool");

        // File creation subcommand
        var createCommand = new Command("create", "Create a new file");
        var fileNameArgument = new Argument<string>("filename", "File name to create");
        var contentOption = new Option<string?>("--content", "File content");
        var forceOption = new Option<bool>("--force", "Overwrite existing file");

        createCommand.AddArgument(fileNameArgument);
        createCommand.AddOption(contentOption);
        createCommand.AddOption(forceOption);

        createCommand.SetHandler(async (string filename, string? content, bool force) =>
        {
            if (File.Exists(filename) && !force)
            {
                Console.WriteLine($"Error: File '{filename}' already exists. Use --force option.");
                return;
            }

            await File.WriteAllTextAsync(filename, content ?? "");
            Console.WriteLine($"File '{filename}' created.");
        }, fileNameArgument, contentOption, forceOption);

        // File list subcommand
        var listCommand = new Command("list", "Display file list");
        var pathArgument = new Argument<DirectoryInfo>("path", () => new DirectoryInfo("."), "Target directory");
        var patternOption = new Option<string>("--pattern", () => "*", "File pattern");
        var recursiveOption = new Option<bool>("--recursive", "Include subdirectories");

        listCommand.AddArgument(pathArgument);
        listCommand.AddOption(patternOption);
        listCommand.AddOption(recursiveOption);

        listCommand.SetHandler((DirectoryInfo path, string pattern, bool recursive) =>
        {
            var searchOption = recursive ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly;
            var files = path.GetFiles(pattern, searchOption);

            Console.WriteLine($"Directory: {path.FullName}");
            Console.WriteLine($"Pattern: {pattern}");
            Console.WriteLine($"Recursive: {(recursive ? "enabled" : "disabled")}");
            Console.WriteLine();

            foreach (var file in files)
            {
                var relativePath = Path.GetRelativePath(path.FullName, file.FullName);
                Console.WriteLine($"  {relativePath} ({file.Length} bytes)");
            }

            Console.WriteLine($"\n{files.Length} files found.");
        }, pathArgument, patternOption, recursiveOption);

        // Delete subcommand
        var deleteCommand = new Command("delete", "Delete a file");
        var deleteFileArgument = new Argument<FileInfo>("file", "File to delete");
        var confirmOption = new Option<bool>("--confirm", "Confirm before deletion");

        deleteCommand.AddArgument(deleteFileArgument);
        deleteCommand.AddOption(confirmOption);

        deleteCommand.SetHandler((FileInfo file, bool confirm) =>
        {
            if (!file.Exists)
            {
                Console.WriteLine($"Error: File '{file.Name}' not found.");
                return;
            }

            if (confirm)
            {
                Console.Write($"Delete file '{file.Name}'? [y/N]: ");
                var response = Console.ReadLine();
                if (response?.ToLower() != "y" && response?.ToLower() != "yes")
                {
                    Console.WriteLine("Deletion cancelled.");
                    return;
                }
            }

            file.Delete();
            Console.WriteLine($"File '{file.Name}' deleted.");
        }, deleteFileArgument, confirmOption);

        // Add subcommands to root
        rootCommand.AddCommand(createCommand);
        rootCommand.AddCommand(listCommand);
        rootCommand.AddCommand(deleteCommand);

        return await rootCommand.InvokeAsync(args);
    }
}

Advanced Option Processing

using System.CommandLine;
using System.CommandLine.Parsing;

public enum LogLevel
{
    Debug,
    Info,
    Warning,
    Error
}

class Program
{
    static async Task<int> Main(string[] args)
    {
        var rootCommand = new RootCommand("Advanced option processing example");

        // Array option
        var tagsOption = new Option<string[]>(
            name: "--tags",
            description: "List of tags")
        {
            AllowMultipleArgumentsPerToken = true
        };

        // Enum option
        var logLevelOption = new Option<LogLevel>(
            name: "--log-level",
            description: "Log level")
        {
            IsRequired = false
        };
        logLevelOption.SetDefaultValue(LogLevel.Info);

        // File existence validation option
        var inputFileOption = new Option<FileInfo?>(
            name: "--input",
            description: "Input file");

        inputFileOption.AddValidator(result =>
        {
            var file = result.GetValueForOption(inputFileOption);
            if (file != null && !file.Exists)
            {
                result.ErrorMessage = $"File '{file.FullName}' does not exist.";
            }
        });

        // Numeric range validation option
        var portOption = new Option<int>(
            name: "--port",
            description: "Port number (1024-65535)")
        {
            IsRequired = true
        };

        portOption.AddValidator(result =>
        {
            var port = result.GetValueForOption(portOption);
            if (port < 1024 || port > 65535)
            {
                result.ErrorMessage = "Port number must be between 1024 and 65535.";
            }
        });

        // Custom parsing option
        var configOption = new Option<Dictionary<string, string>>(
            name: "--config",
            description: "Configuration values (key=value format)",
            parseArgument: result =>
            {
                var config = new Dictionary<string, string>();
                foreach (var token in result.Tokens)
                {
                    var parts = token.Value.Split('=', 2);
                    if (parts.Length != 2)
                    {
                        result.ErrorMessage = $"Invalid config format: {token.Value}. Use key=value format.";
                        return config;
                    }
                    config[parts[0]] = parts[1];
                }
                return config;
            });

        rootCommand.AddOption(tagsOption);
        rootCommand.AddOption(logLevelOption);
        rootCommand.AddOption(inputFileOption);
        rootCommand.AddOption(portOption);
        rootCommand.AddOption(configOption);

        rootCommand.SetHandler((string[] tags, LogLevel logLevel, FileInfo? inputFile, int port, Dictionary<string, string> config) =>
        {
            Console.WriteLine("=== Configuration ===");
            Console.WriteLine($"Tags: [{string.Join(", ", tags)}]");
            Console.WriteLine($"Log Level: {logLevel}");
            Console.WriteLine($"Input File: {inputFile?.FullName ?? "None"}");
            Console.WriteLine($"Port: {port}");
            
            Console.WriteLine("Config:");
            foreach (var kvp in config)
            {
                Console.WriteLine($"  {kvp.Key} = {kvp.Value}");
            }
        }, tagsOption, logLevelOption, inputFileOption, portOption, configOption);

        return await rootCommand.InvokeAsync(args);
    }
}

Dependency Injection Implementation

using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using System.CommandLine;
using System.CommandLine.Hosting;

// Service interface
public interface IDataService
{
    Task<string[]> GetDataAsync();
    Task SaveDataAsync(string data);
}

// Service implementation
public class FileDataService : IDataService
{
    private readonly ILogger<FileDataService> _logger;
    private readonly string _dataFile = "data.txt";

    public FileDataService(ILogger<FileDataService> logger)
    {
        _logger = logger;
    }

    public async Task<string[]> GetDataAsync()
    {
        _logger.LogInformation("Reading from data file...");
        if (!File.Exists(_dataFile))
        {
            return Array.Empty<string>();
        }
        return await File.ReadAllLinesAsync(_dataFile);
    }

    public async Task SaveDataAsync(string data)
    {
        _logger.LogInformation("Saving to data file...");
        await File.AppendAllTextAsync(_dataFile, data + Environment.NewLine);
    }
}

// Command handlers
public class DataCommands
{
    private readonly IDataService _dataService;
    private readonly ILogger<DataCommands> _logger;

    public DataCommands(IDataService dataService, ILogger<DataCommands> logger)
    {
        _dataService = dataService;
        _logger = logger;
    }

    public async Task ListData()
    {
        _logger.LogInformation("Displaying data list");
        var data = await _dataService.GetDataAsync();
        
        if (data.Length == 0)
        {
            Console.WriteLine("No data available.");
            return;
        }

        Console.WriteLine("Saved data:");
        for (int i = 0; i < data.Length; i++)
        {
            Console.WriteLine($"  {i + 1}. {data[i]}");
        }
    }

    public async Task AddData(string value)
    {
        _logger.LogInformation("Adding data: {Value}", value);
        await _dataService.SaveDataAsync(value);
        Console.WriteLine($"Data '{value}' added.");
    }
}

class Program
{
    static async Task<int> Main(string[] args)
    {
        var rootCommand = new RootCommand("CLI app with dependency injection");

        // list command
        var listCommand = new Command("list", "Display data list");
        listCommand.SetHandler<DataCommands>(async (commands) => await commands.ListData());

        // add command
        var addCommand = new Command("add", "Add data");
        var valueArgument = new Argument<string>("value", "Value to add");
        addCommand.AddArgument(valueArgument);
        addCommand.SetHandler<string, DataCommands>(async (value, commands) => await commands.AddData(value), valueArgument);

        rootCommand.AddCommand(listCommand);
        rootCommand.AddCommand(addCommand);

        // Host configuration
        var builder = Host.CreateDefaultBuilder(args)
            .ConfigureServices(services =>
            {
                services.AddSingleton<IDataService, FileDataService>();
                services.AddSingleton<DataCommands>();
            })
            .UseCommandHandler<RootCommand, DataCommands>();

        var host = builder.Build();
        return await host.RunCommandLineApplicationAsync(rootCommand, args);
    }
}

Error Handling and Custom Middleware

using System.CommandLine;
using System.CommandLine.Builder;
using System.CommandLine.Parsing;

class Program
{
    static async Task<int> Main(string[] args)
    {
        var rootCommand = new RootCommand("Error handling example");

        var riskyCommand = new Command("risky", "Command that may cause errors");
        var shouldFailOption = new Option<bool>("--fail", "Intentionally cause an error");
        riskyCommand.AddOption(shouldFailOption);

        riskyCommand.SetHandler(async (bool shouldFail) =>
        {
            Console.WriteLine("Starting risky operation...");
            
            if (shouldFail)
            {
                throw new InvalidOperationException("Intentional error occurred.");
            }

            // Simulate time-consuming process
            await Task.Delay(1000);
            Console.WriteLine("Operation completed successfully.");
        }, shouldFailOption);

        rootCommand.AddCommand(riskyCommand);

        // Custom command line builder
        var commandLineBuilder = new CommandLineBuilder(rootCommand)
            .UseDefaults()
            .UseExceptionHandler((exception, context) =>
            {
                Console.ForegroundColor = ConsoleColor.Red;
                Console.WriteLine($"An error occurred: {exception.Message}");
                Console.ResetColor();
                
                if (exception is InvalidOperationException)
                {
                    Console.WriteLine("This is an expected error. Remove the --fail option and try again.");
                    context.ExitCode = 2;
                }
                else
                {
                    Console.WriteLine("An unexpected error occurred.");
                    context.ExitCode = 1;
                }
            })
            .AddMiddleware(async (context, next) =>
            {
                // Pre-execution logging
                Console.WriteLine($"Command execution started: {string.Join(" ", context.ParseResult.Tokens.Select(t => t.Value))}");
                var startTime = DateTime.Now;

                try
                {
                    await next(context);
                }
                finally
                {
                    // Post-execution logging
                    var duration = DateTime.Now - startTime;
                    Console.WriteLine($"Command execution finished: {duration.TotalMilliseconds:F2}ms");
                }
            });

        var parser = commandLineBuilder.Build();
        return await parser.InvokeAsync(args);
    }
}

Response Files and Custom Completion

using System.CommandLine;
using System.CommandLine.Completions;

class Program
{
    static async Task<int> Main(string[] args)
    {
        var rootCommand = new RootCommand("Response files and completion example");

        // Username option with custom completion
        var userOption = new Option<string>("--user", "Username");
        userOption.AddCompletions(context =>
        {
            // In real applications, fetch from database or API
            var availableUsers = new[] { "alice", "bob", "charlie", "david" };
            var currentInput = context.WordToComplete;
            
            return availableUsers
                .Where(user => user.StartsWith(currentInput, StringComparison.OrdinalIgnoreCase))
                .Select(user => new CompletionItem(user))
                .ToArray();
        });

        // Environment option with fixed choices
        var envOption = new Option<string>("--environment", "Environment name");
        envOption.AddCompletions("development", "staging", "production");

        // File path option (filesystem completion)
        var configOption = new Option<FileInfo>("--config", "Configuration file");
        // FileInfo type automatically enables file path completion

        rootCommand.AddOption(userOption);
        rootCommand.AddOption(envOption);
        rootCommand.AddOption(configOption);

        rootCommand.SetHandler((string? user, string? environment, FileInfo? config) =>
        {
            Console.WriteLine("=== Execution Parameters ===");
            Console.WriteLine($"User: {user ?? "Not specified"}");
            Console.WriteLine($"Environment: {environment ?? "Not specified"}");
            Console.WriteLine($"Config file: {config?.FullName ?? "Not specified"}");

            // Display response file usage example
            Console.WriteLine();
            Console.WriteLine("=== Response File Usage Example ===");
            Console.WriteLine("You can save arguments to a file and use them:");
            Console.WriteLine("  echo '--user alice --environment production' > args.txt");
            Console.WriteLine("  myapp @args.txt");
        }, userOption, envOption, configOption);

        return await rootCommand.InvokeAsync(args);
    }
}

Project Configuration and NuGet Packages

<!-- Project.csproj -->
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <Nullable>enable</Nullable>
    <PublishAot>true</PublishAot>
    <InvariantGlobalization>true</InvariantGlobalization>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="System.CommandLine" Version="2.0.0-beta4.22272.1" />
    <PackageReference Include="System.CommandLine.Hosting" Version="0.4.0-alpha.22272.1" />
    <PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.0" />
    <PackageReference Include="Microsoft.Extensions.Logging.Console" Version="8.0.0" />
  </ItemGroup>

</Project>
# Installation command
dotnet add package System.CommandLine --prerelease

# Execution examples
dotnet run -- --help
dotnet run -- add "Sample data" --verbose
dotnet run -- list --log-level Debug

# Response file example
echo "--user alice --environment production --config config.json" > response.txt
dotnet run -- @response.txt

# AOT publish
dotnet publish -c Release -r win-x64 --self-contained