Skip to content

.NET CLI Design Playbook

A comprehensive guide for building production-quality command-line applications in .NET using System.CommandLine, Spectre.Console, and proven architectural patterns.

  1. Tech Stack
  2. Project Setup
  3. Command Architecture
  4. Global Options
  5. Request Objects
  6. Dependency Injection
  7. Service Layer
  8. Shell Execution
  9. Result Pattern
  10. Rich Terminal Output
  11. Interactive Input
  12. Validation Patterns
  13. Auto-discovered Subcommands
  14. Display Attributes Pattern
  15. Pre-flight Checks
  16. Cancellation Support
  17. Testing
  18. Code Style Rules
  19. XML Documentation
  20. Code Standards Checklist

ComponentPackagePurpose
CLI ParsingSystem.CommandLine (2.0.0-beta4)Arguments, options, command hierarchy
Rich OutputSpectre.Console (0.49.1)Colors, tables, prompts, progress
DI ContainerMicrosoft.Extensions.DependencyInjectionService registration and injection
TestingxUnit, NSubstitute, FluentAssertionsUnit tests with mocking

<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0" />
<PackageReference Include="System.CommandLine" Version="2.0.0-beta4.22272.1" />
<PackageReference Include="Spectre.Console" Version="0.49.1" />
</ItemGroup>
</Project>
using System.CommandLine;
using Microsoft.Extensions.DependencyInjection;
// 1. Build DI container
ServiceCollection services = new();
// Register services (see Section 6)
services.AddSingleton<IFileSystemService, FileSystemService>();
services.AddSingleton<IShellService, ShellService>();
// ... more services
// Register commands
services.AddTransient<TaskCommand>();
services.AddTransient<ITaskSubCommand, TaskAddCommand>();
services.AddTransient<ITaskSubCommand, TaskListCommand>();
ServiceProvider serviceProvider = services.BuildServiceProvider();
// 2. Create root command with global options
RootCommand rootCommand = new("My CLI Application");
rootCommand.AddGlobalOption(GlobalOptions.VerboseOption);
rootCommand.AddGlobalOption(GlobalOptions.DryRunOption);
// 3. Add commands from DI
rootCommand.AddCommand(serviceProvider.GetRequiredService<TaskCommand>());
// 4. Run pre-flight checks (see Section 15)
IPreflightCheckService preflight = serviceProvider.GetRequiredService<IPreflightCheckService>();
if (!await preflight.RunChecksAsync(CancellationToken.None))
{
await serviceProvider.DisposeAsync();
return 1;
}
// 5. Execute
int exitCode = await rootCommand.InvokeAsync(args.Length == 0 ? ["--help"] : args);
// 6. Cleanup
await serviceProvider.DisposeAsync();
return exitCode;

All executable commands inherit from BaseCommand<TRequest> which enforces a consistent flow:

Constructor → RegisterArgumentsAndOptions() → SetHandler
User invokes → ParseRequestAsync() → ValidateAsync() → HandleAsync()
using System.CommandLine;
using System.CommandLine.Invocation;
public interface IRequest { }
public abstract class BaseCommand<TRequest> : Command
where TRequest : IRequest
{
protected BaseCommand(string name, string description) : base(name, description)
{
RegisterArgumentsAndOptions();
this.SetHandler(HandleInvocationAsync);
}
/// <summary>
/// Register arguments and options. Called from constructor.
/// </summary>
protected abstract void RegisterArgumentsAndOptions();
/// <summary>
/// Parse invocation context into strongly-typed request.
/// </summary>
protected abstract Task<TRequest> ParseRequestAsync(InvocationContext context);
/// <summary>
/// Validate business rules. Return non-zero to stop execution.
/// </summary>
protected abstract Task<int> ValidateAsync(TRequest request, CancellationToken ct);
/// <summary>
/// Execute business logic. Only called if validation passes.
/// </summary>
protected abstract Task<int> HandleAsync(TRequest request, CancellationToken ct);
private async Task HandleInvocationAsync(InvocationContext context)
{
CancellationToken ct = context.GetCancellationToken();
try
{
TRequest request = await ParseRequestAsync(context);
int validationResult = await ValidateAsync(request, ct);
if (validationResult != 0)
{
context.ExitCode = validationResult;
return;
}
context.ExitCode = await HandleAsync(request, ct);
}
catch (OperationCanceledException)
{
context.ExitCode = 130; // Standard Unix exit code for Ctrl+C
}
}
}

