• Stars
    star
    1,635
  • Rank 28,601 (Top 0.6 %)
  • Language
    C#
  • License
    MIT License
  • Created almost 6 years ago
  • Updated about 2 months ago

Reviews

There are no reviews yet. Be the first to send feedback to the community and the maintainers!

Repository Details

Zero Dependency, Zero Overhead, Zero Reflection, Zero Allocation, AOT Safe CLI Framework powered by C# Source Generator.

ConsoleAppFramework

GitHub Actions Releases

ConsoleAppFramework is an infrastructure of creating CLI(Command-line interface) tools, daemon, and multi batch application. You can create full feature of command line tool on only one-line.

image

This simplicity is by C# 10.0 and .NET 6 new features, similar as ASP.NET Core 6.0 Minimal APIs.

Most minimal API is one-line(with top-level-statements, global-usings).

ConsoleApp.Run(args, (string name) => Console.WriteLine($"Hello {name}"));

Of course, ConsoleAppFramework has extensibility.

// Register two commands(use short-name, argument)
// hello -m
// sum [x] [y]
var app = ConsoleApp.Create(args);
app.AddCommand("hello", ([Option("m", "Message to display.")] string message) => Console.WriteLine($"Hello {message}"));
app.AddCommand("sum", ([Option(0)] int x, [Option(1)] int y) => Console.WriteLine(x + y));
app.Run();

You can register public method as command. This provides a simple way to registering multiple commands.

// AddCommands register as command.
// echo --msg --repeat(default = 3)
// sum [x] [y]
var app = ConsoleApp.Create(args);
app.AddCommands<Foo>();
app.Run();

public class Foo : ConsoleAppBase
{
    public void Echo(string msg, int repeat = 3)
    {
        for (var i = 0; i < repeat; i++)
        {
            Console.WriteLine(msg);
        }
    }

    public void Sum([Option(0)]int x, [Option(1)]int y)
    {
        Console.WriteLine((x + y).ToString());
    }
}

If you have many commands, you can define class separetely and use AddAllCommandType to register all commands one-line.

// Register `Foo` and `Bar` as SubCommands(You can also use AddSubCommands<T> to register manually).
// foo echo --msg
// foo sum [x] [y]
// bar hello2
var app = ConsoleApp.Create(args);
app.AddAllCommandType();
app.Run();

public class Foo : ConsoleAppBase
{
    public void Echo(string msg)
    {
        Console.WriteLine(msg);
    }

    public void Sum([Option(0)]int x, [Option(1)]int y)
    {
        Console.WriteLine((x + y).ToString());
    }
}

public class Bar : ConsoleAppBase
{
    public void Hello2()
    {
        Console.WriteLine("H E L L O");
    }
}

ConsoleAppFramework is built on .NET Generic Host, you can use configuration, logging, DI, lifetime management by Microsoft.Extensions packages. ConsoleAppFramework do parameter binding from string args, routing many commands, dotnet style help builder, etc.

image

Here is the full-sample of power of ConsoleAppFramework.

// You can use full feature of Generic Host(same as ASP.NET Core).

var builder = ConsoleApp.CreateBuilder(args);
builder.ConfigureServices((ctx,services) =>
{
    // Register EntityFramework database context
    services.AddDbContext<MyDbContext>();

    // Register appconfig.json to IOption<MyConfig>
    services.Configure<MyConfig>(ctx.Configuration);

    // Using Cysharp/ZLogger for logging to file
    services.AddLogging(logging =>
    {
        logging.AddZLoggerFile("log.txt");
    });
});

var app = builder.Build();

// setup many command, async, short-name/description option, subcommand, DI
app.AddCommand("calc-sum", (int x, int y) => Console.WriteLine(x + y));
app.AddCommand("sleep", async ([Option("t", "seconds of sleep time.")] int time) =>
{
    await Task.Delay(TimeSpan.FromSeconds(time));
});
app.AddSubCommand("verb", "childverb", () => Console.WriteLine("called via 'verb childverb'"));

// You can insert all public methods as sub command => db select / db insert
// or AddCommand<T>() all public methods as command => select / insert
app.AddSubCommands<DatabaseApp>();

// some argument from DI.
app.AddRootCommand((ConsoleAppContext ctx, IOptions<MyConfig> config, string name) => { });

app.Run();

// ----

[Command("db")]
public class DatabaseApp : ConsoleAppBase, IAsyncDisposable
{
    readonly ILogger<DatabaseApp> logger;
    readonly MyDbContext dbContext;
    readonly IOptions<MyConfig> config;

