3. Developing Spring Shell Applications

Contributing commands to the shell is very easy. There are only a few annotations you need to learn. The implementation style of the command is the same as developing classes for an application that uses dependency injection. You can leverage all the features of the Spring container to implement your command classes.

3.1 Marker Interface

The first step to creating a command is to implement the marker interface CommandMarker and to annotate your class with Spring's @Component annotation. (Note there is an open JIRA issue to provide a @CliCommand meta-annotation to avoid having to use a marker interface). Using the code from the helloworld sample application, the code of a HelloWorldCommands class is shown below:

@Component
public class HelloWorldCommands implements CommandMarker {
 
  // use any Spring annotations for Dependency Injection or other Spring interfaces 
  // as required.

  // methods with @Cli annotations go here  

}

3.2 Logging

Logging is currently done using JDK logging. Due to the intricacies of console, JLine and Ansi handling, it is generally advised to display messages as return values to the method commands. However, when logging is required, the typical JDK logger declaration should suffice.

@Component
public class HelloWorldCommands implements CommandMarker {
 
  protected final Logger LOG = Logger.getLogger(getClass().getName());

  // methods with @Cli annotations go here  

}
[Warning]Warning
Note: it is the responsibility of the packager/developer to handle logging for third-party libraries. Typically one wants to reduce the logging level so the console/shell does not get affected by logging messages.

3.3 CLI Annotations

There are three annotations used on methods and method arguments that define the main contract for interacting with the shell. These are:

  • CliAvailabilityIndicator - Placed on a method that returns a boolean value and indicates if a particular command can be presented in the shell. This decision is usually based on the history of commands that have been executed previously. It prevents extraneous commands being presented until some preconditions are met, for example the execution of a 'configuration' command.

  • CliCommand - Placed on a method that provides a command to the shell. Its value provides one or more strings that serve as the start of a particular command name. These must be unique within the entire application, across all plugins.

  • CliOption - Placed on the arguments of a command method, allowing it to declare the argument value as mandatory or optional with a default value.

Here is a simple use of these annotations in a command class

@Component
public class HelloWorldCommands implements CommandMarker {

  @CliAvailabilityIndicator({"hw simple"})
  public boolean isCommandAvailable() {
    return true;
  }

  @CliCommand(value = "hw simple", help = "Print a simple hello world message")
  public String simple(
    @CliOption(key = { "message" }, mandatory = true, help = "The hello world message") 
    final String message,
    
    @CliOption(key = { "location" }, mandatory = false, 
               help = "Where you are saying hello", specifiedDefaultValue="At work") 
    final String location) {

    return "Message = [" + message + "] Location = [" + location + "]";

  }
}

The method annotated with @CliAvailabilityIndicator is returning true so that the one and only command in this class is exposed to the shell to be invoked. If there were more commands in the class, you would list them as comma separated value.

The @CliCommand annotation is creating the command 'hw simple' in the shell. The help message is what will be printed if you use the build in help command. The method name is 'simple' but it could just have been any other name.

The @CliOption annotation on each of the command arguments is where you will spend most of your time authoring commands. You need to decide which arguments are required, which are optional, and if they are optional is there a default value. In this case there are two arguments or options to the command: message and location. The message option is required and a help message is provided to give guidance to the user when tabbing to get completion for the command.

The implementation of the 'simple' method is trivial, just a log statement, but this is where you would typically call other collaborating objects that were injected into the class via Spring.

The method argument types in this example are String, which doesn't present any issue with type conversion. You can specify methods with any rich object type as well as basic primitive types such as int, float etc. For all types other than those handled by the shell by default (basic types, Date, File) you will need to register your own implementation of the org.springframework.shell.core.Converter interface with the container in your plugin.

Note that the method return argument can be non-void - in our example, it is the actual message we want to display. Whenever an object is returned, the shell will display its toString() representation.

3.4 Testing shell commands

To perform a test of the shell commands you can instantiate the shell inside a test case, execute the command and then perform assertions on the return value CommandResult. A simple base class to set this up is shown below.

public abstract class AbstractShellIntegrationTest {

	private static JLineShellComponent shell;
	
	@BeforeClass
	public static void startUp() throws InterruptedException {
		Bootstrap bootstrap = new Bootstrap();		
		shell = bootstrap.getJLineShellComponent();
	}
	
	@AfterClass
	public static void shutdown() {
		shell.stop();
	}

	public static JLineShellComponent getShell() {
		return shell;
	}

}

Here is an example testing the Date command

public class BuiltInCommandTests extends AbstractShellIntegrationTest {
	
	@Test
	public void dateTest() throws ParseException {
		
		//Execute command
		CommandResult cr = getShell().executeCommand("date");
		
		//Get result   
		DateFormat df = DateFormat.getDateTimeInstance(DateFormat.FULL, DateFormat.FULL,Locale.US);
		Date result = df.parse(cr.getResult().toString());
		
		//Make assertions - DateMaters is an external dependency not shown here.
		Date now = new Date();
		MatcherAssert.assertThat(now, DateMatchers.within(5, TimeUnit.SECONDS, result));		
	}
}

The java.lang.Class of CommandResult's getResult method will match that of the return value of the method annotated with @CliCommand. You should cast to the appropriate type to help perform your assertions.

3.5 Building and running the shell

In our opinion, the easiest way to build and execute the shell is to cut-n-paste the gradle script in the example application. This uses the application plugin from gradle to create a bin directory with a startup script for windows and Unix and places all dependent jars in a lib directory. Maven has a similar plugin - the AppAssembler plugin.

The main class of the shell is org.springframework.shell.Bootstrap. As long as you place other plugins, perhaps developed independently, on the classpath, the Bootstrap class will incorporate them into the shell.