codewithmukesh/dotnet-claude-kit

console-cli

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

Quelltext ansehen

Verwendung

  1. Installationsbefehl kopieren und das Quellpaket vor der Installation in der eigenen Agentenumgebung prüfen.
  2. Benötigte Werkzeuge, Zugangsdaten und Hosts im Original prüfen.
  3. Eingabe und gewünschtes Ergebnis beschreiben und die Ausgabe mit den Quellen abgleichen.
npx skills add https://github.com/codewithmukesh/dotnet-claude-kit --skill console-cli

Vor dem Start

  • Die Installation umfasst weder Modellabo noch API-Kontingent oder Dateizugriffsrechte.
  • Sterne und Installationen zeigen Aktivität, keine unabhängige Qualitäts- oder Sicherheitsprüfung.
Originales Skill-Dokument

Aus dem Quell-Repository gerendert; Überschriften, Beispiele, Code, Tabellen, Links und Bilder bleiben erhalten.

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.

csharp
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

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

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

csharp
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 verbose)
    {
        // No Args: System.CommandLine owns the command line, not configuration.
        // ContentRoot = install dir, so a stray appsettings.json in the user's cwd is never loaded.
        var builder = Host.CreateApplicationBuilder(new HostApplicationBuilderSettings
        {
            ContentRootPath = AppContext.BaseDirectory
        });
        builder.Logging.ClearProviders();
        builder.Logging.AddConsole(o => o.LogToStandardErrorThreshold = LogLevel.Trace); // logs → stderr
        builder.Logging.SetMinimumLevel(verbose ? LogLevel.Debug : LogLevel.Warning);

        builder.Services.AddSingleton(TimeProvider.System);
        builder.Services.AddHttpClient<OrdersApiClient>();
        builder.Services.AddTransient<ImportHandler>();
        return builder.Build();
    }
}

internal sealed class ImportHandler(OrdersApiClient api, TimeProvider clock, ILogger<ImportHandler> logger)
{
    public async Task<int> RunAsync(FileInfo input, CancellationToken ct) { /* ... */ return ExitCodes.Success; }
}

Why: --help and parse errors stay instant because no host is built for them. The default console logger writes to stdout. Setting LogToStandardErrorThreshold = LogLevel.Trace sends every log line to stderr and keeps stdout clean. You never call host.RunAsync() for a run-to-completion CLI; you only want its container.

Interactive vs Non-Interactive

csharp
public static class Terminal
{
    public static bool IsInteractive =>
        !Console.IsInputRedirected && !Console.IsOutputRedirected &&
        Environment.GetEnvironmentVariable("CI") is null;
}

if (!force && Terminal.IsInteractive)
{
    Console.Error.Write($"Delete {count} orders? [y/N] ");
    if (Console.ReadLine()?.Trim() is not ("y" or "Y"))
        return ExitCodes.Cancelled;
}
else if (!force)
{
    await Console.Error.WriteLineAsync("Refusing to delete without --force in non-interactive mode.");
    return ExitCodes.ValidationFailed;
}

Why: a prompt in CI hangs the job forever. Detect redirection and fail fast with an actionable message. Every prompt needs a flag that bypasses it (--force, --yes). Prompts go to stderr so they never pollute piped stdout.

Reading stdin

csharp
Argument<FileInfo?> fileArgument = new("file") { Arity = ArgumentArity.ZeroOrOne };

TextReader reader = parseResult.GetValue(fileArgument) is { } file
    ? file.OpenText()
    : Console.In; // `cat orders.csv | mytool import`

while (await reader.ReadLineAsync(ct) is { } line)
    Process(line);

Why: accepting a file or stdin (the Unix - convention) makes the tool composable. Stream line by line; never ReadToEnd input of unknown size.

Packaging as a .NET Tool

xml
<PropertyGroup>
  <OutputType>Exe</OutputType>
  <TargetFramework>net10.0</TargetFramework>
  <PackAsTool>true</PackAsTool>
  <ToolCommandName>orders</ToolCommandName>
  <PackageId>Contoso.Orders.Cli</PackageId>
</PropertyGroup>
bash
dotnet pack -c Release
dotnet tool install --global --add-source ./bin/Release Contoso.Orders.Cli
dotnet tool exec Contoso.Orders.Cli -- import -i orders.csv   # .NET 10: run without installing
dnx Contoso.Orders.Cli import -i orders.csv                   # .NET 10 shorthand

Why: a tool package is versioned, installable and updatable through NuGet with no installer. In .NET 10 a tool can also ship platform-specific (RID) builds, including Native AOT, in one package. List the RIDs in <RuntimeIdentifiers> and add any as a framework-dependent fallback. See the native-aot skill.

File-Based Apps for Scripts (dotnet run app.cs)

csharp
#!/usr/bin/env -S dotnet --
#:package System.CommandLine@2.0.14

using System.CommandLine;

Argument<string> nameArgument = new("name");
RootCommand root = new("Greets someone") { nameArgument };
root.SetAction(parseResult => Console.WriteLine($"Hello, {parseResult.GetValue(nameArgument)}"));
return root.Parse(args).Invoke();
bash
dotnet run greet.cs -- Mukesh     # or: dotnet greet.cs Mukesh
dotnet publish greet.cs           # Native AOT executable by default
dotnet project convert greet.cs   # graduate to a .csproj when it grows

Why: single-file apps replace PowerShell/bash glue scripts with typed C#, with no .csproj. #:package without a version only works under central package management, so pin a version or use @*. Keep them outside any project folder so that project's Directory.Build.props doesn't leak in. Convert once the script needs tests or more than one file.