    // you can get DI parameters.
    public DatabaseApp(ILogger<DatabaseApp> logger,IOptions<MyConfig> config, MyDbContext dbContext)
    {
        this.logger = logger;
        this.dbContext = dbContext;
        this.config = config;
    }

    [Command("select")]
    public async Task QueryAsync(int id)
    {
        // select * from...
    }

    // also allow defaultValue.
    [Command("insert")]
    public async Task InsertAsync(string value, int id = 0)
    {
        // insert into...
    }

    // support cleanup(IDisposable/IAsyncDisposable)
    public async ValueTask DisposeAsync()
    {
        await dbContext.DisposeAsync();
    }
}

public class MyConfig
{
    public string FooValue { get; set; } = default!;
    public string BarValue { get; set; } = default!;
}

ConsoleAppFramework can create easily to many command application. Also enable to use GenericHost configuration is best way to share configuration/workflow when creating batch application for other .NET web app. If tool is for CI, git pull and run by dotnet run -- [Command] [Option] is very helpful.

dotnet's standard CommandLine api - System.CommandLine is low level, require many boilerplate codes. ConsoleAppFramework is like ASP.NET Core in CLI Applications, no needs boilerplate. However, with the power of Generic Host, it is simple and easy, but much more powerful.

Table of Contents

Getting Started

NuGet: ConsoleAppFramework

Install-Package ConsoleAppFramework

If you are using .NET 6, automatically enabled implicit global using ConsoleAppFramework;. So you can write one line code.

ConsoleApp.Run(args, (string name) => Console.WriteLine($"Hello {name}"));

You can execute command like sampletool --name "foo".

The Option parser is no longer needed. You can also use the OptionAttribute to describe the parameter and set short-name.

ConsoleApp.Run(args, ([Option("n", "name of send user.")] string name) => Console.WriteLine($"Hello {name}"));
Usage: sampletool [options...]

Options:
  -n, --name <String>    name of user. (Required)

Commands:
  help       Display help.
  version    Display version.

Method parameter will be required parameter, optional parameter will be oprional parameter with default value. Also support boolean flag, if parameter is bool, in default it will be optional parameter and with --foo set true to parameter.

// lambda expression does not support default value so require to use local function
static void Hello([Option("m")]string message, [Option("e")] bool end, [Option("r")] int repeat = 3)
{
    for (int i = 0; i < repeat; i++)
    {
        Console.WriteLine(message);
    }
    if (end)
    {
        Console.WriteLine("END");
    }
}

ConsoleApp.Run(args, Hello);
Options:
  -m, --message <String>     (Required)
  -e, --end                  (Optional)
  -r, --repeat <Int32>       (Default: 3)

help command (or no argument to pass) and version command is enabled in default(You can disable this in options or can override by add same name of command). Also enables command --help option. This help format is similar as dotnet command, version command shows AssemblyInformationalVersion or AssemblylVersion.

> sampletool help
Usage: sampletool [options...]

Options:
  -n, --name <String>     name of user. (Required)
  -r, --repeat <Int32>    repeat count. (Default: 3)

Commands:
  help          Display help.
  version       Display version.
> sampletool version
1.0.0

You can use Run<T> or AddCommands<T> to add multi commands easily.

ConsoleApp.Run<MyCommands>(args);

// require to inherit ConsoleAppBase
public class MyCommands : ConsoleAppBase
{
    //  You can receive DI services in constructor.

    // All public methods is registred.

    // Using [RootCommand] attribute will be root-command
    [RootCommand]
    public void Hello(
        [Option("n", "name of send user.")] string name,
        [Option("r", "repeat count.")] int repeat = 3)
    {
        for (int i = 0; i < repeat; i++)
        {
            Console.WriteLine($"Hello My ConsoleApp from {name}");
        }
    }

    // [Option(int)] describes that parameter is passed by index
    [Command("escape")]
    public void UrlEscape([Option(0)] string input)
    {
        Console.WriteLine(Uri.EscapeDataString(input));
    }

    // define async method returns Task
    [Command("timer")]
    public async Task Timer([Option(0)] uint waitSeconds)
    {
        Console.WriteLine(waitSeconds + " seconds");
        while (waitSeconds != 0)
        {
            // ConsoleAppFramework does not stop immediately on terminate command(Ctrl+C)
            // for allows gracefully shutdown(keeping safe cleanup)
            // so you should pass Context.CancellationToken to async method.
            // If not, abort timeout by HostOptions.ShutdownTimeout(default is 00:00:05).
            await Task.Delay(TimeSpan.FromSeconds(1), Context.CancellationToken);
            waitSeconds--;
            Console.WriteLine(waitSeconds + " seconds");
        }
    }
}

