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.

Program.cs
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.

Program.cs
// 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.

Program.cs
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.

YourApp.Cli.csproj
<PropertyGroup>
  <OutputType>Exe</OutputType>
  <TargetFramework>net10.0</TargetFramework>
  <PackAsTool>true</PackAsTool>
  <ToolCommandName>yourapp</ToolCommandName>
  <PackageOutputPath>./nupkg</PackageOutputPath>
</PropertyGroup>
shell
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 from Main) so the tool composes correctly in shell scripts and CI pipelines — a script checking $? or if yourapp import ...; then depends on it.