GitHub Issue: #328
This proposal outlines how to implement a "sub-command mode" feature that allows users to enter an interactive context for group commands. When a user types a group command name (e.g., module), the shell enters a mode where:
- The prompt changes to reflect the current context (e.g.,
module>) - Only subcommands of that group are available
- Subcommands can be typed without the group prefix
- Nested group commands create nested contexts (e.g.,
module:project>) - Users can exit the mode to return to the parent context
aesh> module # Enter group command mode
Entering module mode. Type 'exit' to return.
module> tag add --name release-1.0 # No need for "module tag add ..."
Tag 'release-1.0' added.
module> project # Enter nested group
Entering project mode.
module:project> add --name myproject # Nested context
Project 'myproject' added.
module:project> exit # Exit nested context
module> exit # Exit to root
aesh> # Back to normal prompt- Entering Sub-Command Mode: Typing just the group command name (without a subcommand) enters the mode
- Command Execution: Commands are automatically prefixed with the current context path
- Tab Completion: Only shows subcommands of the current context
- Exit Command: A built-in
exitor..command returns to the parent context - Nested Contexts: Group commands within group commands create deeper nesting
- Context Display: Prompt clearly shows the current context path
- Parent Context Sharing: Parent command options/arguments are available to subcommands
A key enhancement is the ability to pass parent command state (options and arguments) to subcommands. This works for both direct invocation and sub-command mode, enabling powerful patterns where a group command establishes context that all subcommands can access.
Important: Interactive sub-command mode is only activated when a group command is invoked without a subcommand. If a subcommand is specified, the command executes directly (no interactive mode).
| Command | Behavior |
|---|---|
module --verbose my-module-name tag add v1.0 |
Direct execution - runs tag add, then returns to prompt |
module --verbose my-module-name |
Enters interactive sub-command mode |
module |
Enters interactive sub-command mode (no options) |
When a subcommand is specified, execution is direct - no interactive mode:
# Direct invocation - executes tag add and returns to normal prompt
aesh> module --verbose my-module-name tag add v1.0
Added tag 'v1.0' to module 'my-module-name' (verbose mode).
aesh> # Back to normal prompt immediatelyThe tag command still has access to the parent's --verbose flag and my-module-name argument.
Interactive mode only activates when no subcommand is specified:
aesh> module --verbose my-module-name # No subcommand → enters interactive mode
Entering module mode:
moduleName: my-module-name
verbose: true
Type 'exit' to return.
module[my-module-name]> tag add v1.0 # Subcommand can access parent's values
Added tag 'v1.0' to module 'my-module-name' (verbose mode).
module[my-module-name]> info # Execute multiple subcommands
Module: my-module-name
Verbose: true
module[my-module-name]> exit # Explicitly exit
aesh>When entering sub-command mode, the system displays:
- Entry message with all option/argument values set on the group command
- Prompt showing the command name and primary argument (e.g.,
module[my-module-name]>) - Built-in
contextcommand (optional) to redisplay current context values
Example with multiple options:
aesh> database --host=prod-db.example.com --port=5432 mydb
Entering database mode:
name: mydb
host: prod-db.example.com
port: 5432
Type 'exit' to return.
database[mydb]> query "SELECT * FROM users"
...
database[mydb]> context # Show current context again
Current context: database
name: mydb
host: prod-db.example.com
port: 5432
database[mydb]>The display format is configurable via settings:
| Setting | Default | Description |
|---|---|---|
showContextOnEntry |
true |
Display option/argument values when entering |
showArgumentInPrompt |
true |
Include primary argument in prompt |
contextCommand |
"context" |
Command name to redisplay context (null to disable) |
- Resource Selection:
database mydbenters a mode where all subcommands operate onmydb - Configuration Inheritance:
--verboseon the group command enables verbose mode for all subcommands - Authentication Context:
connect user@hostestablishes connection context for subsequent commands - Namespace Scoping:
namespace prodscopes all Kubernetes-like commands to theprodnamespace
Subcommands can access parent context in two ways:
@CommandDefinition(name = "tag", description = "Manage tags")
public class TagCommand implements Command<CommandInvocation> {
@Argument(description = "Tag name")
private String tagName;
@Override
public CommandResult execute(CommandInvocation invocation) {
// Access parent command's parsed values
CommandContext ctx = invocation.getCommandContext();
// Get parent's argument value
String moduleName = ctx.getParentValue("moduleName", String.class);
// Get parent's option value
boolean verbose = ctx.getParentValue("verbose", Boolean.class, false);
invocation.println("Adding tag '" + tagName + "' to module '" + moduleName + "'");
return CommandResult.SUCCESS;
}
}@CommandDefinition(name = "tag", description = "Manage tags")
public class TagCommand implements Command<CommandInvocation> {
@ParentCommand
private ModuleCommand parent; // Automatically injected
@Argument(description = "Tag name")
private String tagName;
@Override
public CommandResult execute(CommandInvocation invocation) {
// Direct access to parent command instance
String moduleName = parent.getModuleName();
boolean verbose = parent.isVerbose();
invocation.println("Adding tag '" + tagName + "' to module '" + moduleName + "'");
return CommandResult.SUCCESS;
}
}Options marked with inherited = true on the parent are automatically available on subcommands:
@GroupCommandDefinition(name = "module", groupCommands = {TagCommand.class})
public class ModuleCommand implements Command<CommandInvocation> {
@Argument(description = "Module name", inherited = true)
private String moduleName;
@Option(name = "verbose", hasValue = false, inherited = true)
private boolean verbose;
}
@CommandDefinition(name = "tag", description = "Manage tags")
public class TagCommand implements Command<CommandInvocation> {
// These are automatically inherited from parent
// No need to declare moduleName or verbose here
@Argument(description = "Tag name")
private String tagName;
@Override
public CommandResult execute(CommandInvocation invocation) {
// Access inherited values directly via CommandInvocation
String moduleName = invocation.getInheritedValue("moduleName");
// ...
}
}A new class to track the current command context state, including parent command instances and their parsed values.
package org.aesh.command.impl.context;
import org.aesh.command.Command;
import org.aesh.command.impl.internal.ProcessedCommand;
import org.aesh.command.impl.internal.ProcessedOption;
import org.aesh.command.impl.parser.CommandLineParser;
import java.util.*;
public class CommandContext {
private final Deque<ContextFrame> contextStack;
private final String originalPrompt;
public CommandContext(String originalPrompt) {
this.contextStack = new ArrayDeque<>();
this.originalPrompt = originalPrompt;
}
/**
* Push a new context frame when entering a group command.
*
* @param parser The group command's parser
* @param command The populated command instance with parsed values
*/
public void push(CommandLineParser<?> parser, Command<?> command) {
contextStack.push(new ContextFrame(parser, command));
}
/**
* Pop the current context frame when exiting.
*/
public ContextFrame pop() {
return contextStack.pop();
}
/**
* Get the current context frame.
*/
public ContextFrame current() {
return contextStack.peek();
}
/**
* Check if we're in sub-command mode.
*/
public boolean isInSubCommandMode() {
return !contextStack.isEmpty();
}
/**
* Get the context path as a string (e.g., "module:project").
*/
public String getContextPath() {
List<String> names = new ArrayList<>();
for (ContextFrame frame : contextStack) {
names.add(0, frame.getCommandName());
}
return String.join(":", names);
}
/**
* Build a prompt showing the current context.
* Examples: "module> ", "module[my-module]> ", "module:project> "
*/
public String buildPrompt() {
if (contextStack.isEmpty()) {
return originalPrompt;
}
StringBuilder prompt = new StringBuilder();
List<ContextFrame> frames = new ArrayList<>(contextStack);
Collections.reverse(frames);
for (int i = 0; i < frames.size(); i++) {
if (i > 0) prompt.append(":");
ContextFrame frame = frames.get(i);
prompt.append(frame.getCommandName());
// Show primary argument value in prompt if present
String argValue = frame.getPrimaryArgumentValue();
if (argValue != null && !argValue.isEmpty()) {
prompt.append("[").append(argValue).append("]");
}
}
prompt.append("> ");
return prompt.toString();
}
public String getOriginalPrompt() {
return originalPrompt;
}
// ========== Parent Value Access Methods ==========
/**
* Get a value from any parent command in the context stack.
* Searches from immediate parent up to root.
*
* @param fieldName The field name to look for
* @param type The expected type
* @return The value, or null if not found
*/
public <T> T getParentValue(String fieldName, Class<T> type) {
return getParentValue(fieldName, type, null);
}
/**
* Get a value from any parent command with a default.
*/
public <T> T getParentValue(String fieldName, Class<T> type, T defaultValue) {
for (ContextFrame frame : contextStack) {
Object value = frame.getFieldValue(fieldName);
if (value != null && type.isInstance(value)) {
return type.cast(value);
}
}
return defaultValue;
}
/**
* Get the immediate parent command instance.
*/
public Command<?> getParentCommand() {
ContextFrame frame = contextStack.peek();
return frame != null ? frame.getCommand() : null;
}
/**
* Get all parent commands from immediate to root.
*/
public List<Command<?>> getParentCommands() {
List<Command<?>> parents = new ArrayList<>();
for (ContextFrame frame : contextStack) {
parents.add(frame.getCommand());
}
return parents;
}
/**
* Get a specific parent command by type.
*/
@SuppressWarnings("unchecked")
public <T extends Command<?>> T getParentCommand(Class<T> type) {
for (ContextFrame frame : contextStack) {
if (type.isInstance(frame.getCommand())) {
return (T) frame.getCommand();
}
}
return null;
}
// ========== Context Frame Inner Class ==========
/**
* Represents a single frame in the context stack.
* Contains the parser, command instance, and provides value access.
*/
public static class ContextFrame {
private final CommandLineParser<?> parser;
private final Command<?> command;
private final Map<String, Object> cachedValues;
public ContextFrame(CommandLineParser<?> parser, Command<?> command) {
this.parser = parser;
this.command = command;
this.cachedValues = new HashMap<>();
cacheFieldValues();
}
private void cacheFieldValues() {
// Cache all option and argument values for quick access
ProcessedCommand<?, ?> pc = parser.getProcessedCommand();
for (ProcessedOption opt : pc.getOptions()) {
Object value = getFieldValueByReflection(opt.getFieldName());
if (value != null) {
cachedValues.put(opt.getFieldName(), value);
// Also cache by option name for convenience
if (opt.name() != null && !opt.name().isEmpty()) {
cachedValues.put(opt.name(), value);
}
}
}
// Cache argument
if (pc.getArgument() != null) {
Object value = getFieldValueByReflection(pc.getArgument().getFieldName());
if (value != null) {
cachedValues.put(pc.getArgument().getFieldName(), value);
cachedValues.put("_argument", value);
}
}
// Cache arguments (list)
if (pc.getArguments() != null) {
Object value = getFieldValueByReflection(pc.getArguments().getFieldName());
if (value != null) {
cachedValues.put(pc.getArguments().getFieldName(), value);
cachedValues.put("_arguments", value);
}
}
}
private Object getFieldValueByReflection(String fieldName) {
try {
java.lang.reflect.Field field = findField(command.getClass(), fieldName);
if (field != null) {
field.setAccessible(true);
return field.get(command);
}
} catch (IllegalAccessException e) {
// Ignore, return null
}
return null;
}
private java.lang.reflect.Field findField(Class<?> clazz, String fieldName) {
while (clazz != null) {
try {
return clazz.getDeclaredField(fieldName);
} catch (NoSuchFieldException e) {
clazz = clazz.getSuperclass();
}
}
return null;
}
public String getCommandName() {
return parser.getProcessedCommand().name();
}
public Command<?> getCommand() {
return command;
}
public CommandLineParser<?> getParser() {
return parser;
}
public Object getFieldValue(String fieldName) {
return cachedValues.get(fieldName);
}
/**
* Get the primary argument value for display in prompt.
*/
public String getPrimaryArgumentValue() {
Object arg = cachedValues.get("_argument");
if (arg != null) {
return arg.toString();
}
Object args = cachedValues.get("_arguments");
if (args instanceof List && !((List<?>) args).isEmpty()) {
return ((List<?>) args).get(0).toString();
}
return null;
}
/**
* Get all cached values (for debugging/inspection).
*/
public Map<String, Object> getAllValues() {
return Collections.unmodifiableMap(cachedValues);
}
}
}Configuration options for sub-command mode behavior.
package org.aesh.command.settings;
public interface SubCommandModeSettings {
// Enable/disable sub-command mode globally
boolean isSubCommandModeEnabled();
// Command to exit sub-command mode (default: "exit")
String getExitCommand();
// Alternative exit command (default: "..")
String getAlternativeExitCommand();
// Prompt separator for nested contexts (default: ":")
String getContextSeparator();
// Message shown when entering sub-command mode (supports placeholders)
String getEnterMessage();
// Message shown when exiting sub-command mode
String getExitMessage();
// Display option/argument values when entering sub-command mode (default: true)
boolean showContextOnEntry();
// Include primary argument value in prompt, e.g., "module[myarg]>" (default: true)
boolean showArgumentInPrompt();
// Command name to display current context, null to disable (default: "context")
String getContextCommand();
}A new annotation to mark fields that should receive the parent command instance (similar to picocli).
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 to receive the parent command instance when executing
* as a subcommand. The field type must match the parent command class.
*
* <p>Example usage:</p>
* <pre>
* {@literal @}CommandDefinition(name = "sub", description = "Subcommand")
* public class SubCommand implements Command<CommandInvocation> {
*
* {@literal @}ParentCommand
* private ParentGroupCommand parent;
*
* public CommandResult execute(CommandInvocation invocation) {
* // Access parent's parsed values
* String value = parent.getMyOption();
* // ...
* }
* }
* </pre>
*
* <p>This annotation works both in sub-command mode (interactive) and
* when commands are invoked directly (e.g., "parent sub --option value").</p>
*/
@Retention(RUNTIME)
@Target(FIELD)
public @interface ParentCommand {
// No properties needed - the field type determines which parent to inject
}Extend @Option and @Argument to support inheritance to subcommands.
// In Option.java - add new property:
/**
* When true, this option is automatically available to all subcommands.
* Subcommands can access the value via CommandInvocation.getInheritedValue().
* Only valid on group commands.
*/
boolean inherited() default false;// In Argument.java - add new property:
/**
* When true, this argument value is available to all subcommands.
* Subcommands can access the value via CommandInvocation.getInheritedValue().
* Only valid on group commands.
*/
boolean inherited() default false;File: aesh/src/main/java/org/aesh/readline/ReadlineConsole.java
Add a CommandContext field and integrate it with the readline loop.
public class ReadlineConsole implements Console {
private final CommandContext commandContext;
public ReadlineConsole(Settings<?, ?> settings) {
// ... existing code ...
this.commandContext = new CommandContext();
}
// Add getter for use by other components
public CommandContext getCommandContext() {
return commandContext;
}
}File: aesh/src/main/java/org/aesh/command/settings/Settings.java
Add configuration options for sub-command mode.
public interface Settings<C extends Command, CI extends CommandInvocation> {
// ... existing methods ...
boolean subCommandModeEnabled();
String subCommandModeExitCommand();
String subCommandModeContextSeparator();
}File: aesh/src/main/java/org/aesh/command/settings/SettingsBuilder.java
Add builder methods for the new settings.
File: aesh/src/main/java/org/aesh/readline/ReadlineConsole.java
private void processLine(String line, Connection conn) {
try {
String effectiveLine = line;
// If in sub-command mode, check for exit command
if (commandContext.isInSubCommandMode()) {
String trimmed = line.trim();
if (isExitCommand(trimmed)) {
exitSubCommandMode(conn);
read(conn, readline);
return;
}
// Prefix the line with the context path
effectiveLine = commandContext.getContextPath()
.replace(":", " ") + " " + line;
}
Executor<? extends CommandInvocation> executor =
runtime.buildExecutor(effectiveLine);
// Check if this is a group command without subcommand
if (shouldEnterSubCommandMode(executor, line)) {
enterSubCommandMode(executor, conn);
read(conn, readline);
return;
}
processManager.execute(executor, conn);
}
catch (CommandNotFoundException cnfe) {
// ... existing error handling ...
}
}
private boolean isExitCommand(String line) {
return line.equals(settings.subCommandModeExitCommand()) ||
line.equals("..");
}
private boolean shouldEnterSubCommandMode(Executor<?> executor, String line) {
if (!settings.subCommandModeEnabled()) {
return false;
}
CommandLineParser<?> parser = executor.getParser();
// Must be a group command
if (!parser.isGroupCommand()) {
return false;
}
// Check if a subcommand was parsed
// If parsedCommand() returns the group parser itself (not a child),
// then no subcommand was specified
CommandLineParser<?> parsed = parser.parsedCommand();
// Enter sub-command mode only if:
// 1. It's a group command
// 2. No subcommand was parsed (parsed command is the group itself)
return parsed == parser || parsed == null;
}
private void enterSubCommandMode(Executor<?> executor, Connection conn) {
CommandLineParser<?> groupParser = executor.getParser();
String currentPrompt = prompt.getPromptAsString();
commandContext.push(groupParser, currentPrompt);
// Update prompt
setPrompt(commandContext.buildPrompt(currentPrompt));
// Show entry message
conn.write("Entering " + groupParser.getProcessedCommand().name() +
" mode. Type 'exit' to return." + Config.getLineSeparator());
}
private void exitSubCommandMode(Connection conn) {
CommandLineParser<?> exited = commandContext.pop();
// Restore prompt
if (commandContext.isInSubCommandMode()) {
setPrompt(commandContext.buildPrompt(commandContext.getOriginalPrompt()));
} else {
setPrompt(commandContext.getOriginalPrompt());
}
conn.write("Exited " + exited.getProcessedCommand().name() +
" mode." + Config.getLineSeparator());
}File: aesh/src/main/java/org/aesh/readline/ReadlineConsole.java
The completion handler needs to be context-aware.
private void setupCompletion(Readline readline) {
readline.setCompleter((line, cursor) -> {
String effectiveLine = line;
int effectiveCursor = cursor;
// If in sub-command mode, prefix with context
if (commandContext.isInSubCommandMode()) {
String prefix = commandContext.getContextPath()
.replace(":", " ") + " ";
effectiveLine = prefix + line;
effectiveCursor = cursor + prefix.length();
}
// Get completions for effective line
List<Completion> completions = completeOperation(effectiveLine, effectiveCursor);
// Adjust offsets back to original line positions
if (commandContext.isInSubCommandMode()) {
completions = adjustCompletionOffsets(completions, prefix.length());
}
return completions;
});
}When in sub-command mode, initial completions should only show subcommands of the current group.
File: aesh/src/main/java/org/aesh/command/impl/parser/AeshCommandLineCompletionParser.java
Modify completion logic to filter based on context when applicable.
When in sub-command mode, the help command should show only the subcommands available in the current context.
// In help command implementation
if (commandContext.isInSubCommandMode()) {
CommandLineParser<?> currentGroup = commandContext.current();
printGroupHelp(currentGroup);
} else {
printAllCommands();
}This phase implements the mechanism to share parent command state with subcommands.
File: aesh/src/main/java/org/aesh/command/impl/populator/AeshCommandPopulator.java
Modify the populator to detect and inject @ParentCommand fields.
public void populateObject(ProcessedCommand<Command<CI>, CI> processedCommand,
InvocationProviders invocationProviders,
AeshContext aeshContext,
CommandLineParser.Mode mode,
CommandContext commandContext) // New parameter
throws CommandLineParserException, OptionValidatorException {
// ... existing option/argument population ...
// Inject parent command if @ParentCommand annotation is present
if (commandContext != null && commandContext.isInSubCommandMode()) {
injectParentCommand(getObject(), commandContext);
}
}
private void injectParentCommand(Object instance, CommandContext commandContext) {
for (Field field : getAllFields(instance.getClass())) {
if (field.isAnnotationPresent(ParentCommand.class)) {
Class<?> fieldType = field.getType();
// Find matching parent command in context stack
Command<?> parent = commandContext.getParentCommand(fieldType);
if (parent != null) {
try {
field.setAccessible(true);
field.set(instance, parent);
} catch (IllegalAccessException e) {
// Log warning but continue
}
}
}
}
}
private List<Field> getAllFields(Class<?> clazz) {
List<Field> fields = new ArrayList<>();
while (clazz != null) {
fields.addAll(Arrays.asList(clazz.getDeclaredFields()));
clazz = clazz.getSuperclass();
}
return fields;
}File: aesh/src/main/java/org/aesh/command/invocation/CommandInvocation.java
Add methods to access the command context.
public interface CommandInvocation {
// ... existing methods ...
/**
* Get the current command context (for sub-command mode).
* Returns null if not in sub-command mode.
*/
CommandContext getCommandContext();
/**
* Get an inherited value from a parent command.
*
* @param name The field or option name
* @param type The expected type
* @return The value, or null if not found
*/
<T> T getParentValue(String name, Class<T> type);
/**
* Get an inherited value with a default.
*/
<T> T getParentValue(String name, Class<T> type, T defaultValue);
/**
* Check if currently executing in sub-command mode.
*/
default boolean isInSubCommandMode() {
CommandContext ctx = getCommandContext();
return ctx != null && ctx.isInSubCommandMode();
}
}File: aesh/src/main/java/org/aesh/command/impl/invocation/AeshCommandInvocation.java
public final class AeshCommandInvocation implements CommandInvocation {
// ... existing fields ...
private final CommandContext commandContext;
public AeshCommandInvocation(Console console, Shell shell,
CommandRuntime<AeshCommandInvocation> runtime,
CommandInvocationConfiguration config,
CommandContainer<AeshCommandInvocation> commandContainer,
CommandContext commandContext) {
// ... existing initialization ...
this.commandContext = commandContext;
}
@Override
public CommandContext getCommandContext() {
return commandContext;
}
@Override
public <T> T getParentValue(String name, Class<T> type) {
return commandContext != null ? commandContext.getParentValue(name, type) : null;
}
@Override
public <T> T getParentValue(String name, Class<T> type, T defaultValue) {
return commandContext != null
? commandContext.getParentValue(name, type, defaultValue)
: defaultValue;
}
}File: aesh/src/main/java/org/aesh/command/impl/internal/ProcessedOption.java
Add support for the inherited flag.
public final class ProcessedOption {
// ... existing fields ...
private boolean inherited = false;
public boolean isInherited() {
return inherited;
}
}When entering sub-command mode with inherited options, the values are automatically made available to subcommands through the CommandContext.
Allow commands to opt-in or opt-out of sub-command mode.
File: aesh/src/main/java/org/aesh/command/GroupCommandDefinition.java
@Retention(RUNTIME)
@Target(TYPE)
public @interface GroupCommandDefinition {
// ... existing properties ...
/**
* If true, typing just the group command name enters sub-command mode.
* Default is true when sub-command mode is enabled globally.
*/
boolean enableSubCommandMode() default true;
/**
* Custom prompt suffix for this group's sub-command mode.
* Default uses the command name.
*/
String subCommandPrompt() default "";
}| Class | Package | Purpose |
|---|---|---|
CommandContext |
org.aesh.command.impl.context |
Tracks current sub-command context state and parent command values |
CommandContext.ContextFrame |
org.aesh.command.impl.context |
Single frame in context stack with cached field values |
SubCommandModeSettings |
org.aesh.command.settings |
Configuration for sub-command mode |
| Annotation | Package | Purpose |
|---|---|---|
@ParentCommand |
org.aesh.command.option |
Marks a field to receive the parent command instance |
| Annotation | Changes |
|---|---|
@Option |
Add inherited property for parent-to-child value passing |
@Argument |
Add inherited property for parent-to-child value passing |
@GroupCommandDefinition |
Add enableSubCommandMode and subCommandPrompt properties |
| Class | Changes |
|---|---|
ReadlineConsole |
Add CommandContext field, modify processLine() and completion handling |
Settings / SettingsBuilder |
Add sub-command mode configuration options |
CommandInvocation |
Add getCommandContext(), getParentValue(), isInSubCommandMode() methods |
AeshCommandInvocation |
Implement new CommandInvocation methods, store CommandContext |
AeshCommandPopulator |
Inject @ParentCommand fields, handle inherited options |
ProcessedOption |
Add inherited field and getter |
AeshCommandLineCompletionParser |
Context-aware completion filtering |
| Setting | Type | Default | Description |
|---|---|---|---|
subCommandModeEnabled |
boolean |
false |
Enable sub-command mode globally |
subCommandModeExitCommand |
String |
"exit" |
Command to exit sub-command mode |
subCommandModeContextSeparator |
String |
":" |
Separator for nested context display |
showContextOnEntry |
boolean |
true |
Display option/argument values when entering |
showArgumentInPrompt |
boolean |
true |
Include primary argument in prompt |
contextCommand |
String |
"context" |
Command to redisplay context (null to disable) |
- Sub-command mode is disabled by default to maintain backward compatibility
- Existing group command behavior (requiring full
group subcommandsyntax) remains unchanged when disabled - No breaking changes to existing APIs
| Scenario | Behavior |
|---|---|
Typing exit when exit is also a subcommand |
Exit command takes precedence; use ./exit for subcommand |
| Group command has options | Options can still be passed: module --verbose enters mode with verbose flag |
| Empty input in sub-command mode | Shows available subcommands (like pressing Tab) |
| Ctrl-C in sub-command mode | Exits current context (configurable) |
| Nested groups 3+ levels deep | Fully supported with stacked contexts |
CommandContextshould be per-connection/session, not global- Use appropriate synchronization if needed for concurrent access
- Unit Tests: Test
CommandContextpush/pop/path generation - Integration Tests: Test full flow from entering to exiting sub-command mode
- Completion Tests: Verify completions are properly filtered
- Nested Context Tests: Test 3+ levels of nesting
- Edge Case Tests: Exit command conflicts, empty input, Ctrl-C handling
Settings settings = SettingsBuilder.builder()
.subCommandModeEnabled(true)
.subCommandModeExitCommand("exit")
.subCommandModeContextSeparator(":")
.build();
CommandRuntime runtime = AeshCommandRuntimeBuilder.builder()
.settings(settings)
.command(ModuleCommand.class)
.build();@GroupCommandDefinition(
name = "module",
description = "Module management commands",
enableSubCommandMode = true,
subCommandPrompt = "mod", // Optional: custom prompt
groupCommands = {TagCommand.class, ProjectCommand.class}
)
public class ModuleCommand implements Command<CommandInvocation> {
@Override
public CommandResult execute(CommandInvocation invocation) {
// This is called when entering sub-command mode
// Can perform initialization, show welcome message, etc.
return CommandResult.SUCCESS;
}
}
@GroupCommandDefinition(
name = "project",
description = "Project subcommands",
groupCommands = {AddProjectCommand.class, DeleteProjectCommand.class}
)
public class ProjectCommand implements Command<CommandInvocation> {
// Nested group command - creates module:project> context
}- Implement
CommandContextclass withContextFrame - Add settings for sub-command mode
- Modify
processLine()to detect and enter sub-command mode - Implement exit command handling
- Basic prompt switching
- Implement
@ParentCommandannotation - Modify
AeshCommandPopulatorto inject parent command instances - Add
getCommandContext()andgetParentValue()toCommandInvocation - Cache parent command field values in
ContextFrame - Support typed parent command lookup
- Add
inheritedproperty to@Optionannotation - Add
inheritedproperty to@Argumentannotation - Modify
ProcessedOptionto track inherited flag - Auto-populate inherited values in subcommands
- Context-aware completion in
ReadlineConsole - Filter completions to current context subcommands
- Adjust completion offsets for prefixed lines
- Support multiple levels of nesting
- Proper context path building (
module:project:task>) - Stack-based prompt restoration
- Nested parent value access (grandparent values)
- Add annotation properties for per-command configuration
- Help command integration
- Ctrl-C handling in sub-command mode
- Show argument values in prompt (
module[my-module]>) - Documentation and examples
-
Should the group command's
execute()method be called when entering sub-command mode?- Option A: Yes, allows initialization logic
- Option B: No, only called with explicit subcommand
- Recommendation: Option A with a flag indicating sub-command mode entry
-
How should command history work in sub-command mode?
- Option A: Store full commands (
module tag add) - Option B: Store context-relative commands (
tag add) - Recommendation: Option A for consistency and replayability
- Option A: Store full commands (
-
Should there be a visual indicator beyond the prompt?
- Consider: colored prompt, status bar, entry message
- Recommendation: Entry/exit messages + prompt change (configurable)
-
How should parent context work when invoking commands directly (not in sub-command mode)?
- Example:
module --myoption=my-value tag add- shouldtaghave access tomyoption? - Decision: Yes - Parent values are always available to subcommands regardless of invocation style
- This matches picocli behavior and user expectations
- Implementation: The parser already populates the parent command before parsing the subcommand, so we just need to pass the parent instance to the subcommand's populator
- Example:
-
What happens if a subcommand defines the same option/argument as the parent?
- Option A: Subcommand value overrides parent
- Option B: Both available, with explicit access methods
- Option C: Error at command registration time
- Recommendation: Option A with access to parent via
getParentValue()
-
Should inherited options appear in subcommand help?
- Option A: Yes, show all available options including inherited
- Option B: No, only show subcommand-specific options
- Option C: Configurable per-option
- Recommendation: Option A with visual indicator (e.g., "[inherited]")
-
How should validation work for inherited values?
- Parent values are already validated when parent executes
- Should subcommands be able to add additional validation?
- Recommendation: Parent validation only; subcommands trust parent values
This implementation plan provides a comprehensive approach to adding sub-command mode to aesh. The design:
- Maintains full backward compatibility
- Provides a clean, intuitive user experience
- Supports arbitrary nesting depth
- Enables parent-to-child context sharing via multiple mechanisms:
@ParentCommandannotation for direct parent instance accessCommandInvocation.getParentValue()for value lookupinheritedoption property for automatic value inheritance
- Integrates with existing completion and help systems
- Is highly configurable
The phased approach allows for incremental implementation and testing, with each milestone delivering usable functionality.
| Feature | Picocli | Aesh (Proposed) |
|---|---|---|
| Sub-command mode | Not built-in | Full support |
@ParentCommand |
Yes | Yes (new) |
| Inherited options | scope = INHERIT |
inherited = true (new) |
| Context prompt | N/A | Configurable |
| Nested contexts | N/A | Full support |
| Value access | Direct field access | Field access + CommandInvocation API |
This proposal brings aesh to feature parity with picocli for parent-child command relationships while adding unique interactive sub-command mode capabilities.