For commands that only contain subcommands (no direct execution):

public abstract class BaseParentCommand : Command
{
protected BaseParentCommand(string name, string description) : base(name, description)
{
RegisterSubcommands();
}
protected abstract void RegisterSubcommands();
}
// Example usage
public class TaskCommand : BaseParentCommand
{
public TaskCommand() : base("task", "Manage tasks") { }
protected override void RegisterSubcommands()
{
AddCommand(new TaskAddCommand());
AddCommand(new TaskListCommand());
}
}
using System.CommandLine;
using System.CommandLine.Invocation;
using Spectre.Console;
// Request record - ALWAYS FIRST in file
public record TaskAddRequest(
string Name,
string[] Repositories,
bool Force,
GlobalOptions GlobalOptions
) : RequestBase(GlobalOptions);
// Command class - AFTER request
public class TaskAddCommand : BaseCommand<TaskAddRequest>
{
private readonly ITaskService taskService;
// Fields for arguments/options - initialized in RegisterArgumentsAndOptions
private Argument<string> nameArgument = null!;
private Option<string[]> reposOption = null!;
private Option<bool> forceOption = null!;
public TaskAddCommand(ITaskService taskService)
: base("add", "Create a new task")
{
this.taskService = taskService;
AddAlias("new"); // Command alias
}
protected override void RegisterArgumentsAndOptions()
{
nameArgument = new("name", "The task name");
reposOption = new(
aliases: ["--repos", "-r"],
description: "Repositories to include")
{
AllowMultipleArgumentsPerToken = true,
Arity = ArgumentArity.ZeroOrMore
};
forceOption = new(
aliases: ["--force", "-f"],
description: "Force creation even if exists",
getDefaultValue: () => false);
AddArgument(nameArgument);
AddOption(reposOption);
AddOption(forceOption);
}
protected override Task<TaskAddRequest> ParseRequestAsync(InvocationContext context)
{
string name = context.ParseResult.GetValueForArgument(nameArgument);
string[] repos = context.ParseResult.GetValueForOption(reposOption) ?? [];
bool force = context.ParseResult.GetValueForOption(forceOption);
GlobalOptions globals = GlobalOptions.Parse(context);
return Task.FromResult(new TaskAddRequest(name, repos, force, globals));
}
protected override async Task<int> ValidateAsync(TaskAddRequest request, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(request.Name))
{
AnsiConsole.MarkupLine("[red]Error: Task name cannot be empty.[/]");
return 1;
}
if (!request.Force && await taskService.ExistsAsync(request.Name, ct))
{
AnsiConsole.MarkupLine($"[red]Error: Task '{request.Name}' already exists.[/]");
return 1;
}
return 0;
}
protected override async Task<int> HandleAsync(TaskAddRequest request, CancellationToken ct)
{
if (request.Verbose)
AnsiConsole.MarkupLine($"[dim]Creating task '{request.Name}'...[/]");
if (request.DryRun)
{
AnsiConsole.MarkupLine($"[yellow]Dry-run: Would create task '{request.Name}'[/]");
return 0;
}
await taskService.CreateAsync(request.Name, request.Repositories, ct);
AnsiConsole.MarkupLine($"[green]Task '{request.Name}' created successfully.[/]");
return 0;
}
}

Global options are available to all commands without re-registration.

using System.CommandLine;
using System.CommandLine.Invocation;
public record GlobalOptions(
bool Verbose = false,
bool DryRun = false)
{
public static Option<bool> VerboseOption { get; } = new(
aliases: ["--verbose", "-v"],
description: "Enable verbose output",
getDefaultValue: () => false);
public static Option<bool> DryRunOption { get; } = new(
aliases: ["--dry-run", "-n"],
description: "Show what would be done without making changes",
getDefaultValue: () => false);
public static GlobalOptions Parse(InvocationContext context)
{
return new GlobalOptions(
Verbose: context.ParseResult.GetValueForOption(VerboseOption),
DryRun: context.ParseResult.GetValueForOption(DryRunOption));
}
}
rootCommand.AddGlobalOption(GlobalOptions.VerboseOption);
rootCommand.AddGlobalOption(GlobalOptions.DryRunOption);
  1. Add property to GlobalOptions record
  2. Add static Option<T> property
  3. Update Parse() method
  4. Add convenience property to RequestBase
  5. Register in Program.cs

