ConsoleAppFramework

repository·master·Indexed 24 days ago

https://github.com/cysharp/consoleappframework

A high-performance, zero-dependency, and AOT-safe CLI framework for .NET 8 and C# 13. It utilizes C# Source Generators to transform lambda expressions or method references into optimized static parsing code, eliminating reflection and runtime overhead. Features include automatic help and version generation, support for asynchronous commands with graceful shutdown via CancellationToken, nested command hierarchies, and flexible argument binding including JSON deserialization and custom value converters.

Tokens
9.9K
Snippets
29
Records
40
Agent score
31%

What's inside ConsoleAppFramework

  1. Automatic Help and Version options

    master

    ConsoleAppFramework automatically provides help and versioning:

    • Help: Invoked by providing -h or --help, or if no arguments are passed to the application.
    • Version: The --version option is available by default.

    The help text is generated at compile-time as string constants for maximum performance.

  2. Create nested command hierarchies

    master

    You can create deep command hierarchies by using space-separated paths in the command name.

    When using Add(string commandName, ...): Use names like "foo bar baz" to create nested levels. When using Add<T>(string commandPath): All commands within class T will be prefixed with the provided commandPath.

  3. Use Dependency Injection for command parameters

    master

    ConsoleAppFramework supports Dependency Injection (DI) for command parameters.

    • Lambda/Method parameters: Use the [FromServices] attribute to distinguish a service from a command argument.
    • Class/Constructor parameters: Use Constructor Injection by defining services in the class constructor. This results in a cleaner syntax.
    • Keyed Services: All DI-supported parameters (lambdas, methods, constructors, filters) also support [FromKeyedServices].

    ConfigureServices is called after command routing but before parameter parsing. The generated ServiceProvider is assigned to the static ConsoleApp.ServiceProvider.

  4. Pass state between filters and commands using ConsoleAppContext

    master

    Since ConsoleAppContext is an immutable record, you can pass data from a filter to a command by using the with syntax to update the State property. Commands can then access this state by accepting ConsoleAppContext as a parameter.

    Note: ConsoleAppContext.State is an object?, so you must cast it to your specific type inside the command.

  5. How parameter types are parsed

    master

    ConsoleAppFramework uses several strategies to convert string arguments into C# types:

    • Primitives: Uses TryParse for string, char, sbyte, byte, short, int, long, uint, ushort, ulong, decimal, float, and double.
    • ISpanParsable<T>: For types implementing ISpanParsable<T> (e.g., DateTime, Guid, BigInteger), it uses the standard TryParse methods.
    • Enums: Parsed using Enum.TryParse(ignoreCase: true).
    • Booleans: Treated as flags. They are always optional and become true if the parameter name is present in the arguments.
    • Arrays (T[]):
      • If the value starts with [, it is parsed via JsonSerializer.Deserialize.
      • Otherwise, it is parsed as comma-separated values (e.g., 1,2,3 or [1,2,3]).
      • Use [] for an empty array.
    • Params (params T[]): All subsequent arguments are captured into the array.
    • Objects: If no other pattern matches, JsonSerializer.Deserialize<T> is used.
      • Note: CancellationToken, ConsoleAppContext, and parameters marked with [FromServices] are excluded from JSON binding.
      • You can customize deserialization by setting ConsoleApp.JsonSerializerOptions.
  6. Handle Graceful Shutdown with CancellationToken

    master

    If you include a CancellationToken in your command parameters, ConsoleAppFramework automatically hooks into OS signals (SIGINT/SIGTERM/SIGKILL) to allow for graceful shutdowns.

    When an interruption (like Ctrl+C) occurs:

    1. The CancellationToken is set to a canceled state.
    2. The application waits for your code to handle the cancellation.
    3. If the application does not terminate within a specific timeout, it is forcibly terminated.

    The default timeout is 5 seconds. You can change this via ConsoleApp.Timeout. Setting ConsoleApp.Timeout = Timeout.InfiniteTimeSpan; disables the forced termination.

    You can also pass a CancellationToken directly to Run/RunAsync from the ConsoleAppBuilder to cancel the entire execution at any time.

  7. Implement and use Filters (Middleware)

    master

    Filters allow you to hook into the execution pipeline before and after a command runs. To create a filter, define an internal class that implements ConsoleAppFilter and overrides InvokeAsync.

    Filter Lifecycle:

    1. Call await Next.InvokeAsync(context, cancellationToken) to proceed to the next filter or the command body.
    2. Use try/catch/finally blocks within InvokeAsync to handle logic around the command execution.

    Attachment Levels: Filters can be attached at three levels. The execution order is global $\rightarrow$ class $\rightarrow$ method.

    • Global: Use app.UseFilter<T>() on the builder.
    • Class: Use [ConsoleAppFilter<T>] on the command class.
    • Method: Use [ConsoleAppFilter<T>] on the specific command method.
    // 1. Define the filter
    internal class NopFilter(ConsoleAppFilter next) : ConsoleAppFilter(next)
    {
        public override async Task InvokeAsync(ConsoleAppContext context, CancellationToken cancellationToken)
        {
            try
            {
                /* on before */
                await Next.InvokeAsync(context, cancellationToken);
                /* on after */
            }
            catch
            {
                /* on error */
                throw;
            }
            finally
            {
                /* on finally */
            }
        }
    }
    
    // 2. Attach filters
    var app = ConsoleApp.Create();
    
    // Global
    app.UseFilter<NopFilter>(); 
    
    app.Add<MyCommand>();
    app.Run(args);
    
    [ConsoleAppFilter<NopFilter>] // Per class
    public class MyCommand
    {
        [ConsoleAppFilter<NopFilter>] // Per method
        public void Echo(string msg) => Console.WriteLine(msg);
    }
  8. Integrate OpenTelemetry for Tracing, Metrics, and Logging

    master

    ConsoleAppFramework supports OpenTelemetry natively if you configure OpenTelemetry settings using Host.CreateApplicationBuilder() before converting it with .ToConsoleAppBuilder(). This allows you to capture traces, metrics, and logs from your commands, including instrumentation for HTTP clients and runtime metrics.

    builder.Services.AddOpenTelemetry()
        .UseOtlpExporter()
        .ConfigureResource(resource =>
        {
            resource.AddService("ConsoleAppFramework Telemetry Sample");
        })
        .WithMetrics(metrics =>
        {
            metrics.AddRuntimeInstrumentation()
                .AddHttpClientInstrumentation();
        })
        .WithTracing(tracing =>
        {
            tracing.SetSampler(new AlwaysOnSampler())
                .AddHttpClientInstrumentation()
                .AddSource(ConsoleAppFrameworkSampleActivitySource.Name);
        })
        .WithLogging(logging =>
        {
            // configure for logging
        });
    
    var app = builder.ToConsoleAppBuilder();
    
    app.Add<SampleCommand>();
    
    // setup filter
    app.UseFilter<CommandTracingFilter>();
    
    await app.RunAsync(args);
  9. Configure option aliases and descriptions using Document Comments

    master

    Instead of using attributes on parameters, you can provide descriptions and aliases by writing XML Document Comments. This keeps your code clean and readable.

    To define aliases, list them separated by | before the comma in the <param> tag. For example: -a|-b|--abcde, Description. will create aliases -a, -b, and --abcde with the provided description.

    Note: Because of current C# limitations, lambda expressions and local functions do not support document comments; you must use a class for this approach.

    ConsoleApp.Run(args, Commands.Hello);
    
    static class Commands
    {
        /// <summary>
        /// Display Hello.
        /// </summary>
        /// <param name="message">-m, Message to show.</param>
        public static void Hello(string message) => Console.Write($"Hello, {message}");
    }
  10. Bind application configuration from appsettings.json

    master

    You can bind JSON configuration files to strongly-typed options using Microsoft.Extensions.Configuration.Json and Microsoft.Extensions.Options.ConfigurationExtensions.

    1. Call .ConfigureDefaultConfiguration() to automatically set the base path to the current directory and add appsettings.json (optional).
    2. Use .ConfigureServices to bind sections of the configuration to your option classes using services.Configure<TOptions>(configuration.GetSection("SectionName")).
    3. Inject IOptions<TOptions> into your command classes via the constructor.
    // appsettings.json
    {
      "Position": {
        "Title": "Editor",
        "Name": "Joe Smith"
      }
    }
    
    // Program.cs
    var app = ConsoleApp.Create()
        .ConfigureDefaultConfiguration()
        .ConfigureServices((configuration, services) =>
        {
            services.Configure<PositionOptions>(configuration.GetSection("Position"));
        });
    
    app.Add<MyCommand>();
    app.Run(args);
    
    public class MyCommand(IOptions<PositionOptions> options)
    {
        public void Echo() => Console.WriteLine($"{options.Value.Title}");
    }
    
    public class PositionOptions
    {
        public string Title { get; set; } = "";
        public string Name { get; set; } = "";
    }
  11. Prevent automatic ServiceProvider disposal

    master

    By default, Run and RunAsync automatically dispose of the ServiceProvider after execution. If you need to execute commands multiple times within the same process or access the ServiceProvider after a command finishes, set disposeServiceProvider to false in the RunAsync method. You must then manually dispose of the ServiceProvider when your application lifecycle ends.

    try
    {
        while (Environment.ExitCode == 0)
        {
            var command = Console.ReadLine();
            if (command == null) break;
    
            // Set disposeServiceProvider to false to keep the provider alive
            await app.RunAsync(command.Split(' '), disposeServiceProvider: false);
        }
    }
    finally
    {
        // Manually dispose the ServiceProvider
        (ConsoleApp.ServiceProvider as IDisposable)?.Dispose();
    }
  12. Handle asynchronous commands and graceful shutdown

    master

    For asynchronous operations, use ConsoleApp.RunAsync. You can optionally include a CancellationToken as a parameter in your lambda.

    When a CancellationToken is present, the framework uses PosixSignalRegistration to handle SIGINT, SIGTERM, and SIGKILL (e.g., via Ctrl+C). If the signal is received, the token is marked as CancellationRequested, allowing for a graceful shutdown. If you do not include the CancellationToken in your method signature, these signals will not be handled and the program will terminate immediately.

    using ConsoleAppFramework;
    
    // --foo, --bar
    await ConsoleApp.RunAsync(args, async (int foo, int bar, CancellationToken cancellationToken) =>
    {
        await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken);
        Console.WriteLine($"Sum: {foo + bar}");
    });