Skip to content
Development
Skill

/console-cli

Console apps and CLI tools on .NET 10: System.CommandLine 2.x (RootCommand, Option<T>, SetAction, InvokeAsync), Generic Host + DI in console apps, exit codes, stdout/stderr discipline, Ctrl+C cancellation, interactive vs piped use, dotnet tool packaging, and `dotnet run app.cs`

From plugin
dotnet-claude-kit
81752 skills11 agents2 hooks1 MCP

Claude Code

$ npx -y skills add codewithmukesh/dotnet-claude-kit --skill console-cli --agent claude-code

SKILL.md

console-cli.SKILL.md
name: console-cli
description: >
  Console apps and CLI tools on .NET 10: System.CommandLine 2.x (RootCommand,
  Option<T>, SetAction, InvokeAsync), Generic Host + DI in console apps, exit
  codes, stdout/stderr discipline, Ctrl+C cancellation, interactive vs piped
  use, dotnet tool packaging, and `dotnet run app.cs` file-based apps. Load when
  the user mentions "console app", "CLI", "command line", "System.CommandLine",
  "dotnet tool", "exit code", "stdin", "args parsing", or "file-based app".

Console Apps & CLI Tools (.NET 10)

Core Principles

1. **stdout is data, stderr is everything else** — Results go to stdout so `mytool list | jq` works. Logs, progress, prompts and errors go to stderr. Mixing them breaks every pipe and script that consumes your tool. 2. **Exit codes are the API** — `0` = success, non-zero = failure, and each failure class gets a stable code. Scripts and CI branch on exit codes, never on parsing your error text. 3. **Use System.CommandLine 2.x, not hand-rolled `args` parsing** — It went stable at 2.0 and gives help, `--version`, validation, tab completion and Ctrl+C handling for free. Hand parsing always misses `--opt=value`, quoting, and help output. 4. **Cancellation flows from Ctrl+C to every await** — System.CommandLine turns SIGINT/SIGTERM into a `CancellationToken`. Pass it all the way down, or Ctrl+C becomes a hard kill that corrupts half-written files. 5. **Generic Host only when you need it** — DI, configuration, logging and `IHttpClientFactory` justify `Host.CreateApplicationBuilder`. A 50-line utility does not; startup time matters for CLIs.

Patterns

Command Tree with System.CommandLine 2.x

Verified against System.CommandLine 2.0.x (stable). The beta-era APIs (`SetHandler`, `InvocationContext`, `CommandLineBuilder`) are gone.

using System.CommandLine;

Option<FileInfo> inputOption = new("--input", "-i")
{
    Description = "File to import",
    Required = true
};
inputOption.AcceptExistingOnly();

Option<int> batchOption = new("--batch-size")
{
    Description = "Rows per batch",
    DefaultValueFactory = _ => 500
};
batchOption.Validators.Add(result =>
{
    if (result.GetValue(batchOption) is < 1 or > 10_000)
        result.AddError("--batch-size must be between 1 and 10000");
});

Option<bool> dryRunOption = new("--dry-run") { Description = "Validate without writing" };

Command importCommand = new("import", "Import orders from a CSV file")
{
    inputOption, batchOption, dryRunOption
};
importCommand.SetAction((parseResult, ct) => ImportCommand.RunAsync(
    parseResult.GetValue(inputOption)!,
    parseResult.GetValue(batchOption),
    parseResult.GetValue(dryRunOption),
    ct));

RootCommand root = new("Order tooling") { importCommand };
return await root.Parse(args).InvokeAsync();

**Why:** options are typed objects, so `GetValue` is compile-time checked. `InvokeAsync` prints parse errors to stderr, shows help, and returns exit code `1` without your code running. `Required`, `AcceptExistingOnly()`, and `Validators` keep input validation at the boundary.

Exit Codes as a Contract

public static class ExitCodes
{
    public const int Success = 0;
    public const int Failure = 1;          // also what System.CommandLine returns on parse errors
    public const int ValidationFailed = 2;
    public const int NotFound = 3;
    public const int Cancelled = 130;      // 128 + SIGINT, the shell convention
}

