Edit this Page

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.

If you are not familiar with the Quarkus Command Mode, consider reading the Command Mode reference guide first.

Extension

Once you have your Quarkus project configured you can add the aesh extension to your project by running the following command in your project base directory.

CLI
quarkus extension add aesh
Maven
./mvnw quarkus:add-extension -Dextensions='aesh'
Gradle
./gradlew addExtension --extensions='aesh'

This will add the following to your build file:

pom.xml
<dependency>
    <groupId>io.quarkus</groupId>
    <artifactId>quarkus-aesh</artifactId>
</dependency>
build.gradle
implementation("io.quarkus:quarkus-aesh")

For optimal performance (especially in native mode), add the Aesh annotation processor to your Maven compiler plugin configuration. This generates compile-time command metadata, eliminating reflection-based annotation scanning at runtime:

<plugin>
    <artifactId>maven-compiler-plugin</artifactId>
    <configuration>
        <annotationProcessorPaths>
            <path>
                <groupId>org.aesh</groupId>
                <artifactId>aesh-processor</artifactId>
            </path>
        </annotationProcessorPaths>
    </configuration>
</plugin>

The annotation processor is optional. Without it, Aesh falls back to runtime reflection for command metadata, which works correctly but is slower at startup.

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 groupCommands, when there are multiple independent commands, 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;

@CommandDefinition(name = "greet", description = "Greet someone")
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")
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;
    }
}

With two commands, console mode is auto-detected. Without arguments, the interactive REPL starts:

$ java -jar myapp.jar
[quarkus]$ greet --name=Aesh
Hello Aesh!
[quarkus]$ calc -a 5 -b 3
Result: 8
[quarkus]$ exit

If command-line arguments are provided, the command is executed once and the application exits — just like runtime mode:

$ java -jar myapp.jar greet --name=Aesh
Hello Aesh!

This allows console mode applications to be used both interactively and in scripts.

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 @CommandDefinition with groupCommands 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.invocation.CommandInvocation;
import org.aesh.command.option.Argument;


@CommandDefinition(name = "task", description = "Task management",
        groupCommands = {AddTask.class, ListTasks.class}) (1)
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 groupCommands. 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 @CommandDefinition with groupCommands. 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

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.Argument;
import org.aesh.command.option.Option;
import org.aesh.command.option.ParentCommand;


@CommandDefinition(name = "cli", description = "CLI application",
        groupCommands = {RunCommand.class, InfoCommand.class}) (1)
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 (2)
    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 groupCommands lists the subcommands available under this parent command.
2 @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

Programmatic sub-commands with GroupCommand

Instead of declaring sub-commands in the groupCommands annotation attribute, you can implement the GroupCommand interface and override getCommands() to register sub-commands programmatically. This is useful when sub-commands need constructor arguments or when the set of sub-commands is determined at runtime:

package com.acme.aesh;

import java.util.List;

import jakarta.inject.Inject;

import org.aesh.command.Command;
import org.aesh.command.CommandDefinition;
import org.aesh.command.CommandResult;
import org.aesh.command.GroupCommand;
import org.aesh.command.invocation.CommandInvocation;

@CommandDefinition(name = "tools", description = "Developer tools")
public class ToolsCommand implements GroupCommand<CommandInvocation> { (1)

    @Inject
    StatusCommand statusSub; (2)

    @Override
    public List<Command<CommandInvocation>> getCommands() { (3)
        return List.of(statusSub);
    }

    @Override
    public CommandResult execute(CommandInvocation invocation) {
        invocation.println("Use a sub-command: status");
        return CommandResult.SUCCESS;
    }
}

@CommandDefinition(name = "status", description = "Show status")
class StatusCommand implements Command<CommandInvocation> {

    @Inject
    StatusService statusService; (4)

    @Override
    public CommandResult execute(CommandInvocation invocation) {
        invocation.println(statusService.getStatus());
        return CommandResult.SUCCESS;
    }
}
1 Implement GroupCommand instead of plain Command. Do not use groupCommands in the annotation — the sub-commands come from getCommands() instead.
2 Inject the sub-command via CDI so that @Inject fields in the sub-command are properly populated.
3 Return CDI-managed instances from getCommands(). Aesh uses these instances directly, preserving CDI injection.
4 CDI injection works in sub-commands because they are managed by CDI, not created with new.
Sub-commands returned by getCommands() must be injected via @Inject (not created with new) if they need CDI services. Creating sub-commands with new bypasses CDI, and @Inject fields will not be populated.

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).

Mixins (reusable option groups)

Mixins allow you to define a set of options in a separate class and reuse them across multiple commands. Use the @Mixin annotation to include a mixin’s options in a command:

package com.acme.aesh;

import org.aesh.command.option.Mixin;
import org.aesh.command.option.Option;

public class OutputMixin { (1)

    @Option(name = "format", shortName = 'f', defaultValue = "text")
    public String format;

    @Option(name = "verbose", shortName = 'v', hasValue = false)
    public boolean verbose;
}
1 The mixin class is a plain Java class with @Option fields. It does not need any annotations on the class itself.

Commands include the mixin with @Mixin:

@CommandDefinition(name = "greet", description = "Greet someone")
public class GreetCommand implements Command<CommandInvocation> {

    @Option(shortName = 'n', name = "name", defaultValue = "World")
    private String name;

    @Mixin (1)
    private OutputMixin output;

    @Override
    public CommandResult execute(CommandInvocation invocation) {
        String greeting = "Hello " + name + "!";
        if ("json".equals(output.format)) {
            invocation.println("{\"greeting\":\"" + greeting + "\"}");
        } else {
            invocation.println(greeting);
        }
        return CommandResult.SUCCESS;
    }
}
1 The mixin’s options (--format, --verbose) are added to the command as if they were declared directly on it. Tab completion and help output include them automatically.

Dynamic default values

You can provide dynamic default values for command options by implementing the DefaultValueProvider interface as a CDI bean. The extension automatically discovers and applies it to all commands:

package com.acme.aesh;

import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;

import org.aesh.command.DefaultValueProvider;
import org.aesh.command.impl.internal.ProcessedOption;

@ApplicationScoped (1)
public class ConfigBasedDefaults implements DefaultValueProvider {

    @Inject
    AppConfig config;

    @Override
    public String defaultValue(ProcessedOption option) throws Exception {
        return config.getDefault(option.name()); (2)
    }
}
1 Register as a CDI bean. The extension discovers it automatically — no additional wiring needed.
2 Return null to use the annotation-declared default. Return a value to override it at runtime.

Per-command providers can also be specified via @CommandDefinition(defaultValueProvider = MyProvider.class).

Option features

Allowed values

Restrict an option to a fixed set of values with allowedValues. Invalid values are rejected at parse time, and tab completion automatically suggests the allowed values:

@Option(name = "env", allowedValues = {"dev", "staging", "prod"})
private String environment;

Fallback values

Use fallbackValue for three-state options where the option can be omitted, specified without a value, or specified with an explicit value:

@Option(name = "debug", fallbackValue = "5005", hasValue = true) (1)
private String debugPort;
1 --debug alone uses the fallback value "5005". --debug=8000 uses the explicit value. Omitting the option entirely uses the defaultValue (if set).

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.quarkus.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.

Command execution listener

Implement CommandExecutionListener as a CDI bean to receive a callback after each command completes. This is useful for logging, metrics, or audit trails:

package com.acme.aesh;

import jakarta.enterprise.context.ApplicationScoped;

import org.aesh.command.CommandExecutionListener;
import org.aesh.command.CommandResult;

@ApplicationScoped
public class CommandLogger implements CommandExecutionListener {

    @Override
    public void onCommandComplete(String commandLine, CommandResult result, long executionTimeMs) {
        System.out.println("Executed: " + commandLine + " in " + executionTimeMs + "ms");
    }
}

The listener fires in both console mode (after each REPL command) and when arguments are provided on the command line.

Command not found handler

Implement CommandNotFoundHandler as a CDI bean to customize the error message when an unknown command is typed:

package com.acme.aesh;

import jakarta.enterprise.context.ApplicationScoped;

import org.aesh.command.CommandNotFoundHandler;
import org.aesh.command.shell.Shell;

@ApplicationScoped
public class MyNotFoundHandler implements CommandNotFoundHandler {

    @Override
    public void handleCommandNotFound(String line, Shell shell) {
        shell.writeln("Unknown command: " + line + ". Type 'help' for available commands.");
    }
}

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 @CommandDefinition on group sub-commands — a class listed in @CommandDefinition(groupCommands = {…​}) that is not annotated with @CommandDefinition. *

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.quarkus</groupId>
    <artifactId>quarkus-aesh-websocket</artifactId>
</dependency>

Once added, the terminal is accessible at http://localhost:8080/aesh/index.html when the application is running.

The built-in terminal page uses xterm.js, which is bundled with the extension. No external CDN or internet access is required.

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.quarkus</groupId>
    <artifactId>quarkus-aesh-ssh</artifactId>
</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

# Path to an OpenSSH authorized_keys file for public key authentication
# (recommended for production)
quarkus.aesh.ssh.authorized-keys-file=/path/to/authorized_keys

# Password for SSH authentication (default: any password accepted)
# For production, use an environment variable instead of plaintext:
# quarkus.aesh.ssh.password=${SSH_PASSWORD}
quarkus.aesh.ssh.password=mysecret

# 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.quarkus.aesh.runtime.AeshSessionEvent;
import io.quarkus.aesh.runtime.SessionClosed;
import io.quarkus.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-console explicitly 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: Use quarkus.aesh.ssh.authorized-keys-file for public key authentication (recommended), or set quarkus.aesh.ssh.password for password authentication (use ${SSH_PASSWORD} to avoid storing the password in plaintext). Both methods can be used simultaneously. Without either, any password is accepted.

  • WebSocket: Set quarkus.aesh.websocket.authenticated=true or quarkus.aesh.websocket.roles-allowed to 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.

WebSocket connections are not protected by the browser’s same-origin policy. If the WebSocket terminal is exposed on a network, consider configuring CORS via the quarkus.http.cors settings to restrict which origins can connect.

Providing your own QuarkusMain

You can also provide your own application entry point annotated with @QuarkusMain (as described in Command Mode reference guide). When a @QuarkusMain is present, the Aesh extension will not register its own runner.

package com.acme.aesh;

import jakarta.inject.Inject;

import org.aesh.AeshRuntimeRunner;
import org.aesh.command.Command;
import org.aesh.command.CommandDefinition;
import org.aesh.command.CommandResult;
import org.aesh.command.invocation.CommandInvocation;

import io.quarkus.runtime.QuarkusApplication;
import io.quarkus.runtime.annotations.QuarkusMain;

@QuarkusMain
public class CustomMain implements QuarkusApplication {

    @Inject
    AeshRuntimeRunner runner;

    @Override
    public int run(String... args) throws Exception {
        runner.args(args).execute();
        return 0;
    }
}

Development Mode

In development mode (mvn quarkus:dev), the Quarkus dev console takes control of the terminal for its interactive features (live reload, test output, etc.). This means the local terminal is not available for aesh CLI interaction.

To test your CLI commands during development, you have the following options:

  • WebSocket terminal: Add the quarkus-aesh-websocket dependency and access the CLI via browser at http://localhost:8080/aesh/index.html. The dev console stays on the local terminal while the aesh CLI runs in the browser.

  • Pass arguments: Use mvn quarkus:dev -Dquarkus.args='--help' or mvn quarkus:dev -Dquarkus.args='-n Quarkus' to pass arguments directly. The application is re-executed every time the Space bar key is pressed. For Gradle projects, use --quarkus-args.

  • Build and run: Build the application with mvn package -DskipTests and run it directly with java -jar target/quarkus-app/quarkus-run.jar <command> [options].

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 (Group or regular), 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).

Testing

Aesh commands can be tested with @QuarkusMainTest from the Command Mode reference guide. The approach differs depending on the execution mode.

Testing runtime mode commands

Runtime mode commands execute once and exit. Use @Launch to run them with specific arguments and assert on the output:

@QuarkusMainTest
public class CliTest {

    @Test
    @Launch({"--name=Alice"})
    void testGreet(LaunchResult result) {
        assertThat(result.getOutput()).contains("Hello Alice!");
        assertThat(result.exitCode()).isEqualTo(0);
    }

    @Test
    void testMultipleLaunches(QuarkusMainLauncher launcher) {
        LaunchResult first = launcher.launch("--name=Alice");
        assertThat(first.getOutput()).contains("Hello Alice!");

        LaunchResult second = launcher.launch("--name=Bob");
        assertThat(second.getOutput()).contains("Hello Bob!");
    }
}

Testing console mode commands (REPL)

Console mode commands run in an interactive shell. Use AeshLauncher to start a REPL session, send commands, and assert on their output:

<dependency>
    <groupId>io.quarkus</groupId>
    <artifactId>quarkus-test-aesh</artifactId>
    <scope>test</scope>
</dependency>
@QuarkusMainTest
public class ReplTest {

    @Test
    void testInteractiveSession(AeshLauncher launcher) {
        launcher.launch();

        String output = launcher.executeCommand("greet --name=Alice");
        assertThat(output).contains("Hello Alice!");

        output = launcher.executeCommand("calc -a 5 -b 3");
        assertThat(output).contains("Result: 8");

        launcher.exit();
    }
}

AeshLauncher provides:

  • launch(String…​ args) — starts the REPL on a background thread

  • executeCommand(String command) — sends a command and blocks until it completes (10 second default timeout)

  • executeCommand(String command, Duration timeout) — same with a custom timeout

  • getErrorOutput() — returns any error output from the session

  • exit() — sends the exit command and waits for clean shutdown

The exit command must be enabled for AeshLauncher to shut down cleanly. Set quarkus.aesh.add-exit-command=true in application.properties (or src/test/resources/application.properties).

Packaging your application

An Aesh command line application is a standard Quarkus application and can be packaged as a JAR or a native executable.

As a jar

Building an uber-jar is practical if you plan on distributing the JAR directly:

mvn package -Dquarkus.package.jar.type=uber-jar
java -jar target/myapp-runner.jar --name=World

As a native executable

You can build a native executable for faster startup and lower memory usage:

mvn package -Dnative
./target/myapp-runner --name=World

The Aesh extension automatically registers command classes and their option types for reflection in native builds.

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.

  • auto - Automatically detect the mode based on annotations. Uses runtime mode for single command or group commands, console mode for multiple commands.

  • runtime - Use AeshRuntimeRunner for single command execution (like picocli).

  • console - Use AeshConsoleRunner for interactive shell mode.

Environment variable: QUARKUS_AESH_MODE

Show more

auto, runtime, console

auto

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 true to force starting the local console alongside remote transports. Set to false to disable the local console even without remote transports (useful when embedding commands in a server application).

When running in dev mode with the local console enabled, set quarkus.console.enabled=false in application.properties to prevent the Quarkus dev console from competing for stdin.

Environment variable: QUARKUS_AESH_START_CONSOLE

Show more

boolean

Fully qualified class name of the top command to use in runtime mode. or FQCN of class which will be used as entry point for Aesh CommandRunner instance. This class needs to be annotated with org.aesh.command.Command.

Environment variable: QUARKUS_AESH_TOP_COMMAND

Show more

string

The prompt to display in console mode. Only used when mode is set to 'console' or auto-detected as console.

Environment variable: QUARKUS_AESH_PROMPT

Show more

string

[quarkus]$

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: QUARKUS_AESH_ADD_EXIT_COMMAND

Show more

boolean

true

Enable command aliasing. When enabled, users can create aliases for commands.

Environment variable: QUARKUS_AESH_ENABLE_ALIAS

Show more

boolean

false

Enable the export command. When enabled, the 'export' command is available for setting environment variables.

Environment variable: QUARKUS_AESH_ENABLE_EXPORT

Show more

boolean

false

Enable man page support. When enabled, the 'man' command is available for viewing command documentation.

Environment variable: QUARKUS_AESH_ENABLE_MAN

Show more

boolean

false

Enable command history persistence. When enabled, command history is saved to a file between sessions.

Environment variable: QUARKUS_AESH_PERSIST_HISTORY

Show more

boolean

false

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: QUARKUS_AESH_HISTORY_FILE

Show more

string

Maximum number of history entries to keep.

Environment variable: QUARKUS_AESH_HISTORY_SIZE

Show more

int

500

Enable logging of aesh internal operations.

Environment variable: QUARKUS_AESH_LOGGING

Show more

boolean

false

Enable sub-command mode. When enabled, typing a group command without a subcommand enters an interactive context.

Example:

[quarkus]$ module --name mymodule
Entering module mode.
module[mymodule]> tag add v1.0
module[mymodule]> exit
[quarkus]$

Environment variable: QUARKUS_AESH_SUB_COMMAND_MODE_ENABLED

Show more

boolean

true

Command to exit sub-command mode and return to the parent context.

Environment variable: QUARKUS_AESH_SUB_COMMAND_MODE_EXIT_COMMAND

Show more

string

exit

Alternative command to exit sub-command mode (e.g., ".."). Set to empty string to disable.

Environment variable: QUARKUS_AESH_SUB_COMMAND_MODE_ALTERNATIVE_EXIT_COMMAND

Show more

string

..

Separator used for nested context paths in the prompt. For example, with separator ":", nested contexts appear as "module:project>"

Environment variable: QUARKUS_AESH_SUB_COMMAND_MODE_CONTEXT_SEPARATOR

Show more

string

:

Show option/argument values when entering sub-command mode.

Environment variable: QUARKUS_AESH_SUB_COMMAND_MODE_SHOW_CONTEXT_ON_ENTRY

Show more

boolean

true

Show the primary argument value in the prompt. For example, "module[myapp]>" vs "module>"