public abstract record RequestBase(GlobalOptions GlobalOptions) : IRequest
{
public bool Verbose => GlobalOptions.Verbose;
public bool DryRun => GlobalOptions.DryRun;
}
  • Must implement IRequest (via RequestBase)
  • Must use record for immutability
  • Must appear FIRST in the command file, before the command class
  • Must NOT include CancellationToken (passed separately)
  • Should have GlobalOptions as the last parameter
// Correct
public record MyCommandRequest(
string Name,
string Path,
GlobalOptions GlobalOptions
) : RequestBase(GlobalOptions);
// In HandleAsync, access via:
// request.Name, request.Path, request.Verbose, request.DryRun

ServiceCollection services = new();
// Infrastructure services (singletons)
services.AddSingleton<IFileSystemService, FileSystemService>();
services.AddSingleton<IShellService, ShellService>();
// Domain services (singletons)
services.AddSingleton<ITaskService, TaskService>();
services.AddSingleton<IGitService, GitService>();
// Commands (transient)
services.AddTransient<TaskCommand>();
// Subcommands via marker interface
services.AddTransient<ITaskSubCommand, TaskAddCommand>();
services.AddTransient<ITaskSubCommand, TaskListCommand>();
services.AddTransient<ITaskSubCommand, TaskRemoveCommand>();
ServiceProvider serviceProvider = services.BuildServiceProvider();
public class TaskAddCommand : BaseCommand<TaskAddRequest>
{
private readonly ITaskService taskService;
private readonly IGitService gitService;
public TaskAddCommand(ITaskService taskService, IGitService gitService)
: base("add", "Create a new task")
{
this.taskService = taskService;
this.gitService = gitService;
}
}

Commands → Domain Services → Infrastructure Services → External Systems
↓ ↓
ITaskService IShellService
IGitService IFileSystemService
// Interface
public interface ITaskService
{
Task<bool> ExistsAsync(string name, CancellationToken ct = default);
Task CreateAsync(string name, IEnumerable<string> repos, CancellationToken ct = default);
Task<TaskLoadResult> GetAllAsync(CancellationToken ct = default);
}
// Implementation
public class TaskService : ITaskService
{
private readonly IFileSystemService fileSystem;
private readonly IShellService shell;
public TaskService(IFileSystemService fileSystem, IShellService shell)
{
this.fileSystem = fileSystem;
this.shell = shell;
}
public async Task<bool> ExistsAsync(string name, CancellationToken ct = default)
{
string path = GetTaskPath(name);
return fileSystem.DirectoryExists(path);
}
// ... other methods
}
CategoryPurposeExamples
DomainBusiness logicITaskService, IWorkspaceService
InfrastructureSystem operationsIShellService, IFileSystemService
ConfigurationSettings/configIConfigService
ValidationPre-checksIPreflightCheckService

