# .NET CLI Design Playbook

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

1. [Tech Stack](#1-tech-stack)
2. [Project Setup](#2-project-setup)
3. [Command Architecture](#3-command-architecture)
4. [Global Options](#4-global-options)
5. [Request Objects](#5-request-objects)
6. [Dependency Injection](#6-dependency-injection)
7. [Service Layer](#7-service-layer)
8. [Shell Execution](#8-shell-execution)
9. [Result Pattern](#9-result-pattern)
10. [Rich Terminal Output](#10-rich-terminal-output)
11. [Interactive Input](#11-interactive-input)
12. [Validation Patterns](#12-validation-patterns)
13. [Auto-discovered Subcommands](#13-auto-discovered-subcommands)
14. [Display Attributes Pattern](#14-display-attributes-pattern)
15. [Pre-flight Checks](#15-pre-flight-checks)
16. [Cancellation Support](#16-cancellation-support)
17. [Testing](#17-testing)
18. [Code Style Rules](#18-code-style-rules)
19. [XML Documentation](#19-xml-documentation)
20. [Code Standards Checklist](#20-code-standards-checklist)

---

## 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

### Project File (`.csproj`)

```xml
<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

```csharp
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;
```

---

## 3. Command Architecture

### 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

```csharp
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

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

```csharp
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());
    }
}
```

### Example Executable Command

```csharp
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;
    }
}
```

---

## 4. Global Options

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

### GlobalOptions.cs

```csharp
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

```csharp
rootCommand.AddGlobalOption(GlobalOptions.VerboseOption);
rootCommand.AddGlobalOption(GlobalOptions.DryRunOption);
```

### Adding a New Global Option

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`

---

## 5. Request Objects

### RequestBase

```csharp
public abstract record RequestBase(GlobalOptions GlobalOptions) : IRequest
{
    public bool Verbose => GlobalOptions.Verbose;
    public bool DryRun => GlobalOptions.DryRun;
}
```

### Request Record Rules

- **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

```csharp
// Correct
public record MyCommandRequest(
    string Name,
    string Path,
    GlobalOptions GlobalOptions
) : RequestBase(GlobalOptions);

// In HandleAsync, access via:
// request.Name, request.Path, request.Verbose, request.DryRun
```

---

## 6. Dependency Injection

### Service Registration

```csharp
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();
```

### Injection in Commands

```csharp
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

### Architecture

```
Commands → Domain Services → Infrastructure Services → External Systems
                ↓                      ↓
          ITaskService            IShellService
          IGitService             IFileSystemService
```

### Interface/Implementation Pattern

```csharp
// 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
}
```

### 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

### IShellService Interface

```csharp
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

```csharp
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

For operations that can fail without throwing exceptions:

```csharp
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);
}
```

### Usage

```csharp
// 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!;
```

---

## 10. Rich Terminal Output

Using Spectre.Console for colored, formatted output.

### Basic Markup

```csharp
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");
```

### Tables

```csharp
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

```csharp
// 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));
```

### Confirmations

```csharp
bool confirmed = AnsiConsole.Confirm("Are you sure you want to delete this?");
if (!confirmed)
{
    AnsiConsole.MarkupLine("[yellow]Operation cancelled.[/]");
    return 0;
}
```

---

## 11. Interactive Input

Extension methods on AnsiConsole for custom input patterns:

```csharp
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());
    }
}
```

### Usage

```csharp
// Multi-line input
string? description = AnsiConsole.PromptMultiLine("description", "brief summary of the task");

// With default
string branch = AnsiConsole.PromptWithDefault("Branch name:", $"feature/{taskName}");
```

---

## 12. Validation Patterns

### 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

```csharp
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

```csharp
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

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

---

## 13. Auto-discovered Subcommands

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

### Marker Interface

```csharp
public interface ITaskSubCommand { }
```

### Subcommand Implementation

```csharp
public class TaskAddCommand : BaseCommand<TaskAddRequest>, ITaskSubCommand
{
    // ... implementation
}

public class TaskListCommand : BaseCommand<TaskListRequest>, ITaskSubCommand
{
    // ... implementation
}
```

### Parent Command

```csharp
public class TaskCommand : Command
{
    public TaskCommand(IEnumerable<ITaskSubCommand> subCommands)
        : base("task", "Manage tasks")
    {
        AddAlias("tasks");

        foreach (ITaskSubCommand subCommand in subCommands)
        {
            AddCommand((Command)subCommand);
        }
    }
}
```

### Registration

```csharp
// 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>();
```

### 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

Declarative metadata on enum values for consistent display.

### Attribute Definition

```csharp
[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

```csharp
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)

```csharp
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;
}
```

### Usage

```csharp
// 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}");
}
```

---

## 15. Pre-flight Checks

Run validation before any command executes.

### Interface

```csharp
public interface IPreflightCheckService
{
    /// <summary>
    /// Run all checks. Returns true if passed or auto-fixed.
    /// </summary>
    Task<bool> RunChecksAsync(CancellationToken ct);
}
```

### Implementation

```csharp
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

```csharp
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

Proper handling of Ctrl+C, SIGINT, SIGTERM.

### System.CommandLine Provides the Token

```csharp
// 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
    }
}
```

### Service Method Signatures

Always include `CancellationToken` with a default value:

```csharp
public interface ITaskService
{
    Task<bool> ExistsAsync(string name, CancellationToken ct = default);
    Task CreateAsync(string name, CancellationToken ct = default);
}
```

### Respecting Cancellation

```csharp
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

### Test Project Setup

```xml
<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

```csharp
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

| 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

### Project Settings

```xml
<PropertyGroup>
    <Nullable>enable</Nullable>
    <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
```

### 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

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

```csharp
// 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);
}
```

---

## 19. XML Documentation

### Required Elements

All public members should have XML documentation:

```csharp
/// <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

| 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

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

### Example Structure

```markdown
# Code Standards

## 1. Exception Handling

### Never use empty catch blocks

```csharp
// WRONG
catch { }

// CORRECT
catch (Exception ex)
{
    errors.Add($"Error: {ex.Message}");
}
```

## 2. Type Safety

### Prefer enums over strings for known value sets

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

## 3. Validation Placement

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

## 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 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<T> 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);
   ```

2. **Create command class** (after request):
   ```csharp
   public class MyCommand : BaseCommand<MyCommandRequest>, IParentSubCommand
   ```

3. **Implement four methods**:
   - `RegisterArgumentsAndOptions()`
   - `ParseRequestAsync()`
   - `ValidateAsync()`
   - `HandleAsync()`

4. **Register in Program.cs**:
   ```csharp
   services.AddTransient<IParentSubCommand, MyCommand>();
   ```

5. **Build and verify**: `dotnet build` (0 warnings, 0 errors)