You can call like

sampletool -n "foo" -r 3
sampletool escape http://foo.bar/
sampletool timer 10

This is recommended way to register multi commands.

If you omit [Command] attribute, command and option name is used by there name and convert to kebab-case in default.

// Command is url-escape
// Option  is --input-file
public void UrlEscape(string inputFile)
{
}

This converting behaviour can configure by ConsoleAppOptions.NameConverter.

ConsoleApp / ConsoleAppBuilder

ConsoleApp is an entrypoint of creating ConsoleAppFramework app. It has three APIs, Create, CreateBuilder, CreateFromHostBuilder and Run.

// Create is shorthand of CraeteBuilder(args).Build();
var app = ConsoleApp.Create(args);

// Builder returns IHost so you can configure application hosting option.
var app = ConsoleApp.CreateBuilder(args)
    .ConfigureServices(services =>
    {
    })
    .Build();

// Run is shorthand of Create(args).AddRootCommand(rootCommand).Run();
// If you want to create simple app, this API is faster.
ConsoleApp.Run(args, /* lambda expression */);

// Run<T> is shorthand of Create(args).AddCommands<T>().Run();
// AddCommands<T> is recommend option to register many commands.
ConsoleApp.Run<MyCommands>(args);

When calling Create/CreateBuilder/CreateFromHostBuilder, also configure ConsoleAppOptions. Full option details, see ConsoleAppOptions section.

var app = ConsoleApp.Create(args, options =>
{
    options.ShowDefaultCommand = false;
    options.NameConverter = x => x.ToLower();
});

Advanced API of ConsoleApp, CreateFromHostBuilder creates ConsoleApp from IHostBuilder.

// Setup services outside of ConsoleAppFramework.
var hostBuilder = Host.CreateDefaultBuilder()
    .ConfigureServices();
    
var app = ConsoleApp.CreateFromHostBuilder(hostBuilder);

