Building Command‑Line Tools with C# (.NET Guide)

Building Command‑Line Tools with C#

Are you still writing tiny one‑off scripts that slowly turn into monsters? Good news: in under an hour you can turn that script into a fast, testable, cross‑platform CLI your team will actually enjoy using.

I’ve shipped a handful of internal tools over the years – log parsers, migration runners, release helpers – and the same lessons keep paying off. In this guide I’ll show you the patterns I use to build simple yet powerful command‑line apps with C# and .NET: clean parsing, pretty output, DI + logging, robust error handling, packaging as a dotnet global tool, and a sprinkle of performance tuning.

Why a “real” CLI beats a quick script

A solid CLI should be:

  • Discoverable--help explains everything, and usage errors are friendly.
  • Scriptable – stable exit codes and machine‑readable output when asked.
  • Fast – quick startup and predictable performance.
  • Cross‑platform – runs on Windows, macOS, Linux without tweaking.
  • Observable – opt‑in verbose logs for diagnostics (-v, --trace).
  • Idempotent – safe to run repeatedly in CI.

You can get all of this in .NET with very little ceremony.

Architecture at a glance

┌──────────────────────────────┐
│         Program.cs           │  → bootstraps Host + DI + parser
└───────┬───────────────┬──────┘
        │               │
   Commands         Infrastructure
