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 –
--helpexplains 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.Hostinggives 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.CliAdd packages:
dotnet add package System.CommandLine
dotnet add package Spectre.Console
dotnet add package Microsoft.Extensions.Hosting
dotnet add package Microsoft.Extensions.Logging.ConsoleDirectory 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.csprojProgram.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/todoand 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:
- 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>- 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- Publish to a private feed (Azure Artifacts, GitHub Packages, NuGet) and share
dotnet tool installinstructions.
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=trueReadyToRun & NativeAOT (advanced)
ReadyToRun precompiles IL to native code to speed up startup:
dotnet publish -c Release -p:PublishReadyToRun=trueNativeAOT 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 updateis typically enough – no custom updater required.
Real‑world rollout checklist (copy/paste)
--helpand 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
No. A plain console app with System.CommandLine and the Generic Host is enough for 95% of cases.
They solve different problems. Use System.CommandLine for parsing; use Spectre.Console for rendering (tables, progress). They play well together.
Log parser.Parse("...") in tests and inspect CommandResult. Add SetHandler overloads with explicit parameters to catch binding mistakes.
Trim dependencies, avoid reflection where possible, enable ReadyToRun. For extreme needs, consider NativeAOT.
Yes – boot the Generic Host (Host.CreateDefaultBuilder) and resolve services in handlers via DI. Keep handlers thin.
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.
Wrap strings with Resources and let --culture or OS culture drive rendering. Keep JSON output language‑neutral.
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.