Environment variable: QUARKUS_AESH_SUB_COMMAND_MODE_SHOW_ARGUMENT_IN_PROMPT

Show more

boolean

true

Configuration property fixed at build time - All other configuration properties are overridable at runtime

Configuration property

Type

Default

Whether the WebSocket terminal endpoint is enabled.

Environment variable: QUARKUS_AESH_WEBSOCKET_ENABLED

Show more

boolean

true

Comma-separated list of roles required to access the WebSocket terminal. Requires a Quarkus Security extension (e.g. quarkus-elytron-security-properties-file).

Environment variable: QUARKUS_AESH_WEBSOCKET_ROLES_ALLOWED

Show more

list of string

Whether the WebSocket terminal requires an authenticated user (any role). Ignored if roles-allowed is set. Requires a Quarkus Security extension.

Environment variable: QUARKUS_AESH_WEBSOCKET_AUTHENTICATED

Show more

boolean

false

The path for the WebSocket terminal endpoint.

Environment variable: QUARKUS_AESH_WEBSOCKET_PATH

Show more

string

/aesh/terminal

Whether the health check is published when the smallrye-health extension is present.

Environment variable: QUARKUS_AESH_WEBSOCKET_HEALTH_ENABLED

Show more

boolean

true

Maximum number of concurrent WebSocket terminal sessions. Zero means no limit.

Environment variable: QUARKUS_AESH_WEBSOCKET_MAX_CONNECTIONS

Show more

int

0

Idle timeout for WebSocket sessions. If a session has no input activity for this duration, it will be closed. If not set, sessions can remain idle indefinitely.

Environment variable: QUARKUS_AESH_WEBSOCKET_IDLE_TIMEOUT

Show more

Duration 

About the Duration format

To write duration values, use the standard java.time.Duration format. See the Duration#parse() Java API documentation for more information.

You can also use a simplified format, starting with a number:

  • If the value is only a number, it represents time in seconds.

  • If the value is a number followed by ms, it represents time in milliseconds.

In other cases, the simplified format is translated to the java.time.Duration format for parsing:

  • If the value is a number followed by h, m, or s, it is prefixed with PT.

  • If the value is a number followed by d, it is prefixed with P.

Configuration property fixed at build time - All other configuration properties are overridable at runtime

Configuration property

Type

Default

Whether the health check is published when the smallrye-health extension is present.

Environment variable: QUARKUS_AESH_SSH_HEALTH_ENABLED

Show more

boolean

true

Whether the SSH terminal server is enabled.

Environment variable: QUARKUS_AESH_SSH_ENABLED

Show more

boolean

true

SSH server port.

Environment variable: QUARKUS_AESH_SSH_PORT

Show more

int

2222

SSH server bind address.

Environment variable: QUARKUS_AESH_SSH_HOST

Show more

string

localhost

Path to the host key file. If the file does not exist, a new RSA key pair will be generated and saved to this path.

Environment variable: QUARKUS_AESH_SSH_HOST_KEY_FILE

Show more

string

hostkey.ser

Password for SSH authentication. If not set, any password is accepted (suitable for development only).

For production, use public key authentication via authorized-keys-file instead. If password authentication is needed, avoid storing the password in plaintext in application.properties. Instead, reference an environment variable:

quarkus.aesh.ssh.password=${SSH_PASSWORD}

Environment variable: QUARKUS_AESH_SSH_PASSWORD

Show more

string

Path to an OpenSSH-format authorized_keys file for public key authentication. This is the recommended authentication method for production environments. When set, clients can authenticate with a private key whose public key is listed in this file. Can be used alongside password authentication.

Environment variable: QUARKUS_AESH_SSH_AUTHORIZED_KEYS_FILE

Show more

string

Maximum number of concurrent SSH sessions. Zero means no limit.

Environment variable: QUARKUS_AESH_SSH_MAX_CONNECTIONS

Show more

int

0

Idle timeout for SSH sessions. If a session has no input activity for this duration, it will be closed. If not set, sessions can remain idle indefinitely.

Environment variable: QUARKUS_AESH_SSH_IDLE_TIMEOUT

Show more

Duration 

About the Duration format

To write duration values, use the standard java.time.Duration format. See the Duration#parse() Java API documentation for more information.

You can also use a simplified format, starting with a number:

  • If the value is only a number, it represents time in seconds.

  • If the value is a number followed by ms, it represents time in milliseconds.

In other cases, the simplified format is translated to the java.time.Duration format for parsing:

  • If the value is a number followed by h, m, or s, it is prefixed with PT.

  • If the value is a number followed by d, it is prefixed with P.

Related content