ConsoleAppBuilder itself is IHostBuilder so you can use any configuration methods like ConfigureServices, ConfigureLogging, etc. If method chain is not returns ConsoleAppBuilder(for example, using external lib's extension methods), can not get ConsoleApp directly. In that case, use BuildAsConsoleApp() instead of Build().

ConsoleApp exposes some utility properties.

  • IHost Host
  • ILogger<ConsoleApp> Logger
  • IServiceProvider Services
  • IConfiguration Configuration
  • IHostEnvironment Environment
  • IHostApplicationLifetime Lifetime

Run() and RunAsync(CancellationToken) to finally invoke application. Run is shorthand of RunAsync().GetAwaiter().GetResult() so receives same result of await RunAsync(). On Entrypoint, there is not much need to do await RunAsync(). Therefore, it is usually a good to choose Run().

Delegate convention

AddCommand accepts Delegate in argument. In C# 10.0 allows naturaly syntax of lambda expressions.

app.AddCommand("no-argument", () => { });
app.AddCommand("any-arguments",  (int x, string y, TimeSpan z) => { });
app.AddCommand("instance", new MyClass().Cmd);
app.AddCommand("async", async () => { });
app.AddCommand("attribute", ([Option("msg")]string message) => { });

static void Hello1() { }
app.AddCommand("local-static", Hello1);

void Hello2() { }
app.AddCommand("local-method", Hello2);

async Task Async() { }
app.AddCommand("async-method", Async);

void OptionalParameter(int x = 10, int y = 20) { }
app.AddCommand("optional", OptionalParameter);

public class MyClass
{
    public void Cmd()
    {
        Console.WriteLine("OK");
    }
}

lambda expressions can not use optional parameter so if you want to need it, using local/static functions.

Delegate(both lambda and method) allows to receive ConsoleAppContext or any your DI types. DI types is ignored as parameter.

// option is --param1, --param2
app.AddCommand("di", (ConsoleAppContext ctx, ILogger logger, int param1, int param2) => { });

AddCommand

AddRootCommand

RootCommand means default(no command name) command of application. ConsoleApp.Run(Delegate) uses root command.

AddCommand / AddCommands<T>

AddCommand requires first argument as command-name. AddCommands<T> allows to register many command via ConsoleAppBase ConsoleAppBase has Context, it has executing information and CancellationToken.

// Commands:
//   hello
//   world
app.AddCommands<MyCommands>();
app.Run();

// Inherit ConsoleAPpBase
public class MyCommands : ConsoleAppBase, IDisposable
{
    readonly ILogger<MyCommands> logger;

    //  You can receive DI services in constructor.
    public MyCommands(ILogger<MyCommands> logger)
    {
        this.logger = logger;
    }

    // All public methods is registered.

    // Using [RootCommand] attribute will be root-command
    [RootCommand]
    public void Hello() 
    {
        // Context has any useful information.
        Console.WriteLine(this.Context.Timestamp);
    }

    public async Task World() 
    {
        await Task.Delay(1000, this.Context.CancellationToken);
    }

    // If implements IDisposable, called for cleanup
    public void Dispose()
    {
    }
}

AddSubCommand / AddSubCommands<T>

AddSubCommand(string parentCommandName, string commandName, Delegate command) registers nested command.

// Commands:
//   foo bar1
//   foo bar2
//   foo bar3
app.AddSubCommand("foo", "bar1", () => { });
app.AddSubCommand("foo", "bar2", () => { });
app.AddSubCommand("foo", "bar3", () => { });

AddSubCommands<T> is similar as AddCommands<T> but used type-name(or [Command] name) as parentCommandName.

// Commands:
//   my-commands hello
//   my-commands world
app.AddSubCommands<MyCommands>();

AddAllCommandType

AddAllCommandType searches all ConsoleAppBase type in assembly and register by AddSubCommands<T>.

// Commands:
//   foo echo
//   foo sum
//   bar hello2
app.AddAllCommandType();

// Batches.
public class Foo : ConsoleAppBase
{
    public void Echo(string msg)
    {
        Console.WriteLine(msg);
    }

    public void Sum(int x, int y)
    {
        Console.WriteLine((x + y).ToString());
    }
}

public class Bar : ConsoleAppBase
{
    public void Hello2()
    {
        Console.WriteLine("H E L L O");
    }
}

This is most easy to create many commands so useful for application batch that requires many many command.

Commands are searched from loaded assemblies(in default AppDomain.CurrentDomain.GetAssemblies()), when does not touch other assemblies type, it will be trimmed and can not load it. In that case, use AddAllCommandType(params Assembly[] searchAssemblies) overload to pass target assembly, for example AddAllCommandType(typeof(Foo).Assembly) preserve types.

Complex Argument

If the argument is not primitive, you can pass JSON string.

public class ComplexArgTest : ConsoleAppBase
{
    public void Foo(int[] array, Person person)
    {
        Console.WriteLine(string.Join(", ", array));
        Console.WriteLine(person.Age + ":" + person.Name);
    }
}

public class Person
{
    public int Age { get; set; }
    public string Name { get; set; }
}

You can call like here.

> sampletool -array [10,20,30] -person {"Age":10,"Name":"foo"}

# including space, use escaping
> SampleApp.exe -array [10,20,30] -person "{\"Age\":10,\"Name\":\"foo bar\"}"

be careful with JSON string double quotation.

For the array handling, it can be a treat without correct JSON. e.g. one-length argument can handle without [].

Foo(int[] array)
> SampleApp.exe -array 9999

multiple-argument can handle by split with or ,.

Foo(int[] array)
> SampleApp.exe -array "11 22 33"
> SampleApp.exe -array "11,22,33"
> SampleApp.exe -array "[11,22,33]"

string argument can handle without ".

Foo(string[] array)
> SampleApp.exe -array hello
> SampleApp.exe -array "foo bar baz"
> SampleApp.exe -array foo,bar,baz
> SampleApp.exe -array "["foo","bar","baz"]"

Exit Code

If the method returns int or Task<int> or `ValueTask value, ConsoleAppFramework will set the return value to the exit code.

public class ExampleApp : ConsoleAppBase
{
    [Command("exit")]
    public int ExitCode()
    {
        return 12345;
    }
    
    [Command("exit-with-task")]
    public async Task<int> ExitCodeWithTask()
    {
        return 54321;
    }
}

NOTE: If the method throws an unhandled exception, ConsoleAppFramework always set 1 to the exit code.

Implicit Using

In .NET 6, global using ConsoleAppFramework is enabled in default. If you remove global using, setup this element to target .csproj.

<ItemGroup>
    <Using Remove="ConsoleAppFramework" />
</ItemGroup>

CommandAttribute

CommandAttribute enables subscommand on RunConsoleAppFramework<T>()(for single type CLI app), changes command name on RunConsoleAppFramework()(for muilti type command routing), also describes the description.

RunConsoleAppFramework<App>();

public class App : ConsoleAppBase
{
    // as Root Command(no command argument)
    public void Run()
    {
    }

    [Command("sec", "sub comman of this app")]
    public void Second()
    {
    }
}
RunConsoleAppFramework();

public class App2 : ConsoleAppBase
{
    // routing command: `app2 exec`
    [Command("exec", "exec app.")]
    public void Exec1()
    {
    }
}

// command attribute also can use to class.
[Command("mycmd")]
public class App3 : ConsoleAppBase
{
     // routing command: `mycmd e2`
    [Command("e2", "exec app 2.")]
    public void ExecExec()
    {
    }
}

OptionAttribute

OptionAttribute configure parameter, it can set shortName or order index, and help description.

If you want to add only description, set "" or null to shortName parameter.

public void Hello(
    [Option("n", "name of send user.")]string name,
    [Option("r", "repeat count.")]int repeat = 3)
{
}

[Command("escape")]
public void UrlEscape([Option(0, "input of this command")]string input)
{
}

[Command("unescape")]
public void UrlUnescape([Option(null, "input of this command")]string input)
{
}

Command parameters validation

Values of command parameters can be validated via validation attributes from System.ComponentModel.DataAnnotations namespace and custom ones inheriting ValidationAttribute type.

using System.ComponentModel.DataAnnotations;
// ...

internal class TestConsoleApp : ConsoleAppBase
{
    [Command("some-command")]
    public void SomeCommand(
        [EmailAddress] string firstArg,
        [Range(0, 2)]  int secondArg) => Console.WriteLine($"hello from {nameof(TestConsoleApp)}");
}

Output (command invoked with params [--first-arg "invalid-email-address" --second-arg" 10])

Some parameters have invalid values:
first-arg (invalid-email-address): The String field is not a valid e-mail address.
second-arg (10): The field Int32 must be between 0 and 2.

Daemon

If use infinite-loop, it becomes daemon program. ConsoleAppContext.CancellationToken is lifecycle token of application. You can check CancellationToken.IsCancellationRequested and shutdown gracefully.

public class Daemon : ConsoleAppBase
{
    [RootCommand]
    public async Task Run()
    {
        // you can write infinite-loop while stop request(Ctrl+C or docker terminate).
        try
        {
            while (!this.Context.CancellationToken.IsCancellationRequested)
            {
                try
                {
                    Console.WriteLine("Wait One Minutes");
                }
                catch (Exception ex)
                {
                    // error occured but continue to run(or terminate).
                    Console.WriteLine(ex, "Found error");
                }

                // wait for next time
                await Task.Delay(TimeSpan.FromMinutes(1), this.Context.CancellationToken);
            }
        }
        catch (Exception ex) when (!(ex is OperationCanceledException))
        {
            // you can write finally exception handling(without cancellation)
        }
        finally
        {
            // you can write cleanup code here.
        }
    }
}

Abort Timeout

ConsoleAppFramework's execution lifetime is managed via generic host. If you do cancel(Ctrl+C), host starts cancellation process with timeout. If you don't pass CancellationToken in async method, does not cancel immediately.

public async Task Wait1() 
{
    // Not good.
    await Task.Delay(TimeSpan.FromMinutes(60));
}

public async Task Wait2() 
{
    // Good.
    await Task.Delay(TimeSpan.FromMinutes(60), this.Context.CancellationToken);
}

Default timeout time is 00:00:05, you can change via ConfigureHostOptions.

var app = ConsoleApp.CreateBuilder(args)
    .ConfigureHostOptions(options =>
    {
        // change timeout.
        options.ShutdownTimeout = TimeSpan.FromMinutes(30);
    })
    .Build();

Filter

Filter can hook before/after batch running event. You can implement ConsoleAppFilter for it and attach to global/class/method.

public class MyFilter : ConsoleAppFilter
{
    // Filter is instantiated by DI so you can get parameter by constructor injection.

    public async override ValueTask Invoke(ConsoleAppContext context, Func<ConsoleAppContext, ValueTask> next)
    {
        try
        {
            /* on before */
            await next(context); // next
        }
        catch
        {
            /* on after */
            throw;
        }
        finally
        {
            /* on finally */
        }
    }
}

ConsoleAppContext.Timestamp has start time so if subtraction from now, get elapsed time.

public class LogRunningTimeFilter : ConsoleAppFilter
{
    public override async ValueTask Invoke(ConsoleAppContext context, Func<ConsoleAppContext, ValueTask> next)
    {
        context.Logger.LogInformation("Call method at " + context.Timestamp.ToLocalTime()); // LocalTime for human readable time
        try
        {
            await next(context);
            context.Logger.LogInformation("Call method Completed successfully, Elapsed:" + (DateTimeOffset.UtcNow - context.Timestamp));
        }
        catch
        {
            context.Logger.LogInformation("Call method Completed Failed, Elapsed:" + (DateTimeOffset.UtcNow - context.Timestamp));
            throw;
        }
    }
}

In default, ConsoleAppFramework does not prevent double startup but if create filter, can do.

public class MutexFilter : ConsoleAppFilter
{
    public override async ValueTask Invoke(ConsoleAppContext context, Func<ConsoleAppContext, ValueTask> next)
    {
        var name = context.MethodInfo.DeclaringType.Name + "." + context.MethodInfo.Name;
        using (var mutex = new Mutex(true, name, out var createdNew))
        {
            if (!createdNew)
            {
                throw new Exception($"already running {name} in another process.");
            }
            
            await next(context);
        }
    }
}

There filters can pass to ConsoleAppOptions.GlobalFilters on startup or attach by attribute on class, method.

var app = ConsoleApp.Create(args, options =>
{
    options.GlobalFilters = new ConsoleAppFilter[]
    {
        new MutextFilter() { Order = -9999 } ,
        new LogRunningTimeFilter() { Oder = -9998 }, 
    }
});

[ConsoleAppFilter(typeof(MyFilter3))]
public class MyBatch : ConsoleAppBase
{
    [ConsoleAppFilter(typeof(MyFilter4), Order = -9999)]
    [ConsoleAppFilter(typeof(MyFilter5), Order = 9999)]
    public void Do()
    {
    }
}

Execution order can control by int Order property.

Logging

In default, Context.Logger has ILogger<ConsoleApp> and ILogger<T> can inject to constructor. Default ConsoleLogger format in Host.CreateDefaultBuilder is supernumerary and not suitable for console application. ConsoleAppFramework provides SimpleConsoleLogger to replace default ConsoleLogger in default. If you want to keep default ConsoleLogger, use ConsoleAppOptions.ReplaceToUseSimpleConsoleLogger to false.

If you want to use high performance logger/output to file, also use Cysharp/ZLogger that easy to integrate ConsoleAppFramework.

using ZLogger;

var app = ConsoleApp.CreateDefaultBuilder(args)
    .ConfigureLogging(x =>
    {
        x.ClearProviders(); // clear all providers
        x.SetMinimumLevel(LogLevel.Trace); // change log level if you want

        x.AddZLoggerConsole(); // add ZLogger Console
        x.AddZLoggerFile("fileName.log"); // add ZLogger file output
    })
    .Build();

Configuration

ConsoleAppFramework is just an infrastructure. You can add appsettings.json or other configs as .NET Core offers via Microsoft.Extensions.Options. You can add appsettings.json and appsettings.{environment}.json and typesafe load via map config to Class w/IOption.

Here's single contained batch with Config loading sample.

// appconfig.json(Content, Copy to Output Directory)
{
  "Foo": 42,
  "Bar": true
}
using Microsoft.Extensions.DependencyInjection;

var app = ConsoleApp.CreateBuilder(args)
    .ConfigureServices((hostContext, services) =>
    {
        // mapping config json to IOption<MyConfig>
        // requires "Microsoft.Extensions.Options.ConfigurationExtensions" package
        // if you want to map subscetion in json, use Configure<T>(hostContext.Configuration.GetSection("foo"))
        services.Configure<MyConfig>(hostContext.Configuration);
    })
    .Build();

public class ConfigAppSample : ConsoleAppBase
{
    MyConfig config;

    // get configuration from DI.
    public ConfigAppSample(IOptions<MyConfig> config)
    {
        this.config = config.Value;
    }

    public void ShowOption()
    {
        Console.WriteLine(config.Bar);
        Console.WriteLine(config.Foo);
    }
}

for the details, please see .NET Core Generic Host documentation.

DI

You can use DI(constructor injection) by GenericHost.

IOptions<MyConfig> config;
ILogger<MyApp> logger;

public MyApp(IOptions<MyConfig> config, ILogger<MyApp> logger)
{
    this.config = config;
    this.logger = logger;
}

DI also allows delegate registration.

app.AddCommand("di", (ConsoleAppContext ctx, ILogger logger, int param1, int param2) => { });

DI also inject to filter.

Cleanup

You can implement IDisposable.Dispose or IAsyncDisposable.DisposeAsync explicitly, that is called after command finished.

public class MyApp : ConsoleAppBase, IDisposable
{
    public void Hello()
    {
        Console.WriteLine("Hello");
    }

    // Dispose/DisposeAsync method is not registered as Command.
    public void Dispose()
    {
        Console.WriteLine("DISPOSED");
    }
}

If implements both IDisposable and IAsyncDisposable, called only IAsyncDisposable.

public class MyApp : ConsoleAppBase, IDisposable, IAsyncDisposable
{
    public void Hello()
    {
        Console.WriteLine("Hello");
    }

    public void Dispose()
    {
        Console.WriteLine("Not called.");
    }
        
    public async ValueTask DisposeAsync()
    {
        Console.WriteLine("called.");
    }
}

ConsoleAppContext

ConsoleAppContext is injected to property on method executing.

public class ConsoleAppContext
{
    public string?[] Arguments { get; }
    public DateTime Timestamp { get; }
    public CancellationToken CancellationToken { get; }
    public ILogger<ConsoleAppEngine> Logger { get; }
    public MethodInfo MethodInfo { get; }
    public IServiceProvider ServiceProvider { get; }
    public IDictionary<string, object> Items { get; }

    public void Cancel();
    public void Terminate();
}

Cancel() set CancellationToken to canceled. Also Terminate() set token to cancled and terminate process(internal throws OperationCanceledException immediately).

ConsoleAppOptions

You can configure framework behaviour by ConsoleAppOptions.

var app = ConsoleApp.Create(args, options =>
{
    options.StrictOption = false, // default is true.
    options.ShowDefaultCommand = false, // default is true
});
public class ConsoleAppOptions
{
    /// <summary>Argument parser uses strict(-short, --long) option. Default is true.</summary>
    public bool StrictOption { get; set; } = true;

    /// <summary>Show default command(help/version) to help. Default is true.</summary>
    public bool ShowDefaultCommand { get; set; } = true;

    public bool ReplaceToUseSimpleConsoleLogger { get; set; } = true;

    public JsonSerializerOptions? JsonSerializerOptions { get; set; }

    public ConsoleAppFilter[]? GlobalFilters { get; set; }

    public bool NoAttributeCommandAsImplicitlyDefault { get; set; }

    public Func<string, string> NameConverter { get; set; } = KebabCaseConvert;

    public string? ApplicationName { get; set; } = null;
}

If StrictOption = false, does not distinguish between the number of -. For example, this method

public void Hello([Option("m", "Message to display.")]string message)

can pass argument by -m, --message and -message. This is styled like a go lang command. But if you want to strictly distinguish argument of -, set StrcitOption = true(default), that allows -m and --message.

Also, by default, the help and version commands appear as help, which can be hidden by setting ShowDefaultCommand = false.

NameConverter is used type-name, method-name, parameter-name converting as command. Default is convert to lower kebab-case.

// my-command query-data --organization-id --user-id
public class MyCommand
{
    public void QueryData(string organizationId, string userId);
}

You can set func to change this behaviour like NameConverter = x => x.ToLower();.

ApplicationName configure help usages Usage: ***, default(null) shows filename without extension.

Terminate handling in Console.Read

ConsoleAppFramework handle terminate signal(Ctrl+C) gracefully with ConsoleAppContext.CancellationToken. If your application waiting with Console.Read/ReadLine/ReadKey, requires additional handling.

// case of Console.Read/ReadLine, pressed Ctrl+C, Read returns null.
ConsoleApp.Run(args, (ConsoleAppContext ctx) =>
{
    var read = Console.ReadLine();
    if (read == null) ctx.Terminate();
});
// case of Console.ReadKey, can not cancel itself so use with Task.Run and WaitAsync.
ConsoleApp.Run(args, async (ConsoleAppContext ctx) =>
{
    var key = await Task.Run(() => Console.ReadKey()).WaitAsync(ctx.CancellationToken);
});

Publish to executable file

dotnet run is useful for local development or execute in CI tool. For example in CI, git pull and execute by dotnet run -- --options is easy to manage and execute utilities.

dotnet publish to create executable file. .NET Core 3.0 offers Single Executable File via PublishSingleFile.

CLI tool can use .NET Core Local/Global Tools. If you want to create it, check the Tutorial: Create a .NET tool using the .NET CLI and Use a global tool or Use a local tool.

v3 Legacy Compatibility

v1-v3 does not exist minimal api style(ConsoleApp.Create/CreateBuilder).

await Host.CreateDefaultBuilder()
    .RunConsoleAppFrameworkAsync<Program>(args);

RunConsoleAppFrameworkAsync is still exists but does not recommend to use. Also, since v4, there is a change in the default behavior. When RunConsoleAppFrameworkAsync is used, the option settings of v3 and earlier will be used.

options.NoAttributeCommandAsImplicitlyDefault = true;
options.StrictOption = false;
options.NameConverter = x => x.ToLower();
options.ReplaceToUseSimpleConsoleLogger = false;

You can also get this option setting in ConsoleAppOptions.CreateLegacyCompatible().

License

This library is under the MIT License.

More Repositories

1

UniTask

Provides an efficient allocation free async/await integration for Unity.
C#
8,201
star
2

MagicOnion

Unified Realtime/API framework for .NET platform and Unity.
C#
3,838
star
3

MemoryPack

Zero encoding extreme performance binary serializer for C# and Unity.
C#
3,288
star
4

R3

The new future of dotnet/reactive and UniRx.
C#
2,177
star
5

ZString

Zero Allocation StringBuilder for .NET and Unity.
C#
2,060
star
6

MasterMemory

Embedded Typed Readonly In-Memory Document Database for .NET and Unity.
C#
1,521
star
7

MessagePipe

High performance in-memory/distributed messaging pipeline for .NET and Unity.
C#
1,406
star
8

Ulid

Fast .NET C# Implementation of ULID for .NET and Unity.
C#
1,314
star
9

ZLogger

Zero Allocation Text/Structured Logger for .NET with StringInterpolation and Source Generator, built on top of a Microsoft.Extensions.Logging.
C#
1,262
star
10

SimdLinq

Drop-in replacement of LINQ aggregation operations extremely faster with SIMD.
C#
775
star
11

csbindgen

Generate C# FFI from Rust for automatically brings native code and C native library to .NET and Unity.
Rust
688
star
12

ObservableCollections

High performance observable collections and synchronized views, for WPF, Blazor, Unity.
C#
559
star
13

ProcessX

Simplify call an external process with the async streams in C# 8.0.
C#
453
star
14

YetAnotherHttpHandler

YetAnotherHttpHandler brings the power of HTTP/2 (and gRPC) to Unity and .NET Standard.
C#
354
star
15

UnitGenerator

C# Source Generator to create value-object, inspired by units of measure.
C#
330
star
16

RuntimeUnitTestToolkit

CLI/GUI Frontend of Unity Test Runner to test on any platform.
C#
300
star
17

AlterNats

An alternative high performance NATS client for .NET.
C#
284
star
18

NativeMemoryArray

Utilized native-memory backed array for .NET and Unity - over the 2GB limitation and support the modern API(IBufferWriter, ReadOnlySequence, scatter/gather I/O, etc...).
C#
276
star
19

StructureOfArraysGenerator

Structure of arrays source generator to make CPU Cache and SIMD friendly data structure for high-performance code in .NET and Unity.
C#
262
star
20

MagicPhysX

.NET PhysX 5 binding to all platforms(win, osx, linux) for 3D engine, deep learning, dedicated server of gaming.
Rust
258
star
21

PrivateProxy

Source Generator and .NET 8 UnsafeAccessor based high-performance strongly-typed private accessor for unit testing and runtime.
C#
239
star
22

KcpTransport

KcpTransport is a Pure C# implementation of RUDP for high-performance real-time network communication
C#
237
star
23

LogicLooper

A library for building server application using loop-action programming model on .NET.
C#
237
star
24

DFrame

Distributed load testing framework for .NET and Unity.
C#
223
star
25

Utf8StreamReader

Utf8 based StreamReader for high performance text processing.
C#
208
star
26

LitJWT

Lightweight, Fast JWT(JSON Web Token) implementation for .NET.
C#
199
star
27

Claudia

Unofficial Anthropic Claude API client for .NET.
C#
162
star
28

CsprojModifier

CsprojModifier performs additional processing when Unity Editor generates the .csproj.
C#
154
star
29

Utf8StringInterpolation

Successor of ZString; UTF8 based zero allocation high-peformance String Interpolation and StringBuilder.
C#
153
star
30

ValueTaskSupplement

Append supplemental methods(WhenAny, WhenAll, Lazy) to ValueTask.
C#
135
star
31

Kokuban

Simplifies styling strings in the terminal for .NET application.
C#
123
star
32

SlnMerge

SlnMerge merges the solution files when generating solution file by Unity Editor.
C#
114
star
33

GrpcWebSocketBridge

Yet Another gRPC over HTTP/1 using WebSocket implementation, primarily targets .NET platform.
C#
76
star
34

WebSerializer

Convert Object into QueryString/FormUrlEncodedContent for C# HttpClient REST Request.
C#
65
star
35

RandomFixtureKit

Fill random/edge-case value to target type for unit testing, supports both .NET Standard and Unity.
C#
46
star
36

Actions

41
star
37

DocfxTemplate

Patchworked DocFX v2 template for Cysharp
JavaScript
7
star
38

Multicaster

A framework for transparently invoking multiple instances or clients.
C#
5
star
39

com.unity.ide.visualstudio-backport

Backport of com.unity.ide.visualstudio to before Unity 2019.4.21
C#
1
star