public record ShellResult(
int ExitCode,
string StandardOutput,
string StandardError,
bool Success);
public interface IShellService
{
/// <summary>
/// Execute a command with arguments.
/// </summary>
Task<ShellResult> ExecuteAsync(
string command,
string arguments,
string? workingDirectory = null,
CancellationToken ct = default);
/// <summary>
/// Execute through shell (bash/cmd) for pipes and redirections.
/// </summary>
Task<ShellResult> ExecuteShellAsync(
string commandLine,
string? workingDirectory = null,
CancellationToken ct = default);
/// <summary>
/// Execute interactively, handing over terminal control.
/// </summary>
Task<ShellResult> ExecuteInteractiveAsync(
string command,
string arguments,
string? workingDirectory = null,
CancellationToken ct = default);
}
using System.Diagnostics;
using System.Runtime.InteropServices;
public class ShellService : IShellService
{
public async Task<ShellResult> ExecuteAsync(
string command,
string arguments,
string? workingDirectory = null,
CancellationToken ct = default)
{
ProcessStartInfo startInfo = new()
{
FileName = command,
Arguments = arguments,
WorkingDirectory = workingDirectory ?? Environment.CurrentDirectory,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
return await RunProcessAsync(startInfo, ct);
}
public async Task<ShellResult> ExecuteShellAsync(
string commandLine,
string? workingDirectory = null,
CancellationToken ct = default)
{
ProcessStartInfo startInfo;
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
startInfo = new()
{
FileName = "cmd.exe",
Arguments = $"/c \"{commandLine}\"",
WorkingDirectory = workingDirectory ?? Environment.CurrentDirectory,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
}
else
{
startInfo = new()
{
FileName = "/bin/bash",
Arguments = $"-c \"{commandLine.Replace("\"", "\\\"")}\"",
WorkingDirectory = workingDirectory ?? Environment.CurrentDirectory,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
}
return await RunProcessAsync(startInfo, ct);
}
public async Task<ShellResult> ExecuteInteractiveAsync(
string command,
string arguments,
string? workingDirectory = null,
CancellationToken ct = default)
{
ProcessStartInfo startInfo = new()
{
FileName = command,
Arguments = arguments,
WorkingDirectory = workingDirectory ?? Environment.CurrentDirectory,
RedirectStandardOutput = false,
RedirectStandardError = false,
RedirectStandardInput = false,
UseShellExecute = false,
CreateNoWindow = false
};
using Process? process = Process.Start(startInfo);
if (process == null)
{
return new ShellResult(-1, "", "Failed to start process", false);
}
await process.WaitForExitAsync(ct);
return new ShellResult(process.ExitCode, "", "", process.ExitCode == 0);
}
private static async Task<ShellResult> RunProcessAsync(
ProcessStartInfo startInfo,
CancellationToken ct)
{
using Process? process = Process.Start(startInfo);
if (process == null)
{
return new ShellResult(-1, "", "Failed to start process", false);
}
// Read stdout and stderr concurrently to avoid deadlocks
Task<string> stdoutTask = process.StandardOutput.ReadToEndAsync(ct);
Task<string> stderrTask = process.StandardError.ReadToEndAsync(ct);
await process.WaitForExitAsync(ct);
string stdout = await stdoutTask;
string stderr = await stderrTask;
return new ShellResult(process.ExitCode, stdout, stderr, process.ExitCode == 0);
}
}

For operations that can fail without throwing exceptions:

public class Result<T>
{
public T? Value { get; }
public string? ErrorMessage { get; }
public bool IsError => !string.IsNullOrWhiteSpace(ErrorMessage);
public bool IsSuccess => !IsError;
private Result(T? value, string? errorMessage)
{
Value = value;
ErrorMessage = errorMessage;
}
public static Result<T> Success(T value) => new(value, null);
public static Result<T> Error(string errorMessage) => new(default, errorMessage);
}
// In a service
public async Task<Result<string>> GetBranchNameAsync(string repoPath, CancellationToken ct)
{
if (!Directory.Exists(repoPath))
return Result<string>.Error($"Repository not found: {repoPath}");
ShellResult result = await shell.ExecuteAsync("git", "branch --show-current", repoPath, ct);
if (!result.Success)
return Result<string>.Error($"Git error: {result.StandardError}");
return Result<string>.Success(result.StandardOutput.Trim());
}
// In a command
Result<string> branchResult = await gitService.GetBranchNameAsync(path, ct);
if (branchResult.IsError)
{
AnsiConsole.MarkupLine($"[red]Error: {branchResult.ErrorMessage}[/]");
return 1;
}
string branch = branchResult.Value!;

Using Spectre.Console for colored, formatted output.

using Spectre.Console;
// Colors
AnsiConsole.MarkupLine("[green]Success![/]");
AnsiConsole.MarkupLine("[red]Error: Something went wrong.[/]");
AnsiConsole.MarkupLine("[yellow]Warning: Check your input.[/]");
AnsiConsole.MarkupLine("[dim]Verbose: Processing...[/]");
// Styles
AnsiConsole.MarkupLine("[bold]Important text[/]");
AnsiConsole.MarkupLine("[italic]Emphasis[/]");
AnsiConsole.MarkupLine("[bold green]Bold and green[/]");
// Escaping brackets
AnsiConsole.MarkupLine("Use [[double brackets]] to escape");
Table table = new();
table.AddColumn("Property");
table.AddColumn("Value");
table.AddRow("Name", $"[bold]{request.Name}[/]");
table.AddRow("Status", $"[green]Active[/]");
table.AddRow("Created", DateTime.Now.ToString("yyyy-MM-dd"));
AnsiConsole.Write(table);
// Single selection
string workspace = AnsiConsole.Prompt(
new SelectionPrompt<string>()
.Title("Select a [green]workspace[/]:")
.PageSize(10)
.AddChoices(workspaceNames));
// Multi-selection
List<string> repos = AnsiConsole.Prompt(
new MultiSelectionPrompt<string>()
.Title("Select [green]repositories[/]:")
.Required()
.PageSize(10)
.MoreChoicesText("[grey](Move up/down to see more)[/]")
.InstructionsText("[grey](Press [blue]<space>[/] to toggle, [green]<enter>[/] to accept)[/]")
.AddChoices(availableRepos));
bool confirmed = AnsiConsole.Confirm("Are you sure you want to delete this?");
if (!confirmed)
{
AnsiConsole.MarkupLine("[yellow]Operation cancelled.[/]");
return 0;
}

