Kim Rudolph

Context-Aware Spring Shell

The following code snippets help building a context aware Spring Shell application. The help command and the PromptProvider are implemented to reflect the current context, in this example a server context.

spring-shell> help
!                   Allows execution of operating system (OS) commands
cls|clear           Clears the console
date                Displays the local date and time
exit|quit           Exits the shell
help                List all commands available for current context
script              Parses the specified resource file and executes its commands
server              switch context to server with given name
system properties   Shows the shell's properties
version             Displays shell version
spring-shell> server myserver
now talking to server myserver
server:myserver> help
!                   Allows execution of operating system (OS) commands
cls|clear           Clears the console
date                Displays the local date and time
exit|quit           Exits the shell
help                List all commands available for current context
script              Parses the specified resource file and executes its commands
server              switch context to server with given name
system properties   Shows the shell's properties
version             Displays shell version
-----------------
restart             restart the server
shutdown            shutdown the server

Guides how to use Spring Shell with Spring Boot can be found at the Spring Shell Issues or example code in the spring-cloud-dataflow ShellCommandLineRunner class. The code snippets assume a Spring Boot based application.

A simple Properties instance holds information to share a state between executed commands. As the help command is reimplemented, the default HelpCommands class has to be excluded in component registration process.

@Configuration
@ComponentScan(
  value = { "org.springframework.shell.converters",
            "org.springframework.shell.plugin.support",
            "org.springframework.shell.commands" }, 
            excludeFilters = @ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, 
                                                   value = {HelpCommands.class }))
public class ShellConfiguration {

    @Bean
    Properties shellProperties() {
        return new Properties();
    }
}

The example context is a server interaction. A server scope can be entered with a server <servername> command. Additional commands like shutdown and restart should only show up in the help output when a server is selected.

@Component
public class ServerCommands implements CommandMarker {

    @Autowired
    Properties shellProperties;

    @CliAvailabilityIndicator({ "shutdown", "restart" })
    public boolean isAvailable() {
        return StringUtils.isNotEmpty(shellProperties.getProperty("server"));
    }

    @CliCommand(value = "server", help = "switch context to server with given name")
    public String server(
        @CliOption(key = "", mandatory = true, help = "servername") final String serverName) {

        shellProperties.setProperty("server", serverName);
        shellProperties.setProperty("prompt", "server:" + serverName);

        return "now talking to server " + serverName;
    }

    @CliCommand(value = "shutdown", help = "shutdown the server")
    public void shutdown() {
    }

    @CliCommand(value = "restart", help = "restart the server")
    public void restart() {
    }
}

To provide that information, the help commands needs to be implemented again. Reflection is used to find all CommandMarkers and then all @CliAvailabilityIndicator and @CliCommand annotations. A special context like the server context is present if a @CliAvailabilityIndicator check returns true.

@Component
public class HelpContextCommands implements CommandMarker {

    private static final Logger LOGGER =
        HandlerUtils.getLogger(HelpContextCommands.class);

    @Autowired
    ApplicationContext applicationContext;

    @CliCommand(value = "help",
		help = "List all commands available for current context")
    public void contextHelp() throws Exception {

        Map<String, String> defaultCommands = new HashMap<>();
        Map<String, String> contextCommands = new HashMap<>();

        List<String> contextHidden = new ArrayList<>();
        List<String> contextAvailable = new ArrayList<>();

        int maxCommandWidth = 0;

        final Map<String, CommandMarker> markers =
            applicationContext.getBeansOfType(CommandMarker.class);

        for (CommandMarker marker : markers.values()) {

            // Get context availability information
            for (Method method : marker.getClass().getMethods()) {
                if (method
                    .isAnnotationPresent(CliAvailabilityIndicator.class)) {

                    final CliAvailabilityIndicator configuration =
                        method.getAnnotation(CliAvailabilityIndicator.class);

                    boolean available = (boolean) method.invoke(marker);

                    for (String contextCommand : configuration.value()) {
                        if (!available) {
                            contextHidden.add(contextCommand);
                        } else {
                            contextAvailable.add(contextCommand);
                        }
                    }
                }
            }

            // Get all commands an check against availability
            for (Method method : marker.getClass().getMethods()) {
                if (method.isAnnotationPresent(CliCommand.class)) {

                    final CliCommand configuration =
                        method.getAnnotation(CliCommand.class);

                    if (CollectionUtils.containsAny(contextHidden,
                        Arrays.asList(configuration.value()))) {
                        continue;
                    }

                    String command =
                        StringUtils.join(configuration.value(), "|");

                    if (command.length() > maxCommandWidth) {
                        maxCommandWidth = command.length();
                    }

                    if (CollectionUtils.containsAny(contextAvailable,
                        Arrays.asList(configuration.value()))) {
                        contextCommands.put(command, configuration.help());
                        continue;
                    }

                    defaultCommands.put(command, configuration.help());

                }
            }

        }
        
        StringBuilder output = new StringBuilder();

        // Print sorted default commands
        List<String> sortCommands = new ArrayList<>(defaultCommands.keySet());
        Collections.sort(sortCommands);

        for (String command : sortCommands) {
            output.append(StringUtils.rightPad(command, maxCommandWidth, ""))
                .append("   ").append(defaultCommands.get(command))
                .append(OsUtils.LINE_SEPARATOR);
        }

        // Print sorted context commands if available
        if (!contextCommands.isEmpty()) {

            output.append(StringUtils.repeat("-", maxCommandWidth))
                .append(OsUtils.LINE_SEPARATOR);

            sortCommands = new ArrayList<>(contextCommands.keySet());
            Collections.sort(sortCommands);

            for (String command : sortCommands) {
                output
                    .append(StringUtils.rightPad(command, maxCommandWidth, ""))
                    .append("   ").append(contextCommands.get(command))
                    .append(OsUtils.LINE_SEPARATOR);
            }

        }

        LOGGER.info(output.toString());
    }
}

The custom prompt is simply parsed from the prompt property.

@Component
public class CustomPrompt implements PromptProvider {

    @Autowired
    Properties shellProperties;

    @Override
    public String getPrompt() {
        return shellProperties.getProperty("prompt") + "> ";
    }

    @Override
    public String getProviderName() {
        return "server-shell";
    }
}