Mert Özen Further With Every Line
Backend Everyday .NET APIs with C# — #08

Writing Console Apps and CLI Tools: args, System.CommandLine, Exit Codes, Colored Output and the Generic Host

Mert Özen Nov 15, 2026 15 min 11 views
Writing Console Apps and CLI Tools: args, System.CommandLine, Exit Codes, Colored Output and the Generic Host

A good CLI tool does not parse args by hand, writes results to stdout and errors to stderr, returns a documented exit code for every case and leaves no half-written file on Ctrl+C. We build Bookstore's bookstore-admin import-prices tool with System.CommandLine 3.0 and show help and tab completion, the exit code contract, cancellation with SIGINT/SIGTERM, NO_COLOR and .NET 11's FORCE_COLOR support, and DI and logging with the Generic Host, all with real output.

Version note: The code in this post was built and run with .NET 10, System.CommandLine 3.0.0 and Microsoft.Extensions.Hosting 10.0.15; the FORCE_COLOR example was run on the .NET 11 runtime (11.0.0). The output was captured in a Linux container.

Guess First

Bookstore, the online bookstore used as the shared example across this site's series, imports price lists from publishers with a console tool. The first version of the tool read its arguments the way everybody does at first:

// The first version everybody writes: command, file, then flags.
private static (string File, bool DryRun) ParseManually(string[] args)
{
    string file = args[1];
    bool dryRun = args.Contains("--dry-run");
    return (file, dryRun);
}

What happens for four different command lines? In particular, do prices change in the second one?

import-prices prices.csv --dry-run     -> file=prices.csv, dryRun=True
import-prices prices.csv --dryrun      -> file=prices.csv, dryRun=False
import-prices --dry-run prices.csv     -> file=--dry-run, dryRun=True
import-prices                          -> IndexOutOfRangeException

The second line is the most dangerous: the user meant "only check", forgot the dash, and the tool would really change the prices without any warning. In the third line the order changed and the flag became the file name; in the fourth the user saw a stack trace instead of a message telling them what to do. A hand-written parser grows with every new option, and every one of these bugs has to be closed one by one.

In this post we turn this tool into a real CLI: defining commands, arguments and options with System.CommandLine, help text and tab completion, an exit code contract, writing errors to stderr, leaving no half-written file behind when canceled with Ctrl+C, colored output and .NET 11's FORCE_COLOR support, and finally DI, configuration and logging with the Generic Host. This is the target command:

bookstore-admin import-prices prices.csv --dry-run

Commands with System.CommandLine

System.CommandLine is the parser library the .NET team also uses in the dotnet CLI. It stayed in beta for years; 2.0.0 became stable in November 2025. At the time of writing, the latest stable version on NuGet is 3.0.0, published on 2026-11-10; the 2.0 line was updated on the same day with 2.0.15. I compared the public API of the two versions with reflection: 3.0.0 only adds members (Argument.CaptureRemainingTokens, AcceptOnlyFromAmong overloads that take a StringComparer, RootCommand.HelpName) and removes none. The code in this post also compiled unchanged against 2.0.15. Adding the package to your project is enough:

dotnet add src/Bookstore.Admin package System.CommandLine --version 3.0.0

The import-prices command consists of one argument (the file) and three options:

Argument<FileInfo> file = new("file") { Description = "CSV file with isbn,price rows" };
file.AcceptExistingOnly();

Option<bool> dryRun = new("--dry-run", "-n") { Description = "Validate only; do not change any price" };
Option<int> maxErrors = new("--max-errors")
{
    Description = "Stop reading after this many invalid rows",
    DefaultValueFactory = _ => 10,
};
Option<FileInfo> report = new("--report")
{
    Description = "Where to write the applied changes",
    DefaultValueFactory = _ => new FileInfo("import-report.csv"),
};

Command command = new("import-prices", "Import new book prices from a CSV file") { file, dryRun, maxErrors, report };
command.SetAction(async (parseResult, cancellationToken) =>
{
    FileInfo input = parseResult.GetValue(file)!;
    bool isDryRun = parseResult.GetValue(dryRun);
    PriceImporter importer = services.GetRequiredService<PriceImporter>();

    try
    {
        ImportResult result = await importer.ImportAsync(input.FullName, isDryRun, parseResult.GetValue(maxErrors),
            parseResult.GetValue(report)!.FullName, cancellationToken);

        foreach (string error in result.Errors)
            ConsoleOutput.Error(error);
        if (result.Errors.Count > 0)
        {
            ConsoleOutput.Error($"{result.Errors.Count} problem(s) in {input.Name}; no price was changed");
            return ExitCodes.InvalidData;
        }

        ConsoleOutput.Success(isDryRun
            ? $"dry run: {result.ValidRows.Count} of {result.RowsRead} rows are valid; nothing was changed"
            : $"updated {result.Applied} prices; report: {parseResult.GetValue(report)!.Name}");
        return ExitCodes.Success;
    }
    catch (OperationCanceledException)
    {
        ConsoleOutput.Warning("canceled; the report was not completed");
        return ExitCodes.Canceled;
    }
});
return command;