(add/list/done)     (logging, config, I/O)
        │               │
   Handlers ←———→  Services (pure C#)

Keep the command handlers tiny and delegate logic to services you can test independently. Let the parser map CLI args to strongly typed options.

Choosing your building blocks

  • Parsing: [System.CommandLine] is the modern, official library for commands, options, validation, and tab completion. It’s flexible and fast.
  • Output: [Spectre.Console] renders tables, trees, progress bars, and uses ANSI when the terminal supports it.
  • Hosting: Microsoft.Extensions.Hosting gives you DI, configuration, and logging – just like ASP.NET Core but for console apps.

Alternatives exist (e.g., Spectre.Console.Cli, CommandLineParser). Use what your team prefers; I’ll use System.CommandLine + Spectre.Console + Generic Host because the combo is ergonomic and future‑proof.

Start a minimal project

mkdir todo-cli && cd todo-cli
dotnet new console -n Todo.Cli
cd Todo.Cli

Add packages:

dotnet add package System.CommandLine
dotnet add package Spectre.Console
dotnet add package Microsoft.Extensions.Hosting
dotnet add package Microsoft.Extensions.Logging.Console

Directory layout (after a few steps):

Todo.Cli/
  Program.cs
  Commands/
    AddCommand.cs
    ListCommand.cs
    DoneCommand.cs
  Services/
    TodoService.cs
  Models/
    TodoItem.cs
  appsettings.json
  Todo.Cli.csproj

Program.cs: host + parser in ~40 lines

using System.CommandLine;
using System.CommandLine.Builder;
using System.CommandLine.Parsing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Spectre.Console;

var builder = Host.CreateDefaultBuilder(args)
    .ConfigureAppConfiguration(cfg =>
    {
        cfg.AddJsonFile("appsettings.json", optional: true);
        cfg.AddEnvironmentVariables(prefix: "TODO_");
    })
    .ConfigureLogging(l => l.AddConsole())
    .ConfigureServices((ctx, services) =>
    {
        services.AddSingleton<TodoService>();
    });

using var host = builder.Build();

// Commands
var root = new RootCommand("A tiny yet mighty TODO CLI");

var storageOpt = new Option<string>(
    name: "--store",
    description: "Path to JSON storage (defaults to ~/.todo.json)");

root.AddGlobalOption(storageOpt);

var add = new Command("add", "Add a new task")
{
    new Argument<string>("text", "Task description")
};
add.SetHandler(async (string text, string? store, TodoService svc) =>
{
    await svc.InitializeAsync(store);
    var item = await svc.AddAsync(text);
    AnsiConsole.MarkupLine($"[green]Added[/]: {item.Id} {item.Text}");
}, new Argument<string>("text"), storageOpt, host.Services.GetRequiredService<TodoService>());

var list = new Command("list", "List tasks")
{
    new Option<bool>("--done", "Only completed"),
};
list.SetHandler(async (bool done, string? store, TodoService svc) =>
{
    await svc.InitializeAsync(store);
    var items = await svc.ListAsync(done);
    RenderTable(items);
}, new Option<bool>("--done"), storageOpt, host.Services.GetRequiredService<TodoService>());

var done = new Command("done", "Mark a task as done")
{
    new Argument<int>("id", "Task id")
};

done.SetHandler(async (int id, string? store, TodoService svc) =>
{
    await svc.InitializeAsync(store);
    await svc.CompleteAsync(id);
    AnsiConsole.MarkupLine($"[yellow]Completed[/]: {id}");
}, new Argument<int>("id"), storageOpt, host.Services.GetRequiredService<TodoService>());

root.AddCommand(add);
root.AddCommand(list);
root.AddCommand(done);

var parser = new CommandLineBuilder(root)
    .UseDefaults() // help, suggestions, error formatting
    .CancelOnProcessTermination()
    .Build();

try
{
    return await parser.InvokeAsync(args);
}
catch (Exception ex)
{
    host.Services.GetRequiredService<ILoggerFactory>()
        .CreateLogger("Todo.Cli").LogError(ex, "Unhandled error");
    AnsiConsole.MarkupLine("[red]Error:[/] " + ex.Message);
    return 1; // non‑zero exit code
}

static void RenderTable(IEnumerable<TodoItem> items)
{
    var table = new Table().RoundedBorder();
    table.AddColumn("Id");
    table.AddColumn("Text");
    table.AddColumn("Status");

    foreach (var i in items)
        table.AddRow(i.Id.ToString(), i.Text, i.Done ? "✓" : "");

    AnsiConsole.Write(table);
}

Models and services

// Models/TodoItem.cs
public record TodoItem(int Id, string Text, bool Done = false);
// Services/TodoService.cs
using System.Text.Json;

public class TodoService
{
    private readonly JsonSerializerOptions _json = new(JsonSerializerDefaults.Web)
    { WriteIndented = true };

    private string _path = string.Empty;
    private List<TodoItem> _items = new();

    public async Task InitializeAsync(string? store)
    {
        var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
        _path = store ?? Path.Combine(home, ".todo.json");
        if (!File.Exists(_path))
        {
            _items = new();
            await SaveAsync();
        }
        else
        {
            await using var s = File.OpenRead(_path);
            _items = (await JsonSerializer.DeserializeAsync<List<TodoItem>>(s, _json)) ?? new();
        }
    }

    public Task<TodoItem> AddAsync(string text)
    {
        var nextId = _items.Count == 0 ? 1 : _items.Max(i => i.Id) + 1;
        var item = new TodoItem(nextId, text);
        _items.Add(item);
        return SaveAsync().ContinueWith(_ => item);
    }

    public Task CompleteAsync(int id)
    {
        var idx = _items.FindIndex(i => i.Id == id);
        if (idx < 0) throw new InvalidOperationException($"Task {id} not found");
        _items[idx] = _items[idx] with { Done = true };
        return SaveAsync();
    }

    public Task<IReadOnlyList<TodoItem>> ListAsync(bool done)
    {
        IReadOnlyList<TodoItem> result = done ? _items.Where(i => i.Done).ToList() : _items;
        return Task.FromResult(result);
    }

    private async Task SaveAsync()
    {
        await using var s = File.Create(_path);
        await JsonSerializer.SerializeAsync(s, _items, _json);
    }
}

Now you have a functional, testable CLI. Let’s make it delightful.

UX polish that users notice

Helpful errors and exit codes

  • Return 0 for success, 1 for generic failures, and reserve other codes for specific scenarios (e.g., 2 for validation errors, 3 for not‑found).
  • Don’t write stack traces by default; print a concise message. Show full traces behind --trace.
root.SetHandler(async (InvocationContext ctx) =>
{
    try { /* run */ }
    catch (ValidationException vex)
    {
        ctx.Console.Error.WriteLine($"Validation: {vex.Message}");
        ctx.ExitCode = 2;
    }
});

Rich output when interactive, plain when scripted

Use Spectre.Console only when it makes sense:

var interactive = AnsiConsole.Profile.Capabilities.Interactive && !Console.IsOutputRedirected;
if (interactive)
{
    // spinner/progress
    await AnsiConsole.Status().StartAsync("Loading…", async _ => await WorkAsync());
}
else
{
    // CI-friendly
    Console.WriteLine("loading");
    await WorkAsync();
}

Built‑in --version, --verbose, and --no-color

Add a global verbosity option and respect NO_COLOR env var:

var verbose = new Option<bool>(["-v", "--verbose"], "Enable verbose logs");
root.AddGlobalOption(verbose);

var noColor = Environment.GetEnvironmentVariable("NO_COLOR") is not null;
AnsiConsole.Profile = AnsiConsole.Profile with { ColorSystem = noColor ? ColorSystem.NoColors : AnsiConsole.Profile.ColorSystem };

Configuration & secrets without surprises

  • Read from appsettings.json, then environment variables, then command‑line options (CLI should win).
  • For secrets, prefer environment variables or user secrets over plain files.
// appsettings.json
{
  "Storage": {
    "Path": "~/.todo.json"
  }
}
// map to options class if you prefer
builder.ConfigureServices((ctx, services) =>
{
    services.Configure<StorageOptions>(ctx.Configuration.GetSection("Storage"));
});

public class StorageOptions { public string? Path { get; set; } }

Tab completion (Bash/Zsh/PowerShell)

Good CLIs ship completion. System.CommandLine can generate scripts. A simple approach is to add a completions command that writes the script to stdout:

var completions = new Command("completions", "Generate shell completion script")
{
    new Argument<string>("shell", () => "bash", "bash|zsh|pwsh")
};

completions.SetHandler((string shell) =>
{
    var script = shell.ToLowerInvariant() switch
    {
        "bash" => BashCompletionScript.Create(root),
        "zsh"  => ZshCompletionScript.Create(root),
        "pwsh" => PwshCompletionScript.Create(root),
        _ => throw new ArgumentOutOfRangeException(nameof(shell))
    };
    Console.WriteLine(script);
}, new Argument<string>("shell"));

root.AddCommand(completions);

Tip: ship instructions like todo completions bash > /etc/bash_completion.d/todo and a one‑liner for users.

Testing strategies that keep you sane

Unit test handlers and services

Split logic from I/O so tests are trivial:

public class TodoServiceTests
{
    [Fact]
    public async Task Add_assigns_incremental_ids()
    {
        var svc = new TodoService();
        await svc.InitializeAsync(Path.GetTempFileName());
        var a = await svc.AddAsync("one");
        var b = await svc.AddAsync("two");
        Assert.Equal(a.Id + 1, b.Id);
    }
}

Parse tests without launching a process

var result = parser.Parse("add \"read book\"");
Assert.Equal("add", result.CommandResult.Command.Name);

End‑to‑end tests via dotnet test

You can spin the CLI as a child process and assert on stdout/stderr/exit code. Keep the output stable (no timestamps unless --verbose).

Packaging as a dotnet global tool

Turning your CLI into a global tool makes install UX buttery:

  1. Edit .csproj:
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
    <PackAsTool>true</PackAsTool>
    <ToolCommandName>todo</ToolCommandName>
    <PackageOutputPath>./nupkg</PackageOutputPath>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>
</Project>
  1. Pack & install locally:
dotnet pack -c Release
dotnet tool install --global Todo.Cli --add-source ./nupkg
# Usage:
.todo # or just `todo` if PATH is set
  1. Publish to a private feed (Azure Artifacts, GitHub Packages, NuGet) and share dotnet tool install instructions.

Bonus: versioned rollout is easy – dotnet tool update -g Todo.Cli.

Performance tuning: faster startup, smaller footprint

Console apps are already snappy, but for large tools or cold starts in CI you might want more.

Single‑file, trimmed publish

dotnet publish -c Release -r win-x64 --self-contained true \
  -p:PublishSingleFile=true -p:PublishTrimmed=true

ReadyToRun & NativeAOT (advanced)

ReadyToRun precompiles IL to native code to speed up startup:

dotnet publish -c Release -p:PublishReadyToRun=true

NativeAOT can make tiny, blazing‑fast binaries (no JIT). Requires trimming‑friendly code (avoid rampant reflection):

<PropertyGroup>
  <PublishAot>true</PublishAot>
  <InvariantGlobalization>true</InvariantGlobalization>
</PropertyGroup>

Measure before/after with Measure-Command (PowerShell) or /usr/bin/time (Linux/macOS).

Logging that respects the user

  • Default: info‑level messages.
  • -v/--verbose: include debug details.
  • --trace: include stack traces + scopes.
builder.ConfigureLogging(l =>
{
    l.ClearProviders();
    l.AddSimpleConsole(o =>
    {
        o.SingleLine = true;
        o.TimestampFormat = "HH:mm:ss ";
        o.UseUtcTimestamp = true;
    });
});

When printing machine‑readable output (e.g., --format json), silence logs or send logs to stderr so users can pipe stdout safely.

Making output machine‑friendly

Add a --format option and support text and json at minimum.

var format = new Option<string>("--format", () => "text", "text|json");
root.AddGlobalOption(format);

static void Print<T>(T obj, string fmt)
{
    if (fmt.Equals("json", StringComparison.OrdinalIgnoreCase))
        Console.WriteLine(JsonSerializer.Serialize(obj));
    else
        Console.WriteLine(obj?.ToString());
}

This allows scripts to do todo list --format json | jq.

Little things that feel big

  • ANSI detection: Respect NO_COLOR. Disable Unicode glyphs on Windows legacy consoles.
  • Width‑aware formatting: Wrap tables to Console.BufferWidth.
  • Caching: Cache expensive lookups (e.g., remote API) in %LOCALAPPDATA%/~/.cache.
  • Telemetry (opt‑in): If you must, make it explicit and document it.
  • Self‑update: For tools shipped as global tools, dotnet tool update is typically enough – no custom updater required.

Real‑world rollout checklist (copy/paste)

  • --help and examples are clear
  • Stable exit codes (0/1/2/3)
  • --version, --verbose, --trace, --no-color
  • Machine‑readable output (--format json)
  • Tab completion script
  • Unit + parse tests
  • CI job running e2e tests on Windows + Linux + macOS
  • Global tool packaging and README
  • Release notes with breaking‑change policy

FAQ: Building CLIs with C# without drama

Do I need a framework?

No. A plain console app with System.CommandLine and the Generic Host is enough for 95% of cases.

Spectre.Console or System.CommandLine.Cli?

They solve different problems. Use System.CommandLine for parsing; use Spectre.Console for rendering (tables, progress). They play well together.

How do I debug parsing issues?

Log parser.Parse("...") in tests and inspect CommandResult. Add SetHandler overloads with explicit parameters to catch binding mistakes.

How can I keep startup fast?

Trim dependencies, avoid reflection where possible, enable ReadyToRun. For extreme needs, consider NativeAOT.

Can I use dependency injection?

Yes – boot the Generic Host (Host.CreateDefaultBuilder) and resolve services in handlers via DI. Keep handlers thin.

How do I distribute inside my company?

Publish as a global tool to a private NuGet feed, or ship self‑contained binaries via your artifact store. Provide a one‑liner install in the README.

What about localization?

Wrap strings with Resources and let --culture or OS culture drive rendering. Keep JSON output language‑neutral.

How do I add authentication for APIs?

Support --token arg or read from TODO_TOKEN env var; store nothing unencrypted. Prefer DefaultAzureCredential/MSAL when integrating with cloud APIs.

Conclusion: Ship tiny tools that punch above their weight

You don’t need a framework avalanche to build a delightful CLI. With System.CommandLine for parsing, Spectre.Console for output, and the Generic Host for DI/logging, you’ll deliver tools that are fast, predictable, and a joy to automate. Package them as global tools, add completion, keep exit codes stable – and your teammates will thank you every time they type todo add.

What pain point in your team could a 200‑line CLI remove this week? Drop an idea in the comments – I read them all and often turn the best ones into follow‑up posts with code.

Leave a Reply

Your email address will not be published. Required fields are marked *