This document contains detailed implementation proposals for features inspired by picocli that would enhance aesh's CLI framework capabilities.
Support for --no-XXX style options that negate boolean flags. This is a common CLI convention (e.g., --no-color, --no-cache).
@Option(name = "verbose", negatable = true)
boolean verbose = true; // --verbose or --no-verboseFile: aesh/src/main/java/org/aesh/command/option/Option.java
/**
* When set to true for boolean options, automatically supports --no-{name}
* to set the value to false. The default prefix is "no-" but can be customized.
*/
boolean negatable() default false;
/**
* The prefix used for negation. Default is "no-".
* Only used when negatable=true.
*/
String negationPrefix() default "no-";File: aesh/src/main/java/org/aesh/command/impl/internal/ProcessedOption.java
Add fields:
private boolean negatable;
private String negationPrefix;
public boolean isNegatable() { return negatable; }
public String getNegationPrefix() { return negationPrefix; }
public String getNegatedName() {
return negatable ? negationPrefix + name : null;
}File: aesh/src/main/java/org/aesh/command/impl/internal/ProcessedOptionBuilder.java
public ProcessedOptionBuilder negatable(boolean negatable) {
this.negatable = negatable;
return this;
}
public ProcessedOptionBuilder negationPrefix(String prefix) {
this.negationPrefix = prefix;
return this;
}File: aesh/src/main/java/org/aesh/command/impl/container/AeshCommandContainerBuilder.java
In processField() for Option:
.negatable(o.negatable())
.negationPrefix(o.negationPrefix())File: aesh/src/main/java/org/aesh/command/impl/parser/AeshCommandLineParser.java
In option matching logic:
// When looking for option by name, also check negated form
ProcessedOption option = findOption(name);
if (option == null) {
// Check if this is a negated option
for (ProcessedOption opt : processedCommand.getOptions()) {
if (opt.isNegatable() && name.equals(opt.getNegatedName())) {
option = opt;
negatedValue = true;
break;
}
}
}Show both forms in help:
--verbose, --no-verbose Enable verbose output (default: true)
- Only allow
negatable=truefor boolean/Boolean fields - Throw
CommandLineParserExceptionat build time if misused
Support for grouping options with constraints:
- Exclusive: Only one option in the group can be specified
- Dependent: If one option is specified, all must be specified
- Multiplicity: Control how many times a group can appear
@Retention(RUNTIME)
@Target(FIELD)
public @interface ArgGroup {
/**
* Name of the group for error messages and help.
*/
String name() default "";
/**
* If true, options in this group are mutually exclusive.
*/
boolean exclusive() default false;
/**
* If true, all options in this group must be specified together.
*/
boolean dependent() default false;
/**
* Multiplicity constraint: "0..1", "1", "0..*", "1..*"
*/
String multiplicity() default "0..1";
/**
* Heading for this group in help output.
*/
String heading() default "";
/**
* Order in help output.
*/
int order() default -1;
}@CommandDefinition(name = "myapp", description = "My Application")
public class MyCommand implements Command {
// Exclusive group - only one can be specified
@ArgGroup(exclusive = true, multiplicity = "1",
heading = "Output Format (choose one)")
OutputFormat format;
static class OutputFormat {
@Option(name = "json", description = "Output as JSON")
boolean json;
@Option(name = "xml", description = "Output as XML")
boolean xml;
@Option(name = "csv", description = "Output as CSV")
boolean csv;
}
// Dependent group - all or none
@ArgGroup(dependent = true, heading = "Authentication")
AuthOptions auth;
static class AuthOptions {
@Option(name = "user", required = true)
String username;
@Option(name = "password", required = true)
String password;
}
@Override
public CommandResult execute(CommandInvocation invocation) {
// ...
}
}File: aesh/src/main/java/org/aesh/command/option/ArgGroup.java
package org.aesh.command.option;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;
import static java.lang.annotation.ElementType.FIELD;
import static java.lang.annotation.RetentionPolicy.RUNTIME;
@Retention(RUNTIME)
@Target(FIELD)
public @interface ArgGroup {
String name() default "";
boolean exclusive() default false;
boolean dependent() default false;
String multiplicity() default "0..1";
String heading() default "";
int order() default -1;
}File: aesh/src/main/java/org/aesh/command/impl/internal/ProcessedArgGroup.java
package org.aesh.command.impl.internal;
import java.util.List;
import java.util.ArrayList;
public class ProcessedArgGroup {
private final String name;
private final boolean exclusive;
private final boolean dependent;
private final String multiplicity;
private final String heading;
private final int order;
private final List<ProcessedOption> options;
private final Class<?> groupClass;
private final String fieldName;
// Track how many times this group has been specified
private int specifiedCount = 0;
public ProcessedArgGroup(String name, boolean exclusive, boolean dependent,
String multiplicity, String heading, int order,
Class<?> groupClass, String fieldName) {
this.name = name;
this.exclusive = exclusive;
this.dependent = dependent;
this.multiplicity = multiplicity;
this.heading = heading;
this.order = order;
this.groupClass = groupClass;
this.fieldName = fieldName;
this.options = new ArrayList<>();
}
public void addOption(ProcessedOption option) {
options.add(option);
option.setArgGroup(this);
}
public List<ProcessedOption> getOptions() {
return options;
}
public void validate() throws OptionValidatorException {
List<ProcessedOption> specified = getSpecifiedOptions();
if (exclusive && specified.size() > 1) {
throw new OptionValidatorException(
"Options " + getOptionNames(specified) + " are mutually exclusive");
}
if (dependent && specified.size() > 0 && specified.size() < options.size()) {
List<ProcessedOption> missing = getMissingOptions(specified);
throw new OptionValidatorException(
"When using " + getOptionNames(specified) +
", you must also specify " + getOptionNames(missing));
}
validateMultiplicity(specified);
}
private List<ProcessedOption> getSpecifiedOptions() {
List<ProcessedOption> specified = new ArrayList<>();
for (ProcessedOption opt : options) {
if (opt.getValue() != null || opt.getValues().size() > 0) {
specified.add(opt);
}
}
return specified;
}
// ... additional helper methods
}Add support for argument groups:
private List<ProcessedArgGroup> argGroups = new ArrayList<>();
public void addArgGroup(ProcessedArgGroup group) {
argGroups.add(group);
}
public List<ProcessedArgGroup> getArgGroups() {
return argGroups;
}ArgGroup ag;
if ((ag = field.getAnnotation(ArgGroup.class)) != null) {
Class<?> groupClass = field.getType();
ProcessedArgGroup argGroup = new ProcessedArgGroup(
ag.name().isEmpty() ? field.getName() : ag.name(),
ag.exclusive(),
ag.dependent(),
ag.multiplicity(),
ag.heading(),
ag.order(),
groupClass,
field.getName()
);
// Process all fields in the group class
for (Field groupField : groupClass.getDeclaredFields()) {
ProcessedOption option = processFieldAsOption(groupField);
if (option != null) {
argGroup.addOption(option);
processedCommand.addOption(option);
}
}
processedCommand.addArgGroup(argGroup);
}In AeshCommandLineParser.parse() or populate():
// After all options are parsed, validate argument groups
for (ProcessedArgGroup group : processedCommand.getArgGroups()) {
group.validate();
}Group options under headings in help output:
Output Format (choose one):
--json Output as JSON
--xml Output as XML
--csv Output as CSV
Authentication:
--user Username (required with --password)
--password Password (required with --user)
Allow defining reusable sets of options that can be included in multiple commands without code duplication.
// Define a reusable mixin
public class LoggingMixin {
@Option(name = "verbose", shortName = 'v', description = "Verbose output")
boolean verbose;
@Option(name = "debug", shortName = 'd', description = "Debug output")
boolean debug;
@Option(name = "quiet", shortName = 'q', description = "Quiet mode")
boolean quiet;
}
// Use the mixin in a command
@CommandDefinition(name = "mycommand", description = "My Command")
public class MyCommand implements Command {
@Mixin
LoggingMixin logging;
@Option(name = "output", shortName = 'o')
String output;
@Override
public CommandResult execute(CommandInvocation invocation) {
if (logging.verbose) {
// ...
}
}
}File: aesh/src/main/java/org/aesh/command/option/Mixin.java
package org.aesh.command.option;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;
import static java.lang.annotation.ElementType.FIELD;
import static java.lang.annotation.RetentionPolicy.RUNTIME;
/**
* Marks a field as a mixin, meaning its options should be
* incorporated into the parent command.
*/
@Retention(RUNTIME)
@Target(FIELD)
public @interface Mixin {
/**
* Optional name for the mixin, used in error messages.
*/
String name() default "";
}Mixin mixin;
if ((mixin = field.getAnnotation(Mixin.class)) != null) {
Class<?> mixinClass = field.getType();
String mixinName = mixin.name().isEmpty() ? field.getName() : mixin.name();
// Process all fields in the mixin class as options of this command
processMixinClass(processedCommand, mixinClass, field.getName());
}
private static void processMixinClass(ProcessedCommand processedCommand,
Class<?> mixinClass,
String mixinFieldName)
throws CommandLineParserException {
for (Field field : mixinClass.getDeclaredFields()) {
ProcessedOption option = processFieldAsOption(field);
if (option != null) {
// Mark the option as belonging to a mixin for proper injection
option.setMixinFieldName(mixinFieldName);
option.setMixinFieldPath(mixinFieldName + "." + field.getName());
processedCommand.addOption(option);
}
}
// Process superclasses of mixin
if (mixinClass.getSuperclass() != null &&
mixinClass.getSuperclass() != Object.class) {
processMixinClass(processedCommand, mixinClass.getSuperclass(), mixinFieldName);
}
}private String mixinFieldName;
private String mixinFieldPath;
public void setMixinFieldName(String name) { this.mixinFieldName = name; }
public String getMixinFieldName() { return mixinFieldName; }
public boolean isMixinOption() { return mixinFieldName != null; }
public void setMixinFieldPath(String path) { this.mixinFieldPath = path; }
public String getMixinFieldPath() { return mixinFieldPath; }File: aesh/src/main/java/org/aesh/command/impl/populator/AeshCommandPopulator.java
When injecting values, handle mixin fields:
if (option.isMixinOption()) {
// Get or create the mixin object
Field mixinField = findField(command.getClass(), option.getMixinFieldName());
mixinField.setAccessible(true);
Object mixinInstance = mixinField.get(command);
if (mixinInstance == null) {
mixinInstance = mixinField.getType().getDeclaredConstructor().newInstance();
mixinField.set(command, mixinInstance);
}
// Inject value into mixin field
Field targetField = findField(mixinInstance.getClass(), option.getFieldName());
targetField.setAccessible(true);
targetField.set(mixinInstance, convertedValue);
} else {
// Normal field injection
field.set(command, convertedValue);
}Allow loading default values from external sources (config files, environment variables, system properties) via a pluggable provider mechanism.
// Provider interface
public interface DefaultValueProvider {
/**
* Get the default value for an option.
* @param optionName the option name (long form)
* @param commandName the command name
* @return the default value, or null if not provided
*/
String defaultValue(String optionName, String commandName);
/**
* Get default values for a multi-value option.
*/
default List<String> defaultValues(String optionName, String commandName) {
String value = defaultValue(optionName, commandName);
return value != null ? Collections.singletonList(value) : Collections.emptyList();
}
}
// Usage in command definition
@CommandDefinition(name = "myapp",
description = "My Application",
defaultValueProvider = PropertiesDefaultProvider.class)
public class MyCommand implements Command {
// Options will get defaults from the provider
}
// Or per-option
@Option(name = "output", defaultValueProvider = EnvDefaultProvider.class)
String output;Loads defaults from a properties file:
public class PropertiesDefaultProvider implements DefaultValueProvider {
private Properties props;
public PropertiesDefaultProvider() {
props = new Properties();
try {
// Look for .myapp.properties in user home, then current dir
Path userHome = Paths.get(System.getProperty("user.home"), ".myapp.properties");
Path currentDir = Paths.get(".myapp.properties");
if (Files.exists(userHome)) {
props.load(Files.newInputStream(userHome));
} else if (Files.exists(currentDir)) {
props.load(Files.newInputStream(currentDir));
}
} catch (IOException e) {
// Ignore, no defaults available
}
}
@Override
public String defaultValue(String optionName, String commandName) {
// Try command-specific key first, then global
String value = props.getProperty(commandName + "." + optionName);
if (value == null) {
value = props.getProperty(optionName);
}
return value;
}
}Loads defaults from environment variables:
public class EnvironmentDefaultProvider implements DefaultValueProvider {
private final String prefix;
public EnvironmentDefaultProvider() {
this("");
}
public EnvironmentDefaultProvider(String prefix) {
this.prefix = prefix;
}
@Override
public String defaultValue(String optionName, String commandName) {
// Convert option name to ENV_VAR_STYLE
String envName = (prefix + optionName)
.toUpperCase()
.replace('-', '_')
.replace('.', '_');
return System.getenv(envName);
}
}public class SystemPropertyDefaultProvider implements DefaultValueProvider {
private final String prefix;
public SystemPropertyDefaultProvider() {
this("");
}
public SystemPropertyDefaultProvider(String prefix) {
this.prefix = prefix;
}
@Override
public String defaultValue(String optionName, String commandName) {
String value = System.getProperty(prefix + commandName + "." + optionName);
if (value == null) {
value = System.getProperty(prefix + optionName);
}
return value;
}
}/**
* Default value provider for all options in this command.
*/
Class<? extends DefaultValueProvider> defaultValueProvider() default NullDefaultValueProvider.class;/**
* Override the command-level default value provider for this option.
*/
Class<? extends DefaultValueProvider> defaultValueProvider() default NullDefaultValueProvider.class;private DefaultValueProvider defaultValueProvider;
public void setDefaultValueProvider(DefaultValueProvider provider) {
this.defaultValueProvider = provider;
}
public DefaultValueProvider getDefaultValueProvider() {
return defaultValueProvider;
}// In doGenerateCommandLineParser, after creating processedCommand:
if (command.defaultValueProvider() != NullDefaultValueProvider.class) {
DefaultValueProvider provider = ReflectionUtil.newInstance(command.defaultValueProvider());
processedCommand.setDefaultValueProvider(provider);
}In ProcessedOption or during population:
public List<String> getEffectiveDefaultValues() {
// First check annotation defaults
if (!defaultValues.isEmpty()) {
return defaultValues;
}
// Then check option-level provider
if (optionDefaultValueProvider != null) {
List<String> values = optionDefaultValueProvider.defaultValues(name, commandName);
if (!values.isEmpty()) {
return values;
}
}
// Finally check command-level provider
if (commandDefaultValueProvider != null) {
return commandDefaultValueProvider.defaultValues(name, commandName);
}
return Collections.emptyList();
}Allow options to automatically propagate to all subcommands, reducing boilerplate.
@Option(name = "verbose", scope = OptionScope.INHERIT)
boolean verbose; // Available in this command AND all subcommandsFile: aesh/src/main/java/org/aesh/command/option/OptionScope.java
package org.aesh.command.option;
public enum OptionScope {
/**
* Option is only available in the declaring command (default).
*/
LOCAL,
/**
* Option is available in the declaring command and all subcommands.
*/
INHERIT
}/**
* The scope of this option.
* INHERIT makes the option available in all subcommands.
*/
OptionScope scope() default OptionScope.LOCAL;private OptionScope scope = OptionScope.LOCAL;
private ProcessedOption inheritedFrom; // Reference to parent option if inherited
public OptionScope getScope() { return scope; }
public void setScope(OptionScope scope) { this.scope = scope; }
public boolean isInherited() { return inheritedFrom != null; }
public ProcessedOption getInheritedFrom() { return inheritedFrom; }In AeshCommandContainerBuilder.doGenerateCommandLineParser(), when adding child commands:
// Copy inherited options from parent to child
for (ProcessedOption parentOption : processedCommand.getOptions()) {
if (parentOption.getScope() == OptionScope.INHERIT) {
ProcessedOption inheritedOption = parentOption.createInheritedCopy();
childProcessedCommand.addOption(inheritedOption);
}
}When a parent command's inherited option is set, propagate to children:
// In parser or populator
if (option.getScope() == OptionScope.INHERIT) {
for (CommandLineParser<?> child : parser.getAllChildParsers()) {
ProcessedOption childOption = child.getProcessedCommand()
.findOptionByInheritedFrom(option);
if (childOption != null) {
childOption.setValue(option.getValue());
}
}
}Support loading command-line arguments from a file, prefixed with @.
# Create args file
echo "--verbose" > args.txt
echo "--output result.json" >> args.txt
# Use the file
myapp @args.txt --extra-option@CommandDefinition(name = "myapp",
description = "My Application",
expandAtFiles = true) // Enable @-file expansion
public class MyCommand implements Command {
// ...
}/**
* If true, arguments starting with @ are treated as file references
* containing additional arguments (one per line).
*/
boolean expandAtFiles() default false;
/**
* Character encoding for @-files.
*/
String atFileEncoding() default "UTF-8";File: aesh/src/main/java/org/aesh/command/impl/parser/AtFileExpander.java
package org.aesh.command.impl.parser;
import java.io.*;
import java.nio.charset.Charset;
import java.nio.file.*;
import java.util.*;
public class AtFileExpander {
private final Charset charset;
private final Set<Path> expandedFiles = new HashSet<>(); // Prevent circular refs
public AtFileExpander(String encoding) {
this.charset = Charset.forName(encoding);
}
public List<String> expand(List<String> args) throws IOException {
List<String> expanded = new ArrayList<>();
for (String arg : args) {
if (arg.startsWith("@") && arg.length() > 1) {
String filename = arg.substring(1);
// Handle @@ escape
if (filename.startsWith("@")) {
expanded.add(filename); // Literal @filename
continue;
}
Path file = Paths.get(filename).toAbsolutePath();
if (expandedFiles.contains(file)) {
throw new IOException("Circular @-file reference: " + file);
}
expandedFiles.add(file);
List<String> fileArgs = readArgsFromFile(file);
expanded.addAll(expand(fileArgs)); // Recursive expansion
} else {
expanded.add(arg);
}
}
return expanded;
}
private List<String> readArgsFromFile(Path file) throws IOException {
List<String> args = new ArrayList<>();
try (BufferedReader reader = Files.newBufferedReader(file, charset)) {
String line;
while ((line = reader.readLine()) != null) {
line = line.trim();
// Skip empty lines and comments
if (line.isEmpty() || line.startsWith("#")) {
continue;
}
// Handle quoted arguments
args.addAll(splitLine(line));
}
}
return args;
}
private List<String> splitLine(String line) {
// Parse line respecting quotes
List<String> args = new ArrayList<>();
StringBuilder current = new StringBuilder();
boolean inQuote = false;
char quoteChar = 0;
for (int i = 0; i < line.length(); i++) {
char c = line.charAt(i);
if (!inQuote && (c == '"' || c == '\'')) {
inQuote = true;
quoteChar = c;
} else if (inQuote && c == quoteChar) {
inQuote = false;
} else if (!inQuote && Character.isWhitespace(c)) {
if (current.length() > 0) {
args.add(current.toString());
current = new StringBuilder();
}
} else {
current.append(c);
}
}
if (current.length() > 0) {
args.add(current.toString());
}
return args;
}
}In AeshCommandLineParser.parse():
if (processedCommand.isExpandAtFiles()) {
AtFileExpander expander = new AtFileExpander(processedCommand.getAtFileEncoding());
try {
args = expander.expand(args);
} catch (IOException e) {
throw new CommandLineParserException("Failed to expand @-file: " + e.getMessage());
}
}| Feature | Priority | Complexity | Files to Modify |
|---|---|---|---|
| Negatable Options | High | Low | 5-6 files |
| Argument Groups | High | High | 8-10 files |
| Mixins | High | Medium | 6-8 files |
| Default Value Providers | High | Medium | 6-8 files |
| Inherited Options | Medium | Medium | 5-7 files |
| @-File Expansion | Medium | Low | 3-4 files |
- Negatable Options - Simple, high value, low risk
- Default Value Providers - Medium complexity, enables config file integration
- Mixins - Medium complexity, great for code reuse
- @-File Expansion - Simple addition, useful for complex commands
- Inherited Options - Requires careful design for value propagation
- Argument Groups - Most complex, requires significant parser changes
For each feature:
- Unit tests for new classes
- Integration tests for end-to-end parsing
- Help generation tests
- Completion tests
- Error message tests
- Edge case tests (empty values, special characters, etc.)