Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Intermediate
  • 20 minutes
  • Examples run with .NET 10.0.401 and Python 3.14.8
  • By MySmartCoPilot
Free lesson, Pro answers: 20 minutes
Free: the whole lesson. With a pass: the model answers to its 3 interview questions.

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:

gst/gst.cs C# · gst/gst.cs
#!/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:

gst: two commands, the help it writes, and what the parser understood Python · gst_session.py
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:

Interactive · stage by stage One command line through System.CommandLine Step through gst calc --amount 1180 --inclusive, then the same line with --amount abc, and see where each one goes.
One command line through System.CommandLine, step 8 of 8. Showing Tokens, Subcommand, Options and arguments (--amount 1180, --inclusive), Conversion, Checks (one error collected), Your action, Exit code. In focus: Checks, refused, Exit code.errors: exit 1Tokensthe words after gstSubcommandcalc picks its symbolsOptions and arguments--amount 1180, --inclusiveConversiontext to decimal or boolChecksone error collectedYour actionreads the typed valuesExit codewhat the shell gets

The drawing shows the whole scene; its 8 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. 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.
  8. 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.

GST Calculator (India) Check gst's arithmetic: split 1,180 at 18 % with GST included, and compare the base and the tax.

Checkpoint

Check: Subcommands, options and arguments

  1. Question 1 of 2 In gst total 1180 590 --format json, which tokens are arguments?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: 1180 and 590

    An argument is a value known by its place, and total's argument takes one or more amounts, so 1180 and 590 are its values. total is the subcommand, and --format json is an option with its value.

  2. Question 2 of 2 gst [diagram] calc --amount 500 prints *[ --rate <18> ] among its brackets. What does the star say?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: --rate was not typed, so its default of 18 filled in

    In the diagram every option of calc appears, typed or not. The star in front of [ --rate <18> ] says that the value came from the option's default, 18, because the command line did not name --rate.

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”:

Parse errors, the two streams and an exception Python · errors_session.py
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:

check/check.cs C# · check/check.cs
#: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();
A validator that reads a value that never converted Python · crash_session.py
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

  1. Question 1 of 2 A job runs gst calc --amount abc 2>/dev/null > out.txt. What ends up in out.txt, and what exit status does the job see?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The help text, and exit status 1

    The run of gst calc --amount abc 2>/dev/null printed the help, so the help is on standard output and ends up in out.txt. The error sentence was on standard error, which was thrown away, and the parse error returned 1.

  2. Question 2 of 2 Why did check.cs end with exit status 134 instead of 1?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The validator threw inside Parse, before Invoke, so no handler caught it and the runtime aborted the process

    A validator runs even when the value did not convert, and reading it then throws. Inside Parse nothing catches the exception, so .NET ends the process with SIGABRT, which the shell shows as 134. A custom parser reports the same problem with AddError and returns 1.

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:

export/export.cs C# · export/export.cs
#: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:

SIGTERM during an export, and during an action that ignores it Python · cancel_session.py
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:

Interactive · message by message What a stop signal does to a running action Step through the export stopped after two bills, then the action that never looks at its token.
What a stop signal does to a running action, step 7 of 7. Messages so far: System.CommandLine to Your action: token; Shell to System.CommandLine: SIGTERM; System.CommandLine to Your action: cancel; Your action to System.CommandLine: 130; System.CommandLine to Shell: exit 130; System.CommandLine to Shell: exit 143.Shellor a service managerSystem.CommandLine2 s leftYour actionstuck.cs, endedtokenSIGTERMcancel130exit 130exit 143

