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