Building a CLI Tool
Two ways to parse arguments — a zero-dependency approach for a quick script, and System.CommandLine for anything with subcommands, help text, or options that need validation — plus how to reuse the same DI/service layer from your API in a console app, and how to ship the result as an installable tool.
Zero-dependency: a tiny flag parser
For a one-off script this is often all you need — no package, no learning curve. Good enough for a handful of --flag value pairs.
var flags = ParseArgs(args);
string input = flags.GetValueOrDefault("--input") ?? throw new ArgumentException("--input is required");
bool verbose = flags.ContainsKey("--verbose");
Console.WriteLine($"Processing {input} (verbose: {verbose})");
static Dictionary<string, string?> ParseArgs(string[] args)
{
var result = new Dictionary<string, string?>();
for (int i = 0; i < args.Length; i++)
{
if (!args[i].StartsWith("--")) continue;
var next = i + 1 < args.Length ? args[i + 1] : null;
var hasValue = next != null && !next.StartsWith("--");
result[args[i]] = hasValue ? next : null;
if (hasValue) i++;
}
return result;
}
System.CommandLine: subcommands, options, built-in help
Worth reaching for once a tool grows past a handful of flags. It generates --help output, validates required options, and supports nested subcommands (mytool users create ...) without hand-rolled parsing.
// dotnet add package System.CommandLine --prerelease
var inputOption = new Option<string>("--input", "-i") { Required = true };
var verboseOption = new Option<bool>("--verbose", "-v");
var importCommand = new Command("import", "Import records from a file");
importCommand.Options.Add(inputOption);
importCommand.Options.Add(verboseOption);
importCommand.SetAction(parseResult =>
{
var input = parseResult.GetValue(inputOption);
var verbose = parseResult.GetValue(verboseOption);
Console.WriteLine($"Importing {input} (verbose: {verbose})");
});
var root = new RootCommand("YourApp CLI") { importCommand };
return root.Parse(args).Invoke();
Reusing the app's service layer
Same registration pattern as the API and Blazor projects (see Layered Architecture & DI) — build a minimal ServiceCollection, register Application/Infrastructure, resolve what the command needs. The CLI becomes a thin third front-end over the exact same business logic, not a separate reimplementation.
var configuration = new ConfigurationBuilder()
.AddJsonFile("appsettings.json", optional: false)
.Build();
var services = new ServiceCollection();
services.RegisterApplication();
services.RegisterInfrastructure(configuration.GetConnectionString("DefaultConnection")!);
await using var provider = services.BuildServiceProvider();
var resourceService = provider.GetRequiredService<IResourceService>();
var resources = await resourceService.GetAllAsync();
foreach (var r in resources)
Console.WriteLine($"{r.Id}: {r.Name}");
Shipping it as an installable tool
Package the console app as a .NET global tool so it's runnable as a plain command after dotnet tool install — no dotnet run, no remembering the project path.
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<PackAsTool>true</PackAsTool>
<ToolCommandName>yourapp</ToolCommandName>
<PackageOutputPath>./nupkg</PackageOutputPath>
</PropertyGroup>
dotnet pack
dotnet tool install --global --add-source ./nupkg YourApp.Cli
yourapp import --input data.csv --verbose
Exit codes matter for CLI tools in a way they don't for a web API: return a non-zero exit code on failure (Environment.Exit(1)or a non-zero return fromMain) so the tool composes correctly in shell scripts and CI pipelines — a script checking$?orif yourapp import ...; thendepends on it.