An argument matches by position and an option by name; order no longer matters. The generic parameter decides the type: Option<int> rejects "abc" by itself, Argument<FileInfo> turns the path into a FileInfo, and AcceptExistingOnly() checks at parse time that the file exists. The async lambda passed to SetAction receives a CancellationToken and returns an int: that number becomes the exit code of the process. The root command brings two subcommands together; the second one, check-isbn, uses the Isbn type we wrote in the first and second posts of this series. The last line of Program.cs is a single call:

RootCommand root = AdminCommands.Create(host.Services);
return await root.Parse(args).InvokeAsync();

Help, Error Messages and Tab Completion

As soon as you write the definitions, the help text exists too. The output was captured in a Linux container with the bookstore-admin command set up to run the published application:

$ bookstore-admin --help
Description:
  Bookstore admin tool. Exit codes: 0 success, 1 usage error, 2 invalid data, 130 canceled.

Usage:
  bookstore-admin [command] [options]

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

Commands:
  import-prices <file>  Import new book prices from a CSV file
  check-isbn <isbn>     Normalize and validate ISBN-13 values

$ bookstore-admin import-prices --help
Description:
  Import new book prices from a CSV file

Usage:
  bookstore-admin import-prices <file> [options]

Arguments:
  <file>  CSV file with isbn,price rows

Options:
  -n, --dry-run              Validate only; do not change any price
  --max-errors <max-errors>  Stop reading after this many invalid rows [default: 10]
  --report <report>          Where to write the applied changes [default: import-report.csv]
  -?, -h, --help             Show help and usage information

Let us try the bad command lines from the start with the new tool. The user who forgot the dash no longer changes any prices, and the missing file is caught before the action runs:

$ bookstore-admin import-prices prices.csv --dryrun
'--dryrun' was not matched. Did you mean one of the following?
--dry-run

Unrecognized command or argument '--dryrun'.

Description:
  Import new book prices from a CSV file
...
exit=1
$ bookstore-admin import-prices price.csv
File does not exist: 'price.csv'.
...
exit=1

On a parse error the action never runs, the exit code is 1 and the user sees the help for the command. I did not notice one detail until I tried it: the "Did you mean" suggestion and the help text go to stdout, while the error line itself goes to stderr. Run with 2>/dev/null, only the suggestion showed up; with >/dev/null, only the line Unrecognized command or argument '--dryrun'. did. In a tool called from a script, decisions must always be based on the exit code, not on the text of the output.

For tab completion, System.CommandLine offers a [suggest] directive. Shell integration is set up with a global tool called dotnet-suggest; on every Tab press in the shell, that tool calls the application like this:

$ bookstore-admin "[suggest:16]" "import-prices --"
--dry-run
--help
--max-errors
--report
$ bookstore-admin "[suggest:3]" "imp"
import-prices

The number is the cursor position in the command line. I tried the directive by calling it myself; I did not install dotnet-suggest globally on this machine. Installation and the shell scripts are covered in the System.CommandLine documentation.

The Exit Code Contract and stderr

A CLI tool is mostly called by another script, a CI pipeline or a scheduled job. These callers do not read the text on the screen; they look at the exit code. The contract of the Bookstore tool lives in a class and in the description of the root command:

// The contract that scripts and CI pipelines rely on; the root command description repeats it.
public static class ExitCodes
{
    public const int Success = 0;
    public const int UsageError = 1;
    public const int InvalidData = 2;
    public const int Canceled = 130;
}

0 means success; any non-zero value means failure. I kept 1 equal to the value System.CommandLine uses for parse errors. Returning 2 for bad data lets the caller tell "I typed the command wrong" apart from "the file has invalid rows". 130 is the traditional exit code of a process interrupted with Ctrl+C on Unix (128 + 2, the number of SIGINT). Once the contract is set, it is important not to change it; turning 2 into 3 in the next version silently breaks every script written against it.

Where messages are written is part of the contract too. Results go to stdout, errors and warnings go to stderr; someone running bookstore-admin ... > result.txt knows no error message ends up in their file, and sees the error on the screen. We use a small helper to write in color:

// Results go to stdout, problems to stderr; color is decoration only, never the message itself.
public static class ConsoleOutput
{
    public static void Success(string message) => Write(Console.Out, ConsoleColor.Green, message);

    public static void Warning(string message) => Write(Console.Error, ConsoleColor.Yellow, message);

    public static void Error(string message) => Write(Console.Error, ConsoleColor.Red, "error: " + message);

    private static void Write(TextWriter writer, ConsoleColor color, string message)
    {
        Console.ForegroundColor = color;
        writer.WriteLine(message);
        Console.ResetColor();
    }
}

Three scenarios: a dry run with a valid file, a file with invalid rows, and the same file with stderr closed. The sample CSV files are in the samples/prices folder of the repo:

$ bookstore-admin import-prices prices.csv --dry-run
dry run: 6 of 6 rows are valid; nothing was changed
exit=0
$ bookstore-admin import-prices prices-bad.csv
error: line 3: '9780140449137' is not a valid ISBN-13
error: line 4: expected 2 columns (isbn,price), got 3
error: line 5: '-5' is not a price between 0 and 5000
error: 3 problem(s) in prices-bad.csv; no price was changed
exit=2
$ bookstore-admin import-prices prices-bad.csv 2>/dev/null
exit=2

The error messages come with line numbers and no price changes: if a file has even one invalid row, rejecting the whole import is easier to manage than a half-applied price list. The error on line 4 is a familiar one: "129,00" came from Turkish Excel, and with the comma acting as both the column separator and the decimal separator, the row was split into three columns. We parse the price with the invariant culture; the second and third posts of this series explained why. With stderr closed nothing is left on the screen, but the exit code is still 2; the calling script still understands that something went wrong.

Ctrl+C and Cancellation: Leaving No Half-Written File

While running the action, System.CommandLine listens for SIGINT (the signal sent when you press Ctrl+C in a terminal) and SIGTERM, and cancels the CancellationToken it passes to the action. Using the token and exiting in a consistent state is up to us. The import writes every price change to a report file. The report is written to a temporary file and gets its final name only when everything has finished:

private async Task<int> ApplyAsync(IReadOnlyList<PriceRow> rows, string reportPath, CancellationToken cancellationToken)
{
    // The final name appears only when every row succeeded; anything else is kept as .partial.
    string tempPath = reportPath + ".tmp";
    int applied = 0;
    try
    {
        await using (StreamWriter report = new(tempPath))
        {
            await report.WriteLineAsync("isbn,old_price,new_price");
            foreach (PriceRow row in rows)
            {
                decimal oldPrice = await catalog.UpdatePriceAsync(row.Isbn, row.Price, cancellationToken);
                await report.WriteLineAsync(string.Create(CultureInfo.InvariantCulture, $"{row.Isbn},{oldPrice},{row.Price}"));
                applied++;
            }
        }
        File.Move(tempPath, reportPath, overwrite: true);
        return applied;
    }
    catch when (File.Exists(tempPath))
    {
        string partialPath = reportPath + ".partial";
        File.Move(tempPath, partialPath, overwrite: true);
        logger.LogWarning("Import stopped: {Applied} of {Total} prices were changed, see {Partial}", applied, rows.Count, Path.GetFileName(partialPath));
        throw;
    }
}

In this post IPriceCatalog is a simulation standing in for Bookstore's catalog API; every update is a "network call" taking 50 ms. I imitated pressing Ctrl+C while importing a 120-row file by sending SIGINT after 3 seconds with the timeout command:

$ timeout --preserve-status -s INT 3 bookstore-admin import-prices prices-big.csv
warn: Bookstore.Admin.Pricing.PriceImporter[0]
      Import stopped: 54 of 120 prices were changed, see import-report.csv.partial
canceled; the report was not completed
exit=130
$ ls
import-report.csv.partial
prices-bad.csv
prices-big.csv
prices.csv
$ wc -l import-report.csv.partial
55 import-report.csv.partial

There is no file named import-report.csv; the next step that expects this name (sending to accounting, a reporting job) does not mistake a half file for a complete one. But no information is lost either: 54 prices really changed, and which ones is in the .partial file. This also shows that cancellation is not always clean: changes made to an external system are not rolled back. If you need a full rollback, design the import to be safely re-runnable (idempotent) or send the changes in a single batch call. The same idea applies on the file side: write to a temporary file first, then give it its name with File.Move. On the same disk this move happens in one step; the details of atomic file writes are in the FileStream post of the I/O in .NET series.

