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
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:
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:
- From the types found, if there are namespaces to include, keep only those.
- 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.
[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
.
[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.
[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.
[Description(DESC)]
public class SingleDesc : StarterCommand
{
public const string DESC = "One liner";
[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(
[Description(FIRST_PARAM_DESC)] string myFirstParam
)
{
}
}
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.
[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.
public class OptRequired : StarterCommand
{
[Required]
public int IntOpt { get; set; }
public const string INT_OPT_KEBAB = "int-opt";
}
The global options are accessible within any command executions.
First, apply to a class the interface IGlobalOptionsContainer
like in this piece of code.
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.
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)
{
var globalOptions =
this.GlobalOptionsManager?.GetGlobalOptions<MainGlobalOptions>();
var globalInt = (globalOptions?.IntGlobalOption ?? 0);
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"
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.Command
s - 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