The drawing shows the whole scene; its 7 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. The token is cancelled. The library cancels the token it gave the action and starts a two-second clock, its process termination timeout.
  4. 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.
  5. 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.
  6. The shell gets 130. InvokeAsync returns 130, the program returns it, and wait in the shell printed exit status 130.
  7. 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

  1. Question 1 of 2 stuck.cs never looks at its cancellation token. A service manager sends it SIGTERM. What exit status does the manager see?

    Type a number.

    A hint

    After its termination timeout the library returns the code a shell shows for a program that the signal ended, 128 plus the signal's number, and SIGTERM is 15.

    Show the answer: Question 1 of 2

    Answer: 143 exit status

    The library cancels the token, which stuck.cs ignores, and two seconds later it stops waiting and returns 143, which is 128 plus 15, the number of SIGTERM. The session printed exit status 143, and finished was never printed.

  2. Question 2 of 2 Why does export.cs write to report.csv.tmp and rename it at the end, instead of writing report.csv directly?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: A stop at any moment leaves the old report or a complete new one, never half a report for the next import

    The export stopped after two bills left neither report.csv nor a .tmp file, and the full run renamed its finished file into place. Writing to a temporary file and renaming it at the end is how a program makes a stop at any moment safe for the next reader.

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:

Pack gst, run it with dnx, install it locally and into a folder Python · tool_session.py
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

  1. Question 1 of 2 A teammate clones a repository whose dotnet-tools.json lists gst 1.0.0. What do they run to get exactly that version?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: dotnet tool restore

    A local tool manifest pins each tool's version for a repository, and dotnet tool restore installs exactly those versions, so every clone and every CI run gets the same gst.

  2. Question 2 of 2 CI installs an internal tool with dotnet tool install corp-gen --add-source ./feed. A package named corp-gen, with the same version, appears on nuget.org. What is the risk, and the fix?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Both feeds are asked and the fastest wins, so CI may install the public package; use --source, or package source mapping for corp-gen

    With --add-source, a public package with the same id and version can be chosen instead of the internal one. The session used --source, which allows only the named feed; in a repository, package source mapping in nuget.config ties corp-gen to the internal feed.

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:

What gst offers at three cursor positions, and the SDK's scripts Python · suggest_session.py
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:

Interactive · message by message One press of Tab, answered by the app Step through what happens when someone types gst calc --format and presses Tab.
One press of Tab, answered by the app, step 5 of 5. Messages so far: Shell to dotnet-suggest: line and cursor; dotnet-suggest to gst: [suggest:14]; gst to dotnet-suggest: Json Text; dotnet-suggest to Shell: Json Text.Shellzsh, bash or PowerShelldotnet-suggesta global toolgstcursor after --formatline and cursor[suggest:14]Json TextJson Text

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. The app answers. gst prints Json and Text, one per line, and exits, exactly as the recorded run printed them.
  5. 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

  1. One question Which completions does a System.CommandLine app offer on Tab without any completion code?

    Choose every answer that is right.

    Show the answer: One question

    Answer:

    • Values given to AcceptOnlyFromAmong
    • Subcommand and option names, such as calc and --rate
    • The names of an enum option, such as Json and Text

    Everything in the command tree completes by itself: subcommands, option names, enum values and the values of AcceptOnlyFromAmong. Anything computed at run time is added through CompletionSources.

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:

lab/lab.cs C# · lab/lab.cs
#: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();
}
Running lab.cs before Build is written Python · lab_session.py
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.

