Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles / Languages / C#

Integrate System.CommandLine Easily with CmdStarter Layer

5.00/5 (11 votes)
16 Oct 2023MIT5 min read 10.2K  
Layer over System.CommandLine that overall eases Posix style commands integration into existing projects
Would you like to be able to call different commands using a terminal from your existing project? Take a look here!

Introduction

Microsoft has a package System.CommandLine which still is in a beta version, but fully functional. Though the integration couldn't be done on existing class without inheritance nor was there auto wiring of properties to command options. This library is a layer over System.CommandLine to ease integration into existing projects.

This is an open source project under MIT licence.

Features

  • Implement commands using an abstract class or an interface
  • Filter classes to use in current execution by namespaces or by full class names
  • Classes using dependency injection are supported
  • Mark classes as global options container
  • Easy access to the global options inside the executing method
  • Lonely command can be rooted
  • Auto wiring properties to System.CommandLine command options
  • Auto wiring executing method parameters to System.CommandLine command arguments
  • Alias, Hidden, Description and AutoComplete attributes are offered to set command options/arguments properties
  • Automatic commands tree loading via namespaces or Parent|Children attributes

First Simple Example

  • Import the Nuget package.

Ensure to check Prerelease checkbox

  • Command integration:
    1. Create a new class inheriting from StarterCommand.
      C#
      using com.cyberinternauts.csharp.CmdStarter.Lib;
      using System.ComponentModel;
      
      internal class Files : StarterCommand
      {
          public override Delegate HandlingMethod => Execute;
      
          private void Execute([Description("Folder to list files")] string folder)
          {
              Console.WriteLine("Should list " + folder);
          }
      }
    2. Add IStarterCommand interface to an existing class having a constructor without parameter.
      C#
      using com.cyberinternauts.csharp.CmdStarter.Lib;
      using com.cyberinternauts.csharp.CmdStarter.Lib.Interfaces;
      
      internal class ShowInt : IStarterCommand
      {
          private int MyInt { get; set; } = 111;
      
          public GlobalOptionsManager? GlobalOptionsManager { get; set; }
      
          public Delegate HandlingMethod => () =>
          {
              Console.WriteLine(nameof(MyInt) + "=" + MyInt);
          };
      }
      For dependency injection, see below.
  • Create the Program class below:
    C#
    internal class Program
    {
        public static async Task Main(string[] args)
        {
            var starter = new CmdStarter.Lib.Starter();
            await starter.Start(args);
        }
    }
  • Compile and run:
    PROGRAM_NAME.exe "my\path\to\list".
  • It prints "Should list my\path\to\list" to the console.

Dependency injection

Those methods allow classes having a constructor with parameters.

  • IStarterCommand.GetInstance method can be overridden
  • Starter.SetFactory can be used to change the default behavior of instantiation
  • (new Starter()).Start(IServiceManager, string[]) can be used having an object implementing IServiceManager

Any of your preferred library should be usable to support dependency injection. The repository includes an example with Simple Injector.

More broadly, it offers full control over the class instantiation.

What Happens Under the Hood

The library is divided into four tasks (as four Starter methods):

  • Starter.FindCommandsTypes: Finding type that either implements IStarterCommand interface or inherits StarterCommand abstract class. This method also filters using the Starter.Namespaces and Starter.Classes properties. The types are available via CommandsTypes.
  • Starter.BuildTree: Building the tree of commands using namespaces, [Parent]/[Children] attributes or both. The tree is available via CommandsTypesTree.
  • Starter.InstantiateCommands: Instantiating the commands with their options. If there is only one command and Starter.IsRootingLonelyCommand is enabled, then the command is rooted. This means executing it from command line doesn't require its name as in the first example above. This method also loads the global options. The commands are available via RootCommand.
  • Starter.Start: Executing the command using the program arguments.

The last method chains the others as follow:

Image 1

Except the Start method, all the others can only be executed once unless their corresponding variables are changed. They have a protection that ensures the second execution does nothing. This is built like this because it allows pauses in the execution chain.

By example, executing the first method FindCommandsTypes, changing CommandsTypes and continuing by using Start.

The white box in the diagram is the parsing execution from System.CommandLine. It also connects back to StarterCommand.HandleCommand which loads the options parsed into their respective object properties and executes the handling method of the command providing its parameters with arguments.

Filtering Types Found

The library comes with two filters that can be used to narrow the commands types found by FindCommandsTypes: Namespaces and Classes. Both have an exclusion symbol "~" that can be used.

The Namespaces property works this way:

  1. From the types found, if there are namespaces to include, keep only those.
  2. From the types left, remove all those in namespaces to exclude.

The Classes property works the same way as the Namespaces property, but it also accepts wildcards.

Those are:

  • A unary wildcard excluding dot: "?"
  • A unary wildcard including dot: "??"
  • Zero or more characters excluding dots: "*"
  • Zero or more characters including dots: "**"