To see what happens if the action never listens to the token, I wrote, in a separate test, an action that waits 10 seconds and ignores the token. After SIGINT, System.CommandLine waited 2 seconds and ended the process with exit code 130; that is the default of the InvocationConfiguration.ProcessTerminationTimeout property (00:00:02). In other words, if you do not listen to the token, your cleanup code may never run.

In an application that does not use System.CommandLine, you do the same job yourself with PosixSignalRegistration (.NET 6). Console.CancelKeyPress is only for Ctrl+C, while docker stop, which stops a container, sends SIGTERM:

using CancellationTokenSource cts = new();
PosixSignal? received = null;

void OnSignal(PosixSignalContext context)
{
    context.Cancel = true; // Let the app exit on its own instead of being terminated right away.
    received = context.Signal;
    cts.Cancel();
}

using PosixSignalRegistration sigint = PosixSignalRegistration.Create(PosixSignal.SIGINT, OnSignal);
using PosixSignalRegistration sigterm = PosixSignalRegistration.Create(PosixSignal.SIGTERM, OnSignal);

int batches = 0;
try
{
    while (true)
    {
        await Task.Delay(TimeSpan.FromMilliseconds(500), cts.Token);
        Console.WriteLine($"batch {++batches} done");
    }
}
catch (OperationCanceledException)
{
    Console.Error.WriteLine($"received {received}, stopped cleanly after {batches} batches");
}
$ timeout --preserve-status -s INT 1.8 dotnet Bookstore.Bcl.Samples.dll cli-signal
batch 1 done
batch 2 done
batch 3 done
received SIGINT, stopped cleanly after 3 batches
exit=0

context.Cancel = true prevents the default behavior (ending the process immediately) and leaves the shutdown to the application. The 0 on the last line is a warning as well: the sample application's Program.cs returns 0 after every demo, while a real tool that was canceled should return 130. We did that in bookstore-admin with ExitCodes.Canceled.

Colored Output: NO_COLOR and FORCE_COLOR in .NET 11

On Linux, Console.ForegroundColor writes escape codes to the terminal. .NET does this only when the output goes to a terminal, and never when NO_COLOR is set. For those who want to see color in a CI log, .NET 11 also supports FORCE_COLOR. I ran the same bookstore-admin binary first on .NET 10, then on the .NET 11 runtime with DOTNET_ROLL_FORWARD=Major, piping the output to cat -v to make the escape codes visible (on the .NET 11 lines, TERM=xterm is set in the container):