Anti-patterns

Logging to stdout

csharp
// BAD — default console logger + status text on stdout corrupts piped output
logger.LogInformation("Exporting...");
Console.WriteLine("Done!");
Console.WriteLine(json);

// GOOD — only the data goes to stdout
builder.Logging.AddConsole(o => o.LogToStandardErrorThreshold = LogLevel.Trace);
await Console.Error.WriteLineAsync("Exporting...");
Console.WriteLine(json);

Throwing for Expected Failures

csharp
// BAD — stack trace for a missing file, exit code is whatever the default handler picks
var text = File.ReadAllText(path);

// GOOD — validate at the boundary, return a documented exit code
if (!File.Exists(path))
{
    await Console.Error.WriteLineAsync($"File not found: {path}");
    return ExitCodes.NotFound;
}

Sync-over-Async and Ignored Tokens

csharp
// BAD — blocks a thread, Ctrl+C cannot interrupt the download
root.SetAction(parseResult => Console.WriteLine(client.GetStringAsync(url).Result));

// GOOD — async action, token flows to every call
root.SetAction(async (parseResult, ct) =>
{
    var body = await client.GetStringAsync(parseResult.GetValue(urlOption), ct);
    Console.WriteLine(body);
    return ExitCodes.Success;
});
return await root.Parse(args).InvokeAsync();

Don't mix sync Invoke() and async actions in one app. Pick async throughout.

Using Pre-2.0 System.CommandLine APIs

csharp
// BAD — beta API, removed in 2.0
rootCommand.SetHandler(async (FileInfo file) => { ... }, fileOption);
await new CommandLineBuilder(rootCommand).UseDefaults().Build().InvokeAsync(args);

// GOOD — 2.x API
rootCommand.SetAction((parseResult, ct) => RunAsync(parseResult.GetValue(fileOption)!, ct));
await rootCommand.Parse(args).InvokeAsync();

DateTime.Now in Tools

csharp
// BAD — untestable "older than 30 days" logic
var cutoff = DateTime.Now.AddDays(-30);

// GOOD — injectable clock (FakeTimeProvider in tests)
var cutoff = clock.GetUtcNow().AddDays(-30);

Decision Guide

ScenarioRecommendation
One-off script / automation glueFile-based app (dotnet run app.cs)
Script grows past one file or needs testsdotnet project convert, then a normal console project
Any tool with options or subcommandsSystem.CommandLine 2.x
Tool needs HTTP, config, or DIBuild a Host.CreateApplicationBuilder() inside the action, logs to stderr
Simple tool with no servicesNo host; plain static handlers
Rich terminal UI (tables, spinners)Spectre.Console, rendered to stderr unless it is the output
Distribute to .NET developersPackAsTool + NuGet (dotnet tool install / dnx)
Distribute to non-.NET usersNative AOT single binary per RID (see native-aot)
Long-running daemon, not run-to-completionWorker Service with host.RunAsync(), not a CLI
Destructive commandInteractive confirm + --force flag for CI
Testing a commandCall root.Parse([...]).InvokeAsync(new() { Output = sw, Error = errSw }) and assert exit code + output
aus demselben Repository

Weitere Skills

Alle Skills
codewithmukesh
Community

avalonia

Avalonia UI 12 for cross-platform .NET desktop apps: compiled bindings (x:DataType, on by default in 12), StyledProperty/DirectProperty, custom and templated controls, ControlThemes, DataTemplates/ViewLocator, Generic Host + DI, StorageProvider/Clipboard, Native AOT/trimming, and packaging. Load when the user mentions "Avalonia", "AXAML", "x:DataType", "compiled binding", "StyledProperty", "TemplatedControl", "ViewLocator", or "cross-platform desktop".

Installationen / 1h
3
GitHub Stars
804
Aktualisiert
4. Nov.
codewithmukesh
Community

desktop-mvvm

MVVM for .NET desktop apps (WPF, Avalonia, WinForms, MAUI) with CommunityToolkit.Mvvm 8.4+ source generators: [ObservableProperty] partial properties, [RelayCommand] async commands, Generic Host + DI, UI-thread dispatching, navigation, messaging, and testable view models. Load when the user mentions "MVVM", "ViewModel", "WPF", "desktop app", "ObservableProperty", "RelayCommand", "ICommand", "INotifyPropertyChanged", "Dispatcher", or "UI thread".

Installationen / 1h
3
GitHub Stars
804
Aktualisiert
4. Nov.
codewithmukesh
Community

native-aot

Native AOT, trimming, and single-file publishing for .NET 10: PublishAot, IsAotCompatible analyzers, source-generated JSON/regex/logging/config binding, fixing IL2xxx/IL3xxx trim and AOT warnings, per-RID publishing, and when NOT to use AOT. Load when the user mentions "Native AOT", "PublishAot", "trimming", "PublishTrimmed", "single-file", "IL2026", "IL3050", "trim warning", "startup time", "self-contained", or "JsonSerializerContext".

Installationen / 1h
3
GitHub Stars
804
Aktualisiert
4. Nov.
codewithmukesh
Community

token-report

Report how many tokens the Roslyn Navigator MCP server has saved, across one repository or all of them, with a per-tool leaderboard, adoption rate, and the methodology behind the number. Use when: "token report", "token savings", "how many tokens saved", "is the MCP worth it", "MCP usage", "tool leaderboard", "adoption rate", "which MCP tools are used", "prove the savings".

Installationen / 1h
3
GitHub Stars
804
Aktualisiert
4. Nov.