Migrating from Picocli

Migrating from Picocli

This guide helps you migrate CLI applications from picocli to Aesh. The two frameworks use similar annotation-based models, so most migrations are straightforward renaming.

Why Migrate?

Startup performance. Aesh registers commands 67-655x faster than picocli in benchmarks, depending on command complexity. With the optional annotation processor, Aesh eliminates all runtime reflection and is a further 3-4x faster than its own reflection path.

Benchmark (100 commands)Aesh (generated)Picocli (reflection)Speedup
Flat commands (4 options each)40 us2,646 us67x
Group commands (parent + 2 children)26 us7,242 us282x
Nested groups (3-level hierarchy)13 us8,776 us655x

Aesh also parses command lines 8-21x faster than picocli once commands are registered.

GraalVM native-image. The annotation processor generates direct new calls and cached field accessors, reducing the need for reflection configuration in native images.

Smaller footprint. Aesh has no transitive dependencies beyond aesh-readline. Picocli is a single jar but is significantly larger.

Annotation Mapping

Commands

PicocliAesh
@Command(name = "deploy", description = "...")@CommandDefinition(name = "deploy", description = "...")
@Command(subcommands = {Sub.class})@GroupCommandDefinition(name = "grp", groupCommands = {Sub.class})
@Command(mixinStandardHelpOptions = true)@CommandDefinition(generateHelp = true)
@Command(version = "1.0")@CommandDefinition(version = "1.0")
implements Runnable or Callable<Integer>implements Command<CommandInvocation>

In picocli, @Command handles both simple and group commands. In Aesh, group commands use the separate @GroupCommandDefinition annotation with a groupCommands attribute listing subcommand classes.

Options

PicocliAesh
@Option(names = {"-v", "--verbose"})@Option(shortName = 'v', name = "verbose")
@Option(names = "-v", arity = "0")@Option(shortName = 'v', hasValue = false)
@Option(names = "--count", defaultValue = "10")@Option(name = "count", defaultValue = "10")
@Option(names = "--out", required = true)@Option(name = "out", required = true)
@Option(names = "--items", split = ",") List<String>@OptionList(name = "items", valueSeparator = ',')
@Option(names = "-D") Map<String,String>@OptionGroup(shortName = 'D')
@Option mapFallbackValue = ""@OptionGroup(defaultValue = "")

Key differences:

  • Aesh shortName is a char, not a string. The long name defaults to the field name or is set via name.
  • Boolean flags require hasValue = false in Aesh (picocli infers this from the field type or arity = "0").
  • List options use the dedicated @OptionList annotation instead of putting split on @Option.
  • Map/property options use @OptionGroup instead of type inference on Map fields.

Arguments

PicocliAesh
@Parameters(index = "0")@Argument
@Parameters List<String>@Arguments
@Parameters(description = "...")@Argument(description = "...")

Aesh uses @Argument for a single positional argument and @Arguments for multiple. There is no index attribute – field declaration order determines position.

Other Annotations

PicocliAesh
@Mixin@Mixin
@ParentCommand@ParentCommand

These work the same way in both frameworks.

Execution

PicocliAesh
new CommandLine(cmd).execute(args)AeshRuntimeRunner.builder().command(Cmd.class).args(args).execute()
new CommandLine(cmd) + interactive loopAeshConsoleRunner.builder().command(Cmd.class).prompt("$ ").start()

Before and After

Picocli

import picocli.CommandLine;
import picocli.CommandLine.Command;
import picocli.CommandLine.Option;
import picocli.CommandLine.Parameters;

@Command(name = "deploy", description = "Deploy an application",
         mixinStandardHelpOptions = true, version = "1.0")
public class DeployCommand implements Callable<Integer> {

    @Option(names = {"-e", "--environment"}, defaultValue = "production",
            description = "Target environment")
    private String environment;

    @Option(names = {"-f", "--force"}, description = "Force deployment")
    private boolean force;

    @Option(names = {"-t", "--tags"}, split = ",",
            description = "Deployment tags")
    private List<String> tags;

    @Option(names = "-D", description = "Properties")
    private Map<String, String> properties;

    @Parameters(index = "0", description = "Application name")
    private String application;

    @Override
    public Integer call() {
        System.out.println("Deploying " + application + " to " + environment);
        return 0;
    }

    public static void main(String[] args) {
        int exitCode = new CommandLine(new DeployCommand()).execute(args);
        System.exit(exitCode);
    }
}

Aesh

import org.aesh.command.*;
import org.aesh.command.invocation.CommandInvocation;
import org.aesh.command.option.*;
import org.aesh.AeshRuntimeRunner;

