System.CommandLine
The official command-line parsing library for .NET developed by Microsoft. Provides a modern and flexible API.
GitHub Overview
dotnet/command-line-api
Command line parsing, invocation, and rendering of terminal output.
Topics
Star History
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