Developing CLI Application with Spring Shell (Part 2)

In the previous Spring Shell post we created the skeleton of the sample CLI application with some helper classes added to support the display of contextual messages to the user with Spring Shell (similar to Bootstrap’s contextual messages).
In this blog post we go a step further and cover the topic of interacting with a user in a CLI application. What we will concentrate on will be capturing of the user’s input in the form of: free text, passwords, an option from the list of values, etc.
What will we be building in this part?
For this tutorial, we will build a command that captures the data necessary to create a new user. This command will interact with the user and ask him to provide the following data for the new user:
- Full name (free text)
- password (hidden text)
- gender (a choice from the list of options)
- superuser (input from a list of available values — with default value)
NOTE: Spring Shell relies on JLine, a powerful Java library. Most of the examples provided in the rest of this post are mainly related to the use of JLine and are not Spring Shell specific.
The model we will use
Package com.ag04.clidemo.model contains CliUser class we will use to store the user’s input and pass it on to the user service for further processing.
public class CliUser {
private Long id;
private String username;
private String password;
private String fullName;
private Gender gender;
private boolean superuser;
//--- get / set methods ----
}
Enumeration Gender is pretty basic:
public enum Gender {
MALE, FEMALE, DIVERSE;
}
Simple UserService interface, together with its mock implementation, one we will invoke to process user-provided data is available in the package: com.ag04.clidemo.service, and defines the following contract:
public interface UserService {
boolean exists(String username);
CliUser create(CliUser user);
CliUser update(CliUser user);
}
package com.ag04.clidemo.service;
import com.ag04.clidemo.model.CliUser;
import org.springframework.stereotype.Service;
/**
* Mock implementation of UserService.
*
*/
@Service
public class MockUserService implements UserService {
@Override
public boolean exists(String username) {
if ("admin".equals(username)) {
return true;
}
return false;
}
@Override
public CliUser create(CliUser user) {
user.setId(10000L);
return user;
}
@Override
public CliUser update(CliUser user) {
return user;
}
}
With this model at hand, we can now proceed, and implement a command that will collect user input and then invoke UserService.create() method.
Create new user
Let’s start with creating a new Spring Shell command named UserCommand with just one method designed to create a new user.
package com.ag04.clidemo.command;
import com.ag04.clidemo.service.UserService;
import com.ag04.clidemo.shell.ShellHelper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.shell.standard.ShellComponent;
import org.springframework.shell.standard.ShellMethod;
import org.springframework.shell.standard.ShellOption;
@ShellComponent
public class UserCommand {
@Autowired
ShellHelper shellHelper;
@Autowired
UserService userService;
@ShellMethod("Create new user with supplied username")
public void createUser(@ShellOption({"-U", "--username"}) String username) {
if (userService.exists(username)) {
shellHelper.printError(
String.format("User with username='%s' already exists --> ABORTING", username)
);
return;
}
}
}
After rebuilding and re-running the application, invoking the help command should provide output similar as below (listing also our new command among the list of available commands):
CLI-DEMO:>help
AVAILABLE COMMANDS
Built-In Commands
clear: Clear the shell screen.
exit, quit: Exit the shell.
help: Display help about available commands.
history: Display or save the history of previously run commands
script: Read and execute commands from a file.
stacktrace: Display the full stacktrace of the last error.
Echo Command
echo: Displays greeting message to the user whose name is supplied
User Command
create-user: Create new user with supplied username
Available mock implementation of UserService.exists() method returns true for “admin” username. Now, invoke create-user command with admin as a parameter and you should see the following output.