Yes, the Classes filter considers also the namespace and is more versatile, but for ease, the library continues to offer Namespaces filter.

Using the Attributes

Tree Positioning Attributes

Children Attribute

Set manually the children of a command class using a namespace.

C#
[Children<Child1>]
public class ChildingParent : StarterCommand
{
}

Here, the ChildrenAttribute can either take a class from which its namespace is used or a string of a namespace.

Parent Attribute

Set manually the parent of a command class using another IStarterCommand.

C#
[Parent<ParentWithChildren>]
public class ChildWithParent : StarterCommand
{
}

GlobalOption Attribute

See the Global options container example section.

Commands/Options Features Attributes

Alias Attribute

If you need to have an alias for a command or an option, the AliasAttribute class can be used to associate a new alias.

C#
[Alias(COMMAND_ALIAS)]
public sealed class OneAlias : StarterCommand
{
    private const string COMMAND_ALIAS = "oa";
    private const string OPTION_ALIAS = "o";

    [Alias(true, OPTION_ALIAS)]
    public int Option { get; set; }
}

AutoComplete Attribute

This attribute won't be completely detailed because it would probably deserve a whole article.

Briefly, it allows to provide autocompletion suggestions when typing the value of an option or an argument. The choices can come from a static list or a provider that could be connected to a database. There is also an IAutoCompleteFactory that can be used to have a full control over each autocompletion suggestion.

Description Attribute

The description attribute is taken to build the help. It can be applied on a command class, an option property or an argument method parameter.

C#
// ---- On a command class ----
[Description(DESC)]
public class SingleDesc : StarterCommand
{
    public const string DESC = "One liner";

    // ---- On an option property ----
    [Description(STRING_OPT_DESC)]
    public string? StringOpt { get; set; }
    public const string STRING_OPT_DESC = "My first string option";

    public override Delegate HandlingMethod => Handle;

    public const string FIRST_PARAM_DESC = "My first parameter";
    private void Handle(
        // ---- On an argument method parameter ----
        [Description(FIRST_PARAM_DESC)] string myFirstParam
        )
    {
        // Code handling the command
    }
}

Hidden Attribute

As the previous provides a way to display a description in the help, this one is the opposite. It completely hides the command, option or argument from help.

C#
[Hidden]
public sealed class HiddenCommand : StarterCommand
{
    public override Delegate HandlingMethod => ([Hidden] int parameter) => { };

    [Hidden]
    public bool Option { get; set; }
}

Required Attribute

The only applicable component for the RequiredAttribute is the options. Even though there is an implicit [Required] on arguments parameters that don't have a default value.

C#
public class OptRequired : StarterCommand
{
    [Required]
    public int IntOpt { get; set; }
    public const string INT_OPT_KEBAB = "int-opt";
}

Global Options Container Example

The global options are accessible within any command executions.

First, apply to a class the interface IGlobalOptionsContainer like in this piece of code.

C#
public class MainGlobalOptions : IGlobalOptionsContainer
{
    [Description(INT_GLOBAL_OPTION_DESC)]
    public int IntGlobalOption { get; set; } = INT_GLOBAL_OPTION_VALUE;
    public const int INT_GLOBAL_OPTION_VALUE = 888;
    public const string INT_GLOBAL_OPTION_DESC = "My first global option";
}

Second, access the option value and use it.

C#
public class ExecSum : StarterCommand
{
    private const int DEFAULT_INT_OPTION_VALUE = 11;

    public int MyInt { get; set; } = DEFAULT_INT_OPTION_VALUE;

    public override Delegate HandlingMethod => Execute;

    public int Execute(int param1)
    {
        // ---- Obtain the global option container object ----
        var globalOptions = 
            this.GlobalOptionsManager?.GetGlobalOptions<MainGlobalOptions>();

        // ---- Read the global option value ---
        var globalInt = (globalOptions?.IntGlobalOption ?? 0);

        // ---- Sum it with an option and an argument
        return globalInt + MyInt + param1;
    }
}

Final Usage When Compiled

PROGRAM_NAME.exe \
--global-option1 "global option value 1" \
command-one \
--option1 "option value 1" \
"param value 1"

Image 2

Conclusion

The library acts as a layer on top of System.CommandLine. It finds commands, associates them their options and arguments. It finds global options. The library takes care of filling the appropriate properties and parameters upon execution. Everything provided by System.CommandLine shall be supported by CmdStarter.

Points of Interest

  • No need to create and/or populate the System.CommandLine.Commands
  • Integration can be really minimalist
  • Expansion is already present, even if there is always place to amelioration
  • Fully using TDD (Test driven development)

Special Thanks!

I would like to give a enormous thank you to Norbert Ormándi for its participation in the project.

Future Possible Development

  • REPL mode
  • Filter properties that become options
  • Fluent support
  • Others: See Jira project

History

  • 1.0-2023.06.23: First article version
  • 1.1-2023.07.12: Add a lot of how it works and its possibilities

License

This article, along with any associated source code and files, is licensed under The MIT License