Extension methods on AnsiConsole for custom input patterns:

public static class AnsiConsoleExtensions
{
/// <summary>
/// Prompts for multi-line text input.
/// Press Enter twice to finish, or Enter immediately to skip.
/// </summary>
public static string? PromptMultiLine(
this IAnsiConsole console,
string fieldName,
string contextDescription)
{
console.MarkupLine($"Enter [green]{fieldName}[/] ({contextDescription}):");
console.MarkupLine("[dim]Press Enter twice to finish, or Enter to skip:[/]");
List<string> lines = [];
bool lastLineWasEmpty = false;
while (true)
{
string? line = Console.ReadLine();
if (line == null) break;
if (string.IsNullOrWhiteSpace(line))
{
if (lastLineWasEmpty || lines.Count == 0) break;
lastLineWasEmpty = true;
lines.Add(line);
}
else
{
lastLineWasEmpty = false;
lines.Add(line);
}
}
// Trim trailing empty lines
while (lines.Count > 0 && string.IsNullOrWhiteSpace(lines[^1]))
lines.RemoveAt(lines.Count - 1);
return lines.Count == 0 ? null : string.Join(Environment.NewLine, lines);
}
/// <summary>
/// Prompts for optional single-line input with a default value.
/// </summary>
public static string PromptWithDefault(
this IAnsiConsole console,
string prompt,
string defaultValue)
{
return console.Prompt(
new TextPrompt<string>(prompt)
.DefaultValue(defaultValue)
.AllowEmpty());
}
}
// Multi-line input
string? description = AnsiConsole.PromptMultiLine("description", "brief summary of the task");
// With default
string branch = AnsiConsole.PromptWithDefault("Branch name:", $"feature/{taskName}");

LevelWhereWhatExample
Parse-levelRegisterArgumentsAndOptions()Syntax, mutual exclusivity”—delete and —keep are exclusive”
Business rulesValidateAsync()Entity exists, permissions”Workspace ‘foo’ not found”
protected override void RegisterArgumentsAndOptions()
{
deleteOption = new(["--delete", "-d"], "Delete after processing");
keepOption = new(["--keep", "-k"], "Keep after processing");
AddOption(deleteOption);
AddOption(keepOption);
// Mutually exclusive options
this.AddValidator(result =>
{
bool hasDelete = result.GetValueForOption(deleteOption);
bool hasKeep = result.GetValueForOption(keepOption);
if (hasDelete && hasKeep)
{
result.ErrorMessage = "Cannot specify both --delete and --keep.";
}
});
}
protected override async Task<int> ValidateAsync(MyRequest request, CancellationToken ct)
{
// Entity must exist
if (!await workspaceService.ExistsAsync(request.WorkspaceName, ct))
{
AnsiConsole.MarkupLine($"[red]Error: Workspace '{request.WorkspaceName}' not found.[/]");
return 1;
}
// Warn but don't fail
if (request.Repositories.Length == 0)
{
AnsiConsole.MarkupLine("[yellow]Warning: No repositories specified.[/]");
}
return 0; // Validation passed
}

ParseRequestAsync() should ONLY extract and transform values. Never validate there.


Use marker interfaces to automatically discover and register subcommands via DI.

public interface ITaskSubCommand { }
public class TaskAddCommand : BaseCommand<TaskAddRequest>, ITaskSubCommand
{
// ... implementation
}
public class TaskListCommand : BaseCommand<TaskListRequest>, ITaskSubCommand
{
// ... implementation
}
public class TaskCommand : Command
{
public TaskCommand(IEnumerable<ITaskSubCommand> subCommands)
: base("task", "Manage tasks")
{
AddAlias("tasks");
foreach (ITaskSubCommand subCommand in subCommands)
{
AddCommand((Command)subCommand);
}
}
}
// Register parent command
services.AddTransient<TaskCommand>();
// Register all subcommands - parent discovers them automatically
services.AddTransient<ITaskSubCommand, TaskAddCommand>();
services.AddTransient<ITaskSubCommand, TaskListCommand>();
services.AddTransient<ITaskSubCommand, TaskRemoveCommand>();
  • Adding new subcommand: create class + register in DI. Done.
  • Parent command doesn’t change
  • All subcommands visible in one place (DI registration)

