.NET CLI Design Playbook
.NET CLI Design Playbook
Section titled “.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.
Table of Contents
Section titled “Table of Contents”- Tech Stack
- Project Setup
- Command Architecture
- Global Options
- Request Objects
- Dependency Injection
- Service Layer
- Shell Execution
- Result Pattern
- Rich Terminal Output
- Interactive Input
- Validation Patterns
- Auto-discovered Subcommands
- Display Attributes Pattern
- Pre-flight Checks
- Cancellation Support
- Testing
- Code Style Rules
- XML Documentation
- Code Standards Checklist
1. Tech Stack
Section titled “1. Tech Stack”| Component | Package | Purpose |
|---|---|---|
| CLI Parsing | System.CommandLine (2.0.0-beta4) | Arguments, options, command hierarchy |
| Rich Output | Spectre.Console (0.49.1) | Colors, tables, prompts, progress |
| DI Container | Microsoft.Extensions.DependencyInjection | Service registration and injection |
| Testing | xUnit, NSubstitute, FluentAssertions | Unit tests with mocking |
2. Project Setup
Section titled “2. Project Setup”Project File (.csproj)
Section titled “Project File (.csproj)”<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>Program.cs Structure
Section titled “Program.cs Structure”using System.CommandLine;using Microsoft.Extensions.DependencyInjection;
// 1. Build DI containerServiceCollection services = new();
// Register services (see Section 6)services.AddSingleton<IFileSystemService, FileSystemService>();services.AddSingleton<IShellService, ShellService>();// ... more services
// Register commandsservices.AddTransient<TaskCommand>();services.AddTransient<ITaskSubCommand, TaskAddCommand>();services.AddTransient<ITaskSubCommand, TaskListCommand>();
ServiceProvider serviceProvider = services.BuildServiceProvider();
// 2. Create root command with global optionsRootCommand rootCommand = new("My CLI Application");rootCommand.AddGlobalOption(GlobalOptions.VerboseOption);rootCommand.AddGlobalOption(GlobalOptions.DryRunOption);
// 3. Add commands from DIrootCommand.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. Executeint exitCode = await rootCommand.InvokeAsync(args.Length == 0 ? ["--help"] : args);
// 6. Cleanupawait serviceProvider.DisposeAsync();return exitCode;3. Command Architecture
Section titled “3. Command Architecture”Template Method Pattern
Section titled “Template Method Pattern”All executable commands inherit from BaseCommand<TRequest> which enforces a consistent flow:
Constructor → RegisterArgumentsAndOptions() → SetHandler ↓User invokes → ParseRequestAsync() → ValidateAsync() → HandleAsync()BaseCommand Implementation
Section titled “BaseCommand Implementation”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 } }}Parent Command Pattern
Section titled “Parent Command Pattern”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 usagepublic class TaskCommand : BaseParentCommand{ public TaskCommand() : base("task", "Manage tasks") { }
protected override void RegisterSubcommands() { AddCommand(new TaskAddCommand()); AddCommand(new TaskListCommand()); }}Example Executable Command
Section titled “Example Executable Command”using System.CommandLine;using System.CommandLine.Invocation;using Spectre.Console;
// Request record - ALWAYS FIRST in filepublic record TaskAddRequest( string Name, string[] Repositories, bool Force, GlobalOptions GlobalOptions) : RequestBase(GlobalOptions);
// Command class - AFTER requestpublic 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; }}4. Global Options
Section titled “4. Global Options”Global options are available to all commands without re-registration.
GlobalOptions.cs
Section titled “GlobalOptions.cs”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)); }}Registration in Program.cs
Section titled “Registration in Program.cs”rootCommand.AddGlobalOption(GlobalOptions.VerboseOption);rootCommand.AddGlobalOption(GlobalOptions.DryRunOption);Adding a New Global Option
Section titled “Adding a New Global Option”- Add property to
GlobalOptionsrecord - Add static
Option<T>property - Update
Parse()method - Add convenience property to
RequestBase - Register in
Program.cs
5. Request Objects
Section titled “5. Request Objects”RequestBase
Section titled “RequestBase”public abstract record RequestBase(GlobalOptions GlobalOptions) : IRequest{ public bool Verbose => GlobalOptions.Verbose; public bool DryRun => GlobalOptions.DryRun;}Request Record Rules
Section titled “Request Record Rules”- Must implement
IRequest(viaRequestBase) - Must use
recordfor immutability - Must appear FIRST in the command file, before the command class
- Must NOT include
CancellationToken(passed separately) - Should have
GlobalOptionsas the last parameter
// Correctpublic record MyCommandRequest( string Name, string Path, GlobalOptions GlobalOptions) : RequestBase(GlobalOptions);
// In HandleAsync, access via:// request.Name, request.Path, request.Verbose, request.DryRun6. Dependency Injection
Section titled “6. Dependency Injection”Service Registration
Section titled “Service Registration”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 interfaceservices.AddTransient<ITaskSubCommand, TaskAddCommand>();services.AddTransient<ITaskSubCommand, TaskListCommand>();services.AddTransient<ITaskSubCommand, TaskRemoveCommand>();
ServiceProvider serviceProvider = services.BuildServiceProvider();Injection in Commands
Section titled “Injection in Commands”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; }}7. Service Layer
Section titled “7. Service Layer”Architecture
Section titled “Architecture”Commands → Domain Services → Infrastructure Services → External Systems ↓ ↓ ITaskService IShellService IGitService IFileSystemServiceInterface/Implementation Pattern
Section titled “Interface/Implementation Pattern”// Interfacepublic 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);}
// Implementationpublic 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}Service Categories
Section titled “Service Categories”| Category | Purpose | Examples |
|---|---|---|
| Domain | Business logic | ITaskService, IWorkspaceService |
| Infrastructure | System operations | IShellService, IFileSystemService |
| Configuration | Settings/config | IConfigService |
| Validation | Pre-checks | IPreflightCheckService |
8. Shell Execution
Section titled “8. Shell Execution”IShellService Interface
Section titled “IShellService Interface”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);}Implementation
Section titled “Implementation”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); }}9. Result Pattern
Section titled “9. Result Pattern”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 servicepublic 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 commandResult<string> branchResult = await gitService.GetBranchNameAsync(path, ct);if (branchResult.IsError){ AnsiConsole.MarkupLine($"[red]Error: {branchResult.ErrorMessage}[/]"); return 1;}string branch = branchResult.Value!;10. Rich Terminal Output
Section titled “10. Rich Terminal Output”Using Spectre.Console for colored, formatted output.
Basic Markup
Section titled “Basic Markup”using Spectre.Console;
// ColorsAnsiConsole.MarkupLine("[green]Success![/]");AnsiConsole.MarkupLine("[red]Error: Something went wrong.[/]");AnsiConsole.MarkupLine("[yellow]Warning: Check your input.[/]");AnsiConsole.MarkupLine("[dim]Verbose: Processing...[/]");
// StylesAnsiConsole.MarkupLine("[bold]Important text[/]");AnsiConsole.MarkupLine("[italic]Emphasis[/]");AnsiConsole.MarkupLine("[bold green]Bold and green[/]");
// Escaping bracketsAnsiConsole.MarkupLine("Use [[double brackets]] to escape");Tables
Section titled “Tables”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);Selection Prompts
Section titled “Selection Prompts”// Single selectionstring workspace = AnsiConsole.Prompt( new SelectionPrompt<string>() .Title("Select a [green]workspace[/]:") .PageSize(10) .AddChoices(workspaceNames));
// Multi-selectionList<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));Confirmations
Section titled “Confirmations”bool confirmed = AnsiConsole.Confirm("Are you sure you want to delete this?");if (!confirmed){ AnsiConsole.MarkupLine("[yellow]Operation cancelled.[/]"); return 0;}11. Interactive Input
Section titled “11. Interactive Input”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 inputstring? description = AnsiConsole.PromptMultiLine("description", "brief summary of the task");
// With defaultstring branch = AnsiConsole.PromptWithDefault("Branch name:", $"feature/{taskName}");12. Validation Patterns
Section titled “12. Validation Patterns”Two Levels of Validation
Section titled “Two Levels of Validation”| Level | Where | What | Example |
|---|---|---|---|
| Parse-level | RegisterArgumentsAndOptions() | Syntax, mutual exclusivity | ”—delete and —keep are exclusive” |
| Business rules | ValidateAsync() | Entity exists, permissions | ”Workspace ‘foo’ not found” |
Parse-Level Validation
Section titled “Parse-Level Validation”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."; } });}Business Rule Validation
Section titled “Business Rule Validation”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}Key Principle
Section titled “Key Principle”ParseRequestAsync() should ONLY extract and transform values. Never validate there.
13. Auto-discovered Subcommands
Section titled “13. Auto-discovered Subcommands”Use marker interfaces to automatically discover and register subcommands via DI.
Marker Interface
Section titled “Marker Interface”public interface ITaskSubCommand { }Subcommand Implementation
Section titled “Subcommand Implementation”public class TaskAddCommand : BaseCommand<TaskAddRequest>, ITaskSubCommand{ // ... implementation}
public class TaskListCommand : BaseCommand<TaskListRequest>, ITaskSubCommand{ // ... implementation}Parent Command
Section titled “Parent Command”public class TaskCommand : Command{ public TaskCommand(IEnumerable<ITaskSubCommand> subCommands) : base("task", "Manage tasks") { AddAlias("tasks");
foreach (ITaskSubCommand subCommand in subCommands) { AddCommand((Command)subCommand); } }}Registration
Section titled “Registration”// Register parent commandservices.AddTransient<TaskCommand>();
// Register all subcommands - parent discovers them automaticallyservices.AddTransient<ITaskSubCommand, TaskAddCommand>();services.AddTransient<ITaskSubCommand, TaskListCommand>();services.AddTransient<ITaskSubCommand, TaskRemoveCommand>();Benefits
Section titled “Benefits”- Adding new subcommand: create class + register in DI. Done.
- Parent command doesn’t change
- All subcommands visible in one place (DI registration)
14. Display Attributes Pattern
Section titled “14. Display Attributes Pattern”Declarative metadata on enum values for consistent display.
Attribute Definition
Section titled “Attribute Definition”[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;}Enum with Attributes
Section titled “Enum with Attributes”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}Helper Class (with Caching)
Section titled “Helper Class (with Caching)”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 commandforeach (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}");}15. Pre-flight Checks
Section titled “15. Pre-flight Checks”Run validation before any command executes.
Interface
Section titled “Interface”public interface IPreflightCheckService{ /// <summary> /// Run all checks. Returns true if passed or auto-fixed. /// </summary> Task<bool> RunChecksAsync(CancellationToken ct);}Implementation
Section titled “Implementation”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; } }}Usage in Program.cs
Section titled “Usage in Program.cs”IPreflightCheckService preflight = serviceProvider.GetRequiredService<IPreflightCheckService>();bool checksPassedOrFixed = await preflight.RunChecksAsync(CancellationToken.None);
if (!checksPassedOrFixed){ await serviceProvider.DisposeAsync(); return 1; // Exit before any command runs}16. Cancellation Support
Section titled “16. Cancellation Support”Proper handling of Ctrl+C, SIGINT, SIGTERM.
System.CommandLine Provides the Token
Section titled “System.CommandLine Provides the Token”// In BaseCommandprivate async Task HandleInvocationAsync(InvocationContext context){ CancellationToken ct = context.GetCancellationToken(); // Automatically wired
try { // ... execution } catch (OperationCanceledException) { context.ExitCode = 130; // Unix standard for Ctrl+C }}Service Method Signatures
Section titled “Service Method Signatures”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);}Respecting Cancellation
Section titled “Respecting Cancellation”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;}17. Testing
Section titled “17. Testing”Test Project Setup
Section titled “Test Project Setup”<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>Example Test Class
Section titled “Example Test Class”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>(); }}Testing Patterns
Section titled “Testing Patterns”| Pattern | Use 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 |
18. Code Style Rules
Section titled “18. Code Style Rules”Project Settings
Section titled “Project Settings”<PropertyGroup> <Nullable>enable</Nullable> <TreatWarningsAsErrors>true</TreatWarningsAsErrors></PropertyGroup>Enforced Rules
Section titled “Enforced Rules”| Rule | Correct | Incorrect |
|---|---|---|
No var | string name = "foo"; | var name = "foo"; |
Target-typed new | List<string> x = new(); | var x = new List<string>(); |
| Collection expressions | string[] x = ["a", "b"]; | new[] { "a", "b" } |
| Private fields: camelCase | nameArgument | _nameArgument |
No readonly on late-init | private Option<bool> opt = null!; | private readonly Option<bool> opt = null!; |
Field Initialization Pattern
Section titled “Field Initialization Pattern”Fields assigned in RegisterArgumentsAndOptions() (called from base constructor):
// Correct: null! because assigned in RegisterArgumentsAndOptionsprivate 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);}19. XML Documentation
Section titled “19. XML Documentation”Required Elements
Section titled “Required Elements”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);}Common Tags
Section titled “Common Tags”| Tag | Purpose |
|---|---|
<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 |
20. Code Standards Checklist
Section titled “20. Code Standards Checklist”Create a CODE_STANDARDS.md file with rules and a review checklist.
Example Structure
Section titled “Example Structure”# Code Standards
## 1. Exception Handling
### Never use empty catch blocks
```csharp// WRONGcatch { }
// CORRECTcatch (Exception ex){ errors.Add($"Error: {ex.Message}");}2. Type Safety
Section titled “2. Type Safety”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.
3. Validation Placement
Section titled “3. Validation Placement”- Parse-level (syntax):
AddValidator()inRegisterArgumentsAndOptions() - Business rules:
ValidateAsync() - Never validate in
ParseRequestAsync()
Review Checklist
Section titled “Review Checklist”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 Organizationsrc/
├── 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
---
## Quick Reference: Adding a New Command
1. **Create request record** (first in file): ```csharp public record MyCommandRequest(..., GlobalOptions GlobalOptions) : RequestBase(GlobalOptions);-
Create command class (after request):
public class MyCommand : BaseCommand<MyCommandRequest>, IParentSubCommand -
Implement four methods:
RegisterArgumentsAndOptions()ParseRequestAsync()ValidateAsync()HandleAsync()
-
Register in Program.cs:
services.AddTransient<IParentSubCommand, MyCommand>(); -
Build and verify:
dotnet build(0 warnings, 0 errors)