$ FORCE_COLOR=1 bookstore-admin import-prices prices.csv --dry-run | cat -v   # .NET 10
dry run: 6 of 6 rows are valid; nothing was changed
$ bookstore-admin import-prices prices.csv --dry-run | cat -v                 # .NET 11
dry run: 6 of 6 rows are valid; nothing was changed
$ FORCE_COLOR=1 bookstore-admin import-prices prices.csv --dry-run | cat -v   # .NET 11
^[[39;49m^[[32mdry run: 6 of 6 rows are valid; nothing was changed
^[[39;49m
$ NO_COLOR=1 FORCE_COLOR=1 bookstore-admin import-prices prices.csv --dry-run | cat -v   # .NET 11
^[[39;49m^[[32mdry run: 6 of 6 rows are valid; nothing was changed
^[[39;49m

^[[32m is the code for green, ^[[39;49m the code for returning to the default colors. .NET 10 ignores FORCE_COLOR when output is redirected; .NET 11 honors it. I learned two details while trying this. First, on .NET 11 the effect of FORCE_COLOR showed up only when the TERM environment variable was set; in a container without TERM the same command produced uncolored output, because the runtime learns which escape codes to write from the terminal definition. If you want color in CI, set both. Second, with NO_COLOR and FORCE_COLOR both set, FORCE_COLOR won. When writing directly to the terminal (without redirection), .NET 10 printed colors too, and NO_COLOR=1 turned them off.

Color should always be extra information, never the only information: error lines start with an error: prefix, so a log or a screen reader that does not show color can still tell the error apart.

DI, Configuration and Logging with the Generic Host

As the tool grows, the need for DI, configuration and logging arrives. For that you can use the Generic Host that web applications use in a console tool as well; building the services without running the host is enough:

// System.CommandLine owns args; the host only provides DI, configuration and logging.
HostApplicationBuilder builder = Host.CreateApplicationBuilder(new HostApplicationBuilderSettings
{
    ContentRootPath = AppContext.BaseDirectory,
});
builder.Logging.ClearProviders();
builder.Logging.AddConsole(options => options.LogToStandardErrorThreshold = LogLevel.Trace);
builder.Services.Configure<ImportOptions>(builder.Configuration.GetSection("Bookstore:Import"));
builder.Services.AddSingleton<IPriceCatalog, SimulatedPriceCatalog>();
builder.Services.AddSingleton<PriceImporter>();

using IHost host = builder.Build();
RootCommand root = AdminCommands.Create(host.Services);
return await root.Parse(args).InvokeAsync();

Three lines matter especially for a CLI. We do not pass args to the host: the host's command line configuration provider would try to interpret arguments such as --dry-run as configuration keys, and we leave that job to System.CommandLine. We set ContentRootPath to AppContext.BaseDirectory: the default is the working directory, and when the tool is run from any folder of the user's, appsettings.json cannot be found (we saw the difference between CurrentDirectory and BaseDirectory in the previous post). The console logger's LogToStandardErrorThreshold setting sends all logs to stderr; by default logs go to stdout and mix with the tool's real output. That is why the warn: line in the cancellation scenario is on stderr. The MaxPrice value comes from appsettings.json, and the "between 0 and 5000" in the error message was read from there. The details of configuration and the options pattern are in the configuration post of the ASP.NET Core Web API series.

Packaging as a dotnet tool

The easiest way to distribute the tool to the team is to make it a .NET tool. A few properties in the csproj are enough:

<OutputType>Exe</OutputType>
<AssemblyName>bookstore-admin</AssemblyName>
<PackAsTool>true</PackAsTool>
<ToolCommandName>bookstore-admin</ToolCommandName>
<PackageId>Bookstore.Admin</PackageId>
<Version>1.0.0</Version>
dotnet pack src/Bookstore.Admin -c Release -o nupkg
dotnet new tool-manifest
dotnet tool install --local --add-source ./nupkg Bookstore.Admin
dotnet tool run bookstore-admin -- check-isbn 9780441172719

I ran these four commands in a separate working folder with the .NET 10 SDK; the package was created, installed as a local tool and printed 9780441172719 -> ISBN 9780441172719. Global installation, the local tool manifest and versioning are covered in the dotnet tool post of the .NET SDK, CLI and MSBuild series. The --version output (1.0.0) comes from Version in the csproj.

In Production

Design the tool to be callable from scripts: do not ask interactive questions (or make them skippable with an option such as --yes), add an option to produce machine-readable output (such as --output json), and document the exit codes in the help text. For dangerous operations the default should be safe; instead of --dry-run, "a real run requires --apply" is also an option.

Make every long-running job cancellable and exit in a consistent state when canceled. A tool running in a container receives SIGTERM from docker stop and by default is killed with SIGKILL 10 seconds later; cleanup must finish within that time. Write logs to stderr and the result to stdout, and avoid taking secrets (API keys, connection strings) as arguments: arguments show up in the process list and in shell history; use an environment variable or a file.

Measure the effect of dependencies on startup time. The Generic Host and configuration load a few dozen assemblies; for a small tool called every second, that is noticeable. Publishing with Native AOT can shorten startup; the publish post of the .NET SDK, CLI and MSBuild series covers the options.

Common Pitfalls

Returning 0 on failure. A tool that catches an exception, prints it and finishes normally tells the pipeline calling it that "everything is fine". Every error path should have a non-zero exit code.

Writing error messages to stdout. In a tool whose output is redirected to a file or another command, the error message mixes into the data and nobody sees it. Errors and warnings go to stderr, and so do logs.

Leaving a half-written file on cancellation. A file written directly under its final name looks complete after Ctrl+C but is not. Write to a temporary file and move it when done; if you need to keep partial data, give it a clearly different name.

Parsing args by hand. A parser that ignores unknown options turns a typo into different behavior silently. System.CommandLine treats unknown tokens as errors by default.

Passing the cancellation token all the way to the action and then not using it. System.CommandLine waits 2 seconds and ends the process; your cleanup code does not run.

A Small Experiment

★☆☆ Add a --currency option to import-prices that accepts only TRY, EUR and USD (AcceptOnlyFromAmong). Run it with a wrong value and check the error message and the exit code.

★★☆ Add an --output json option: write the result summary (rowsRead, valid, errors) to stdout as JSON, and send colors and human-oriented messages to stderr. Confirm that you can read the output with jq.

★★★ Make the import re-runnable: if a .partial report exists, a new run should skip the rows in the report and continue with the rest. Try it with a scenario that is canceled twice and completes on the third run, and check that no price was updated twice.

The constructor of Bookstore's Order class still contains hand-written if (... == null) throw blocks. In the last post of the series we turn them into .NET's throw helpers, look at the equality helpers and gather the APIs that arrived in the BCL from .NET 8 to 11 in a single table.