Declarative metadata on enum values for consistent display.

[AttributeUsage(AttributeTargets.Field, AllowMultiple = false)]
public sealed class StatusDisplayAttribute : Attribute
{
public string Icon { get; set; } = "?";
public string Color { get; set; } = "white";
public int Priority { get; set; } = 99;
}
public enum TaskState
{
[StatusDisplay(Icon = "", Color = "green", Priority = 0)]
Active,
[StatusDisplay(Icon = "", Color = "yellow", Priority = 1)]
Paused,
[StatusDisplay(Icon = "", Color = "blue", Priority = 2)]
Done,
[StatusDisplay(Icon = "", Color = "dim", Priority = 3)]
Archived
}
using System.Reflection;
public static class TaskStateHelper
{
private static readonly Dictionary<TaskState, StatusDisplayAttribute> Cache = [];
private static readonly StatusDisplayAttribute Default = new() { Icon = "?", Color = "white", Priority = 99 };
static TaskStateHelper()
{
foreach (TaskState state in Enum.GetValues<TaskState>())
{
FieldInfo? field = typeof(TaskState).GetField(state.ToString());
StatusDisplayAttribute? attr = field?.GetCustomAttribute<StatusDisplayAttribute>();
if (attr != null)
Cache[state] = attr;
}
}
public static string GetIcon(TaskState state) =>
Cache.TryGetValue(state, out StatusDisplayAttribute? attr) ? attr.Icon : Default.Icon;
public static string GetColor(TaskState state) =>
Cache.TryGetValue(state, out StatusDisplayAttribute? attr) ? attr.Color : Default.Color;
public static int GetPriority(TaskState state) =>
Cache.TryGetValue(state, out StatusDisplayAttribute? attr) ? attr.Priority : Default.Priority;
}
// In a list command
foreach (TaskInfo task in tasks.OrderBy(t => TaskStateHelper.GetPriority(t.Status)))
{
string icon = TaskStateHelper.GetIcon(task.Status);
string color = TaskStateHelper.GetColor(task.Status);
AnsiConsole.MarkupLine($"[{color}]{icon}[/] {task.Name}");
}

Run validation before any command executes.

public interface IPreflightCheckService
{
/// <summary>
/// Run all checks. Returns true if passed or auto-fixed.
/// </summary>
Task<bool> RunChecksAsync(CancellationToken ct);
}
public class PreflightCheckService : IPreflightCheckService
{
private readonly IShellService shell;
private readonly IFileSystemService fileSystem;
private readonly string configDirectory;
public PreflightCheckService(IShellService shell, IFileSystemService fileSystem)
{
this.shell = shell;
this.fileSystem = fileSystem;
this.configDirectory = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
".myapp");
}
public async Task<bool> RunChecksAsync(CancellationToken ct)
{
// Check 1: Required tool installed (cannot auto-fix)
if (!await IsGitInstalledAsync(ct))
{
AnsiConsole.MarkupLine("[red]Error: Git is not installed or not in PATH.[/]");
AnsiConsole.MarkupLine("[yellow]Please install Git and try again.[/]");
return false;
}
// Check 2: Config directory exists (auto-fix)
if (!fileSystem.DirectoryExists(configDirectory))
{
fileSystem.CreateDirectory(configDirectory);
AnsiConsole.MarkupLine($"[dim]Created config directory: {configDirectory}[/]");
}
// Check 3: Config file exists (auto-fix with defaults)
string configPath = Path.Combine(configDirectory, "config.yaml");
if (!fileSystem.FileExists(configPath))
{
await fileSystem.WriteAllTextAsync(configPath, "# MyApp Configuration\n", ct);
AnsiConsole.MarkupLine($"[dim]Created default config: {configPath}[/]");
}
return true;
}
private async Task<bool> IsGitInstalledAsync(CancellationToken ct)
{
try
{
ShellResult result = await shell.ExecuteAsync("git", "--version", null, ct);
return result.Success;
}
catch
{
return false;
}
}
}
IPreflightCheckService preflight = serviceProvider.GetRequiredService<IPreflightCheckService>();
bool checksPassedOrFixed = await preflight.RunChecksAsync(CancellationToken.None);
if (!checksPassedOrFixed)
{
await serviceProvider.DisposeAsync();
return 1; // Exit before any command runs
}