Itemised Bill Split Calculator Compare splitbill's shares with a bill split in your browser, tip included.

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.

  1. Question 1 of 10 --inclusive is an Option<bool>. What does the action read when the user types gst calc -a 1180 without it?

    Choose one answer.

    Show the answer: Question 1 of 10

    Answer: false

    A bool option is a flag: present means true, absent means false. That is why the session's calc -a 1000 printed 180.00 of GST on top of 1,000.00 instead of inside it.

  2. Question 2 of 10 What GST does gst total 1180 590 --rate 5 report, in rupees, to two decimal places?

    Type a number.

    A hint

    total treats every amount as including GST, so the tax is gross × rate ÷ (100 + rate).

    Show the answer: Question 2 of 10

    Answer: 84.29 rupees (anything from 84.28 to 84.3 counts)

    The gross amount is 1,180 + 590 = 1,770. With the GST included, the tax is 1,770 × 5 ÷ 105 = 84.2857…, which gst rounds to 84.29, leaving a base of 1,685.71.

  3. Question 3 of 10 What does exit status 0 tell the shell?

    Choose one answer.

    Show the answer: Question 3 of 10

    Answer: The program succeeded

    0 is success. Scripts test it with if, && or set -e, which is why a program must never return 0 after a failure or a cancellation.

  4. Question 4 of 10 A script runs gst calc --amount "$AMOUNT" > bill.txt && upload bill.txt, and $AMOUNT is empty because of a misspelt variable. What happens?

    Choose one answer.

    Show the answer: Question 4 of 10

    Answer: gst returns 1, upload never runs, and bill.txt holds only the help text

    Exit codes are what make the chain safe: the empty amount is a parse error, gst returns 1, and upload does not run. The streams decide what bill.txt holds, the help from standard output.

  5. Question 5 of 10 An async action catches OperationCanceledException after Ctrl+C. Which ending is best?

    Choose one answer.

    Show the answer: Question 5 of 10

    Answer: Clean up, write one line to standard error and return 130

    export.cs deleted its temporary file, wrote stopped to standard error and returned 130, the code Bash shows for a program that SIGINT ended, so its caller can tell a stopped run from a finished one.

  6. Question 6 of 10 After Ctrl+C an action needs five seconds to roll back a database transaction. What happens with the library's defaults, and what do you change?

    Choose one answer.

    Show the answer: Question 6 of 10

    Answer: The process is ended two seconds after the signal, mid-rollback; raise ProcessTerminationTimeout or shorten the cleanup

    System.CommandLine gives an action two seconds after a stop signal, then ends the process. Cleanup that needs longer must either fit or come with a longer ProcessTerminationTimeout in the InvocationConfiguration.

  7. Question 7 of 10 A folder's dotnet-tools.json pins gst 1.0.0, and the feed now also holds gst 1.1.0. Which version does dnx gst run in that folder?

    Choose one answer.

    Show the answer: Question 7 of 10

    Answer: 1.0.0, the manifest's version

    dnx and dotnet tool exec respect a local tool manifest: in the repository they run the pinned 1.0.0, so a one-off run behaves like dotnet tool run.

  8. Question 8 of 10 What should you check before you install a .NET tool from a public feed?

    Choose one answer.

    Show the answer: Question 8 of 10

    Answer: That you trust its author, because a tool runs in full trust, with your rights

    A tool is code that runs as you. Check its author, prefer a pinned version in a manifest, and name the feed it may come from.

  9. Question 9 of 10 What does a user set up once to get Tab completion for gst in zsh?

    Choose one answer.

    Show the answer: Question 9 of 10

    Answer: The dotnet-suggest global tool, its zsh shim in ~/.zshrc, and gst registered with dotnet-suggest register

    For apps built on System.CommandLine the steps are the dotnet-suggest tool, its shim script in the shell profile and dotnet-suggest register for the app's path. After that, completions come from the app itself.

  10. Question 10 of 10 A --branch option should offer the names of the git branches in the current repository. How do you build that?

    Choose one answer.

    Show the answer: Question 10 of 10

    Answer: Add a function to the option's CompletionSources that reads the branch names when Tab is pressed

    Static values come from the command tree; dynamic ones from a completion source, a function that the library calls with the current context and that returns CompletionItem values.

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.

  1. 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?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. 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.

    Model answer, Pro. First step and follow-ups

  3. 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?

    Model answer, Pro. First step and follow-ups

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 CustomParser and report problems with AddError instead 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 install pins it in dotnet-tools.json, dnx runs it once, and --source or package source mapping decides which feed it may come from.
  • Tab completion reaches the app through the [suggest] directive; enum values and AcceptOnlyFromAmong complete 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

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.