Lesson 1.3: Nested Commands

The Spectre.Console.Cli supports having multiple nested commands to enable multiple operations on a particular resource. For example: if we have a command with additional operations, we can represent that as nested commands: command operation1, command operation2, command operation3, and so on. To enable this type of nesting, the Spectre.Console.Cli provides the AddBranch configuration method.

To show the behavior, we are going to build a console version of the Contoso University sample that saves and retrieves data from a local file. This is a familiar sample data model that is used in many .NET samples. To begin with, we will define some operations on the Student entity and define nested commands to add, remove, and view this entity.

Reminder: you can find the source for this lesson in our Azure DevOps repo: Cli.Lesson1.3 – Repos (azure.com).

First, let’s start by creating a new Console App in Visual Studio named Cli.Lesson1.3… review project creation in Lesson 1.1, for the full details on getting setup.

Create the Commands

We are going to create four separate commands in the the project Commands folder that represent the operations we discussed above. We will start with simple commands that only take parameters and print out messages… in future lessons, we will expand the behavior of these commands.

First, create the StudentAddCommand class:

using Spectre.Console.Cli;
using Spectre.Console;
using System;
using System.ComponentModel;

namespace Cli.Lessons.Commands
{
    public class StudentAddCommand : Command<StudentAddCommand.Settings>
    {
        public class Settings : CommandSettings
        {
            [CommandArgument(0, "<ID>")]
            [Description("New student unique id.")]
            public int Id { get; set; }

            [CommandArgument(1, "<FIRST-NAME>")]
            [Description("New student first name.")]
            public string FirstName { get; set; }

            [CommandArgument(2, "<LAST-NAME>")]
            [Description("New student last name.")]
            public string LastName { get; set; }

            [CommandOption("-e|--enrollment <DATE>")]
            [Description("New student enrollment date.")]
            public DateTimeOffset? EnrollmentDate { get; set; }
        }

        public override int Execute(CommandContext context, Settings settings)
        {
            var enrolled = settings.EnrollmentDate ?? DateTimeOffset.Now;
            AnsiConsole.MarkupLine(
                $"[bold]Add Student =>[/] id[[{settings.Id}]], " +
                $"name[[{settings.FirstName} {settings.LastName}]] " +
                $"enrolled[[{enrolled}]]");
            return 0;
        }
    }
}

This command starts with its own Settings class. In these settings, we specify all of the data needed to create a Student as command line arguments. We use a new setting attribute, CommandArgument, to specify that these are required arguments and not options: Id, FirstName, and LastName are required; EnrollmentDate is optional. We also specify the fixed order of the arguments.

Then, lines #29-37 define the implementation of the Execute method. The settings object contains the data for the command line parameters. And we use them all to display the information about the new Student. Also, if the optional EnrollmentDate is not specified, then we use the current date instead.

Then, create the StudentDeleteCommand class:

using Spectre.Console.Cli;
using Spectre.Console;
using System.ComponentModel;

namespace Cli.Lessons.Commands
{
    public class StudentDeleteCommand : Command<StudentDeleteCommand.Settings>
    {
        public class Settings : CommandSettings
        {
            [CommandArgument(0, "<ID>")]
            [Description("Id used to remove student.")]
            public int Id { get; set; }
        }

        public override int Execute(CommandContext context, Settings settings)
        {
            AnsiConsole.MarkupLine($"[bold]Delete Student =>[/] id[[{settings.Id}]]");
            return 0;
        }
    }
}

This is simple command that only takes the Id as a required argument, and prints out a message about which Student will be deleted.

Next, create the StudentListCommand class:

using Spectre.Console.Cli;
using Spectre.Console;

namespace Cli.Lessons.Commands
{
    public class StudentListCommand : Command
    {
        public override int Execute(CommandContext context)
        {
            AnsiConsole.MarkupLine($"[bold]List All Students[/]");
            return 0;
        }
    }
}

This is the simplest of the commands… requiring no parameters and displaying the list of all Students.

Finally, we create the StudentViewCommand class:

using Spectre.Console.Cli;
using Spectre.Console;
using System.ComponentModel;