Proper handling of Ctrl+C, SIGINT, SIGTERM.

// In BaseCommand
private async Task HandleInvocationAsync(InvocationContext context)
{
CancellationToken ct = context.GetCancellationToken(); // Automatically wired
try
{
// ... execution
}
catch (OperationCanceledException)
{
context.ExitCode = 130; // Unix standard for Ctrl+C
}
}

Always include CancellationToken with a default value:

public interface ITaskService
{
Task<bool> ExistsAsync(string name, CancellationToken ct = default);
Task CreateAsync(string name, CancellationToken ct = default);
}
public async Task<List<TaskInfo>> GetAllAsync(CancellationToken ct = default)
{
List<TaskInfo> results = [];
foreach (string dir in directories)
{
ct.ThrowIfCancellationRequested(); // Check between iterations
TaskInfo? task = await LoadTaskAsync(dir, ct);
if (task != null)
results.Add(task);
}
return results;
}

<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<PackageReference Include="xunit" Version="2.6.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.4" />
<PackageReference Include="NSubstitute" Version="5.1.0" />
<PackageReference Include="FluentAssertions" Version="6.12.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\MyApp.Cli\MyApp.Cli.csproj" />
</ItemGroup>
</Project>
using FluentAssertions;
using NSubstitute;
public class TaskServiceTests
{
private readonly ITaskService sut;
private readonly IFileSystemService fileSystem;
private readonly IShellService shell;
public TaskServiceTests()
{
fileSystem = Substitute.For<IFileSystemService>();
shell = Substitute.For<IShellService>();
sut = new TaskService(fileSystem, shell);
}
[Fact]
public async Task ExistsAsync_WhenDirectoryExists_ReturnsTrue()
{
// Arrange
fileSystem.DirectoryExists(Arg.Any<string>()).Returns(true);
// Act
bool result = await sut.ExistsAsync("my-task");
// Assert
result.Should().BeTrue();
}
[Fact]
public async Task ExistsAsync_WhenDirectoryDoesNotExist_ReturnsFalse()
{
// Arrange
fileSystem.DirectoryExists(Arg.Any<string>()).Returns(false);
// Act
bool result = await sut.ExistsAsync("my-task");
// Assert
result.Should().BeFalse();
}
[Theory]
[InlineData("task-1")]
[InlineData("feature/login")]
[InlineData("bug-fix-123")]
public async Task ExistsAsync_WithVariousNames_ChecksCorrectPath(string taskName)
{
// Arrange
fileSystem.DirectoryExists(Arg.Any<string>()).Returns(true);
// Act
await sut.ExistsAsync(taskName);
// Assert
fileSystem.Received(1).DirectoryExists(Arg.Is<string>(p => p.Contains(taskName)));
}
[Fact]
public async Task CreateAsync_WhenCancelled_ThrowsOperationCanceledException()
{
// Arrange
CancellationTokenSource cts = new();
cts.Cancel();
// Act
Func<Task> act = async () => await sut.CreateAsync("task", cts.Token);
// Assert
await act.Should().ThrowAsync<OperationCanceledException>();
}
}
PatternUse Case
[Fact]Single test case
[Theory] + [InlineData]Data-driven tests
Substitute.For<T>()Create mock
mock.Returns(value)Set return value
mock.Received(n)Verify call count
result.Should().BeTrue()Fluent assertion

<PropertyGroup>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
RuleCorrectIncorrect
No varstring name = "foo";var name = "foo";
Target-typed newList<string> x = new();var x = new List<string>();
Collection expressionsstring[] x = ["a", "b"];new[] { "a", "b" }
Private fields: camelCasenameArgument_nameArgument
No readonly on late-initprivate Option<bool> opt = null!;private readonly Option<bool> opt = null!;

Fields assigned in RegisterArgumentsAndOptions() (called from base constructor):

