|
| 1 | +--- |
| 2 | +date: '2026-04-29T12:00:00+02:00' |
| 3 | +draft: false |
| 4 | +title: 'Migrating from Picocli' |
| 5 | +weight: 19 |
| 6 | +--- |
| 7 | + |
| 8 | +This guide helps you migrate CLI applications from [picocli](https://picocli.info/) to Aesh. The two frameworks use similar annotation-based models, so most migrations are straightforward renaming. |
| 9 | + |
| 10 | +## Why Migrate? |
| 11 | + |
| 12 | +**Startup performance.** Aesh registers commands 67-655x faster than picocli in benchmarks, depending on command complexity. With the optional [annotation processor](../annotation-processor), Aesh eliminates all runtime reflection and is a further 3-4x faster than its own reflection path. |
| 13 | + |
| 14 | +| Benchmark (100 commands) | Aesh (generated) | Picocli (reflection) | Speedup | |
| 15 | +|---|---|---|---| |
| 16 | +| Flat commands (4 options each) | 40 us | 2,646 us | **67x** | |
| 17 | +| Group commands (parent + 2 children) | 26 us | 7,242 us | **282x** | |
| 18 | +| Nested groups (3-level hierarchy) | 13 us | 8,776 us | **655x** | |
| 19 | + |
| 20 | +Aesh also parses command lines 8-21x faster than picocli once commands are registered. |
| 21 | + |
| 22 | +**GraalVM native-image.** The annotation processor generates direct `new` calls and cached field accessors, reducing the need for reflection configuration in native images. |
| 23 | + |
| 24 | +**Smaller footprint.** Aesh has no transitive dependencies beyond aesh-readline. Picocli is a single jar but is significantly larger. |
| 25 | + |
| 26 | +## Annotation Mapping |
| 27 | + |
| 28 | +### Commands |
| 29 | + |
| 30 | +| Picocli | Aesh | |
| 31 | +|---------|------| |
| 32 | +| `@Command(name = "deploy", description = "...")` | `@CommandDefinition(name = "deploy", description = "...")` | |
| 33 | +| `@Command(subcommands = {Sub.class})` | `@GroupCommandDefinition(name = "grp", groupCommands = {Sub.class})` | |
| 34 | +| `@Command(mixinStandardHelpOptions = true)` | `@CommandDefinition(generateHelp = true)` | |
| 35 | +| `@Command(version = "1.0")` | `@CommandDefinition(version = "1.0")` | |
| 36 | +| `implements Runnable` or `Callable<Integer>` | `implements Command<CommandInvocation>` | |
| 37 | + |
| 38 | +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. |
| 39 | + |
| 40 | +### Options |
| 41 | + |
| 42 | +| Picocli | Aesh | |
| 43 | +|---------|------| |
| 44 | +| `@Option(names = {"-v", "--verbose"})` | `@Option(shortName = 'v', name = "verbose")` | |
| 45 | +| `@Option(names = "-v", arity = "0")` | `@Option(shortName = 'v', hasValue = false)` | |
| 46 | +| `@Option(names = "--count", defaultValue = "10")` | `@Option(name = "count", defaultValue = "10")` | |
| 47 | +| `@Option(names = "--out", required = true)` | `@Option(name = "out", required = true)` | |
| 48 | +| `@Option(names = "--items", split = ",") List<String>` | `@OptionList(name = "items", valueSeparator = ',')` | |
| 49 | +| `@Option(names = "-D") Map<String,String>` | `@OptionGroup(shortName = 'D')` | |
| 50 | +| `@Option mapFallbackValue = ""` | `@OptionGroup(defaultValue = "")` | |
| 51 | + |
| 52 | +Key differences: |
| 53 | +- Aesh `shortName` is a `char`, not a string. The long name defaults to the field name or is set via `name`. |
| 54 | +- Boolean flags require `hasValue = false` in Aesh (picocli infers this from the field type or `arity = "0"`). |
| 55 | +- List options use the dedicated `@OptionList` annotation instead of putting `split` on `@Option`. |
| 56 | +- Map/property options use `@OptionGroup` instead of type inference on `Map` fields. |
| 57 | + |
| 58 | +### Arguments |
| 59 | + |
| 60 | +| Picocli | Aesh | |
| 61 | +|---------|------| |
| 62 | +| `@Parameters(index = "0")` | `@Argument` | |
| 63 | +| `@Parameters List<String>` | `@Arguments` | |
| 64 | +| `@Parameters(description = "...")` | `@Argument(description = "...")` | |
| 65 | + |
| 66 | +Aesh uses `@Argument` for a single positional argument and `@Arguments` for multiple. There is no `index` attribute -- field declaration order determines position. |
| 67 | + |
| 68 | +### Other Annotations |
| 69 | + |
| 70 | +| Picocli | Aesh | |
| 71 | +|---------|------| |
| 72 | +| `@Mixin` | `@Mixin` | |
| 73 | +| `@ParentCommand` | `@ParentCommand` | |
| 74 | + |
| 75 | +These work the same way in both frameworks. |
| 76 | + |
| 77 | +### Execution |
| 78 | + |
| 79 | +| Picocli | Aesh | |
| 80 | +|---------|------| |
| 81 | +| `new CommandLine(cmd).execute(args)` | `AeshRuntimeRunner.builder().command(Cmd.class).args(args).execute()` | |
| 82 | +| `new CommandLine(cmd)` + interactive loop | `AeshConsoleRunner.builder().command(Cmd.class).prompt("$ ").start()` | |
| 83 | + |
| 84 | +## Before and After |
| 85 | + |
| 86 | +### Picocli |
| 87 | + |
| 88 | +```java |
| 89 | +import picocli.CommandLine; |
| 90 | +import picocli.CommandLine.Command; |
| 91 | +import picocli.CommandLine.Option; |
| 92 | +import picocli.CommandLine.Parameters; |
| 93 | + |
| 94 | +@Command(name = "deploy", description = "Deploy an application", |
| 95 | + mixinStandardHelpOptions = true, version = "1.0") |
| 96 | +public class DeployCommand implements Callable<Integer> { |
| 97 | + |
| 98 | + @Option(names = {"-e", "--environment"}, defaultValue = "production", |
| 99 | + description = "Target environment") |
| 100 | + private String environment; |
| 101 | + |
| 102 | + @Option(names = {"-f", "--force"}, description = "Force deployment") |
| 103 | + private boolean force; |
| 104 | + |
| 105 | + @Option(names = {"-t", "--tags"}, split = ",", |
| 106 | + description = "Deployment tags") |
| 107 | + private List<String> tags; |
| 108 | + |
| 109 | + @Option(names = "-D", description = "Properties") |
| 110 | + private Map<String, String> properties; |
| 111 | + |
| 112 | + @Parameters(index = "0", description = "Application name") |
| 113 | + private String application; |
| 114 | + |
| 115 | + @Override |
| 116 | + public Integer call() { |
| 117 | + System.out.println("Deploying " + application + " to " + environment); |
| 118 | + return 0; |
| 119 | + } |
| 120 | + |
| 121 | + public static void main(String[] args) { |
| 122 | + int exitCode = new CommandLine(new DeployCommand()).execute(args); |
| 123 | + System.exit(exitCode); |
| 124 | + } |
| 125 | +} |
| 126 | +``` |
| 127 | + |
| 128 | +### Aesh |
| 129 | + |
| 130 | +```java |
| 131 | +import org.aesh.command.*; |
| 132 | +import org.aesh.command.invocation.CommandInvocation; |
| 133 | +import org.aesh.command.option.*; |
| 134 | +import org.aesh.AeshRuntimeRunner; |
| 135 | + |
| 136 | +import java.util.List; |
| 137 | +import java.util.Map; |
| 138 | + |
| 139 | +@CommandDefinition(name = "deploy", description = "Deploy an application", |
| 140 | + generateHelp = true, version = "1.0") |
| 141 | +public class DeployCommand implements Command<CommandInvocation> { |
| 142 | + |
| 143 | + @Option(shortName = 'e', name = "environment", defaultValue = "production", |
| 144 | + description = "Target environment") |
| 145 | + private String environment; |
| 146 | + |
| 147 | + @Option(shortName = 'f', name = "force", hasValue = false, |
| 148 | + description = "Force deployment") |
| 149 | + private boolean force; |
| 150 | + |
| 151 | + @OptionList(shortName = 't', name = "tags", valueSeparator = ',', |
| 152 | + description = "Deployment tags") |
| 153 | + private List<String> tags; |
| 154 | + |
| 155 | + @OptionGroup(shortName = 'D', description = "Properties") |
| 156 | + private Map<String, String> properties; |
| 157 | + |
| 158 | + @Argument(description = "Application name", required = true) |
| 159 | + private String application; |
| 160 | + |
| 161 | + @Override |
| 162 | + public CommandResult execute(CommandInvocation invocation) { |
| 163 | + invocation.println("Deploying " + application + " to " + environment); |
| 164 | + return CommandResult.SUCCESS; |
| 165 | + } |
| 166 | + |
| 167 | + public static void main(String[] args) { |
| 168 | + AeshRuntimeRunner.builder() |
| 169 | + .command(DeployCommand.class) |
| 170 | + .args(args) |
| 171 | + .execute(); |
| 172 | + } |
| 173 | +} |
| 174 | +``` |
| 175 | + |
| 176 | +### What Changed |
| 177 | + |
| 178 | +1. `@Command` became `@CommandDefinition` with `generateHelp = true` |
| 179 | +2. `implements Callable<Integer>` became `implements Command<CommandInvocation>` |
| 180 | +3. `@Option(names = {"-f", "--force"})` became `@Option(shortName = 'f', name = "force", hasValue = false)` -- boolean flags need `hasValue = false` |
| 181 | +4. `@Option(split = ",") List<String>` became `@OptionList(valueSeparator = ',')` |
| 182 | +5. `Map<String, String>` with `@Option` became `@OptionGroup` |
| 183 | +6. `@Parameters` became `@Argument` |
| 184 | +7. `System.out.println` became `invocation.println` |
| 185 | +8. Return `CommandResult.SUCCESS` instead of an exit code integer |
| 186 | +9. `CommandLine.execute(args)` became `AeshRuntimeRunner.builder()...execute()` |
| 187 | + |
| 188 | +## Key Differences |
| 189 | + |
| 190 | +### Output |
| 191 | + |
| 192 | +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. |
| 193 | + |
| 194 | +### Exit Codes |
| 195 | + |
| 196 | +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). |
| 197 | + |
| 198 | +### Group Commands |
| 199 | + |
| 200 | +Picocli uses `subcommands = {...}` on `@Command`. Aesh uses a separate `@GroupCommandDefinition` annotation: |
| 201 | + |
| 202 | +```java |
| 203 | +// Picocli |
| 204 | +@Command(name = "remote", subcommands = {AddCommand.class, RemoveCommand.class}) |
| 205 | +public class RemoteCommand implements Runnable { ... } |
| 206 | + |
| 207 | +// Aesh |
| 208 | +@GroupCommandDefinition(name = "remote", |
| 209 | + groupCommands = {AddCommand.class, RemoveCommand.class}) |
| 210 | +public class RemoteCommand implements Command<CommandInvocation> { ... } |
| 211 | +``` |
| 212 | + |
| 213 | +### Inherited Options |
| 214 | + |
| 215 | +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: |
| 216 | + |
| 217 | +```java |
| 218 | +@GroupCommandDefinition(name = "app", groupCommands = {RunCommand.class}) |
| 219 | +public class AppCommand implements Command<CommandInvocation> { |
| 220 | + @Option(name = "verbose", hasValue = false, inherited = true) |
| 221 | + boolean verbose; |
| 222 | +} |
| 223 | +``` |
| 224 | + |
| 225 | +Both `app --verbose run` and `app run --verbose` set `verbose = true` on the child command. |
| 226 | + |
| 227 | +## Aesh-Only Features |
| 228 | + |
| 229 | +These features have no picocli equivalent: |
| 230 | + |
| 231 | +- **[Negatable booleans](../options#negatable-options)** -- `@Option(negatable = true)` generates `--no-verbose` automatically |
| 232 | +- **[Exclusive options](../options#mutually-exclusive-options)** -- `@Option(exclusiveWith = "json")` enforces mutual exclusion at parse time |
| 233 | +- **[Option visibility](../options#visibility-levels)** -- `OptionVisibility.BRIEF/FULL/HIDDEN` controls which options appear in `--help` and tab completion |
| 234 | +- **[Interactive prompting](../options)** -- `@Option(askIfNotSet = true)` prompts the user for missing required values |
| 235 | +- **[Ghost text suggestions](../ghost-text-suggestions)** -- Inline suggestions as the user types |
| 236 | +- **[Selectors](../selectors)** -- Interactive list/checkbox selection for option values |
| 237 | +- **[Sub-command mode](../sub-command-mode)** -- Enter a context where subcommands run without repeating the parent name |
| 238 | + |
| 239 | +## Shell Completion Scripts |
| 240 | + |
| 241 | +Aesh generates bash, zsh, and fish completion scripts automatically. See [Completers -- Shell Completion Scripts](../completers#shell-completion-scripts) for details. |
| 242 | + |
| 243 | +```java |
| 244 | +AeshRuntimeRunner.builder() |
| 245 | + .command(DeployCommand.class) |
| 246 | + .args(new String[] {"--aesh-completion", "bash"}) |
| 247 | + .execute(); |
| 248 | +``` |
| 249 | + |
| 250 | +## Annotation Processor |
| 251 | + |
| 252 | +For maximum startup performance, add the [annotation processor](../annotation-processor) to eliminate all runtime reflection: |
| 253 | + |
| 254 | +```xml |
| 255 | +<dependency> |
| 256 | + <groupId>org.aesh</groupId> |
| 257 | + <artifactId>aesh-processor</artifactId> |
| 258 | + <version>${aesh.version}</version> |
| 259 | + <scope>provided</scope> |
| 260 | +</dependency> |
| 261 | +``` |
| 262 | + |
| 263 | +No code changes needed -- the processor runs at compile time and the runtime automatically uses the generated metadata when available. See [Annotation Processor](../annotation-processor) for setup details. |
0 commit comments