.NET Console Applications
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production; see the section bibliography for the reference material consulted while preparing these pages. |
A .NET console program is one of the most approachable ways to write a command-line tool for Windows, macOS and Linux
from one code base: the base class library hides most operating-system differences, the SDK can produce anything from
a script run straight from source to a single native executable, and System.CommandLine, the Generic Host and
Spectre.Console cover argument parsing, application plumbing and rich output. The price of that convenience is that
the abstractions leak at the edges: Console behaves differently when its streams are redirected, signals exist on
Unix but not on Windows, and Native AOT turns reflection-based conveniences into build warnings. This page covers the
parts of .NET that matter for a program that runs in a terminal, and links rather than repeats the design rules that
are not specific to .NET.
It builds on the cross-language material of this section:
Windows Console & Processes (code pages, Ctrl+C events, CreateProcess),
POSIX Processes, Signals & I/O (signals, descriptors, buffering) and
Cross-Platform Portability (paths, line endings, config directories).
The first steps with System.CommandLine, Spectre.Console and dotnet tool packaging are in
Frameworks: Swift & .NET and are not repeated; the SDK commands
themselves are documented in Publish and Deployment Models,
Run and File-Based Apps and
.NET Tools. Full-screen interfaces are in
.NET TUIs with Spectre.Console & Terminal.Gui.
The page was written against .NET 10 LTS (runtime 10.0.13) with C# 14, System.CommandLine 2.0.13 (the stable 2.0 line, released in November 2025) and the documentation current in October 2026. .NET 11 is still at release candidate stage, so features that the documentation marks as ".NET 11 and later" are mentioned only to say they are not used here. Behaviour is described for Windows 10/11, macOS and Linux (glibc and musl).
Which snippets were compiled or run. No .NET SDK was available on the machine this page was written on, so none of the C#, project-file or shell snippets below were compiled or executed. They are assembled from the signatures, properties and samples in the Microsoft documentation linked after each example. The outputs shown are the outputs the code is written to produce, not captured runs. Treat every snippet as a starting point to build and test on your own machine before relying on it.
The running example is the Bookshelf shelf tool of this section, which lists books, adds them, loans and returns
them, and syncs a small catalogue.
The Console Class
System.Console is the lowest-level, always-available way to talk to a terminal. It exposes three streams, a set of
terminal-control members and a few events, and almost every higher-level library (System.CommandLine’s help output,
the console logger, Spectre.Console) is built on it.
Streams and redirection
When a console application starts, the operating system associates standard input, standard output and standard error
with it, and .NET presents them as Console.In, Console.Out and Console.Error. The documentation states the
defaults plainly: In is a TextReader for the keyboard, and Out and Error are TextWriter objects for the console
window, but each can be replaced with Console.SetIn, Console.SetOut and Console.SetError. Three details matter
for a command-line tool:
-
Console streams are synchronized. The documentation notes that methods that are normally asynchronous, such as
TextReader.ReadLineAsync, execute synchronously when the object represents a console stream, so multiple threads can read and write safely butawait Console.In.ReadLineAsync()does not free the thread while it waits. -
Console.WriteLinewrites to standard output. There is noConsole.Error.WriteLineshortcut onConsoleitself; diagnostics go throughConsole.Error. The line terminator comes fromEnvironment.NewLine, which follows the platform (CRLF on Windows, LF elsewhere); to change it for a stream, setConsole.Out.NewLineorConsole.Error.NewLine. -
Some members throw when a stream is redirected. Members that work on a real console can throw
IOExceptionwhen the underlying stream is a file or a pipe, andReadKeythrowsInvalidOperationExceptionwhenConsole.Inis redirected. The documented guards areConsole.IsInputRedirected,Console.IsOutputRedirectedandConsole.IsErrorRedirected.
Those three properties are .NET’s isatty: they are how a tool decides between "a human is watching" and "another
program is reading". The decision rules themselves (when to colour, when to prompt, when to emit JSON) are in
TTY, Colour & Interactivity and
Streams & Exit Codes; this page only shows the .NET calls that implement them.
The Console documentation also warns that Console.Write and Console.WriteLine have no effect in GUI applications
and should not be used for output in unattended server applications, which is why services use logging instead (see
Long-Running Processes & Services).
The members that matter for a CLI
| Member | What it does | Platform notes |
|---|---|---|
|
The three standard streams as |
Same on all platforms; replaceable with |
|
Raw |
Use it for non-text payloads; |
|
Whether a stream is redirected away from the console. |
The portable "is this a terminal" test. |
|
Reads one key press as a |
Throws |
|
Whether a key press is waiting. |
Check |
|
|
Can throw |
|
Event raised for Ctrl+C and Ctrl+Break; |
|
|
Size of the console window and of its buffer, in character cells. |
The getters work on Windows and Unix; the documentation says the setters for window size are supported only on Windows and throw |
|
Cursor placement, visibility and size. |
Check the platform attributes in the reference for each member before using it on Unix. |
|
The sixteen |
Portable, but limited to 16 colours; use escape sequences for more (see below). |
|
Encoding used for text written to / read from the console. |
The default code page comes from the system locale (documented). Set it explicitly when it matters. |
|
Screen and window helpers. |
Some are tied to a Windows console; check the |
Encoding
On Windows the console writes through a code page: the default is determined by the system locale, so characters outside
that code page show up as question marks. The documentation shows the fix: set Console.OutputEncoding to a
UTF8Encoding (or UnicodeEncoding) before writing, and make sure the console font has the glyphs. Setting it can
throw IOException, so guard it when the program may run without a console, and Console.OutputEncoding can return a cached value if something
else (for example a call to the Win32 SetConsoleOutputCP) changed the code page behind .NET’s back. The reference
pages consulted do not state the default encoding on Linux and macOS; set it explicitly if your output must be UTF-8
regardless of locale. The documentation does not discuss byte order marks either: Encoding.UTF8 carries a preamble,
so, as a precaution when output may be piped, pass new UTF8Encoding(encoderShouldEmitUTF8Identifier: false) to avoid
placing a BOM at the start of a stream another program will parse. The Windows-specific side (the activeCodePage
manifest setting, wmain, WriteConsoleW) is covered in
Windows Console & Processes.
Colour, cursor movement and escape sequences
ForegroundColor and ResetColor are the portable, documented way to colour text, and they are fine for a handful of
highlights. For anything richer (256-colour or true-colour, hyperlinks, the alternate screen) a program writes ANSI
escape sequences itself or lets a library such as Spectre.Console do it; the sequences and the capability
detection (TERM, COLORTERM, NO_COLOR) are in
Escape Sequences & Terminal Capabilities.
The sizes reported by Console.WindowWidth are in character cells, not display columns, so wide East Asian characters
and emoji need the width rules described on that page when you align tables.
Example: a shelf list that behaves in a terminal and in a pipe
The first example applies the stream rules: data on standard output, diagnostics on standard error, colour only for a human at a terminal, tab-separated plain text for a pipe, and a defensive terminal width.
using System.Text;
try
{
// No byte order mark: the output may be piped into another program.
Console.OutputEncoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false);
}
catch (IOException)
{
// The encoding could not be applied (for example there is no console); keep the defaults.
}
bool human = !Console.IsOutputRedirected;
bool colour = human && string.IsNullOrEmpty(Environment.GetEnvironmentVariable("NO_COLOR"));
int width = human ? TerminalWidth() : 100;
Book[] books =
[
new("978-0-261-10235-4", "The Hobbit", "J. R. R. Tolkien", OnLoan: false),
new("978-0-14-143951-8", "Pride and Prejudice", "Jane Austen", OnLoan: true),
new("978-0-7432-7356-5", "The Great Gatsby", "F. Scott Fitzgerald", OnLoan: false),
];
int titleWidth = Math.Max(10, width - 30);
foreach (Book book in books)
{
string status = book.OnLoan ? "on loan" : "on shelf";
if (!human)
{
// Stable, parseable form for pipes and files.
Console.Out.WriteLine($"{book.Isbn}\t{book.Title}\t{status}");
continue;
}
string title = book.Title.Length > titleWidth ? book.Title[..(titleWidth - 1)] + "…" : book.Title;
Console.Out.Write($"{book.Isbn} {title.PadRight(titleWidth)} ");
if (colour && book.OnLoan)
{
Console.ForegroundColor = ConsoleColor.Yellow;
}
try
{
Console.Out.WriteLine(status);
}
finally
{
if (colour)
{
Console.ResetColor();
}
}
}
// Status for the human goes to stderr, so `shelf list > books.txt` still shows it.
if (human)
{
Console.Error.WriteLine($"shelf: {books.Length} books, {books.Count(b => b.OnLoan)} on loan");
}
return 0;
static int TerminalWidth()
{
try
{
int w = Console.WindowWidth;
return w > 0 ? w : 80;
}
catch (IOException)
{
return 80;
}
}
record Book(string Isbn, string Title, string Author, bool OnLoan);
Expected behaviour (the program is written to print this; it was not run):
$ shelf list # terminal: aligned columns, "on loan" in yellow, summary on stderr
$ shelf list | head -n 2 # pipe: tab-separated, no colour, no summary
978-0-261-10235-4 The Hobbit on shelf
978-0-14-143951-8 Pride and Prejudice on loan
$ NO_COLOR=1 shelf list # terminal, but no colour
Example: reading single keys safely
Console.ReadKey turns a terminal into an event source, which is enough for a small picker without a full TUI
library. The pitfalls are all in what happens around it: redirected input, Ctrl+C, and a cursor that is left hidden.
static class Picker
{
// Returns null when the user cancels, or when there is no terminal to draw on.
public static Book? Pick(IReadOnlyList<Book> books)
{
if (Console.IsInputRedirected || Console.IsOutputRedirected)
{
return null; // the caller falls back to a plain "isbn on stdin" path
}
bool oldTreat = Console.TreatControlCAsInput;
Console.TreatControlCAsInput = true; // Ctrl+C arrives as a key, so the finally block always runs
Console.CursorVisible = false;
try
{
for (int i = 0; i < books.Count; i++)
{
Console.WriteLine(); // reserve the lines first, so the screen scrolls now
}
int top = Console.CursorTop - books.Count;
int selected = 0;
while (true)
{
Console.SetCursorPosition(0, top);
for (int i = 0; i < books.Count; i++)
{
string line = $"{(i == selected ? ">" : " ")} {books[i].Title}";
Console.WriteLine(line.PadRight(Math.Max(line.Length, Console.WindowWidth - 1)));
}
ConsoleKeyInfo key = Console.ReadKey(intercept: true);
if (key.Key == ConsoleKey.C && key.Modifiers.HasFlag(ConsoleModifiers.Control))
{
return null;
}
switch (key.Key)
{
case ConsoleKey.UpArrow: selected = Math.Max(0, selected - 1); break;
case ConsoleKey.DownArrow: selected = Math.Min(books.Count - 1, selected + 1); break;
case ConsoleKey.Enter: return books[selected];
case ConsoleKey.Escape: return null;
}
}
}
finally
{
Console.CursorVisible = true;
Console.TreatControlCAsInput = oldTreat;
}
}
}
This is deliberately small. It does not handle terminal resizing, wide characters or a list longer than the window, and on Unix a terminal that is closed while the program waits delivers a hangup rather than a key. The libraries in .NET TUIs with Spectre.Console & Terminal.Gui exist for those cases, and the line-editing alternative (history, completion) is in Interactive Prompts & Line Editing.
Pitfalls
-
Testing for a terminal on only one stream.
shelf list > out.txtredirects output but not input;echo x | shelf addredirects input but not output. Check the stream you are about to use. -
Printing status to standard output. A "3 books found" line on stdout corrupts
shelf list | jqandshelf list > file. UseConsole.Error. -
Reading window size in a pipe.
Console.WindowWidthcan throw or return zero when there is no console; always keep a fallback. -
Assuming Windows-only members exist everywhere. The
Console.WindowWidthsetter and several other members are Windows-specific; the platform analyzer raises CA1416 warnings for them, so treat that warning as a portability bug. -
Leaving the terminal in a strange state. Hiding the cursor or changing input mode and then dying on Ctrl+C leaves the user’s shell broken. Restore in
finally, and also handle the signals in the next section.
Handling Ctrl+C and Termination Signals
A command-line program is stopped far more often than it finishes the way it was designed to: the user presses Ctrl+C, the terminal window closes, a container orchestrator sends SIGTERM, a CI system cancels the job. A well-behaved tool turns every one of those into the same thing: cancel the work in flight, clean up, and exit with a status that says why. .NET offers three layers for this, and the platform differences decide which to use.
Console.CancelKeyPress
Console.CancelKeyPress is raised for Ctrl+C and Ctrl+Break. The handler receives a ConsoleCancelEventArgs:
SpecialKey is ConsoleSpecialKey.ControlC or ControlBreak, and Cancel decides what happens next. Its default is
false, which terminates the process when the handler returns; setting it to true keeps the process running so you
can finish cleanup and return from Main normally. The event is documented as unsupported on Android, iOS, tvOS and
browser. Console.TreatControlCAsInput = true is the alternative when you want Ctrl+C delivered as a key (as in the
picker above); then no event fires.
PosixSignalRegistration
PosixSignalRegistration.Create(PosixSignal, Action<PosixSignalContext>) registers a handler for a signal and returns an
IDisposable that unregisters it. PosixSignal names SIGHUP, SIGINT, SIGQUIT, SIGTERM, SIGCHLD, SIGCONT,
SIGWINCH, SIGTTIN, SIGTTOU and SIGTSTP, and on Unix raw signal numbers can be passed by casting them to
PosixSignal. The context’s Signal says what arrived and Cancel suppresses the default handling. The remarks on the
Create method state the platform rules exactly:
| Signal | Unix (Linux, macOS) | Windows |
|---|---|---|
|
Handler runs; |
Maps to Ctrl+C; can be cancelled. |
|
Handler runs; can be cancelled. |
Maps to Ctrl+Break; can be cancelled. |
|
Handler runs; can be cancelled. |
A handler can be registered, but the documentation says |
|
Handler runs on terminal hangup. |
The reference page does not tabulate which console event maps to it. |
|
Terminal configuration can be cancelled. |
Not applicable. |
Since .NET 10 the handlers for a signal run in reverse order of registration, so the last registered handler runs first.
If your tool and a library both register for the same signal, remember that the library’s handler, registered first, runs last.
For the POSIX mechanics behind these signals (sigaction, async-signal safety, job control) see
POSIX Processes, Signals & I/O; for the Windows console control
events they map to, Windows Console & Processes.
Choosing between them
-
Use
CancelKeyPressalone for a simple interactive tool: it covers Ctrl+C everywhere. -
Add
PosixSignalRegistrationforSIGTERMandSIGHUPfor anything that can be run by a supervisor, in a container or over SSH. Do not registerSIGINTin addition toCancelKeyPress, or the same keystroke cancels twice. -
Use System.CommandLine’s
ProcessTerminationTimeout(below) when the command line library already drives your actions: it turns Ctrl+C,SIGINTandSIGTERMinto aCancellationTokenand enforces a deadline. -
Use the Generic Host’s
ConsoleLifetimefor a long-running service; it listens forSIGINT,SIGQUITandSIGTERMand stops the host gracefully.
Do not stack all four on one program without testing; each installs handlers and they interact.
Exit codes after a signal
POSIX shells report a process killed by signal N as exit status 128 plus N, so 130 means SIGINT and 143 means SIGTERM, and many tools return the same numbers after handling the signal themselves so scripts can tell "cancelled" from "failed". Windows has no such convention. Pick one policy, document it, and keep it identical on every platform; the conventions for exit codes in general are in Streams & Exit Codes.
Example: shelf sync with graceful cancellation
using System.Runtime.InteropServices;
Book[] books = [new("978-0-261-10235-4", "The Hobbit", "J. R. R. Tolkien", false),
new("978-0-14-143951-8", "Pride and Prejudice", "Jane Austen", true)];
using CancellationTokenSource cts = new();
int signalExit = 0;
// Ctrl+C and Ctrl+Break on every platform.
Console.CancelKeyPress += (_, e) =>
{
e.Cancel = true; // keep running so cleanup can happen
Interlocked.CompareExchange(ref signalExit, 130, 0);
cts.Cancel();
};
// Termination requests from a supervisor, a container runtime or a closing terminal.
using PosixSignalRegistration? term = Register(PosixSignal.SIGTERM, 143);
using PosixSignalRegistration? hup = Register(PosixSignal.SIGHUP, 129);
PosixSignalRegistration? Register(PosixSignal signal, int exitCode)
{
try
{
return PosixSignalRegistration.Create(signal, ctx =>
{
ctx.Cancel = true; // honoured for SIGTERM on Unix only; Windows ends the process anyway
Interlocked.CompareExchange(ref signalExit, exitCode, 0);
cts.Cancel();
});
}
catch (PlatformNotSupportedException)
{
return null; // this platform has no such signal
}
}
try
{
foreach (Book book in books)
{
cts.Token.ThrowIfCancellationRequested();
Console.Error.WriteLine($"shelf: syncing {book.Title}");
await Task.Delay(TimeSpan.FromMilliseconds(500), cts.Token); // stands in for a network call
}
return 0;
}
catch (OperationCanceledException)
{
Console.Error.WriteLine("shelf: interrupted, leaving the catalogue unchanged");
return Volatile.Read(ref signalExit);
}
record Book(string Isbn, string Title, string Author, bool OnLoan);
Create throws PlatformNotSupportedException for a signal the platform does not support, which is why the example
wraps it. After a SIGHUP the terminal is usually gone, so writing to standard error can fail; keep cleanup short and quiet in
that case, and never assume the handler can prompt the user.
The Generic Host in a Command-Line Program
The .NET Generic Host (the Microsoft.Extensions.Hosting package) is the object that the Worker Service and ASP.NET Core
templates build on. The documentation describes it as encapsulating an app’s resources and lifetime: dependency
injection, logging, configuration, graceful shutdown and IHostedService implementations, and notes explicitly that
it can be used with other application types "such as Console apps". For a CLI it is attractive when the tool has
several commands that share services (a catalogue repository, an HTTP client, a logger) and a layered configuration
(defaults, a config file, environment variables, flags). It is overkill for a ten-line script, and its defaults are
tuned for servers, so a few of them need changing.
What Host.CreateApplicationBuilder sets up
Both host builders (Host.CreateApplicationBuilder, the recommended style for new projects, and the older
Host.CreateDefaultBuilder) apply the same defaults, which the documentation lists:
-
the content root is the current working directory (
Directory.GetCurrentDirectory()); -
host configuration comes from
DOTNET_-prefixed environment variables and the command line; -
app configuration comes from
appsettings.json,appsettings.{Environment}.json, user secrets (in theDevelopmentenvironment), environment variables and the command line; -
logging providers are Console, Debug, EventSource and, on Windows, EventLog;
-
IHostApplicationLifetime,IHostLifetime(by defaultConsoleLifetime) andIHostEnvironmentare registered.
For a command-line tool four of these defaults are traps:
| Default | Why it hurts a CLI, and the remedy |
|---|---|
Content root = current directory |
|
The command line is configuration |
The host turns |
Console logger writes to standard output |
The Console provider sends messages to stdout unless |
Lifetime messages |
When the host is started, |
Using the host without running it
host.Run() starts every IHostedService and then blocks until shutdown, which is right for a daemon
(see Long-Running Processes & Services) and wrong for a one-shot
command. For a command, use the host as a container: build it, resolve the service you need from host.Services, run
the work, and dispose the host. That way ConsoleLifetime never installs its own signal handlers, and cancellation stays
under the control of System.CommandLine or your own CancellationTokenSource.
When the command needs the host’s own lifecycle (hosted services, IHostApplicationLifetime.StopApplication()),
call await host.StartAsync() and await host.StopAsync() explicitly, and remember that ConsoleLifetime then handles
SIGINT, SIGQUIT and SIGTERM (documented as Ctrl+C, Ctrl+Break on Windows or Ctrl+\ on Unix, and the signal sent
by docker stop). Since .NET 6 it uses the POSIX-signal support and no longer interferes with Environment.Exit.
Example: shelf sync with configuration, logging and DI
using System.CommandLine;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
Option<bool> verbose = new("--verbose", "-v") { Description = "Log debug output to stderr.", Recursive = true };
Command sync = new("sync", "Synchronise the catalogue.");
RootCommand root = new("shelf -- the Bookshelf tool.") { verbose, sync };
sync.SetAction(async (ParseResult parseResult, CancellationToken ct) =>
{
HostApplicationBuilderSettings settings = new()
{
ContentRootPath = AppContext.BaseDirectory, // not the current directory
};
HostApplicationBuilder builder = Host.CreateApplicationBuilder(settings);
builder.Configuration.AddEnvironmentVariables(prefix: "SHELF_");
builder.Logging.ClearProviders();
builder.Logging.AddConsole(o => o.LogToStandardErrorThreshold = LogLevel.Trace); // all logs to stderr
builder.Logging.SetMinimumLevel(parseResult.GetValue(verbose) ? LogLevel.Debug : LogLevel.Information);
builder.Logging.AddFilter("Microsoft.Hosting.Lifetime", LogLevel.Warning);
builder.Services.AddSingleton<ICatalogue, FileCatalogue>();
builder.Services.AddTransient<SyncService>();
using IHost host = builder.Build();
SyncService service = host.Services.GetRequiredService<SyncService>();
return await service.RunAsync(ct);
});
return await root.Parse(args).InvokeAsync();
interface ICatalogue { Task<int> CountAsync(CancellationToken ct); }
sealed class FileCatalogue(IConfiguration configuration) : ICatalogue
{
public Task<int> CountAsync(CancellationToken ct) =>
Task.FromResult(configuration["Catalogue:Path"] is { Length: > 0 } ? 3 : 0);
}
sealed class SyncService(ICatalogue catalogue, ILogger<SyncService> logger)
{
public async Task<int> RunAsync(CancellationToken ct)
{
logger.LogInformation("Starting sync");
int count = await catalogue.CountAsync(ct);
logger.LogDebug("Catalogue has {Count} books", count);
Console.Out.WriteLine($"synced {count} books"); // the result, on stdout
return 0;
}
}
$ SHELF_Catalogue__Path=/srv/shelf.json shelf sync --verbose
info: SyncService[0]
Starting sync
dbug: SyncService[0]
Catalogue has 3 books
synced 3 books
$ SHELF_Catalogue__Path=/srv/shelf.json shelf sync 2>/dev/null
synced 3 books
The double underscore in the environment variable name is how the configuration system maps Catalogue:Path to a
variable on every platform (a colon is not valid in variable names on all shells). The info: and dbug: lines are
the console logger’s default simple format and go to standard error because of the threshold set above.
Logging formats
The console logger ships three formatters (Simple, Systemd and Json), selectable with AddSimpleConsole, AddSystemdConsole
and AddJsonConsole or through the Logging:Console:FormatterName configuration key. For a tool whose logs are consumed by
another program, AddJsonConsole gives one JSON object per line; the documentation warns not to pass already-serialized
JSON as the message, because the logger serializes it again. Colour in the Simple formatter is a property of the
formatter options, and, like any colour, should be off when standard error is not a terminal.
Native AOT and trimming with the host
The reflection-based configuration binder is not AOT-friendly. Starting with .NET 8 a configuration binding source
generator replaces it with generated code; enable it by setting EnableConfigurationBindingGenerator to true in the
project file. Dependency-injection registrations that rely on open generics or reflection can also raise trim and AOT
warnings, so publish with the analyzers on and treat every IL2xxx / IL3xxx warning as a runtime failure in waiting
(see the publishing section below). For a small tool, skipping the host and wiring two or three services by hand is
often the better trade.
System.CommandLine 2.0 Beyond the Basics
The basics (RootCommand, Option<T>, Argument<T>, SetAction, Parse(args).Invoke(), the exit-code contract) are in
Frameworks: Swift & .NET. This section covers the parts that
come up once a tool grows: separating parsing from invocation, custom parsing and validation, response files,
directives, cross-cutting behaviour now that middleware is gone, and tests. The API described is the stable 2.0 line;
the library documentation is explicit that 2.0 differs from the long-running beta releases that older articles and
blog posts describe (see the migration guide listed in the references).
Parsing and invocation are separate
Command.Parse(args) returns a ParseResult that contains the parsed values, Errors and UnmatchedTokens and does not run
anything. ParseResult.Invoke(InvocationConfiguration) (or InvokeAsync(InvocationConfiguration, CancellationToken))
runs the action selected by the parse: the command’s SetAction delegate, or a built-in action such as help, version or
a directive. Each step has its own configuration object:
| Configuration | Properties and meaning |
|---|---|
|
|
|
|
The behaviours worth knowing:
-
Exit codes. An action returns an
int(orTask<int>); an action with no return value yields 0. When parsing fails,Invokeprints the errors to standard error and returns a non-zero exit code (1 by default). A tool that has its own contract, for example 2 for usage errors, remaps the code afterInvokeor handlesParseResult.Errorsbefore it. -
Synchronous and asynchronous actions are not mixed. If any action is asynchronous, make them all asynchronous, use
InvokeAsync, and pass the action’sCancellationTokento every cancellable call. -
Termination.
ProcessTerminationTimeoutlinks Ctrl+C,SIGINTandSIGTERMto the token given to asynchronous actions; if the action has not finished when the timeout expires, the process is terminated. Use it instead of hand-writtenCancelKeyPresscode when the library drives your actions. -
Unmatched tokens.
ParseResult.UnmatchedTokenslists tokens no symbol claimed. For a wrapper command that forwards the rest of the line to another program (likeshelf exec -- git status), setCommand.TreatUnmatchedTokensAsErrors = falseand forward the list.
Custom parsing and validation
The built-in parsers handle the common types: bool, the integer and floating-point types, decimal, DateTime,
DateTimeOffset, DateOnly, TimeOnly, Guid, FileInfo, DirectoryInfo, enums, and arrays and lists of those.
Anything else needs a custom parser, and any value can be validated. The documentation distinguishes the tools:
| Tool | Use it for |
|---|---|
|
A default computed when the option is absent (replaces the beta-era |
|
Parsing a type with no built-in parser, parsing a supported type differently (for example a comma-separated string into an |
|
Checks run after parsing. The delegate receives the symbol’s result and calls |
|
Built-in validators for a fixed set of values, existing files or directories, and legal file names. |
|
Required options (an option is optional unless |
The 2.0 constructors take a mandatory name and optional aliases (new Option<int>("--delay", "-d")); a description is set
through the Description property, not the constructor, which is the one breaking change from the betas that the
compiler does not catch.
Response files
A token that begins with @ names a response file whose contents are spliced into the command line. A response file is
a list of tokens separated by whitespace, can span lines, supports quotes to keep a token together, treats # to the end
of the line as a comment, and may include other response files. They solve two problems: command lines longer than the
terminal or operating system accepts, and re-running the same long invocation. Because the library expects all response files to come from trustworthy agents, and response files can open other files, a tool that
accepts arbitrary command lines from untrusted input (for example one invoked by a web service) should disable them
with ParserConfiguration.ResponseFileTokenReplacer = null.
Directives
A directive is a bracketed token placed after the program name and before any subcommand, such as shelf [suggest] ad. It
cannot contain spaces, may carry an argument after a colon, and an unrecognised directive is ignored rather than
causing an error. They give users cross-cutting features that do not belong to any one command. [suggest] is enabled
by default on RootCommand. DiagramDirective ([diagram], which prints how the input was parsed and marks parse errors
with ! and unmatched input with ???-->) and EnvironmentVariablesDirective (which lets users supply environment variables for
the invocation) are public types you add with root.Directives.Add. You can write your own by deriving from
Directive or constructing one with new Directive("name") and setting its action. Directives are an intentional
debugging surface: ship [diagram] only if you are comfortable with users seeing the parse tree.
No middleware: what replaced it
Versions before 2.0 let you add middleware to the invocation pipeline (AddMiddleware, UseExceptionHandler,
UseTypoCorrections, UseParseErrorReporting, UseHelpBuilder, …). The migration guide says middleware was removed because it slowed application startup and the
features can be expressed without it; the library is documented as trim-friendly and suited to AOT-compiled tools.
The replacements are concrete: exception handling is the default and is controlled by EnableDefaultExceptionHandler;
CancelOnProcessTermination became ProcessTerminationTimeout; parse-error reporting is the default and is tuned
through the ParseErrorAction found in ParseResult.Action (ShowTypoCorrections, ShowHelp); help and version are the
public HelpOption and VersionOption types on RootCommand; localisation moved into the library; and for anything
else the answer is plain code around Parse and Invoke, as in the example below.
Example: shelf with custom parsing, validation and a wrapper around Invoke
using System.CommandLine;
using System.CommandLine.Parsing;
// --isbn: custom parser that also validates the ISBN-13 check digit.
Option<Isbn?> isbn = new("--isbn")
{
Description = "ISBN-13, with or without hyphens.",
Required = true,
CustomParser = result =>
{
if (result.Tokens.Count != 1)
{
result.AddError("--isbn takes exactly one value.");
return null;
}
string text = result.Tokens[0].Value;
if (Isbn.TryParse(text, out Isbn? value))
{
return value;
}
result.AddError($"'{text}' is not a valid ISBN-13.");
return null;
},
};
Option<bool> verbose = new("--verbose", "-v") { Description = "Log progress to stderr.", Recursive = true };
Option<string> status = new("--status")
{
Description = "Which books to show.",
DefaultValueFactory = _ => "all",
};
status.AcceptOnlyFromAmong("all", "shelf", "loan");
Option<FileInfo> source = new("--from") { Description = "Text file with one title per line.", Required = true };
source.AcceptExistingOnly();
Option<DateOnly> until = new("--until") { Description = "Due date." };
Option<int> days = new("--days") { Description = "Loan length in days." };
days.Validators.Add(result =>
{
if (result.GetValue(days) < 1)
{
result.AddError("--days must be at least 1.");
}
});
Argument<string> title = new("title") { Description = "Book title." };
Command list = new("list", "List the books.") { status };
list.SetAction(parseResult =>
{
parseResult.InvocationConfiguration.Output.WriteLine($"listing: {parseResult.GetValue(status)}");
return 0;
});
Command add = new("add", "Add a book.") { title, isbn };
add.SetAction(parseResult =>
{
parseResult.InvocationConfiguration.Output.WriteLine(
$"added {parseResult.GetValue(title)} ({parseResult.GetValue(isbn)!.Value})");
return 0;
});
Command import = new("import", "Add many books from a file.") { source };
import.SetAction(parseResult =>
{
parseResult.InvocationConfiguration.Output.WriteLine($"importing {parseResult.GetValue(source)!.Name}");
return 0;
});
Command loan = new("loan", "Lend a book.") { isbn, until, days };
loan.Validators.Add(result =>
{
bool hasUntil = result.GetResult(until) is not null;
bool hasDays = result.GetResult(days) is not null;
if (hasUntil == hasDays)
{
result.AddError("Give exactly one of --until and --days.");
}
});
loan.SetAction(parseResult =>
{
parseResult.InvocationConfiguration.Output.WriteLine($"loaned {parseResult.GetValue(isbn)!.Value}");
return 0;
});
RootCommand root = new("shelf -- the Bookshelf tool.") { verbose, list, add, import, loan };
root.Directives.Add(new DiagramDirective());
root.Directives.Add(new EnvironmentVariablesDirective());
// Cross-cutting behaviour lives around Parse/Invoke, not in middleware.
InvocationConfiguration invocation = new()
{
EnableDefaultExceptionHandler = false,
ProcessTerminationTimeout = TimeSpan.FromSeconds(5),
};
ParseResult parsed = root.Parse(args);
try
{
int code = parsed.Invoke(invocation);
// Invoke has already printed the parse errors (and help) to stderr; map them to the usage code 2.
return code != 0 && parsed.Errors.Count > 0 ? 2 : code;
}
catch (IOException ex)
{
Console.Error.WriteLine($"shelf: {ex.Message}");
return 4;
}
sealed record Isbn(string Value)
{
public static bool TryParse(string text, out Isbn? isbn)
{
isbn = null;
string digits = text.Replace("-", "");
if (digits.Length != 13 || !digits.All(char.IsAsciiDigit))
{
return false;
}
int sum = 0;
for (int i = 0; i < 13; i++)
{
sum += (digits[i] - '0') * (i % 2 == 0 ? 1 : 3);
}
if (sum % 10 != 0)
{
return false;
}
isbn = new Isbn(digits);
return true;
}
}
$ shelf add "The Hobbit" --isbn 978-0-261-10235-4
added The Hobbit (9780261102354)
$ shelf add "The Hobbit" --isbn 978-0-261-10235-5
'978-0-261-10235-5' is not a valid ISBN-13.
$ shelf loan --isbn 978-0-261-10235-4 --days 14 --until 2026-12-01
Give exactly one of --until and --days.
$ shelf list --status misplaced
Argument 'misplaced' not recognized. Must be one of:
'all'
'shelf'
'loan'
$ shelf [diagram] loan --isbn 978-0-261-10235-4 --days 14 # prints the parse tree instead of running loan
$ cat borrow.rsp
loan
--isbn 978-0-261-10235-4
--days 14
$ shelf @borrow.rsp
loaned 9780261102354
The exact wording of the library’s error messages (such as the misplaced message above) comes from the library and its
localisation, so it may differ between releases; treat the text shown as illustrative.
Testing commands without a terminal
Because InvocationConfiguration exposes Output and Error, a test can run the whole command in process and assert
on the text and the exit code. The actions must write through parseResult.InvocationConfiguration.Output (as the
example does) rather than Console.Out, or the capture sees nothing.
using System.CommandLine;
StringWriter stdout = new();
StringWriter stderr = new();
int code = root.Parse("add \"The Hobbit\" --isbn 978-0-261-10235-4")
.Invoke(new InvocationConfiguration { Output = stdout, Error = stderr });
Debug.Assert(code == 0);
Debug.Assert(stdout.ToString().Contains("9780261102354"));
Debug.Assert(stderr.ToString().Length == 0);
Process-level tests (a real pseudo-terminal, exit codes from the built binary) are in Testing Terminal Apps & TUIs.
Help and completion
Help is generated from Name, Description, HelpName and Hidden, and RootCommand answers -h, --help, -?,
/h and /?; --version is provided too. Options restricted with AcceptOnlyFromAmong or typed as enums list their
allowed values in help automatically. Hiding a symbol removes it from help and completions but not from parsing.
Shell Completion
Two different mechanisms exist, and they should not be confused.
The dotnet CLI itself. The .NET 10 SDK can generate native completion scripts with dotnet completions script [SHELL]
for bash, fish, nushell, PowerShell and zsh. For example, in PowerShell one line in $PROFILE enables them:
dotnet completions script pwsh | Out-String | Invoke-Expression
# bash: add to ~/.bashrc
eval "$(dotnet completions script bash)"
This completes the dotnet command, not your own tool.
Your own System.CommandLine tool. The library provides completions for static values (enums, AcceptOnlyFromAmong) and
for dynamic ones through CompletionSources. To make them available in a shell, the end user installs the
dotnet-suggest global tool, adds a shim script for their shell (bash, zsh or PowerShell; the shim files live in the
dotnet/command-line-api repository) to the profile, and registers each tool with
dotnet-suggest register --command-path <path-to-executable>. The library no longer registers itself on startup
(RegisterWithDotnetSuggest was removed because it did expensive work at launch), so the registration step is manual.
The documentation states that cmd.exe has no pluggable tab-completion mechanism, so no shim exists for it, and that
other shells are tracked as GitHub issues.
shelf loan --isbn (not compiled)using System.CommandLine.Completions;
isbn.CompletionSources.Add(context =>
{
// Offer ISBNs of books that are on the shelf; keep this fast, it runs on every Tab press.
return [new CompletionItem("978-0-261-10235-4"), new CompletionItem("978-0-7432-7356-5")];
});
Source: Microsoft — Tab completion for System.CommandLine; Microsoft — What’s new in the .NET 10 SDK
Because the dotnet-suggest route needs per-user setup and a .NET process start for every Tab press, many tools also
ship static completion scripts generated at build time; the shells' own mechanisms and how to install scripts are in
Shells, Completions & Shell Integration. The SDK also
accepts --cli-schema on every command, which prints a JSON description of the command tree, handy for generating
completions or documentation for the dotnet CLI itself.
Running Other Programs with System.Diagnostics.Process
A CLI that wraps other tools (git, a compiler, a converter) uses Process and ProcessStartInfo. Most of the
portability problems come from how arguments are passed and how streams are read.
| API | Notes |
|---|---|
|
A collection of arguments that need no prior escaping; .NET builds the platform’s command line from it. Prefer it over the single |
|
|
|
Redirect a stream so you can read or write it. Streams not redirected are inherited from the parent when |
|
Read both output streams concurrently (start both reads before awaiting either), then wait for exit. Reading one to the end while the other fills its pipe buffer can deadlock the child. |
|
Kills the process and its descendants. |
|
Process-group and window controls; relevant mostly on Windows (see the job-object discussion in the Windows page). |
Platform notes: on Windows UseShellExecute = false calls CreateProcess, so shell built-ins such as dir are not programs and
need cmd.exe /c, and a batch file is run through cmd.exe too, where its escaping rules apply on top of .NET’s. On Unix, the program is found through PATH, and a script without an executable bit or a shebang fails with a
permission error. When the user presses Ctrl+C in a terminal, the signal goes to the whole foreground process group, so
a child you start without changing its group receives SIGINT as well; the POSIX details are in
POSIX Processes, Signals & I/O.
using System.Diagnostics;
static async Task<(int ExitCode, string Output, string Error)> RunAsync(
string fileName, IEnumerable<string> arguments, CancellationToken ct)
{
ProcessStartInfo info = new(fileName)
{
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
};
foreach (string argument in arguments)
{
info.ArgumentList.Add(argument); // no manual quoting
}
using Process process = Process.Start(info)
?? throw new InvalidOperationException($"Could not start {fileName}.");
Task<string> output = process.StandardOutput.ReadToEndAsync(ct);
Task<string> error = process.StandardError.ReadToEndAsync(ct);
try
{
await process.WaitForExitAsync(ct);
}
catch (OperationCanceledException)
{
process.Kill(entireProcessTree: true);
throw;
}
return (process.ExitCode, await output, await error);
}
(int code, string stdout, string stderr) = await RunAsync(
"git", ["-C", "/srv/shelf", "status", "--porcelain"], CancellationToken.None);
Console.Out.Write(stdout);
return code;
Security notes: never build a command line by concatenating user input into Arguments (use ArgumentList); do not
set UseShellExecute = true for input you did not write; resolve the executable by an explicit path when the tool runs
with elevated rights, since the search path can be controlled by the caller.
File-Based Apps
The .NET 10 SDK can build, run and publish a single .cs file with no project file. The documentation presents it as
a lightweight alternative for scripts, utilities and small apps; the SDK generates a virtual project from #: directives
at the top of the file.
| Directive | Meaning |
|---|---|
|
A NuGet package reference. Without a version it works only with central package management ( |
|
An MSBuild property, for example |
|
The SDK to use (default |
|
A reference to another project. |
|
Include another file. Available in .NET 11 preview / SDK 10.0.300 and later per the documentation; not used here. |
The commands: dotnet run file.cs (also dotnet file.cs), arguments after --, dotnet build file.cs, dotnet clean file.cs,
dotnet publish file.cs, dotnet pack file.cs (file-based apps default to PackAsTool=true) and dotnet project convert file.cs
(creates a normal project from the file). Code can be piped to dotnet run - on standard input.
Native AOT is the default for dotnet publish file.cs, so a file-based app gets a self-contained native executable in an
artifacts directory next to the source unless you add #:property PublishAot=false. That also means a publish needs the
Native AOT prerequisites described below.
Running a script directly with a shebang
On Linux and macOS a file-based app can be an executable script. The documented shebang uses env -S so that -- can
be passed to dotnet, which stops it from swallowing arguments such as --help:
#!/usr/bin/env -S dotnet --
#:package System.CommandLine@2.0.13
using System.CommandLine;
Option<int> within = new("--within-days") { Description = "Only loans due within this many days.", DefaultValueFactory = _ => 7 };
RootCommand root = new("List overdue Bookshelf loans.") { within };
root.SetAction(parseResult =>
{
int limit = parseResult.GetValue(within);
parseResult.InvocationConfiguration.Output.WriteLine($"no loans are overdue by more than {limit} days");
return 0;
});
return root.Parse(args).Invoke();
$ chmod +x shelf-overdue.cs
$ ./shelf-overdue.cs --within-days 3
no loans are overdue by more than 3 days
$ dotnet run shelf-overdue.cs -- --within-days 3 # works on Windows too
Source: Microsoft — File-based apps
Platform and pitfall notes drawn from the documentation:
-
The shebang file must use LF line endings and no byte order mark. If
env -Sis not supported on your system, use#!/usr/bin/env dotnet, which may letdotnetconsume arguments that match its own parameters. -
An extensionless file with a shebang (for example copied to a directory on
PATHasshelf-overdue) can be run by name; the documentation shows this for~/utils/hello. On Windows the shebang is a Unix concept: invokedotnet file.cs. -
If a
.csprojexists in the current directory,dotnet run file.csruns that project and passesfile.csas an argument to it, to preserve backward compatibility; usedotnet run --file file.csto be explicit. -
Implicit build files apply.
Directory.Build.props,Directory.Build.targets,Directory.Packages.props,nuget.configandglobal.jsonin the file’s directory or any parent directory affect the build, and the targets inDirectory.Build.targetsexecute. Runningdotnet shelf.csin a directory tree you did not write therefore runs build logic from that tree, and#:packagedownloads and runs NuGet content on first use. Keep scripts in an isolated directory, and do not run untrusted.csfiles. -
Build output is cached under the system temp directory; concurrent runs of the same file can fight over output files, so build once and run with
--no-build.dotnet clean file-based-appsclears the cache. -
A user-secrets ID is derived from the file’s full path;
dotnet user-secrets listprints the secrets, so keep it out of logs and CI output.
Publishing Console Applications
dotnet publish produces the files you ship. A console application has more options than most project types because
the audience can be developers with the SDK installed or end users with nothing. The SDK’s general documentation is
linked at the top of this page (and the Windows-desktop view of the same options is
.NET Publishing Options); this section focuses on the choices for a CLI.
The deployment models
| Model | How to ask for it | What you ship | Trade-offs |
|---|---|---|---|
Framework-dependent (the default) |
|
The app, its dependencies and a platform-specific launcher ("apphost"), or a portable |
Smallest output. Needs a compatible .NET runtime on the target and rolls forward to the newest installed patch. |
Self-contained |
|
The app plus the .NET runtime and libraries, but not the OS-level native dependencies of .NET. |
No runtime needed; larger; you own runtime servicing. Trimming and globalisation invariant mode reduce size. |
Single-file |
|
One file; managed assemblies and configuration bundled. Works framework-dependent or self-contained. |
OS and architecture specific. Native runtime libraries stay separate unless |
ReadyToRun |
|
Ahead-of-time compiled code alongside IL, to improve startup. |
Larger output. Works framework-dependent or self-contained, and combines with single-file and trimming. |
Trimmed |
|
Self-contained app with unused framework and library code removed. |
Self-contained only. Reflection-heavy code can fail at run time; fix the trim warnings. |
Native AOT |
|
One native executable with a stripped-down runtime and no JIT. |
Fast startup, small footprint, no runtime needed; several .NET features unavailable; must be built on the target OS. |
.NET tool |
|
A NuGet package installed with |
Runs on the user’s runtime (unless platform-specific); a distribution channel rather than a binary format. |
Choosing: publish options decision flow
The flow below condenses the trade-offs for the shelf tool. It is a starting point, not a rule: a tool can ship through
more than one channel (a .NET tool for developers and a native binary for everyone else).
Runtime identifiers
Every self-contained, single-file, ReadyToRun or AOT publish targets a runtime identifier (RID) such as win-x64,
win-arm64, linux-x64, linux-arm64, linux-musl-x64 (Alpine and other musl distributions) or osx-arm64.
dotnet publish -r <RID> selects one; the catalogue and how RIDs relate to target frameworks are in
Target Frameworks and Runtime Identifiers.
Single-file and self-contained output is always specific to an OS and architecture: you publish once per RID.
Native AOT in detail
Native AOT compiles IL to native code at publish time. The result is self-contained, has no JIT at run time, starts quickly and uses less memory; the documentation says the benefit is most significant for workloads with many deployed instances, and a short-lived CLI invoked many times is exactly that case. Everything it implies for a console app:
-
Enable it in the project file (
<PublishAot>true</PublishAot>), not only on the command line, because the property also turns on the AOT, trim and single-file analyzers during build and editing. -
Prerequisites on the build machine. Windows: Visual Studio 2022 or later with the "Desktop development with C++" workload. macOS: the Xcode Command Line Tools. Linux: clang and zlib development files (for example
sudo apt-get install clang zlib1g-devon Ubuntu,sudo apk add clang build-base zlib-devon Alpine,sudo dnf install clang zlib-develplus the zlib-ng packages on Fedora and RHEL). -
Supported targets (.NET 9 and later). Windows x64, Arm64 and x86; Linux x64, Arm64 and Arm; macOS x64 and Arm64 (iOS, tvOS, Mac Catalyst and Android have their own, partly experimental, support).
-
Limitations the documentation lists: no dynamic loading (
Assembly.LoadFile), no runtime code generation (System.Reflection.Emit), no C++/CLI, on Windows no built-in COM, trimming is required and has its own limits,System.Linq.Expressionsalways uses its slower interpreted form, every struct generic instantiation is pre-generated (which can grow the binary), not all runtime libraries are fully annotated for AOT, and diagnostics and profiling are limited. Publish reports a warning for each limitation it can find; treat each as a bug. -
Cross-compilation. The documentation is explicit: Native AOT does not support cross-OS compilation (no standard way to get the macOS, Windows or Linux SDKs on another OS) and has only limited cross-architecture support (x64 and Arm64 on Windows, macOS and Linux) when the native toolchain for the target is installed. On Linux that means a cross-linker, the C-runtime and zlib objects for the target, and a target-compatible
objcopyorstrip. A Linux binary built on one distribution works on the same or a newer version of that distribution family (a binary built on Ubuntu 20.04 runs on 20.04 and later, not on 18.04), and glibc and musl are different targets. In practice: build each OS on its own runner or in a container. -
Debug information is placed in a separate file:
.dbgon Linux,.pdbon Windows, a.dSYMfolder on macOS. On Unix setStripSymbolstofalseto keep it inside the binary (larger file). -
Size versus speed.
<OptimizationPreference>Size</OptimizationPreference>favours a smaller executable;Speedfavours throughput; the default is a blend. More size reductions come from trimming options such as disabling globalisation.
Single-file and trimming details
-
Single-file extraction. Managed DLLs are bundled and loaded from memory; the native runtime libraries are separate files unless
IncludeNativeLibrariesForSelfExtractistrue, in which case they are extracted before the app starts. The extraction directory is taken fromDOTNET_BUNDLE_EXTRACT_BASE_DIRif set, otherwise$HOME/.neton Linux and macOS and%TEMP%/.neton Windows. The documentation’s security advice: the directory should not be writable by users or services with different privileges, and/tmpor/var/tmpshould not be used. Under systemd$HOMEmay be undefined, so set the variable in the unit (setDOTNET_BUNDLE_EXTRACT_BASE_DIRto%h/.netwith anEnvironment=line in the[Service]section; systemd expands%hto the home directory). -
Incompatible APIs. Code that locates files next to the assembly by
Assembly.Locationbreaks in a single file; useAppContext.BaseDirectoryandEnvironment.ProcessPathinstead. -
Trimming is only supported for self-contained apps, turns off trim-incompatible features, and shows warnings during build when
PublishTrimmedis set in the project file. Unbounded reflection (legacy serializers, COM) is the usual source of failures. -
Globalisation invariant mode (
InvariantGlobalization) removes ICU dependence and size at the cost of culture-aware behaviour; the effect on sorting and formatting needs a test before you turn it on for a tool that prints dates or sorts titles.
Example: a Native AOT shelf project
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<AssemblyName>shelf</AssemblyName>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<PublishAot>true</PublishAot>
<OptimizationPreference>Size</OptimizationPreference>
<InvariantGlobalization>true</InvariantGlobalization>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="System.CommandLine" Version="2.0.13" />
</ItemGroup>
</Project>
# On a Linux x64 machine with clang and zlib1g-dev installed
$ dotnet publish -c Release -r linux-x64
$ ls bin/Release/net10.0/linux-x64/publish/
shelf shelf.dbg
$ ./bin/Release/net10.0/linux-x64/publish/shelf --version
# On Windows, from a Developer PowerShell with the C++ workload installed
dotnet publish -c Release -r win-x64
Get-ChildItem .\bin\Release\net10.0\win-x64\publish\
Building for macOS (-r osx-arm64) needs a Mac, and building for Windows needs Windows or a Windows virtual machine; a
repeatable matrix over operating-system runners is described in
Native Binaries & Cross-Compilation.
Example: a framework-dependent single-file and a self-contained single-file build
# Framework-dependent single file: small, needs the .NET 10 runtime on the target
dotnet publish -c Release -r linux-x64 --self-contained false -p:PublishSingleFile=true
# Self-contained single file, trimmed, ready-to-run: no runtime needed, any RID from one machine
dotnet publish -c Release -r win-x64 --self-contained true \
-p:PublishSingleFile=true -p:PublishTrimmed=true -p:PublishReadyToRun=true
Setting the properties in the project file is preferred, because it also enables the analyzers during build; the
command-line form is for experiments. Do not build framework-dependent and then publish with --no-build as self-contained
(or the reverse): the documentation says to use the same options for both steps.
.NET tools versus native binaries
A .NET tool is a NuGet package containing a console application. It installs as a global tool
(dotnet tool install -g shelf, into $HOME/.dotnet/tools or %USERPROFILE%\.dotnet\tools, added to the user’s PATH
and visible only to that user), a tool-path tool (--tool-path, not added to PATH), or a local tool recorded in
.config/dotnet-tools.json so every contributor to a repository gets the same version (dotnet new tool-manifest,
dotnet tool install, then dotnet tool restore after cloning). .NET 10 added three things relevant to CLI authors:
-
Platform-specific tools. A single package can carry binaries for several RIDs; the CLI picks the matching one. The documented variations are framework-dependent platform-agnostic, framework-dependent platform-specific, self-contained platform-specific, trimmed platform-specific and AOT-compiled platform-specific. Adding the RID
anytoRuntimeIdentifierskeeps a portable fallback for platforms you did not list. -
One-shot execution.
dotnet tool exec <package> [args]and its short formdnx <package> [args]download and run a tool without installing it, prompting for confirmation unless the tool is already available; a local manifest pins the version. This suits CI jobs. -
--cli-schemaon all SDK commands, as noted above.
<PropertyGroup>
<PackAsTool>true</PackAsTool>
<ToolCommandName>shelf</ToolCommandName>
<PackageId>Bookshelf.Shelf</PackageId>
<RuntimeIdentifiers>linux-x64;linux-arm64;osx-arm64;win-x64;win-arm64;any</RuntimeIdentifiers>
</PropertyGroup>
The documentation’s mechanics for packing and publishing a platform-specific AOT tool are described only briefly in the SDK release notes; check the current "create a .NET tool" guide before building a release pipeline on this.
Security: .NET tools run in full trust, so install only tools whose author you trust; a global tool is on the user’s PATH
and could shadow other commands. The documentation warns that a tool manifest must be stored in a controlled location,
because dotnet tool run executes whatever the manifest lists, and that tools may not work when .NET is installed via Snap.
| .NET tool | Native binary (AOT or self-contained) | |
|---|---|---|
Audience |
Developers and CI that already have .NET |
Anyone, including end users on Windows |
Install |
|
Archive, package manager or installer (see Native Packages & Installers) |
Updates |
|
Replace the file; you own runtime security fixes if self-contained |
Trust |
NuGet package, full trust |
Code signing and notarization (see Signing, Notarization & OS Trust) |
Startup |
JIT unless ReadyToRun / AOT |
Fast with Native AOT |
Console apps can also be packaged as container images with dotnet publish /t:PublishContainer; since .NET 10 this
works for console projects without extra project settings, and ContainerImageFormat selects Docker or OCI. See
Container Images for the details.
Platform Differences at a Glance
| Concern | Windows | macOS | Linux |
|---|---|---|---|
Ctrl+C |
|
|
Same as macOS |
|
Handler can run but the process cannot be kept alive by cancelling |
Cancellable |
Cancellable (containers send it on stop) |
Default output encoding |
System-locale code page; set |
Not stated in the reference pages |
Not stated in the reference pages |
Window size setters |
Supported |
|
|
Shell completion for your tool |
|
|
|
Shebang scripts |
Not applicable; run |
|
Same as macOS |
Native AOT build host |
Windows with Visual Studio C++ workload |
macOS with Xcode Command Line Tools |
Linux with clang and zlib |
Global tools directory |
|
|
|
Single-file extraction base |
|
|
|
Pitfalls and Security Notes
-
Colour and prompts without a terminal check. Always branch on
Console.IsOutputRedirectedandConsole.IsInputRedirected. -
Logging on stdout. The console logger writes to standard output by default; set
LogToStandardErrorThreshold. -
Host defaults tuned for servers. Content root, command-line configuration and lifetime messages need adjusting for a CLI.
-
Double cancellation handlers. Pick one of
CancelKeyPress,PosixSignalRegistration,ProcessTerminationTimeoutorConsoleLifetimeper signal; since .NET 10 their order is reverse registration. -
Reflection under AOT. Treat trim and AOT warnings as errors in CI for any tool you plan to publish with
PublishAotorPublishTrimmed. -
Cross-OS Native AOT. It does not exist; plan one build runner per OS.
-
Untrusted inputs. Response files,
.csscripts, tool manifests, single-file extraction directories and NuGet tools are all code-execution paths; the notes above say which to restrict. -
Secrets. Do not print user secrets or tokens through
dotnet user-secrets listor verbose log levels; see Security for the general rules.
Gaps in the Official Documentation
-
The reference pages for
Console,PosixSignalRegistrationandPosixSignaldo not map each signal to a Windows console event beyond Ctrl+C / Ctrl+Break and theSIGTERMcancellation rule, and do not state the default console encoding on Linux and macOS. The tables above say "not stated" rather than guessing. -
There is no official guidance for screen readers or accessibility of terminal output in .NET; see Accessibility & Internationalization for the general position.
-
The documentation for Native AOT states that the benefits are most significant at scale and lists limitations, but gives no startup-time or size numbers for command-line tools; measure your own binary rather than quoting figures.
-
The
dotnet-suggestregistration flow is documented as manual, with shim scripts living in the GitHub repository; there is no supported equivalent forcmd.exe.
Related Pages
-
Frameworks: Swift & .NET — first steps with System.CommandLine, Spectre.Console and .NET tools
-
Native Binaries & Cross-Compilation and Signing, Notarization & OS Trust
-
Long-Running Processes & Services — the Worker Service and Windows service side of the Generic Host