C# & .NET (C# 14, .NET 10 LTS) Module 20 – Command-line apps, hosting, DI, configuration and telemetry
Command-line apps with System.CommandLine and tools
Build a C# command-line app with System.CommandLine 2.0: commands, options, help, exit codes and Ctrl+C, then ship it as a .NET tool and run it with dnx.
What you will learn
- Build a command-line app with subcommands, options, arguments and generated help using System.CommandLine 2.0
- Report failures with exit codes that a script can act on, and every error message on standard error
- Cancel long work cleanly when the user presses Ctrl+C or the system sends SIGTERM
- Package the app as a .NET tool, install it as a local tool and run a tool once with dnx
- Explain how tab completion reaches the app, and which values complete without extra code
Before you start
What you’ll learn · Before you start
What you will learn
- Build a command-line app with subcommands, options, arguments and generated help using System.CommandLine 2.0
- Report failures with exit codes that a script can act on, and every error message on standard error
- Cancel long work cleanly when the user presses Ctrl+C or the system sends SIGTERM
- Package the app as a .NET tool, install it as a local tool and run a tool once with dnx
- Explain how tab completion reaches the app, and which values complete without extra code
Before you start
On this page
A validator that reads its option’s value turns --amount abc into a crash: a stack trace of 12 frames and exit
status 134. The finished gst answers the same mistake with one sentence on standard error and exit status 1, which a
script can act on.
You will build gst, a GST calculator for the terminal, with System.CommandLine 2.0.12 on .NET 10: subcommands,
options and arguments with help written for you, exit codes and error output that scripts can rely on, and a clean
stop on Ctrl+C. Then you will pack it as a .NET tool, run it once with dnx, install it as a local tool and see how
tab completion reaches it.
A command line is a tree of commands, options and arguments
gst calc --amount 1180 --rate 18 --inclusive holds three kinds of word. calc is a
subcommand: A word on a command line that chooses an action of the app, such as calc in gst calc. Each subcommand has its own options and arguments, and can have subcommands of its own.: it chooses which action runs. --amount 1180 and --rate 18 are
option (command line): A named value on a command line, such as --amount 1180: a name that starts with - or --, usually followed by its value. Options can come in any order; a bool option is true when it is present., each a name and its value, in any order, and --inclusive is a bool
option that is true when it is present. gst total 1180 590 236 takes
argument (command line): A value on a command line that is known by its place rather than by a name, such as the amounts in gst total 1180 590. When a command takes several arguments, their order matters.: values known by their place, not by a name. System.CommandLine builds
this tree from objects, a RootCommand, Command, Option<T> and Argument<T>, with an action for each command:
#!/usr/bin/env dotnet
#:package System.CommandLine@2.0.12
#:property PublishAot=false
// gst: GST arithmetic for bills, built with System.CommandLine 2.0. calc splits one amount into its base and its
// GST; total adds up amounts that already include GST. The rates in the examples are examples, not tax advice.
using System.CommandLine;
using System.Globalization;
CultureInfo india = CultureInfo.GetCultureInfo("en-IN");
Option<decimal> amount = new("--amount", "-a")
{
Description = "The amount in rupees",
Required = true,
// Parse and check in one place: text that is not a number gets the same clear error as a number below 1 paisa.
CustomParser = result =>
{
string text = result.Tokens.Single().Value;
if (decimal.TryParse(text, NumberStyles.Number, CultureInfo.InvariantCulture, out decimal value) && value > 0)
return value;
result.AddError($"--amount must be a number above 0, such as 1180 or 99.50, not '{text}'.");
return 0;
},
};
Option<decimal> rate = new("--rate", "-r") { Description = "The GST rate in per cent", DefaultValueFactory = _ => 18m };
Option<bool> inclusive = new("--inclusive") { Description = "The amount already includes GST" };
Option<Format> format = new("--format") { Description = "How to print the result", DefaultValueFactory = _ => Format.Text };
Argument<decimal[]> amounts = new("amounts") { Description = "Amounts in rupees, each including GST", Arity = ArgumentArity.OneOrMore };
Command calc = new("calc", "Split one amount into its base and its GST") { Options = { amount, rate, inclusive, format } };
calc.SetAction(result =>
{
decimal a = result.GetValue(amount), r = result.GetValue(rate);
bool included = result.GetValue(inclusive);
decimal tax = included ? a * r / (100 + r) : a * r / 100;
Print(result.GetValue(format), included ? a - tax : a, tax);
return 0;
});
Command total = new("total", "Add up amounts that include GST") { Arguments = { amounts }, Options = { rate, format } };
total.SetAction(result =>
{
decimal r = result.GetValue(rate), gross = result.GetValue(amounts)!.Sum();
decimal tax = gross * r / (100 + r);
Print(result.GetValue(format), gross - tax, tax);
return 0;
});
RootCommand root = new("GST arithmetic for bills") { Subcommands = { calc, total }, Directives = { new DiagramDirective() } };
return root.Parse(args).Invoke();
void Print(Format how, decimal net, decimal tax)
{
decimal n = Math.Round(net, 2, MidpointRounding.AwayFromZero), t = Math.Round(tax, 2, MidpointRounding.AwayFromZero);
if (how == Format.Json)
Console.WriteLine(string.Create(CultureInfo.InvariantCulture, $$"""{"base": {{n:0.00}}, "gst": {{t:0.00}}, "total": {{n + t:0.00}}}"""));
else
Console.WriteLine($"base ₹{n.ToString("N2", india)} gst ₹{t.ToString("N2", india)} total ₹{(n + t).ToString("N2", india)}");
}
enum Format { Text, Json } The #:package line restores the library, as any package of a file-based app is restored. Everything after -- in
dotnet run gst.cs -- goes to the app:
from session import run
# network: true. gst.cs names System.CommandLine 2.0.12 in its #:package line, and dotnet run restores it from
# nuget.org into the session's own, empty home folder before it builds. Everything after -- goes to the app.
run("""
cd gst
dotnet run gst.cs -- calc --amount 1180 --rate 18 --inclusive
dotnet run gst.cs -- calc -a 1000
dotnet run gst.cs -- total 1180 590 236 --format json
dotnet run gst.cs -- "[diagram]" calc --amount 1180 --inclusive
dotnet run gst.cs -- "[diagram]" total 1180 590 --format json
dotnet run gst.cs -- --help
dotnet run gst.cs -- calc --help
dotnet run gst.cs -- --version
""", network=True) Output
$ cd gst
$ dotnet run gst.cs -- calc --amount 1180 --rate 18 --inclusive
base ₹1,000.00 gst ₹180.00 total ₹1,180.00
$ dotnet run gst.cs -- calc -a 1000
base ₹1,000.00 gst ₹180.00 total ₹1,180.00
$ dotnet run gst.cs -- total 1180 590 236 --format json
{"base": 1700.00, "gst": 306.00, "total": 2006.00}
$ dotnet run gst.cs -- "[diagram]" calc --amount 1180 --inclusive
[ gst [ calc [ --amount <1180> ] [ --inclusive ] *[ --rate <18> ] *[ --format <Text> ] ] ]
$ dotnet run gst.cs -- "[diagram]" total 1180 590 --format json
[ gst [ total <1180> <590> [ --format <Json> ] *[ --rate <18> ] ] ]
$ dotnet run gst.cs -- --help
Description:
GST arithmetic for bills
Usage:
gst [command] [options]
Options:
-?, -h, --help Show help and usage information
--version Show version information
Commands:
calc Split one amount into its base and its GST
total <amounts> Add up amounts that include GST
$ dotnet run gst.cs -- calc --help
Description:
Split one amount into its base and its GST
Usage:
gst calc [options]
Options:
-a, --amount <amount> (REQUIRED) The amount in rupees
-r, --rate <rate> The GST rate in per cent [default: 18]
--inclusive The amount already includes GST
--format <Json|Text> How to print the result [default: Text]
-?, -h, --help Show help and usage information
$ dotnet run gst.cs -- --version
1.0.0
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 gst_session.py
An amount of 1,180 that includes 18 % GST holds 1,000 of base and 180 of tax, because 1,180 × 18 ÷ 118 = 180; without
--inclusive the tax of 180 comes on top of 1,000. total added 1,180, 590 and 236 into 2,006 and split out 306 of
GST.
Rupees and GST
gst prints amounts as rupees with the en-IN culture, as in ₹1,000.00, and 18 % is only the default of --rate:
pass the rate that applies to what is sold.
The A token in square brackets, typed after the app's name, that System.CommandLine handles itself instead of running a command: [diagram] shows the parse result, [suggest] lists completions.
[diagram], which gst.cs adds to its root command, printed how the parser read each line: one pair of brackets for
each command and option, the value in angle brackets, and a star where a default filled in, as in *[ --rate <18> ].
Nobody wrote the help or --version.
RootCommand adds a help option, a version option and the [suggest] directive by itself, and the help prints
(REQUIRED) and [default: 18] from the options’ own Required and DefaultValueFactory. Step through the path of
one line, then the same line with a mistake in it:
The drawing shows the whole scene; its 8 steps are listed below.
Every step as text
- The shell splits the line. The shell starts gst and hands it four tokens, calc, --amount, 1180 and --inclusive. Quotes and spaces are the shell's work; the app only ever sees the words.
- calc is a subcommand. calc matches a subcommand of the root command, so from here on only the options and arguments of calc count. A token that matched nothing would be reported, with a suggestion, as --rat was.
- Options take their values. --amount takes one value, so 1180 becomes its value. --inclusive is a bool option, and naming it is enough to make it true. --rate and --format were not typed, so their defaults, 18 and Text, fill in.
- Text becomes typed values. Each value is converted to its option's type. The custom parser of --amount turns the text 1180 into the decimal 1180 and refuses anything that is not a number above 0.
- The checks pass. The parser checks what must be there. --amount is required and present, so the parse result holds no errors, which the [diagram] directive drew as brackets without a ! mark.
- The action runs. Invoke calls the action of calc. It reads each value with GetValue, works out the base and the GST, and prints the base, the GST and the total, 1,000.00, 180.00 and 1,180.00.
- The exit code goes back. The action returns 0, Invoke returns it, and the top-level statements return it from the program. The shell sees exit status 0.
- The same line with abc. With --amount abc the custom parser adds an error instead of a value. Invoke never calls the action. It prints the error to standard error and the help to standard output, and returns 1.
Checkpoint
Check: Subcommands, options and arguments
Exit codes and standard error tell a script what happened
A script cannot read your messages. It reads the exit code: The whole number a program hands back to the shell or the program that started it when it ends. 0 means success; any other value means some kind of failure. More in “Your first C# program as a file-based app”, and at most keeps the two
output streams apart. Run gst wrongly on purpose, keeping one stream at a time: >/dev/null throws away standard
output, 2>/dev/null throws away standard error: A second output stream, for messages about problems, written with Console.Error. It reaches the terminal even when standard output goes to a file, and a shell redirects it on its own with 2>. More in “Console input, output, arguments and exit codes”:
from session import run
# network: true (System.CommandLine is restored from nuget.org). Lines that start with ! are meant to fail. A shell
# redirection keeps one stream: >/dev/null drops standard output, 2>/dev/null drops standard error.
run("""
cd gst
!dotnet run gst.cs -- calc --amount abc >/dev/null
dotnet run gst.cs -- calc --amount abc 2>/dev/null | head -n 4
!dotnet run gst.cs -- calc --rate 18 >/dev/null
!dotnet run gst.cs -- calc --amount 1180 --format xml >/dev/null
!dotnet run gst.cs -- "[diagram]" calc --amount abc --rat 5
dotnet run gst.cs -- calc --amount 1180 --inclusive 2>/dev/null; echo "exit status $?"
cd ../export
!dotnet run export.cs -- --bills 1 --out missing/report.csv
""", network=True) Output
$ cd gst $ dotnet run gst.cs -- calc --amount abc >/dev/null --amount must be a number above 0, such as 1180 or 99.50, not 'abc'. [exit status 1] $ dotnet run gst.cs -- calc --amount abc 2>/dev/null | head -n 4 Description: Split one amount into its base and its GST Usage: $ dotnet run gst.cs -- calc --rate 18 >/dev/null Option '--amount' is required. [exit status 1] $ dotnet run gst.cs -- calc --amount 1180 --format xml >/dev/null Cannot parse argument 'xml' for option '--format' as expected type 'Format'. Did you mean one of the following? Json Text [exit status 1] $ dotnet run gst.cs -- "[diagram]" calc --amount abc --rat 5 [ gst ![ calc ![ --amount <abc> ] *[ --rate <18> ] *[ --inclusive ] *[ --format <Text> ] ] ] ???--> --rat 5 [exit status 1] $ dotnet run gst.cs -- calc --amount 1180 --inclusive 2>/dev/null; echo "exit status $?" base ₹1,000.00 gst ₹180.00 total ₹1,180.00 exit status 0 $ cd ../export $ dotnet run export.cs -- --bills 1 --out missing/report.csv Unhandled exception: System.IO.DirectoryNotFoundException: Could not find a part of the path 'export/missing/report.csv.tmp'. at Interop.ThrowExceptionForIoErrno(ErrorInfo errorInfo, String path, Boolean isDirError) at Microsoft.Win32.SafeHandles.SafeFileHandle.Open(String path, OpenFlags flags, Int32 mode, Boolean failForSymlink, Boolean& wasSymlink, Func`4 createOpenException) at Microsoft.Win32.SafeHandles.SafeFileHandle.Open(String fullPath, FileMode mode, FileAccess access, FileShare share, FileOptions options, Int64 preallocationSize, UnixFileMode openPermissions, Int64& fileLength, UnixFileMode& filePermissions, Boolean failForSymlink, Boolean& wasSymlink, Func`4 createOpenException) at System.IO.Strategies.OSFileStreamStrategy..ctor(String path, FileMode mode, FileAccess access, FileShare share, FileOptions options, Int64 preallocationSize, Nullable`1 unixCreateMode) at System.IO.StreamWriter.ValidateArgsAndOpenPath(String path, Boolean append, Int32 bufferSize) at System.IO.StreamWriter..ctor(String path) at Program.<>c__DisplayClass0_0.<<<Main>$>b__0>d.MoveNext() in export/export.cs:line 18 --- End of stack trace from previous location --- at System.CommandLine.Invocation.InvocationPipeline.InvokeAsync(ParseResult parseResult, CancellationToken cancellationToken) [exit status 1]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 errors_session.py
Every parse error ended with exit status 1, and none of them ran the action. The error sentence went to standard error,
since it survived >/dev/null, and the help went to standard output, since it survived 2>/dev/null: a script that
logs standard error gets the reason without the help. The messages came from three places, the custom parser of
--amount, the Required rule and the enum conversion of --format, which also suggested Json and Text. With
[diagram] the parser marked each error with ! and pointed at the misspelt --rat 5 with ???-->. A good run left
standard error empty and returned 0.
The last run let an exception escape the action, because the folder missing does not exist. The library’s default
exception handler printed Unhandled exception: with the whole stack trace and returned 1. That is a fine safety net
and a poor message: catch the failures you expect, such as a missing folder, and return your own code with one
sentence that says what to do.
Key idea
Exit status 0 means success and anything else means failure: System.CommandLine returns 1 for every parse error and writes the error to standard error, so your actions return their own codes and keep messages off standard output.
One more failure is worth reproducing, because the pattern looks harmless. check.cs checks --amount with a
validator that reads the value:
#:package System.CommandLine@2.0.12
#:property PublishAot=false
// A validator that reads the option's value. It runs even when the text did not convert to a decimal.
using System.CommandLine;
Option<decimal> amount = new("--amount") { Description = "The amount in rupees", Required = true };
amount.Validators.Add(result =>
{
if (result.GetValue(amount) <= 0)
result.AddError("--amount must be more than 0.");
});
RootCommand root = new("Checks an amount") { Options = { amount } };
root.SetAction(result => { Console.WriteLine($"amount {result.GetValue(amount)}"); return 0; });
return root.Parse(args).Invoke(); from session import run
# network: true (System.CommandLine is restored from nuget.org). check.cs validates --amount by reading its value.
run("""
cd check
!dotnet run check.cs -- --amount abc
""", network=True) Output
$ cd check $ dotnet run check.cs -- --amount abc Unhandled exception. System.InvalidOperationException: Cannot parse argument 'abc' for option '--amount' as expected type 'System.Decimal'. at System.CommandLine.Binding.ArgumentConverter.GetValueOrDefault[T](ArgumentConversionResult result) at System.CommandLine.Parsing.OptionResult.GetValueOrDefault[T]() at System.CommandLine.Parsing.SymbolResult.GetValue[T](Option`1 option) at Program.<>c__DisplayClass0_0.<<Main>$>b__0(OptionResult result) in check/check.cs:line 9 at System.CommandLine.Parsing.CommandResult.ValidateOptionsAndAddDefaultResults(Boolean completeValidation) at System.CommandLine.Parsing.CommandResult.Validate(Boolean isInnermostCommand) at System.CommandLine.Parsing.ParseOperation.ValidateAndAddDefaultResults() at System.CommandLine.Parsing.ParseOperation.Parse() at System.CommandLine.Parsing.CommandLineParser.Parse(Command command, IReadOnlyList`1 arguments, String rawInput, ParserConfiguration configuration) at System.CommandLine.Parsing.CommandLineParser.Parse(Command command, IReadOnlyList`1 args, ParserConfiguration configuration) at System.CommandLine.Command.Parse(IReadOnlyList`1 args, ParserConfiguration configuration) at Program.<Main>$(String[] args) in check/check.cs:line 14 [exit status 134]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 crash_session.py
The validator ran although abc never became a decimal, and GetValue threw. That happened inside Parse, before
Invoke, where no handler waits, so the runtime ended the process with SIGABRT: exit status 134, which is 128 plus 6,
the number of SIGABRT.
Reading the value inside a validator
Wrong: amount.Validators.Add(r => { if (r.GetValue(amount) <= 0) r.AddError("…"); }), which throws on text that is
not a number. Right: a CustomParser, as in gst.cs, that calls decimal.TryParse on the token and reports every
bad value with AddError. A validator runs even when the conversion failed, so it may read only what it parsed
itself.
Checkpoint
Check: Exit codes and the two streams
Ctrl+C cancels a token, and the action decides how to stop
Ctrl+C in a terminal sends termination signal: A signal from the operating system that asks a program to stop: SIGINT when the user presses Ctrl+C in a terminal, SIGTERM from kill, a service manager or a container runtime. A program that handles it can finish cleanly. to the program; kill, a service manager or a
container runtime sends SIGTERM. Since .NET 10 the runtime no longer handles SIGTERM itself, so a plain console app is
ended on the spot. System.CommandLine registers its own handlers: an async action receives a
cancellation token: A CancellationToken: a small value passed to work so that it can notice a request to stop. The CancellationTokenSource that made it asks for the stop, with Cancel or after a timeout. More in “Cancellation tokens and timeouts”, and a stop signal cancels it. export.cs writes one bill every
two seconds into report.csv.tmp, and renames the file only when every bill is done:
#:package System.CommandLine@2.0.12
#:property PublishAot=false
// export: writes one line per bill to a report. Each bill waits two seconds, standing in for a slow call to a remote
// service. The report is written to a temporary file and renamed only when every bill is done, so a stopped run never
// leaves half a report behind.
using System.CommandLine;
Option<int> bills = new("--bills") { Description = "How many bills to export", DefaultValueFactory = _ => 6 };
Option<FileInfo> output = new("--out") { Description = "The report to write", DefaultValueFactory = _ => new FileInfo("report.csv") };
RootCommand root = new("Exports bills to a report") { Options = { bills, output } };
root.SetAction(async (result, token) =>
{
int count = result.GetValue(bills);
string target = result.GetValue(output)!.FullName, temporary = target + ".tmp";
try
{
await using (StreamWriter writer = new(temporary))
{
for (int i = 1; i <= count; i++)
{
await Task.Delay(2000, token);
await writer.WriteLineAsync($"bill-{i},filed");
Console.WriteLine($"bill {i} of {count} exported");
}
}
File.Move(temporary, target, overwrite: true);
Console.WriteLine($"report written: {Path.GetFileName(target)}");
return 0;
}
catch (OperationCanceledException)
{
File.Delete(temporary);
Console.Error.WriteLine("stopped: no report was written, run the export again");
return 130;
}
});
return await root.Parse(args).InvokeAsync(); The session stops an export after two bills with kill -TERM, runs a short export to the end, then stops stuck.cs,
whose action never looks at its token:
from session import run
# network: true (System.CommandLine is restored from nuget.org). Each long run starts in the background, the script
# waits until the program has printed a given line, then sends SIGTERM with kill, as a service manager does when it
# stops a program; Ctrl+C in a terminal sends SIGINT, which System.CommandLine handles the same way. "wait" then
# reports the program's exit status; perl, which macOS includes, measures the seconds between the signal and the end.
run("""
cd export
dotnet build export.cs -v q
dotnet run export.cs -- --bills 6 > run.log & while ! grep -qs "bill 2 of 6" run.log; do sleep 0.1; done; kill -TERM $!; wait $!; echo "exit status $?"
cat run.log
ls
dotnet run export.cs -- --bills 2
ls
dotnet build stuck.cs -v q
dotnet run stuck.cs > stuck.log & while ! grep -qs working stuck.log; do sleep 0.1; done; kill -TERM $!; S=$(perl -MTime::HiRes=time -e "print time"); wait $!; echo "exit status $?, $(perl -MTime::HiRes=time -e "printf q(%.0f), time - $S") s after the signal"
cat stuck.log
""", network=True) Output
$ cd export $ dotnet build export.cs -v q $ dotnet run export.cs -- --bills 6 > run.log & while ! grep -qs "bill 2 of 6" run.log; do sleep 0.1; done; kill -TERM $!; wait $!; echo "exit status $?" stopped: no report was written, run the export again exit status 130 $ cat run.log bill 1 of 6 exported bill 2 of 6 exported $ ls export.cs run.log stuck.cs $ dotnet run export.cs -- --bills 2 bill 1 of 2 exported bill 2 of 2 exported report written: report.csv $ ls export.cs report.csv run.log stuck.cs $ dotnet build stuck.cs -v q $ dotnet run stuck.cs > stuck.log & while ! grep -qs working stuck.log; do sleep 0.1; done; kill -TERM $!; S=$(perl -MTime::HiRes=time -e "print time"); wait $!; echo "exit status $?, $(perl -MTime::HiRes=time -e "printf q(%.0f), time - $S") s after the signal" exit status 143, 2 s after the signal $ cat stuck.log working, and not watching the token
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 cancel_session.py
The stopped export printed its message on standard error and returned 130, and ls shows neither report.csv nor a
.tmp file: nothing half-written was left for the next import to read. The full run renamed its temporary file into
report.csv. stuck.cs behaved differently. Its action kept waiting, so two seconds after the signal the library
stopped waiting for it: InvokeAsync returned 143, the program ended, and finished was never printed. Those two
seconds are the library’s process termination timeout, its default. Step through both stops:
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- The action starts. InvokeAsync calls the action with a cancellation token that nobody has cancelled yet. The action writes one line to report.csv.tmp every two seconds.
- A stop signal arrives. After two bills the shell sends SIGTERM, as kill or a service manager does; Ctrl+C in a terminal sends SIGINT instead. The library's handler receives it, so the process is not ended on the spot.
- The token is cancelled. The library cancels the token it gave the action and starts a two-second clock, its process termination timeout.
- The wait throws at once. The action is inside Task.Delay(2000, token), which ends straight away with OperationCanceledException. Two bills are done; the third never starts.
- Clean up, then a code. The catch block deletes report.csv.tmp, writes a message to standard error and returns 130. No half-written report is left in the folder.
- The shell gets 130. InvokeAsync returns 130, the program returns it, and wait in the shell printed exit status 130.
- An action that does not listen. stuck.cs never looks at its token, so cancelling it changes nothing. Two seconds after the signal the library stops waiting and returns 143, which is 128 plus 15, the number of SIGTERM. The program ends, and finished is never printed.
Every exit status that this lesson’s runs produced, and who chose it:
| Exit status | Where it came from |
|---|---|
| 0 | an action that finished and returned 0 |
| 1 | a parse error, or an exception that the library’s handler caught |
| 130 | export.cs, after cancellation: 128 plus 2, the number of SIGINT, by the shell’s convention |
| 134 | the runtime, after an exception escaped Parse: 128 plus 6, SIGABRT |
| 143 | the library, two seconds after SIGTERM, for stuck.cs: 128 plus 15 |
Bash reports a program that a signal ended as 128 plus the signal’s number, and System.CommandLine returns the same
code when its timeout runs out. export.cs returns 130 itself, the code Bash shows for a program that SIGINT ended, so
a script can tell a stopped export from a failed one.
Returning 0 after a cancellation
Wrong: catch OperationCanceledException and return 0;, so the script that started the export carries on as if
the report existed. Right: delete or keep the partial output on purpose, write one line to standard error and
return a non-zero code such as 130.
Checkpoint
Check: Stopping on Ctrl+C or SIGTERM
A .NET tool is a NuGet package with a command inside
A .NET tool: A NuGet package that holds a console app. dotnet tool install makes its command available globally, in a folder or for one repository, and dnx runs it once without installing it. is a NuGet package: A .nupkg file that holds compiled libraries, their dependencies and metadata such as the package id and version. Projects name the packages they need, and restore fetches them. More in “Packages and settings in file-based apps” that holds a console app;
installing it gives you its command. A file-based app is packed as a tool by default, so dotnet pack gst.cs is all it
takes. The session packs gst into a folder named feed, runs it once, installs it as a local tool and then into a
folder:
from session import run
# network: true: dotnet pack restores System.CommandLine from nuget.org. Every tool command names the folder feed
# with --source, which replaces all other package sources, so the gst tool can only come from that folder. The last
# line prints the SDK's own help for --source.
run("""
cd gst
dotnet pack gst.cs -o ../feed -v q
cd ..
ls feed
dnx gst --source feed -- calc -a 500
dotnet tool install gst@1.0.0 --source feed
cat dotnet-tools.json
dotnet gst calc --amount 1180 --inclusive
dotnet tool run gst -- total 1180 590
dotnet tool install gst --source feed --tool-path tools
./tools/gst --version
dotnet tool install --help | grep -A1 -e "--source <SOURCE>"
""", network=True) Output
$ cd gst
$ dotnet pack gst.cs -o ../feed -v q
$ cd ..
$ ls feed
gst.1.0.0.nupkg
$ dnx gst --source feed -- calc -a 500
Skipping NuGet package signature verification.
base ₹500.00 gst ₹90.00 total ₹590.00
$ dotnet tool install gst@1.0.0 --source feed
You can invoke the tool from this directory using the following commands: 'dotnet tool run gst' or 'dotnet gst'.
Tool 'gst' (version '1.0.0') was successfully installed. Entry is added to the manifest file dotnet-tools.json.
$ cat dotnet-tools.json
{
"version": 1,
"isRoot": true,
"tools": {
"gst": {
"version": "1.0.0",
"commands": [
"gst"
],
"rollForward": false
}
}
}
$ dotnet gst calc --amount 1180 --inclusive
base ₹1,000.00 gst ₹180.00 total ₹1,180.00
$ dotnet tool run gst -- total 1180 590
base ₹1,500.00 gst ₹270.00 total ₹1,770.00
$ dotnet tool install gst --source feed --tool-path tools
Skipping NuGet package signature verification.
You can invoke the tool using the following command: gst
Tool 'gst' (version '1.0.0') was successfully installed.
$ ./tools/gst --version
1.0.0
$ dotnet tool install --help | grep -A1 -e "--source <SOURCE>"
--source <SOURCE> Replace all NuGet package sources to use during
installation with these.
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 tool_session.py
dotnet pack wrote gst.1.0.0.nupkg; the package id and the command name both came from the file name. dnx gst ran
the tool without installing it, a .NET 10 addition: dnx is a script that hands its arguments to dotnet tool exec. It
printed Skipping NuGet package signature verification. because on macOS, where this run was recorded, the SDK does not
verify package signatures; on Windows it always does, and on Linux by default since .NET 8.
dotnet tool install gst@1.0.0 found no tool manifest: The file dotnet-tools.json, which lists the local .NET tools of a repository with their exact versions. dotnet tool restore installs those versions, so every clone runs the same tools. in the folder or above it, so the
.NET 10 SDK created dotnet-tools.json itself, with version 1.0.0 pinned. dotnet gst and dotnet tool run gst --
both ran it, and someone who clones the repository runs dotnet tool restore to get the same version.
--tool-path tools put an executable in tools/ instead. The last command printed the SDK’s own help for --source:
it replaces every other package source, so each command could take gst from feed and nowhere else.
New in the .NET 10 SDK
dotnet tool exec and dnx are new in the .NET 10 SDK, and so is a local install that creates its own manifest.
With the .NET 8 and 9 SDKs, a local dotnet tool install without a manifest stops with an error unless it is given
--create-manifest-if-needed, which is why older guides run dotnet new tool-manifest first.
The four ways to get a tool, and which version each one runs:
| Install | Command | Where it lives | Which version runs |
|---|---|---|---|
| Global | dotnet tool install -g gst |
$HOME/.dotnet/tools, a folder for the PATH |
one for the user, in every folder |
| Tool path | … --tool-path tools |
the folder you name | the one in that folder |
| Local | dotnet tool install gst@1.0.0 |
listed in dotnet-tools.json |
the manifest’s, for the repository |
| One run | dnx gst |
the NuGet package cache only | the manifest’s if there is one, else the newest |
A tool is code you run with your own rights
Microsoft’s documentation is plain about it: .NET tools run in full trust, so install only tools whose author you
trust. And --add-source adds a feed beside nuget.org, where the fastest feed wins, so a public package with the
same id could be installed instead of yours. The session used --source, which replaces every other source; for a
repository, package source mapping in nuget.config ties each package id to its feed.
Checkpoint
Check: .NET tools, manifests and dnx
Tab completion asks the app what can come next
Typing gst calc --format and pressing Tab should offer Json and Text. The shell does not guess them: it asks the
app, through the [suggest] directive that every RootCommand has. The session asks the same questions by hand, then
asks the SDK for its own completion scripts:
from session import run
# network: true (System.CommandLine is restored from nuget.org). The [suggest] directive returns what a shell would
# offer at a position of a command line: "[suggest:14]" means after the 14th character of the text that follows.
run("""
cd gst
dotnet run gst.cs -- "[suggest]" ca
dotnet run gst.cs -- "[suggest:7]" "calc --"
dotnet run gst.cs -- "[suggest:14]" "calc --format "
dotnet completions script bash | head -n 3
dotnet completions script zsh | wc -l
""", network=True) Output
$ cd gst
$ dotnet run gst.cs -- "[suggest]" ca
calc
$ dotnet run gst.cs -- "[suggest:7]" "calc --"
--amount
--format
--help
--inclusive
--rate
$ dotnet run gst.cs -- "[suggest:14]" "calc --format "
Json
Text
$ dotnet completions script bash | head -n 3
#!/usr/bin/env bash
_dotnet() {
$ dotnet completions script zsh | wc -l
2112
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 suggest_session.py
[suggest] ca completed to calc. At position 7 of calc -- the answers were the options of calc, and at position
14 of calc --format the two names of the enum. Values that are known in advance complete without any code: enum
values, as here, and values given to AcceptOnlyFromAmong. Values known only at run time, such as dates of the coming
week, come from a function added to the option’s CompletionSources. To have this in a shell, the user installs the
dotnet-suggest global tool, adds its shim script to the shell’s profile and registers the app once with
dotnet-suggest register. For the dotnet command itself, the .NET 10 SDK writes native scripts with
dotnet completions script for bash, fish, nushell, PowerShell and zsh; the zsh one was 2,112 lines here. Step
through one Tab press:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- Tab is pressed. The user has typed gst calc --format and presses Tab. The shim script added to the shell's profile catches the key and passes the line and the cursor position to dotnet-suggest.
- The helper asks the app. dotnet-suggest knows gst because the app was registered with dotnet-suggest register. It starts gst with the [suggest] directive, the line and the position, as the suggest session did by hand.
- The app parses, without acting. gst parses the line as far as the cursor. The cursor follows --format, an option of the enum type Format, so the answers are the names of the enum. No action runs.
- The app answers. gst prints Json and Text, one per line, and exits, exactly as the recorded run printed them.
- The shell offers them. dotnet-suggest hands the two words back, and the shell offers them under the cursor. Nothing in gst was written for completion; the enum and its option did it.
Checkpoint
Check: Tab completion
Practise: a bill splitter you can test without a terminal
lab.cs holds eight checks for splitbill, a command that splits a bill between people, with a tip, a rounding mode
and text or JSON output. The checks call Parse and Invoke with an InvocationConfiguration whose Output and
Error are StringWriter objects, so they read what the command printed and the code it returned without starting a
process. That is a good way to test any command built on the library. As it starts, Build throws, and every check
fails:
#:package System.CommandLine@2.0.12
#:property PublishAot=false
// A lab: write splitbill, a command that splits a bill between people. Its eight checks parse and invoke the command
// in this process, with its output and its errors captured, so no terminal is needed. As it starts, SplitBill.Build
// throws, so every check fails. Write Build and Split until all eight pass.
using System.CommandLine;
using System.Globalization;
using System.Text.Json;
(string Name, string[] Input, Func<int, string, string, bool> Passes)[] checks =
[
("1000 split three ways adds up to 1000.00", ["1000", "--names", "Asha", "Ravi", "Meera"],
(code, output, _) => code == 0 && output == "Asha 333.34\nRavi 333.33\nMeera 333.33\n"),
("a 10 % tip is shared too", ["1000", "--names", "Asha", "Ravi", "Meera", "--tip", "10"],
(code, output, _) => code == 0 && output == "Asha 366.67\nRavi 366.67\nMeera 366.66\n"),
("--round rupee gives whole rupees", ["1000", "--names", "Asha", "Ravi", "Meera", "--round", "rupee"],
(code, output, _) => code == 0 && output == "Asha 334.00\nRavi 333.00\nMeera 333.00\n"),
("--format json prints every share", ["250", "--names", "Asha", "Ravi", "--format", "json"],
(code, output, _) => code == 0 && JsonShares(output) == "Asha=125.00 Ravi=125.00"),
("names keep every letter", ["900", "--names", "अनन्या", "Zoë", "Đức"],
(code, output, _) => code == 0 && output == "अनन्या 300.00\nZoë 300.00\nĐức 300.00\n"),
("no names: exit 1, the error names --names", ["1000"],
(code, _, error) => code == 1 && error.Contains("--names")),
("a negative tip: exit 1, the error names --tip", ["1000", "--names", "Asha", "--tip", "-5"],
(code, _, error) => code == 1 && error.Contains("--tip")),
("a total that is not a number: exit 1", ["a lot", "--names", "Asha"],
(code, output, error) => code == 1 && error.Length > 0 && !output.Contains("Asha")),
];
int passed = 0;
foreach (var (name, input, passes) in checks)
{
StringWriter output = new(), error = new();
string verdict;
try
{
int code = SplitBill.Build().Parse(input).Invoke(new InvocationConfiguration { Output = output, Error = error });
bool ok = passes(code, Lines(output), Lines(error));
verdict = ok ? "pass" : $"FAIL (exit {code})";
if (ok) passed++;
}
catch (Exception e)
{
verdict = $"FAIL ({e.GetType().Name})";
}
Console.WriteLine($"{verdict,-31} {name}");
}
Console.WriteLine($"{passed} of {checks.Length} checks pass");
return passed == checks.Length ? 0 : 1;
static string Lines(StringWriter writer) => writer.ToString().Replace("\r\n", "\n");
static string JsonShares(string json)
{
using JsonDocument document = JsonDocument.Parse(json);
return string.Join(" ", document.RootElement.EnumerateArray()
.Select(e => $"{e.GetProperty("name").GetString()}={e.GetProperty("share").GetDecimal().ToString("0.00", CultureInfo.InvariantCulture)}"));
}
static class SplitBill
{
// The command: an argument for the total in rupees; --names, one or more, required; --tip, a percentage from 0 to
// 100 (default 0); --round, paise or rupee (default paise); --format, text or json (default text). Text prints one
// "Name 333.34" line per person; json prints [{"name": "Asha", "share": 333.34}, …]. Write through
// result.InvocationConfiguration.Output and .Error, never Console, so the checks can read what it printed.
public static RootCommand Build() => throw new NotImplementedException();
// Splits a total between `people` in whole units of `unit` (0.01 or 1). The first people get one unit more each
// until the shares add up to the total exactly.
public static decimal[] Split(decimal total, int people, decimal unit) => throw new NotImplementedException();
} from session import run
# network: true. lab.cs restores System.CommandLine 2.0.12 from nuget.org into the session's own home folder. As it
# starts, its command is unwritten, so the run is meant to fail: it prints every check and exits with 1.
run("""
cd lab
!dotnet run lab.cs
""", network=True) Output
$ cd lab $ dotnet run lab.cs FAIL (NotImplementedException) 1000 split three ways adds up to 1000.00 FAIL (NotImplementedException) a 10 % tip is shared too FAIL (NotImplementedException) --round rupee gives whole rupees FAIL (NotImplementedException) --format json prints every share FAIL (NotImplementedException) names keep every letter FAIL (NotImplementedException) no names: exit 1, the error names --names FAIL (NotImplementedException) a negative tip: exit 1, the error names --tip FAIL (NotImplementedException) a total that is not a number: exit 1 0 of 8 checks pass [exit status 1]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 lab_session.py
Make lab.cs pass
Write Build: an Argument<decimal> for the total, --names with Required, Arity = ArgumentArity.OneOrMore
and AllowMultipleArgumentsPerToken, --tip with a custom parser that refuses values outside 0 to 100, and
enums for --round and --format. Then write Split in whole paise or whole rupees. Run dotnet run lab.cs until
it prints 8 of 8 checks pass.
Two checks catch the usual slips. Shares of 1,000 between three people are 333.34, 333.33 and 333.33: split in
whole paise and give the remainder to the first people, and the shares always add up to the bill. And the action must
write through result.InvocationConfiguration.Output, not Console, or the checks see nothing.
The practice below mixes all five ideas of the lesson. Each answer says what it got right or missed, and the next question follows from how you did:
Practice that adapts to you
Practise this lesson
10 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
What is the difference between a subcommand, an option and an argument?
A subcommand is a word that chooses an action, such as calc in gst calc, and it has its own options and arguments.
An option is a named value: --amount 1180, or a flag such as --inclusive that means true when it is present.
Options can come in any order, may have short aliases such as -a, and can have defaults. An argument is a value known
by its position: in gst total 1180 590, both amounts are arguments, and their order matters. A rule of thumb: what
the command is about is an argument, and what changes how it works is an option.
What does an exit code tell the script that ran a program?
Whether the program succeeded: 0 means success, and any other value means a kind of failure, which the script can test
with if, && or set -e. System.CommandLine returns 1 for every parse error; an action returns its own codes.
Messages belong on standard error, so that standard output holds only results that another program can read.
What is the difference between a global tool, a local tool and running a tool with dnx?
A global tool is installed once for the user, in $HOME/.dotnet/tools, a folder that belongs on the PATH, and the same
version runs in every folder. A local tool is listed with its exact version in a repository’s dotnet-tools.json, so
dotnet tool restore gives everyone that version. dnx runs a tool once without installing it; inside a folder with a
manifest it runs the manifest’s version.
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Fresher to mid
A nightly job runs your C# command-line app to import the day's bank statement. For a week the statement file was missing, yet the job reported success every night. How do you find the cause, fix the app and the job script, and show that it cannot happen again?
-
Mid to senior
Users sometimes press Ctrl+C during a long export from your command-line app, and the next morning's import fails on a half-written CSV file. Explain why it happens, how you would change the export, and what the app should return when it is stopped.
-
Mid to senior
Your build runs a code-generation tool that is packaged as a .NET tool. One developer's generated code differs from what CI generates, and nobody changed the input. How do you make every machine and CI run exactly the same tool, from a source you trust?
Key takeaways
- A command line is a tree: subcommands choose the action, options are named values in any order, and arguments are values known by their place; System.CommandLine writes the help, the version and the errors from that tree.
- Exit status 0 means success; every parse error returns 1 with its message on standard error, and an action returns its own codes, never 0 after a failure or a cancellation.
- A validator runs even when a value did not convert, so parse and check in a
CustomParserand report problems withAddErrorinstead of throwing. - A stop signal cancels the token of an async action; pass it to every call that waits, clean up, and return a code such as 130, because after two seconds the library stops waiting and the program ends anyway.
- A .NET tool is a NuGet package with a command inside:
dotnet tool installpins it indotnet-tools.json,dnxruns it once, and--sourceor package source mapping decides which feed it may come from. - Tab completion reaches the app through the
[suggest]directive; enum values andAcceptOnlyFromAmongcomplete without extra code.
Terms in this lesson
- .NET tool
- A NuGet package that holds a console app. dotnet tool install makes its command available globally, in a folder or for one repository, and dnx runs it once without installing it.
- argument (command line)
- A value on a command line that is known by its place rather than by a name, such as the amounts in gst total 1180 590. When a command takes several arguments, their order matters.
- option (command line)
- A named value on a command line, such as --amount 1180: a name that starts with - or --, usually followed by its value. Options can come in any order; a bool option is true when it is present.
- subcommand
- A word on a command line that chooses an action of the app, such as calc in gst calc. Each subcommand has its own options and arguments, and can have subcommands of its own.
- termination signal
- A signal from the operating system that asks a program to stop: SIGINT when the user presses Ctrl+C in a terminal, SIGTERM from kill, a service manager or a container runtime. A program that handles it can finish cleanly.
- tool manifest
- The file dotnet-tools.json, which lists the local .NET tools of a repository with their exact versions. dotnet tool restore installs those versions, so every clone runs the same tools.
References
- System.CommandLine overview (Microsoft Learn)
- Tutorial: Build a command-line app with System.CommandLine (Microsoft Learn)
- Command-line syntax overview for System.CommandLine (Microsoft Learn)
- How to parse and invoke the result (Microsoft Learn)
- How to customize parsing and validation in System.CommandLine (Microsoft Learn)
- Tab completion for System.CommandLine (Microsoft Learn)
- .NET tools: install, use, update and remove (Microsoft Learn)
- Tutorial: Create a .NET tool (Microsoft Learn)
- dotnet tool install command (Microsoft Learn)
- dotnet tool exec command (dnx) (Microsoft Learn)
- What's new in the SDK and tooling for .NET 10 (Microsoft Learn)
- Breaking change: 'dotnet tool install --local' creates manifest by default (Microsoft Learn)
- Breaking change: .NET runtime no longer provides default termination signal handler (Microsoft Learn)
- Package Source Mapping (Microsoft Learn)
- NuGet signed-package verification (Microsoft Learn)
- Bash Reference Manual: Exit Status (GNU Project (Free Software Foundation))
- System.CommandLine package (NuGet Gallery (.NET Foundation))
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress