Command Mode with Aesh
Aesh is a Java library for building interactive command line applications. It supports option parsing, tab completion, command grouping, and an interactive shell (REPL) mode. For more details about the Aesh library itself, see the Aesh documentation.
Quarkus provides support for using Aesh. This guide contains examples of the aesh extension usage.
Installation
If you want to use this extension, you need to add the io.quarkiverse.aesh:quarkus-aesh extension first to your build file.
For instance, with Maven, add the following dependency to your POM file:
<dependency>
<groupId>io.quarkiverse.aesh</groupId>
<artifactId>quarkus-aesh</artifactId>
<version>999-SNAPSHOT</version>
</dependency>
Building a command line application
The Aesh extension supports two execution modes, controlled by the quarkus.aesh.mode build-time property.
Console mode is what makes Aesh distinctive — it provides an interactive shell (REPL) where users can type multiple commands with tab completion and command history.
Runtime mode executes a single command and exits, like a standard CLI tool.
In most cases, the mode is auto-detected based on your command annotations.
Console mode (interactive shell)
Console mode starts an interactive shell (REPL) where users can type multiple commands. This is what differentiates Aesh from other CLI frameworks: users get an interactive session with tab completion, command history, and shared state across commands.
Console mode is automatically selected when there are multiple @CommandDefinition classes
without a @GroupCommandDefinition, when any class is annotated with @CliCommand,
or when a remote transport (WebSocket or SSH) is present.
You can also force it with quarkus.aesh.mode=console.
package com.acme.aesh;
import org.aesh.command.Command;
import org.aesh.command.CommandDefinition;
import org.aesh.command.CommandResult;
import org.aesh.command.invocation.CommandInvocation;
import org.aesh.command.option.Option;
import io.quarkiverse.aesh.runtime.annotations.CliCommand;
@CommandDefinition(name = "greet", description = "Greet someone")
@CliCommand (1)
public class GreetCommand implements Command<CommandInvocation> {
@Option(shortName = 'n', name = "name", defaultValue = "World")
private String name;
@Override
public CommandResult execute(CommandInvocation invocation) {
invocation.println("Hello " + name + "!");
return CommandResult.SUCCESS;
}
}
@CommandDefinition(name = "calc", description = "Add two numbers")
@CliCommand
class CalcCommand implements Command<CommandInvocation> {
@Option(shortName = 'a', name = "a", defaultValue = "0")
private int a;
@Option(shortName = 'b', name = "b", defaultValue = "0")
private int b;
@Override
public CommandResult execute(CommandInvocation invocation) {
invocation.println("Result: " + (a + b));
return CommandResult.SUCCESS;
}
}
| 1 | @CliCommand explicitly marks a command for console mode. When auto-detection is used, @CliCommand is automatically added to @CommandDefinition classes that are not sub-commands of a group. |
With two commands, console mode is auto-detected:
$ java -jar myapp.jar
[quarkus]$ greet --name=Aesh
Hello Aesh!
[quarkus]$ calc -a 5 -b 3
Result: 8
[quarkus]$ exit
The prompt, exit command, and other console settings are configurable via quarkus.aesh.* properties.
Group commands in console mode (sub-command mode)
You can use @GroupCommandDefinition with @CliCommand in console mode to create command groups with sub-command mode.
When a user types a group command without a sub-command, they enter an interactive context for that group:
package com.acme.aesh;
import jakarta.inject.Inject;
import org.aesh.command.Command;
import org.aesh.command.CommandDefinition;
import org.aesh.command.CommandResult;
import org.aesh.command.GroupCommandDefinition;
import org.aesh.command.invocation.CommandInvocation;
import org.aesh.command.option.Argument;
import io.quarkiverse.aesh.runtime.annotations.CliCommand;
@GroupCommandDefinition(name = "task", description = "Task management",
groupCommands = {AddTask.class, ListTasks.class}) (1)
@CliCommand
public class TaskGroup implements Command<CommandInvocation> {
@Override
public CommandResult execute(CommandInvocation invocation) {
invocation.println("Sub-commands: add, list");
return CommandResult.SUCCESS;
}
}
@CommandDefinition(name = "add", description = "Add a task") (2)
class AddTask implements Command<CommandInvocation> {
@Argument(description = "Task name", required = true)
private String name;
@Inject (3)
TaskService taskService;
@Override
public CommandResult execute(CommandInvocation invocation) {
taskService.addTask(name);
invocation.println("Added: " + name);
return CommandResult.SUCCESS;
}
}
@CommandDefinition(name = "list", description = "List tasks")
class ListTasks implements Command<CommandInvocation> {
@Inject
TaskService taskService;
@Override
public CommandResult execute(CommandInvocation invocation) {
taskService.getTasks().forEach(t -> invocation.println(" - " + t));
return CommandResult.SUCCESS;
}
}
| 1 | Sub-commands listed in groupCommands are only accessible through their parent group — they do not appear as top-level commands in the shell. |
| 2 | Sub-commands use @CommandDefinition without @CliCommand. The extension automatically excludes them from top-level registration. |
| 3 | CDI injection works in sub-commands. The extension injects @Inject fields after Aesh creates the sub-command instances. |
An interactive session using sub-command mode:
$ java -jar myapp.jar
[quarkus]$ task add groceries (1)
Added: groceries
[quarkus]$ task (2)
task> add laundry (3)
Added: laundry
task> list
- groceries
- laundry
task> exit (4)
[quarkus]$ exit
| 1 | Sub-commands can be called directly with their parent prefix. |
| 2 | Typing just the group command name enters sub-command mode. |
| 3 | Inside sub-command mode, type sub-commands directly without the parent prefix. |
| 4 | Type exit (or ..) to leave sub-command mode and return to the main prompt. |
Because TaskService is @ApplicationScoped, state is shared across all commands in the session.
This is a key advantage of console mode over runtime mode, where each invocation is a separate process.
|
Runtime mode (single command execution)
This is the default mode when there is a single @CommandDefinition or a @GroupCommandDefinition.
The application executes one command and exits, similar to standard CLI tools.
Simple application
A simple Aesh application with a single command can be created as follows:
package com.acme.aesh;
import jakarta.inject.Inject;
import org.aesh.command.Command;
import org.aesh.command.CommandDefinition;
import org.aesh.command.CommandResult;
import org.aesh.command.invocation.CommandInvocation;
import org.aesh.command.option.Option;
@CommandDefinition(name = "hello", description = "Greet someone") (1)
public class HelloCommand implements Command<CommandInvocation> {
@Option(shortName = 'n', name = "name", description = "Who to greet", defaultValue = "World")
private String name;
@Inject (2)
GreetingService greetingService;
@Override
public CommandResult execute(CommandInvocation invocation) {
invocation.println(greetingService.greet(name));
return CommandResult.SUCCESS;
}
}
| 1 | If there is only one class annotated with @CommandDefinition, it is automatically used as the entry point of the command line application. |
| 2 | All command classes are registered as CDI beans. You can use @Inject to inject other beans. |
package com.acme.aesh;
import jakarta.enterprise.context.ApplicationScoped;
@ApplicationScoped
class GreetingService {
String greet(String name) {
return "Hello " + name + "!";
}
}
Beans annotated with @CommandDefinition should not use proxied scopes (e.g. do not use @ApplicationScoped)
because Aesh sets field values directly via reflection.
By default, the Aesh extension registers command classes with the @Dependent scope.
|
Grouped commands with subcommands
When you have a parent command with subcommands, use @GroupCommandDefinition and @TopCommand:
package com.acme.aesh;
import org.aesh.command.Command;
import org.aesh.command.CommandDefinition;
import org.aesh.command.CommandResult;
import org.aesh.command.GroupCommandDefinition;
import org.aesh.command.invocation.CommandInvocation;
import org.aesh.command.option.Argument;
import org.aesh.command.option.Option;
import org.aesh.command.option.ParentCommand;
import io.quarkiverse.aesh.runtime.annotations.TopCommand;
@GroupCommandDefinition(name = "cli", description = "CLI application",
groupCommands = {RunCommand.class, InfoCommand.class}) (1)
@TopCommand (2)
public class CliCommand implements Command<CommandInvocation> {
@Option(shortName = 'v', name = "verbose", hasValue = false)
private boolean verbose;
public boolean isVerbose() {
return verbose;
}
@Override
public CommandResult execute(CommandInvocation invocation) {
invocation.println("Use a subcommand: run, info");
return CommandResult.SUCCESS;
}
}
@CommandDefinition(name = "run", description = "Run a task")
class RunCommand implements Command<CommandInvocation> {
@ParentCommand (3)
private CliCommand parent;
@Argument(description = "Task name")
private String taskName;
@Override
public CommandResult execute(CommandInvocation invocation) {
if (parent.isVerbose()) {
invocation.println("[VERBOSE] Running...");
}
invocation.println("Running task: " + taskName);
return CommandResult.SUCCESS;
}
}
@CommandDefinition(name = "info", description = "Show info")
class InfoCommand implements Command<CommandInvocation> {
@Override
public CommandResult execute(CommandInvocation invocation) {
invocation.println("CLI App v1.0");
return CommandResult.SUCCESS;
}
}
| 1 | @GroupCommandDefinition lists the subcommands available under this parent command. |
| 2 | @TopCommand marks this as the entry point. This is required when there are multiple command classes. |
| 3 | @ParentCommand injects the parent command, giving access to parent options like --verbose. |
The application can then be invoked as:
$ java -jar myapp.jar run build
Running task: build
$ java -jar myapp.jar info
CLI App v1.0
Custom option completers, converters, and validators
Aesh supports custom implementations for tab-completion, type conversion, option validation, and more. These implementations are CDI beans, so they can inject services:
package com.acme.aesh;
import java.util.List;
import jakarta.enterprise.context.Dependent;
import jakarta.inject.Inject;
import org.aesh.command.Command;
import org.aesh.command.CommandDefinition;
import org.aesh.command.CommandResult;
import org.aesh.command.completer.CompleterInvocation;
import org.aesh.command.completer.OptionCompleter;
import org.aesh.command.invocation.CommandInvocation;
import org.aesh.command.option.Option;
import org.aesh.command.validator.OptionValidator;
import org.aesh.command.validator.OptionValidatorException;
import org.aesh.command.validator.ValidatorInvocation;
@CommandDefinition(name = "deploy", description = "Deploy to an environment")
public class DeployCommand implements Command<CommandInvocation> {
@Option(name = "env", completer = EnvCompleter.class, validator = EnvValidator.class) (1)
private String environment;
@Override
public CommandResult execute(CommandInvocation invocation) {
invocation.println("Deploying to " + environment);
return CommandResult.SUCCESS;
}
}
@Dependent (2)
class EnvCompleter implements OptionCompleter<CompleterInvocation> {
@Inject (3)
EnvironmentService envService;
@Override
public void complete(CompleterInvocation completerInvocation) {
String input = completerInvocation.getGivenCompleteValue();
for (String env : envService.getAvailableEnvironments()) {
if (input == null || input.isEmpty() || env.startsWith(input)) {
completerInvocation.addCompleterValue(env);
}
}
}
}
@Dependent
class EnvValidator implements OptionValidator<ValidatorInvocation<String, ?>> {
private static final List<String> VALID = List.of("dev", "staging", "prod");
@Override
public void validate(ValidatorInvocation<String, ?> validatorInvocation) throws OptionValidatorException {
if (!VALID.contains(validatorInvocation.getValue())) {
throw new OptionValidatorException("Invalid environment. Valid values: " + VALID);
}
}
}
| 1 | Reference custom completers, validators, and converters via their class in annotation attributes. |
| 2 | CDI beans implementing aesh interfaces (OptionCompleter, OptionValidator, Converter, CommandActivator, etc.) are automatically kept by Arc even though they are only referenced from annotations. |
| 3 | Completers can inject CDI services. This is particularly useful in console mode, where a completer can offer context-aware suggestions based on application state (e.g., completing task names from a service). |
Customizing CLI settings
You can customize the underlying Aesh SettingsBuilder by implementing the CliSettings interface:
package com.acme.aesh;
import jakarta.enterprise.context.ApplicationScoped;
import org.aesh.command.settings.SettingsBuilder;
import io.quarkiverse.aesh.runtime.CliSettings;
@ApplicationScoped
public class MyCliSettings implements CliSettings {
@Override
public void customize(SettingsBuilder<?, ?, ?, ?, ?, ?> builder) {
builder.enableAlias(true)
.persistHistory(true)
.historySize(1000);
}
}
Multiple CliSettings beans can be registered. They are applied in arbitrary order, so avoid conflicting settings across implementations.
Build-time validation
The Aesh extension validates command configurations at build time. Invalid configurations are reported as deployment failures with descriptive error messages, so you can fix them before the application starts.
The following checks are performed:
-
Duplicate top-level command names — two or more top-level command classes with the same
@CommandDefinition(name = "…")value. -
Missing
@CommandDefinitionon group sub-commands — a class listed in@GroupCommandDefinition(groupCommands = {…})that is not annotated with@CommandDefinition. -
Multiple
@TopCommandannotations — more than one class annotated with@TopCommand, but only one entry point is allowed in runtime mode. -
Conflicting
@TopCommandand@CliCommand— both annotations on the same class.@TopCommanddesignates a runtime-mode entry point while@CliCommanddesignates a console-mode command; these are mutually exclusive.
Remote terminal access
The Aesh extension provides optional sub-extensions for remote terminal access via WebSocket and SSH. These allow users to interact with your CLI application from a browser or an SSH client while the application is running in console mode.
WebSocket terminal
Add the quarkus-aesh-websocket dependency to expose a browser-based terminal:
<dependency>
<groupId>io.quarkiverse.aesh</groupId>
<artifactId>quarkus-aesh-websocket</artifactId>
<version>999-SNAPSHOT</version>
</dependency>
Once added, the terminal is accessible at http://localhost:8080/aesh/index.html when the application is running.
The WebSocket endpoint is registered at /aesh/terminal by default. You can change the endpoint path:
quarkus.aesh.websocket.path=/custom/terminal
The built-in HTML page connects to the default path. When using a custom path, access the page with a path query parameter: http://localhost:8080/aesh/index.html?path=/custom/terminal.
|
To disable the WebSocket endpoint:
quarkus.aesh.websocket.enabled=false
WebSocket authentication
The WebSocket terminal endpoint has no authentication by default. You can secure it by
requiring an authenticated user or restricting access to specific roles. Both options
require a Quarkus Security extension to be present (e.g., quarkus-elytron-security-properties-file).
To require any authenticated user:
quarkus.aesh.websocket.authenticated=true
To restrict access to specific roles:
quarkus.aesh.websocket.roles-allowed=admin,operator
When roles-allowed is set, authenticated is ignored since role-based access implies authentication.
| The extension will log a warning at build time if the WebSocket terminal is enabled in production without authentication configured. |
SSH terminal
Add the quarkus-aesh-ssh dependency to expose an SSH server:
<dependency>
<groupId>io.quarkiverse.aesh</groupId>
<artifactId>quarkus-aesh-ssh</artifactId>
<version>999-SNAPSHOT</version>
</dependency>
Connect to the terminal with any SSH client:
ssh -p 2222 localhost
Configuration properties:
# SSH server port (default: 2222)
quarkus.aesh.ssh.port=2222
# SSH server bind address (default: localhost)
quarkus.aesh.ssh.host=localhost
# Password for SSH authentication (default: any password accepted)
quarkus.aesh.ssh.password=mysecret
# Path to an OpenSSH authorized_keys file for public key authentication
quarkus.aesh.ssh.authorized-keys-file=/path/to/authorized_keys
# Path to the host key file (default: hostkey.ser)
quarkus.aesh.ssh.host-key-file=hostkey.ser
# Enable or disable the SSH server (default: true)
quarkus.aesh.ssh.enabled=true
Password and public key authentication can be used simultaneously. When both are configured, clients can authenticate with either method. If neither is configured, any password is accepted.
| The extension will log a warning at startup if the SSH server is running without any authentication configured (no password, no authorized-keys-file). |
Connection management
Both SSH and WebSocket transports support limiting concurrent sessions and closing idle connections.
# Maximum concurrent SSH sessions (default: no limit)
quarkus.aesh.ssh.max-connections=10
# Close idle SSH sessions after a duration (default: disabled)
quarkus.aesh.ssh.idle-timeout=30m
# Maximum concurrent WebSocket sessions (default: no limit)
quarkus.aesh.websocket.max-connections=10
# Close idle WebSocket sessions after a duration (default: disabled)
quarkus.aesh.websocket.idle-timeout=30m
When max-connections is set, new connections beyond the limit are rejected immediately. When idle-timeout is set, sessions with no input activity for the specified duration are closed automatically.
Session events
The extension fires CDI events when remote sessions open and close. Use these to monitor session lifecycle, log access, or perform cleanup:
package com.acme.aesh;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.event.ObservesAsync;
import io.quarkiverse.aesh.runtime.AeshSessionEvent;
import io.quarkiverse.aesh.runtime.SessionClosed;
import io.quarkiverse.aesh.runtime.SessionOpened;
@ApplicationScoped
public class SessionLogger {
void onOpen(@ObservesAsync @SessionOpened AeshSessionEvent event) { (1)
System.out.println("Session opened: " + event.sessionId()
+ " via " + event.transport());
}
void onClose(@ObservesAsync @SessionClosed AeshSessionEvent event) {
System.out.println("Session closed: " + event.sessionId()
+ " at " + event.timestamp());
}
}
| 1 | Events are fired asynchronously. Use @ObservesAsync (not @Observes) to receive them. |
The AeshSessionEvent payload provides:
-
sessionId()— unique identifier for the session -
transport()—"ssh"or"websocket" -
timestamp()— when the event occurred
Events are fired for both SSH and WebSocket sessions.
Health checks
When the quarkus-smallrye-health extension is present, readiness health checks are automatically registered for SSH and WebSocket transports. The health endpoints report whether each transport is running and the number of active connections.
To disable the health checks:
quarkus.aesh.ssh.health.enabled=false
quarkus.aesh.websocket.health.enabled=false
Local console behavior with remote transports
When a remote transport extension (quarkus-aesh-websocket or quarkus-aesh-ssh) is present,
the local console (stdin/stdout) is not started by default. This means the application starts
as a normal Quarkus server, and CLI access is available only through the remote transport.
This auto-detection prevents the local console from blocking the terminal when the application is intended to run as a server with remote CLI access.
You can override this behavior with the quarkus.aesh.start-console property:
# Force start the local console alongside remote transports
quarkus.aesh.start-console=true
# Disable the local console even without remote transports
# (useful when embedding commands in a server application)
quarkus.aesh.start-console=false
The auto-detection logic:
-
No remote transport present → local console starts (default CLI application behavior)
-
Remote transport present → local console skipped (server mode with remote CLI access)
-
quarkus.aesh.start-consoleexplicitly set → overrides auto-detection in either direction
Security considerations
| The WebSocket and SSH terminals provide remote access to your application’s CLI. In production, always configure authentication: |
-
SSH: Set
quarkus.aesh.ssh.passwordfor password auth, orquarkus.aesh.ssh.authorized-keys-filefor public key auth (or both). Without either, any password is accepted. -
WebSocket: Set
quarkus.aesh.websocket.authenticated=trueorquarkus.aesh.websocket.roles-allowedto require authentication. This requires a Quarkus Security extension.
Additionally, restrict network access to these endpoints using firewalls or bind addresses.
Use idle-timeout to automatically close abandoned sessions and max-connections to limit concurrent sessions and prevent resource exhaustion. Health checks (when quarkus-smallrye-health is present) provide visibility into the number of active sessions.
Development Mode
In the development mode, i.e. when running mvn quarkus:dev, the application is executed and restarted every time the Space bar key is pressed. You can also pass arguments to your command line app via the quarkus.args system property, e.g. mvn quarkus:dev -Dquarkus.args='--help' and mvn quarkus:dev -Dquarkus.args='-n Quarkus'.
Dev UI
The Aesh extension provides Dev UI pages accessible at http://localhost:8080/q/dev-ui when running in development mode. The Aesh card in the Dev UI offers up to three pages depending on which sub-extensions are present.
Commands page
Always available. Shows a table of all discovered CLI commands with their name, description, type (@TopCommand, @CliCommand, or Group), class name, and sub-commands for group commands. The resolved execution mode (console or runtime) is displayed at the top. A search field allows filtering commands by name, description, or class name.
Sessions page
Available when a remote transport extension (quarkus-aesh-websocket or quarkus-aesh-ssh) is present. Displays transport cards showing the status, active session count, and maximum session limit for each transport. Below the cards, a live event log shows session opened and closed events in real-time as users connect and disconnect.
Terminal page
Available when the quarkus-aesh-websocket extension is present. Embeds an interactive terminal directly in the Dev UI using xterm.js. Click Connect to open a WebSocket connection to the terminal endpoint and interact with your CLI commands from the browser. The terminal supports the same features as the standalone terminal page (color, resize, unicode).
Packaging your application
An Aesh command line application is a standard Quarkus application and can be packaged as a JAR or a native executable.
Extension Configuration Reference
Configuration property fixed at build time - All other configuration properties are overridable at runtime
Configuration property |
Type |
Default |
|---|---|---|
The execution mode for the Aesh extension.
Environment variable: |
string |
|
Whether to start the local console (stdin/stdout) for interactive CLI access. When not set, auto-detected: the local console is started if no remote transport (SSH, WebSocket) is present, and skipped if a remote transport is present. Set to Environment variable: |
boolean |
|
Name of bean annotated with Environment variable: |
string |
|
Enable or disable ANSI color output. When not specified, color output is automatically detected based on terminal capabilities. Environment variable: |
boolean |
|
The prompt to display in console mode. Only used when mode is set to 'console' or auto-detected as console. Environment variable: |
string |
|
Whether to add a built-in 'exit' command in console mode. Only used when mode is set to 'console' or auto-detected as console. Environment variable: |
boolean |
|
Enable command aliasing. When enabled, users can create aliases for commands. Environment variable: |
boolean |
|
Enable the export command. When enabled, the 'export' command is available for setting environment variables. Environment variable: |
boolean |
|
Enable man page support. When enabled, the 'man' command is available for viewing command documentation. Environment variable: |
boolean |
|
Enable command history persistence. When enabled, command history is saved to a file between sessions. Environment variable: |
boolean |
|
Path to the history file. Only used when persistHistory is enabled. If not specified, defaults to ".aesh_history" in the user’s home directory. Environment variable: |
string |
|
Maximum number of history entries to keep. Environment variable: |
int |
|
Enable logging of aesh internal operations. Environment variable: |
boolean |
|
Enable sub-command mode. When enabled, typing a group command without a subcommand enters an interactive context. Example:
Environment variable: |
boolean |
|
Command to exit sub-command mode and return to the parent context. Environment variable: |
string |
|
Alternative command to exit sub-command mode (e.g., ".."). Set to empty string to disable. Environment variable: |
string |
|
Separator used for nested context paths in the prompt. For example, with separator ":", nested contexts appear as "module:project>" Environment variable: |
string |
|
Show option/argument values when entering sub-command mode. Environment variable: |
boolean |
|
Show the primary argument value in the prompt. For example, "module[myapp]>" vs "module>" Environment variable: |
boolean |
|