With all these pieces in place, we can now start collecting user input and implementing create-user command.
Interacting with a user
Similar to the ShellHelper class we created in the previous post, in this post we will create a new InputReader class that will contain various helper methods for interacting with a user. The first method we need is the one that prompts the user to enter data in free format.
The listing below contains our first implementation of InputReader class, with three methods designed to capture user data in free entry format:
package com.ag04.clidemo.shell;
import org.jline.reader.LineReader;
import org.springframework.util.StringUtils;
public class InputReader {
public static final Character DEFAULT_MASK = '*';
private Character mask;
private LineReader lineReader;
public InputReader(LineReader lineReader) {
this(lineReader, null);
}
public InputReader(LineReader lineReader, Character mask) {
this.lineReader = lineReader;
this.mask = mask != null ? mask : DEFAULT_MASK;
}
public String prompt(String prompt) {
return prompt(prompt, null, true);
}
public String prompt(String prompt, String defaultValue) {
return prompt(prompt, defaultValue, true);
}
public String prompt(String prompt, String defaultValue, boolean echo) {
String answer = "";
if (echo) {
answer = lineReader.readLine(prompt + ": ");
} else {
answer = lineReader.readLine(prompt + ": ", mask);
}
if (StringUtils.isEmpty(answer)) {
return defaultValue;
}
return answer;
}
}
These three methods allow us to ask a user for input by displaying a customized prompt message at the beginning of the line. Additionally, we have the option to supply the default return value in case a user just pressed enter, and to mask the user’s entered input (in case we need to capture sensitive data, i.e. password).
To be able to use this class, we need to add the following configuration lines to an existing SpringShellConfig class:
@Bean
public InputReader inputReader(@Lazy LineReader lineReader) {
return new InputReader(lineReader);
}
Now we can start implementing UserCommand.
Prompt user for full name and password
Now, let’s modify UserCommand to resemble the code snippet below:
@ShellComponent
public class UserCommand {
@Autowired
ShellHelper shellHelper;
@Autowired
InputReader inputReader;
@Autowired
UserService userService;
@ShellMethod("Create new user with supplied username")
public void createUser(@ShellOption({"-U", "--username"}) String username) {
if (userService.exists(username)) {
shellHelper.printError(String.format("User with username='%s' already exists --> ABORTING", username));
return;
}
CliUser user = new CliUser();
user.setUsername(username);
// 1. read user's fullName --------------------------------------------
do {
String fullName = inputReader.prompt("Full name");
if (StringUtils.hasText(fullName)) {
user.setFullName(fullName);
} else {
shellHelper.printWarning("User's full name CAN NOT be empty string? Please enter valid value!");
}
} while (user.getFullName() == null);
// 2. read user's password --------------------------------------------
do {
String password = inputReader.prompt("Password", "secret", false);
if (StringUtils.hasText(password)) {
user.setPassword(password);
} else {
shellHelper.printWarning("Password'CAN NOT be empty string? Please enter valid value!");
}
} while (user.getPassword() == null);
// Print user's input -------------------------------------------------
shellHelper.printInfo("\nCreating new user:");
shellHelper.print("\nUsername: " + user.getUsername());
shellHelper.print("Password: " + user.getPassword());
shellHelper.print("Fullname: " + user.getFullName());
shellHelper.print("Gender: " + user.getGender());
shellHelper.print("Superuser: " + user.isSuperuser() + "\n");
CliUser createdUser = userService.create(user);
shellHelper.printSuccess("Created user with id=" + createdUser.getId());
}
}
Here, we have added two blocks of code that capture the user’s data. One that loops until the user’s full name, containing at least one nonspace character, was entered. And there’s the other one that prompts the user to enter a password, which masks user input (replacing his entry with default mask: *) and returns “secret” as default password value if the user just presses enter.
Finally, the last block of code we added, prints out the user’s entered data and invokes UserService.create() method. In the real-life application user’s password would not be printed. Here we have done it for debugging.
Now, try it out, (press enter when prompted for the password) and the output should be similar to this:

The Gender property of CliUser is set to null and the superuser property is set to false, which is fine since these are default values of CliUser. Now, let’s improve InputReader and add support to allow users to choose one of the options from the list. Then we will expand UserCommand with the block of code that uses this functionality to capture users’ Gender data.
Prompt the user to select one value from the list of available options
Now we are going to attempt something more ambitious. That is, to prompt a user to select one value from the list of values. Once implemented, we are going to use this capability to ask the user to provide his gender in create-user command. As you could have noticed Gender enumeration defines three values. We will print each value, associate with each of them one character key, and ask the user to enter one key as his choice.
What we will build, in the end, will look as follows:

First, add ShellHelper as a class property and change the constructor of the InputReader util class as follows:
ShellHelper shellHelper;
public InputReader(LineReader lineReader, ShellHelper shellHelper) {
this(lineReader, shellHelper, null);
}
public InputReader(LineReader lineReader, ShellHelper shellHelper, Character mask) {
this.lineReader = lineReader;
this.shellHelper = shellHelper;
this.mask = mask != null ? mask : DEFAULT_MASK;
}
Then, at the end of the InputReader class add the following methods:
//--- select one option from the list of values --------------------
public String selectFromList(String headingMessage, String promptMessage, Map<String, String> options, boolean ignoreCase, String defaultValue) {
String answer;
Set<String> allowedAnswers = new HashSet<>(options.keySet());
if (defaultValue != null && !defaultValue.equals("")) {
allowedAnswers.add("");
}
shellHelper.print(String.format("%s: ", headingMessage));
do {
for (Map.Entry<String, String> option: options.entrySet()) {
String defaultMarker = null;
if (defaultValue != null) {
if (option.getKey().equals(defaultValue)) {
defaultMarker = "*";
}
}
if (defaultMarker != null) {
shellHelper.printInfo(String.format("%s [%s] %s ", defaultMarker, option.getKey(), option.getValue()));
} else {
shellHelper.print(String.format(" [%s] %s", option.getKey(), option.getValue()));
}
}
answer = lineReader.readLine(String.format("%s: ", promptMessage));
} while (!containsString(allowedAnswers, answer, ignoreCase) && "" != answer);
if (StringUtils.isEmpty(answer) && allowedAnswers.contains("")) {
return defaultValue;
}
return answer;
}
private boolean containsString(Set <String> l, String s, boolean ignoreCase){
if (!ignoreCase) {
return l.contains(s);
}
Iterator<String> it = l.iterator();
while(it.hasNext()) {
if(it.next().equalsIgnoreCase(s))
return true;
}
return false;
}
Note: above implementation of selectFromList() method will also print the default option values with different color styles (info).
For these additions to work we need to change the method that creates InputReader bean in SpringShellConfig class as follows:
@Bean
public InputReader inputReader(@Lazy LineReader lineReader, ShellHelper shellHelper) {
return new InputReader(lineReader, shellHelper);
}
Finally, modify the UserCommand by adding the following lines before printing of user’s input:
// 3. read user's Gender ----------------------------------------------
Map<String, String> options = new HashMap<>();
options.put("M", Gender.MALE.name());
options.put("F", Gender.FEMALE.name() );
options.put("D", Gender.DIVERSE.name());
String genderValue = inputReader.selectFromList("Gender", "Please enter one of the [] values", options, true, null);
Gender gender = Gender.valueOf(options.get(genderValue.toUpperCase()));
user.setGender(gender);
// Print user's input ----------------------------------------------
Gender property has no default value so we supplied null as the fourth argument to the selectFromList() method.
Rebuild, run clidemo and invoke create-user command to test newly implemented features!
Method selectFromList() is convenient for cases when the number of available options is greater than 2 or 3, and we would like to print each on a single line. When there are only two options for the user to choose from (for example Y/N) it would be more suited to have a method in InputReader. It will present a user with a single prompt line consisting of all available options.
Contact