public static class ImportCommand
{
    public static async Task<int> RunAsync(FileInfo input, int batchSize, bool dryRun, CancellationToken ct)
    {
        try
        {
            var rows = await CsvReader.ReadAsync(input, ct);
            if (rows.Errors is [_, ..])
            {
                foreach (var error in rows.Errors)
                    await Console.Error.WriteLineAsync(error);
                return ExitCodes.ValidationFailed;
            }

            if (!dryRun)
                await OrderImporter.ImportAsync(rows.Items, batchSize, ct);

            Console.WriteLine(rows.Items.Count); // the data result → stdout
            return ExitCodes.Success;
        }
        catch (OperationCanceledException) when (ct.IsCancellationRequested)
        {
            await Console.Error.WriteLineAsync("Cancelled.");
            return ExitCodes.Cancelled;
        }
    }
}

**Why:** named constants document the contract and stop magic numbers drifting. Catching `OperationCanceledException` only when the token fired distinguishes a user Ctrl+C from a real timeout bug. Document the codes in `--help` text or the README.

Ctrl+C and Graceful Shutdown

`ParseResult.InvokeAsync` cancels the action's token on Ctrl+C, SIGINT and SIGTERM. `InvocationConfiguration.ProcessTerminationTimeout` (default 2 seconds) is how long the action gets to finish before the process is killed.

InvocationConfiguration config = new()
{
    ProcessTerminationTimeout = TimeSpan.FromSeconds(10) // time to flush a partial batch
};
return await root.Parse(args).InvokeAsync(config);

**Why:** the default 2 seconds suits most tools; raise it only when cleanup (flushing, releasing a lock file) genuinely takes longer. Never install your own `Console.CancelKeyPress` handler alongside it — two owners of Ctrl+C race each other.

Generic Host + DI Inside a CLI

There is no stable `System.CommandLine.Hosting` package (it is still alpha), so compose them yourself: parse first, then build the host inside the action that needs services.

Option<bool> verboseOption = new("--verbose", "-v") { Recursive = true }; // added to root, inherited by subcommands

importCommand.SetAction(async (parseResult, ct) =>
{
    using var host = AppHost.Build(parseResult.GetValue(verboseOption));
    var handler = host.Services.GetRequiredService<ImportHandler>();
    return await handler.RunAsync(parseResult.GetValue(inputOption)!, ct);
});

internal static class AppHost
{
    public static IHost Build(bool
Read more
@codewithmukesh
Ships withdotnet-claude-kit

Make Claude Code a .NET 10 Expert

81752 skills11 agents1 MCP
Get the whole plugin

Stars on GitHub

Momentum

stars in total
817
Star history of codewithmukesh/dotnet-claude-kit on GitHub, a chart by star-history.com

Other skills on dotnet-claude-kit.

More skills from other plugins.

brainstorming
Auto-invoked

brainstorming

You MUST use this before any creative work - creating features, building components, adding functionality, modifying behavior, or…

@obra@obraInstall Skill
ask-matt

ask-matt

Ask which skill or flow fits your situation. A router over the skills in this repo.

skills2w309k
@mattpocock@mattpocockInstall Skill
accessibility
Auto-invoked

accessibility

Design, implement, and audit accessible UI to WCAG 2.2 Level AA across Web, iOS, and Android — semantic ARIA roles and labels,…

ecc2w293k
@affaan-m@affaan-mInstall Skill
compiler-commit

compiler-commit

Use when you want to verify compiler changes and commit with the correct convention. Runs tests, lint, and format, then commits…

react5d252k
@facebook@facebookInstall Skill

Questions about console-cli.

Which coding agents can use console-cli?
console-cli installs in Claude Code, Codex, Cursor, Gemini CLI, GitHub Copilot, OpenCode and Windsurf with the skills CLI, using each agent's flag shown on this page. Flowy's auto-invocation, which fires a skill when your prompt matches, runs in Claude Code only.