namespace Cli.Lessons.Commands
{
    public class StudentViewCommand : Command<StudentViewCommand.Settings>
    {
        public class Settings : CommandSettings
        {
            [CommandArgument(0, "<ID>")]
            [Description("Id used to find student.")]
            public int Id { get; set; }
        }

        public override int Execute(CommandContext context, Settings settings)
        {
            AnsiConsole.MarkupLine($"[bold]View Student =>[/] id[[{settings.Id}]]");
            return 0;
        }
    }
}

Like the StudentDeleteCommand, this command takes only an Id argument. We then use the Id to search for a specific Student. For now, we just print out a message with the id being used.

These are the commands that we defined for this lesson. Now, let’s see how we can configure these commands.

Configure the Commands

As we discussed earlier, these commands are all operations of the Student entity, so in our command line, we would like to have a root command and then sub-commands for each. For example, to view a Student, the command line would look like: lesson student view 1001.

To enable nested commands, we need to use the AddBranch method in the command configuration… similar to what we previously did with AddCommand. Let’s update the Program.cs file to the following code:

using Cli.Lessons.Commands;
using Spectre.Console.Cli;

namespace Cli.Lessons
{
    class Program
    {
        public static int Main(string[] args)
        {
            var app = new CommandApp();
            app.Configure(config =>
            {
                config.CaseSensitivity(CaseSensitivity.None);
                config.SetApplicationName("lesson");
                config.ValidateExamples();

                config.AddBranch("student", student =>
                {
                    student.SetDescription("View, list, add or remove students.");

                    student.AddCommand<StudentAddCommand>("new")
                        .WithAlias("add")
                        .WithDescription("Add new student information.")
                        .WithExample(new[] { "student", "new", "1001", "Bill", "Shakespeare", "--enrollment", "5/14/1549" });

                    student.AddCommand<StudentViewCommand>("view")
                        .WithDescription("View student information by id.")
                        .WithExample(new[] { "student", "view", "1001" });

                    student.AddCommand<StudentListCommand>("list")
                        .WithDescription("View list of students.")
                        .WithExample(new[] { "student", "list" });

                    student.AddCommand<StudentDeleteCommand>("delete")
                        .WithAlias("del")
                        .WithDescription("Remove student from list.")
                        .WithExample(new[] { "student", "delete", "1001" });
                });
            });

            return app.Run(args);
        }
    }
}
  • We start the Main method as we did before by calling the CommandApp.Configure method (line #11).
  • Here we configure the root of our commands. Line #13 sets the case sensitivity to insensitive, so that all commands and parameters don’t care about case. Line #14 sets the display name for the app in the help text (this name is used instead of the executable name).
  • Then we use the AddBranch method to create a root command (line #17). This method passes in the name for the root (“student”) and the configuration of its sub-commands.
  • Line #19 sets the descriptive text for the root command… this is also shown in the help text.
  • Finally line #21-37 configure each of the specific commands. This is done exactly as we have done in previous lessons. But rather than configuring these commands at the root, we configure them on the “student” branch.

With this configuration in place, we should get the command/sub-command behavior that we are looking for.

Build and Run

With all of the code in place, we can now build and run our command line app. Let’s run some commands and see what they display.

1. Just the root command – lesson student:

Fig 1 – Student Root Help Text

When we just call the root command, it displays help text for all of its sub-commands. We can see the four commands we built and configured, the definition of those commands, and examples of how to use them.

2. Let’s add a student – lesson student new 1001 Test Student:

Fig 2 – Student Add Command

3. Let’s try the other commands – lesson student delete 1001, lesson student view 1001, and lesson student list:

Fig 3 – Other Student SubCommands

We can see that each command executed and printed out the corresponding message that we expected. Now our app is configured with a command and multiple sub-commands.

4. Since we used required command arguments in this lesson, let’s see what happens when they are missing – lesson student new 1001 Foo:

Fig 4 – Command With Missing Argument

The Spectre.Console.Cli library knows which arguments are required, so if they are missing from the command line, then it knows how to display error messages. All of this is take care of for us by the package, so we don’t need error handling code for that.

In conclusion, we have been able to add some nested sub-commands and learned the difference between required arguments and optional parameters. We have defined some more complex operations, and in the future we will flesh out those operations with actual business logic.

2 thoughts on “Lesson 1.3: Nested Commands

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s