Looking for Java experts?
To do so, we will now modify InputReader class and add the two following methods:
/**
* Loops until one of the `options` is provided. Pressing return is equivalent to
* returning `defaultValue`.
* <br/>
* Passing null for defaultValue signifies that there is no default value.<br/>
* Passing "" or null among optionsAsList means that an empty answer is allowed, in these cases this method returns
* empty String "" as the result of its execution.
*/
public String promptWithOptions(String prompt, String defaultValue, List<String> optionsAsList) {
String answer;
List<String> allowedAnswers = new ArrayList<>(optionsAsList);
if (StringUtils.hasText(defaultValue)) {
allowedAnswers.add("");
}
do {
answer = lineReader.readLine(String.format("%s %s: ", prompt, formatOptions(defaultValue, optionsAsList)));
} while (!allowedAnswers.contains(answer) && !"".equals(answer));
if (StringUtils.isEmpty(answer) && allowedAnswers.contains("")) {
return defaultValue;
}
return answer;
}
private List<String> formatOptions(String defaultValue, List<String> optionsAsList) {
List<String> result = new ArrayList();
for (String option : optionsAsList) {
String val = option;
if ("".equals(option) || option == null) {
val = "''";
}
if (defaultValue != null ) {
if (defaultValue.equals(option) || (defaultValue.equals("") && option == null)) {
val = shellHelper.getInfoMessage(val);
}
}
result.add(val);
}
return result;
}
These methods provide previously described functionality. Similar to the previously created selectFromList() method, if a default value is provided, the option of that value will be printed with the info color style.
To see it in action, we will use this method to ask the user, if should the newly created user be marked as a superuser. In the UserCommand class, after the block of code that asks the user to provide Gender, add the following lines of code:
// 4. Prompt for superuser attribute ------------------------------
String superuserValue = inputReader.promptWithOptions("New user is superuser", "N", Arrays.asList("Y", "N"));
if ("Y".equals(superuserValue)) {
user.setSuperuser(true);
} else {
user.setSuperuser(false);
}
// Print user's input ----------------------------------------------
...
As can be seen default value, in this case, is set to “N”, resulting in the return of “N” by InputReader.promptWithOptions() method in case the user just pressed ENTER as its input.
Finally, running clidemo again should result in the following output:

Now we finished creating a new user method for our UserCommand. We collect all the necessary data from a user, map it to the CliUser object and pass it to UserService.create() method to create a new user.
Additionally, we could add one more action to this method, prompting a user to confirm that provided data is correct, using InputReader.promptWithOptions() as described above after entered data is printed out and before the invocation of UserService.create() method.
Final fine-tuning
Yet, one small issue remains to be addressed. As you can see in the pictures above, user input values are in red color. This is the result of using SpringShell auto-configured LineReader, which is configured to display all user input in red color unless an input matches one of the shell commands.
To avoid this behavior we need to configure our own LineReader and pass it to InputReader’s constructor.
Modify SpringShellConfig class method inputReader() to match the code snippet below:
@Bean
public InputReader inputReader(
@Lazy Terminal terminal,
@Lazy Parser parser,
JLineShellAutoConfiguration.CompleterAdapter completer,
@Lazy History history,
ShellHelper shellHelper
) {
LineReaderBuilder lineReaderBuilder = LineReaderBuilder.builder()
.terminal(terminal)
.completer(completer)
.history(history)
.highlighter(
(LineReader reader, String buffer) -> {
return new AttributedString(
buffer, AttributedStyle.BOLD.foreground(PromptColor.WHITE.toJlineAttributedStyle())
);
}
).parser(parser);
LineReader lineReader = lineReaderBuilder.build();
lineReader.unsetOpt(LineReader.Option.INSERT_TAB);
return new InputReader(lineReader, shellHelper);
}
Now all user’s input will be displayed with the default color and bold style, as visible in the image below:

This concludes the second part of this series of posts. In the next part, we will show you how to build a progress bar component.
Rest of the series:
Part 1: Conveying contextual messages to the users in the CLI application
Part 2: Capturing user’s input in the CLI application
Part 4: Displaying the data with the use of tables in a Spring Shell-based CLI application
Part 5: Securing CLI application with Spring Security
Sample Code:
The entire source code for this tutorial is available at the GitHub repository:
https://github.com/dmadunic/clidemo
Additional resources:
Spring Shell project site:
https://projects.spring.io/spring-shell/
Spring Shell official documentation:
https://docs.spring.io/spring-shell/docs/current-SNAPSHOT/reference/htmlsingle/
https://docs.spring.io/spring-shell/docs/current/api/
Jline project site:
Keywords:
Java, Spring, Spring Shell, Spring boot, CLI, Terminal, JLine
Thank you for reading! I do hope you enjoyed it and please share if you did.