import java.util.List;
import java.util.Map;

@CommandDefinition(name = "deploy", description = "Deploy an application",
                   generateHelp = true, version = "1.0")
public class DeployCommand implements Command<CommandInvocation> {

    @Option(shortName = 'e', name = "environment", defaultValue = "production",
            description = "Target environment")
    private String environment;

    @Option(shortName = 'f', name = "force", hasValue = false,
            description = "Force deployment")
    private boolean force;

    @OptionList(shortName = 't', name = "tags", valueSeparator = ',',
                description = "Deployment tags")
    private List<String> tags;

    @OptionGroup(shortName = 'D', description = "Properties")
    private Map<String, String> properties;

    @Argument(description = "Application name", required = true)
    private String application;

    @Override
    public CommandResult execute(CommandInvocation invocation) {
        invocation.println("Deploying " + application + " to " + environment);
        return CommandResult.SUCCESS;
    }

    public static void main(String[] args) {
        AeshRuntimeRunner.builder()
                .command(DeployCommand.class)
                .args(args)
                .execute();
    }
}

What Changed

  1. @Command became @CommandDefinition with generateHelp = true
  2. implements Callable<Integer> became implements Command<CommandInvocation>
  3. @Option(names = {"-f", "--force"}) became @Option(shortName = 'f', name = "force", hasValue = false) – boolean flags need hasValue = false
  4. @Option(split = ",") List<String> became @OptionList(valueSeparator = ',')
  5. Map<String, String> with @Option became @OptionGroup
  6. @Parameters became @Argument
  7. System.out.println became invocation.println
  8. Return CommandResult.SUCCESS instead of an exit code integer
  9. CommandLine.execute(args) became AeshRuntimeRunner.builder()...execute()

Key Differences

Output

Picocli commands use System.out directly. Aesh commands use CommandInvocation.println(), which routes output through the terminal connection. This enables the same command to work with local terminals, SSH, telnet, and WebSocket connections.

Exit Codes

Picocli commands return an int exit code from Callable.call(). Aesh commands return CommandResult.SUCCESS, CommandResult.FAILURE, or CommandResult.valueOf(code). Parse errors automatically return exit code 2 (POSIX convention).

Group Commands

Picocli uses subcommands = {...} on @Command. Aesh uses a separate @GroupCommandDefinition annotation:

// Picocli
@Command(name = "remote", subcommands = {AddCommand.class, RemoveCommand.class})
public class RemoteCommand implements Runnable { ... }

// Aesh
@GroupCommandDefinition(name = "remote",
        groupCommands = {AddCommand.class, RemoveCommand.class})
public class RemoteCommand implements Command<CommandInvocation> { ... }

Inherited Options

Picocli supports inherited options via @Option(scope = INHERIT). Aesh uses @Option(inherited = true) on the parent command. Inherited options can appear before or after the subcommand name on the command line:

@GroupCommandDefinition(name = "app", groupCommands = {RunCommand.class})
public class AppCommand implements Command<CommandInvocation> {
    @Option(name = "verbose", hasValue = false, inherited = true)
    boolean verbose;
}

Both app --verbose run and app run --verbose set verbose = true on the child command.

Aesh-Only Features

These features have no picocli equivalent:

  • Negatable booleans@Option(negatable = true) generates --no-verbose automatically
  • Exclusive options@Option(exclusiveWith = "json") enforces mutual exclusion at parse time
  • Option visibilityOptionVisibility.BRIEF/FULL/HIDDEN controls which options appear in --help and tab completion
  • Interactive prompting@Option(askIfNotSet = true) prompts the user for missing required values
  • Ghost text suggestions – Inline suggestions as the user types
  • Selectors – Interactive list/checkbox selection for option values
  • Sub-command mode – Enter a context where subcommands run without repeating the parent name

Shell Completion Scripts

Aesh generates bash, zsh, and fish completion scripts automatically. See Completers – Shell Completion Scripts for details.

AeshRuntimeRunner.builder()
        .command(DeployCommand.class)
        .args(new String[] {"--aesh-completion", "bash"})
        .execute();

Annotation Processor

For maximum startup performance, add the annotation processor to eliminate all runtime reflection:

<dependency>
  <groupId>org.aesh</groupId>
  <artifactId>aesh-processor</artifactId>
  <version>${aesh.version}</version>
  <scope>provided</scope>
</dependency>

No code changes needed – the processor runs at compile time and the runtime automatically uses the generated metadata when available. See Annotation Processor for setup details.