Note
Access to this page requires authorization. You can try signing in or changing directories.
Access to this page requires authorization. You can try changing directories.
Tip
This article is part of the Fundamentals section, written for developers who know at least one programming language and are learning C#. If you're new to programming, start with Get started. If you need comprehensive library coverage, see the System.CommandLine library documentation.
The System.CommandLine library handles command-line parsing, help-text generation, and input validation so you can focus on your app's logic. In this tutorial, you build a task tracker CLI that demonstrates the core concepts: commands, subcommands, options, and arguments.
In this tutorial, you:
- Create a file-based app by using the
System.CommandLinepackage. - Define options and arguments with typed values.
- Build subcommands and attach options and arguments to them.
- Handle each subcommand with an action.
- Test the app with different command-line inputs.
Prerequisites
- Install the .NET 10 SDK or later.
Create the app
Start by creating a file-based C# program and adding the System.CommandLine package.
Create a file named
TaskCli.cswith the following content:#!/usr/bin/env dotnetThe
#!(shebang) line lets you run the file directly on Unix systems. On Windows, run the file withdotnet run TaskCli.cs.Add the
System.CommandLinepackage directive and the requiredusingstatements:#:package System.CommandLine@2.0.0using System.CommandLine; using System.CommandLine.Parsing; using System.Text.Json;Important
Version
2.0.0is the latest version at the time of writing. Check the package's NuGet page for the latest version to ensure you have the latest security fixes.
Understand the command structure
Before writing any parsing code, consider what the CLI looks like from the user's perspective. The task tracker supports four operations:
dotnet TaskCli.cs -- add "Write documentation" --priority High --due 2026-04-01
dotnet TaskCli.cs -- list --all
dotnet TaskCli.cs -- complete 3
dotnet TaskCli.cs -- remove 3
dotnet TaskCli.cs -- --verbose list
Note
The -- after TaskCli.cs in the preceding examples tells dotnet run that all remaining arguments are passed to your app rather than interpreted by the dotnet CLI itself.
Each line uses several command-line concepts:
- Subcommands are verbs that tell the app what to do. The task tracker has four:
add,list,complete, andremove. Each subcommand can define its own parameters. - Arguments are positional values that follow a subcommand. In
add "Write documentation", the string"Write documentation"is an argument specifying the task description. Incomplete 3, the number3is an argument specifying the task ID. - Options are named values prefixed with
--. Inadd --priority High --due 2026-04-01, both--priorityand--dueare options with their own values. Inlist --all, the--alloption is a Boolean flag that doesn't need a value. - Global options apply to every subcommand. The
--verboseoption is defined on the root command withRecursive = true, so it works with any subcommand. In--verbose list, the verbose flag appears before the subcommand, butlist --verboseworks equally well.
In the sections that follow, you build these pieces from the bottom up. First, you define the individual options (like --priority and --all) and arguments (like the task description and ID). Next, you create the four subcommands and attach the relevant options and arguments to each one. Then you wire up an action for each subcommand. The action is the code that runs when the user invokes that command. Finally, you assemble the root command, parse the input, and invoke the matched action.
For a deeper look at command-line syntax concepts, see Command-line syntax overview.
Define options and arguments
Options represent named values that users specify with a -- prefix. Arguments represent positional values. Both are strongly typed. System.CommandLine parses the input string into the type you specify.
The System.CommandLine library uses generic types to enforce type safety. When you write Option<int>, the int between the angle brackets is a type argument. It tells the library what type of value the option holds. The class itself declares a type parameter T (as in System.CommandLine.Option<T>), and you supply the concrete type when you create an instance. The library parses the user's input string and converts it to that type automatically. If the user provides --delay abc for an Option<int>, System.CommandLine reports a parse error instead of passing bad data to your code. You'll see this pattern with Option<bool>, Option<Priority>, Option<DateOnly?>, and System.CommandLine.Argument<T> in the steps that follow. For more information on generics, see Generics.
Define the options. Each
Option<T>specifies the value type, the name, and a description. The--priorityoption uses anenumtype, andSystem.CommandLineautomatically validates the input against valid enum values:var verboseOption = new Option<bool>("--verbose") { Description = "Show detailed output", Recursive = true }; var priorityOption = new Option<Priority>("--priority") { Description = "Task priority level", DefaultValueFactory = _ => Priority.Medium }; var dueOption = new Option<DateOnly?>("--due") { Description = "Due date (uses current culture date format)" }; var allOption = new Option<bool>("--all") { Description = "Include completed tasks" };The
Recursive = truesetting on--verbosemakes the option available on every subcommand. TheDefaultValueFactoryon--priorityprovides a default value so users can omit the option.Define the arguments. Each
Argument<T>specifies the value type and a name:var descriptionArgument = new Argument<string>("description") { Description = "Task description" }; var taskIdArgument = new Argument<int>("id") { Description = "Task ID" };
Build commands and subcommands
A System.CommandLine.Command represents an action that the user can invoke. Add the relevant options and arguments to each command so System.CommandLine knows which parameters belong where.
Create the four subcommands. Each command gets its own combination of options and arguments:
var addCommand = new Command("add", "Add a new task") { Arguments = { descriptionArgument }, Options = { priorityOption, dueOption } };var listCommand = new Command("list", "List all tasks") { Options = { allOption } };var completeCommand = new Command("complete", "Mark a task as complete") { Arguments = { taskIdArgument } };var removeCommand = new Command("remove", "Remove a task") { Arguments = { taskIdArgument } };Assemble the root command. The System.CommandLine.RootCommand is the entry point for the CLI. Add the global
--verboseoption and all subcommands:var rootCommand = new RootCommand("A simple task tracker CLI") { Options = { verboseOption }, Subcommands = { addCommand, listCommand, completeCommand, removeCommand } };The autogenerated help text shows the root command description when the user runs
TaskCli --help.
Handle commands with actions
Each subcommand needs an action. An action is a delegate that runs when the user invokes that command. A delegate is a type that represents a reference to a method. Here, you pass a lambda expression (an inline anonymous function defined with =>) as the delegate. Call SetAction to assign each action. The delegate receives a ParseResult that provides access to parsed values through GetValue.
Set the action for the
addcommand. This action introduces string interpolation ($"..."strings that embed expressions in braces), the conditional operator (?:), and a type test pattern (due is DateOnly dueDate) that checks whether a nullable value has a value and assigns it to a new variable in one step:addCommand.SetAction(parseResult => { var description = parseResult.GetValue(descriptionArgument)!; var priority = parseResult.GetValue(priorityOption); var due = parseResult.GetValue(dueOption); var verbose = parseResult.GetValue(verboseOption); var tasks = LoadTasks(); var id = tasks.Count > 0 ? tasks.Max(t => t.Id) + 1 : 1; var task = new TaskItem(id, description, priority, due, false); tasks.Add(task); SaveTasks(tasks); Console.WriteLine($"Added task {id}: {description}"); if (verbose) { Console.WriteLine($" Priority: {priority}"); if (due is DateOnly dueDate) { Console.WriteLine($" Due: {dueDate}"); } } });Set the action for the
listcommand. LINQ (Language Integrated Query) gives you standard query operators for in-memory collections. In this action, Enumerable.Where filters the tasks to only the items that match a condition, and Enumerable.ToList materializes the filtered sequence into a list. The action then uses aforeachloop to iterate over the results, and the conditional operator to pick a status symbol:listCommand.SetAction(parseResult => { var showAll = parseResult.GetValue(allOption); var verbose = parseResult.GetValue(verboseOption); var tasks = LoadTasks(); var filtered = showAll ? tasks : tasks.Where(t => !t.IsComplete).ToList(); if (filtered.Count == 0) { Console.WriteLine("No tasks found."); return; } foreach (var task in filtered) { var status = task.IsComplete ? "✓" : " "; Console.WriteLine($" [{status}] {task.Id}: {task.Description}"); if (verbose) { Console.WriteLine($" Priority: {task.Priority}"); if (task.Due is DateOnly dueDate) { Console.WriteLine($" Due: {dueDate}"); } } } });For more detail, see LINQ.
Set the action for the
completecommand. This action uses LINQ's Enumerable.FirstOrDefault to find:- A matching task.
- An
is nullpattern to check whether the task exists. - A
withexpression to create a new record instance by copying the existing values first, and then applying the properties you set in thewithinitializer (here,IsComplete = true). Records are immutable by default, so this copy-and-update pattern is how you produce a modified value.
Because the action can fail (for example, the task ID doesn't exist), the action returns an integer error code that becomes the app's exit code:
completeCommand.SetAction(parseResult => { var id = parseResult.GetValue(taskIdArgument); var verbose = parseResult.GetValue(verboseOption); var tasks = LoadTasks(); var task = tasks.FirstOrDefault(t => t.Id == id); if (task is null) { Console.Error.WriteLine($"Task {id} not found."); return -1; } tasks[tasks.IndexOf(task)] = task with { IsComplete = true }; SaveTasks(tasks); Console.WriteLine($"Completed task {id}: {task.Description}"); if (verbose) { Console.WriteLine($" Priority: {task.Priority}"); } return 0; });Set the action for the
removecommand. This action follows the same look-up-and-validate pattern ascomplete. UseFirstOrDefaultto find the task andis nullto handle the missing case:removeCommand.SetAction(parseResult => { var id = parseResult.GetValue(taskIdArgument); var verbose = parseResult.GetValue(verboseOption); var tasks = LoadTasks(); var task = tasks.FirstOrDefault(t => t.Id == id); if (task is null) { Console.Error.WriteLine($"Task {id} not found."); return -1; } tasks.Remove(task); SaveTasks(tasks); Console.WriteLine($"Removed task {id}: {task.Description}"); if (verbose) { Console.WriteLine($" Priority: {task.Priority}"); } return 0; });Parse the command line and invoke the matched action:
return rootCommand.Parse(args).Invoke();rootCommand.Parse(args)parses the input into a ParseResult, and.Invoke()runs the action for the matched command. The return value is an exit code (0 for success).
Add supporting types and data helpers
The app needs a few supporting pieces: local functions, an enum, a record, and a serialization context. The following sections introduce each concept, explain why you'd choose it, and show the code. The app uses System.Text.Json to store tasks as JSON (JavaScript Object Notation). File-based apps require type declarations to appear after all top-level statements and local functions.
Add the local functions that load and save tasks. A local function is a method declared inside another function, including inside other local functions. Local functions keep helper logic close to the code that calls it, which improves readability because a reader doesn't have to jump to a separate class or file to understand the flow. Here,
LoadTasksandSaveTasksencapsulate the file I/O that multiple command actions share, so the app writes the load/save logic once and reuses it:static string GetTaskFilePath() => Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "taskcli-sample", "tasks.json"); static List<TaskItem> LoadTasks() { var path = GetTaskFilePath(); if (!File.Exists(path)) { return []; } var json = File.ReadAllText(path); return JsonSerializer.Deserialize(json, TaskJsonContext.Default.ListTaskItem) ?? []; } static void SaveTasks(List<TaskItem> tasks) { var path = GetTaskFilePath(); Directory.CreateDirectory(Path.GetDirectoryName(path)!); var json = JsonSerializer.Serialize(tasks, TaskJsonContext.Default.ListTaskItem); File.WriteAllText(path, json); }Add the
Priorityenum andTaskItemrecord at the end of the file:public enum Priority { Low, Medium, High }An
enumis a value type that defines a fixed set of named constants backed by an integral type. You could represent priority levels with plain integers (0, 1, 2), but anenumis a better choice for several reasons: the compiler restricts assignments to the defined names, so a typo likeHihgcauses a compile-time error instead of a silent bug; the namesLow,Medium, andHighmake code more readable; andSystem.CommandLineautomatically validates user input against the enum members, so you get free input checking.public record TaskItem(int Id, string Description, Priority Priority, DateOnly? Due, bool IsComplete);A
recordis a type that the compiler equips with value-based equality and nondestructive mutation. Arecordis the right fit forTaskItembecause task data is plain state with no complex behavior—you compare tasks by their values, not by reference identity. The compiler generatesEquals,GetHashCode,ToString, andwithexpression support from the constructor parameters, so you get correct equality checks, easy debugging output, and immutable updates without writing boilerplate. Because records are immutable by default, you use awithexpression to produce a modified copy (as thecompleteaction does) rather than mutating the original, which prevents accidental side effects when different actions read and write the same list.Add the JSON serialization context for AOT-compatible serialization:
[System.Text.Json.Serialization.JsonSourceGenerationOptions(WriteIndented = true)] [System.Text.Json.Serialization.JsonSerializable(typeof(List<TaskItem>))] internal partial class TaskJsonContext : System.Text.Json.Serialization.JsonSerializerContext;This class uses two attributes (metadata annotations placed in square brackets above a declaration). The
[JsonSourceGenerationOptions(WriteIndented = true)]attribute tells the source generator to emit indented JSON for readability. The[JsonSerializable(typeof(List<TaskItem>))]attribute tells the generator which type to create serialization code for. Together, these attributes enable source-generated serialization, which avoids run-time reflection and supports ahead-of-time (AOT) compilation.
Test the application
Run the app with different inputs to exercise each subcommand.
View the autogenerated help:
dotnet run TaskCli.cs -- --helpDescription: A simple task tracker CLI Usage: TaskCli [command] [options] Options: --verbose Show detailed output -?, -h, --help Show help and usage information --version Show version information Commands: add <description> Add a new task list List all tasks complete <id> Mark a task as complete remove <id> Remove a taskAdd tasks with different options:
dotnet run TaskCli.cs -- add "Write documentation" --priority High --due 2026-04-01 dotnet run TaskCli.cs -- add "Review pull request" dotnet run TaskCli.cs -- add "Fix build errors"Added task 1: Write documentation Added task 2: Review pull request Added task 3: Fix build errorsList tasks and use
--verboseto show extra detail:dotnet run TaskCli.cs -- --verbose list[ ] 1: Write documentation Priority: High Due: 4/1/2026 [ ] 2: Review pull request Priority: Medium [ ] 3: Fix build errors Priority: MediumComplete a task, then verify the list filters completed tasks by default:
dotnet run TaskCli.cs -- complete 2 dotnet run TaskCli.cs -- listCompleted task 2: Review pull request [ ] 1: Write documentation [ ] 3: Fix build errorsUse
--allto include completed tasks:dotnet run TaskCli.cs -- list --all[ ] 1: Write documentation [✓] 2: Review pull request [ ] 3: Fix build errorsRemove a task:
dotnet run TaskCli.cs -- remove 3Removed task 3: Fix build errors
Clean up resources
The task tracker stores data in a JSON file under your local application data folder. Delete the taskcli-sample folder to remove the sample data:
- Windows: Delete
%LOCALAPPDATA%\taskcli-sample. - macOS: Delete
~/Library/Application Support/taskcli-sample. - Linux: Delete
~/.local/share/taskcli-sample.