// Correct: null! because assigned in RegisterArgumentsAndOptions
private Argument<string> nameArgument = null!;
private Option<bool> forceOption = null!;
protected override void RegisterArgumentsAndOptions()
{
nameArgument = new("name", "The name");
forceOption = new(["--force", "-f"], () => false);
AddArgument(nameArgument);
AddOption(forceOption);
}

All public members should have XML documentation:

/// <summary>
/// Provides task management operations including CRUD and querying.
/// </summary>
/// <remarks>
/// <para>
/// This service manages task lifecycle operations, handling:
/// </para>
/// <list type="bullet">
/// <item><description>Loading task metadata from disk</description></item>
/// <item><description>Creating new tasks</description></item>
/// <item><description>Querying tasks with filtering</description></item>
/// </list>
/// </remarks>
public interface ITaskService
{
/// <summary>
/// Checks whether a task exists.
/// </summary>
/// <param name="name">The task name to check.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns><c>true</c> if the task exists; otherwise <c>false</c>.</returns>
/// <example>
/// <code>
/// bool exists = await taskService.ExistsAsync("my-task");
/// </code>
/// </example>
Task<bool> ExistsAsync(string name, CancellationToken ct = default);
}
TagPurpose
<summary>Brief description
<param name="">Parameter description
<returns>Return value description
<remarks>Additional details
<example>Code examples
<list type="bullet">Bulleted list
<see cref=""/>Link to other type/member
<c>Inline code
<code>Code block

Create a CODE_STANDARDS.md file with rules and a review checklist.

# Code Standards
## 1. Exception Handling
### Never use empty catch blocks
```csharp
// WRONG
catch { }
// CORRECT
catch (Exception ex)
{
errors.Add($"Error: {ex.Message}");
}

Prefer enums over strings for known value sets

Section titled “Prefer enums over strings for known value sets”

Parse strings to enums at the boundary, not at point of use.

  • Parse-level (syntax): AddValidator() in RegisterArgumentsAndOptions()
  • Business rules: ValidateAsync()
  • Never validate in ParseRequestAsync()

Before completing any code task, verify:

  • No empty catch blocks
  • Known value sets use enums, not strings
  • No duplicate code between commands
  • Mutually exclusive options use AddValidator()
  • All async methods accept CancellationToken
  • All public members have XML documentation
  • Build produces 0 warnings, 0 errors
---
## Quick Reference: File Organization

src/ ├── MyApp.Cli/ │ ├── Program.cs # Entry point, DI setup │ ├── BaseCommand.cs # Template method base class │ ├── BaseParentCommand.cs # Parent command base class │ ├── GlobalOptions.cs # Global option definitions │ ├── RequestBase.cs # Base record for requests │ ├── IRequest.cs # Marker interface │ ├── Result.cs # Result pattern │ │ │ ├── TaskCommand.cs # Parent: task │ ├── TaskAddCommand.cs # Command: task add │ ├── TaskListCommand.cs # Command: task list │ ├── TaskRemoveCommand.cs # Command: task remove │ │ │ ├── Services/ │ │ ├── ITaskService.cs │ │ ├── TaskService.cs │ │ ├── IShellService.cs │ │ ├── ShellService.cs │ │ ├── IFileSystemService.cs │ │ ├── FileSystemService.cs │ │ ├── IPreflightCheckService.cs │ │ └── PreflightCheckService.cs │ │ │ ├── Configuration/ │ │ ├── TaskState.cs # Enum with StatusDisplayAttribute │ │ └── StatusDisplayAttribute.cs │ │ │ ├── Helpers/ │ │ ├── TaskStateHelper.cs # Enum display helper │ │ └── AnsiConsoleExtensions.cs # Input helpers │ │ │ └── MyApp.Cli.csproj │ └── MyApp.Cli.Tests/ ├── Services/ │ └── TaskServiceTests.cs └── MyApp.Cli.Tests.csproj

---
## Quick Reference: Adding a New Command
1. **Create request record** (first in file):
```csharp
public record MyCommandRequest(..., GlobalOptions GlobalOptions) : RequestBase(GlobalOptions);
  1. Create command class (after request):

    public class MyCommand : BaseCommand<MyCommandRequest>, IParentSubCommand
  2. Implement four methods:

    • RegisterArgumentsAndOptions()
    • ParseRequestAsync()
    • ValidateAsync()
    • HandleAsync()
  3. Register in Program.cs:

    services.AddTransient<IParentSubCommand, MyCommand>();
  4. Build and verify: dotnet build (0 warnings, 0 errors)