command-line-api icon indicating copy to clipboard operation
command-line-api copied to clipboard

Fluent syntax for command creation

Open MihailsKuzmins opened this issue 1 year ago • 1 comments

System.CommandLine is a nice library that convers a lot of edge cases, but in my opinion creating commands (adding handlers) is quite verbose and adds a lot of boilerplate code to the main function.

What I was thinking is that maybe it could be possible to add some extension methods to make creating commands more simple. For example, in my project I have something like this:

private static async Task<int> Main(string[] args)
{
	var rootCommand = new RootCommand
	{
		new CommandBuilder("do-something", "Does something")
			.WithOption("--type", CommandLineUtils.GetType)
			.WithOption<bool>("--run-migrations")
			.Build(DoSomethingAsync),

		new CommandBuilder("do-another-thing", "Does another thing")
			.WithArgument<string>()
			.Build(DoAnotherThingAsync),
	};

	return await rootCommand.InvokeAsync(args)
		.ConfigureAwait(false);
}

private static async Task DoSomethingAsync(SomeType type, bool runMigrations)
{
	await using var services = BuildServiceProvider();

	if (runMigrations)
		services.RunMigrations();

	await services.GetRequiredService<ISomethingService>()
		.DoSomethingAsync(type);
}

private static async Task DoAnotherThingAsync(string arg)
{
	await using var services = BuildServiceProvider();

	await services.GetRequiredService<IAnotherService>()
		.DoSomethingElseAsync(arg);
}

The CommandBuilder is a class that encapsulated the boiler plate code. For example:

namespace System.CommandLine;

public sealed class CommandBuilder
{
	internal readonly string Name;
	internal readonly string Description;

	public CommandBuilder(string name, string description)
	{
		Name = name;
		Description = description;
	}

	public CommandHandler1Builder<T> WithOption<T>(in string name, ParseArgument<T> parseArgument) =>
		new(this, name, ArgType.Option, parseArgument);
}

And CommandHandler1Builder<T>, CommandHandler2Builder<T1, T2>, CommandHandler3Builder<T1, T2, T3>, etc. are used to create handlers, arguments and options. For example:

using System.CommandLine.Binding;

namespace System.CommandLine;

public sealed class CommandHandler1Builder<T> : CommandHandlerBuilderBase
{
	internal readonly CommandBuilder CommandBuilder;
	private readonly string _name;
	private readonly ParseArgument<T>? _parseArgument;

	internal CommandHandler1Builder(in CommandBuilder commandBuilder, in string name, in ArgType argType, in ParseArgument<T>? parseArgument = null)
		: base(argType)
	{
		CommandBuilder = commandBuilder;
		_name = name;
		_parseArgument = parseArgument;
	}

	public Command Build(Func<T, Task> handler)
	{
		var command = new Command(CommandBuilder.Name, CommandBuilder.Description);

		var value = AppendValueDescriptor(command);
		command.SetHandler(handler, value);

		return command;
	}

	public CommandHandler2Builder<T, T2> WithOption<T2>(in string name) =>
		new(this, name, ArgType.Option);

	public CommandHandler2Builder<T, T2> WithArgument<T2>(in string name) =>
		new(this, name, ArgType.Argument);

	internal IValueDescriptor<T> AppendValueDescriptor(in Command command) =>
		AppendValueDescriptor(command, _name, _parseArgument);
}
using System.CommandLine.Binding;

namespace System.CommandLine;

public sealed class CommandHandler2Builder<T1, T2> : CommandHandlerBuilderBase
{
	private readonly CommandHandler1Builder<T1> _commandBuilder;
	private readonly string _name;

	internal CommandHandler2Builder(CommandHandler1Builder<T1> commandBuilder, string name, ArgType argType)
		: base(argType)
	{
		_commandBuilder = commandBuilder;
		_name = name;
	}

	public Command Build(Func<T1, T2, Task> handler)
	{
		var command = new Command(_commandBuilder.CommandBuilder.Name, _commandBuilder.CommandBuilder.Description);

		var value1 = _commandBuilder.AppendValueDescriptor(command);
		var option2 = AppendValueDescriptor(command);
		command.SetHandler(handler, value1, option2);

		return command;
	}

	private IValueDescriptor<T2> AppendValueDescriptor(in Command command) =>
		AppendValueDescriptor<T2>(command, _name);
}

Would it be something that you can be interested in? If yes, I might work on this feature and prepare a PR.

MihailsKuzmins avatar Oct 08 '24 23:10 MihailsKuzmins

Funny, I just implemented the following classes in my own solution: CommandExtensions which contains extensions for Command and RootCommand to fluidly build up options, arguments, subcommands, and directives (for the RootCommand), as well as an OptionExtensions and ArgumentExtensions classes. And I was just thinking I should pull those into a separate project that could be uploaded as System.CommandLine.FluentExtensions NuGet package.

I have another project using an older version of System.CommandLine (2.0.0-beta4) that I now need to update, and it uses the GenericHostBuilder pattern, along with Dependency Injection. So, that will be fun to migrate to 😒. So I'd also like to come up with some patterns for more easily scaffolding a GenericHost application complete with command line handling. On the plus side, while I don't yet know how Dependency Injection will work with the command actions, I do think that the just-released v2.0.0 of System.CommandLine will make it easier, over all, to scaffold an application. It just requires a different way of thinking about how that's done compared to the previous version of the library.

fourpastmidnight avatar Nov 12 '25 16:11 fourpastmidnight