Terminal Utilities
A set of Java libraries for building interactive CLI programs. They provide
argument parsing, progress indicators, user input, and list selection without
requiring ncurses or any native terminal graphics library.
See morimekta.net/utils for procedures on releases.
Getting Started
To add to maven:
<!-- -->
<dependencies>
<dependency>
<groupId>net.morimekta.utils</groupId>
<artifactId>terminal-args</artifactId>
<version>5.0.1</version>
</dependency>
<dependency>
<groupId>net.morimekta.utils</groupId>
<artifactId>terminal-io</artifactId>
<version>5.0.1</version>
</dependency>
</dependencies>
To add to gradle:
implementation 'net.morimekta.utils:terminal-args:5.0.1'
implementation 'net.morimekta.utils:terminal-io:5.0.1'
Argument Parser
The ArgParser interface and its companion types form a lightweight but
flexible CLI argument parser. Rather than storing parsed values internally, it
delegates to closures and callbacks so you can populate whatever data structure
you prefer.
import net.morimekta.terminal.args.ArgException;
import java.util.ArrayList;
import java.util.concurrent.atomic.AtomicBoolean;
import static net.morimekta.terminal.args.ArgParser.argParser;
import static net.morimekta.terminal.args.ArgHelp.argHelp;
import static net.morimekta.terminal.args.Flag.flagShort;
import static net.morimekta.terminal.args.Argument.argument;
class SimpleApp {
public static void main(String[] args) {
try {
var help = new AtomicBoolean();
var lines = new ArrayList<String>();
var parser = argParser("simple", "0.0.1", "Test Application")
.add(flagShort("h?", "Show help", help::set))
.add(argument("print", "Strings to print one on each line", lines::add))
.parse(args);
if (help.get()) {
argHelp(e.getParser()).printHelp(System.out);
return;
}
// run the actual program...
lines.forEach(System.out::println);
} catch (ArgException e) {
if (e.getParser() != null) {
// Invalid argument.
argHelp(e.getParser()).printPreamble(System.err);
System.err.println();
System.err.println(e.getMessage());
} else {
// Invalid parser setup.
e.printStackTrace();
}
System.exit(1);
}
}
}
ArgParser: The central entry point for argument parsing. Its builder lets you add options, flags, and positional arguments, or switch to aSubCommandSetbuilder for sub-command support.Option: A named option that accepts a value, specified as--name valueor--name=value. Each option has a long name (starting with--) and optionally one or more single-character short names (e.g.-h,-?).Flag: A boolean option that takes no value. Flags can also define a negation name (e.g.--no-verbose) and a negation short name.Property: A key-value option stored via aPutter, useful for passing arbitrary configuration pairs like-Dkey=value.Argument: A positional argument that follows after all options. When multiple arguments are defined, they are matched in order, so the sequence matters.ArgHelp: Generates formatted help output and error preambles for an argument parser.ValueParser: Converts raw string arguments into typed values with validation. Includes built-in parsers for primitives, files, directories, durations, and enum types.
Sub Command Set
In addition to options and positional arguments, an ArgParser can define
sub-commands. Each sub-command creates its own instance (typed by the
SubCommandDef generic) and has its own argument parser for sub-command-specific
flags and options.
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import static net.morimekta.terminal.args.ArgHelp.argHelp;
import static net.morimekta.terminal.args.ArgParser.argParser;
import static net.morimekta.terminal.args.Flag.flagShort;
import static net.morimekta.terminal.args.Option.optionShort;
import static net.morimekta.terminal.args.SubCommand.subCommand;
class SimpleApp {
public static class Cmd implements Runnable {
private AtomicInteger i = new AtomicInteger();
public Cmd(ArgParser.Builder parser) {
parser.add(optionShort("i", "Integer", i::set));
}
public void run() {
System.out.println("i = " + i.get());
}
}
public static void main(String[] args) {
var help = new AtomicBoolean();
var cmd = new AtomicReference<>();
var parser = argParser("simple", "0.0.1", "Test Application")
.add(flagShort("h?", "Show help", help::set))
.withSubCommands("cmd", "Do something", cmd::set)
.add(subCommand("foo", "Foo!!!1!", Cmd::new))
.parse(args);
if (help.get()) {
argHelp(e.getParser()).printHelp(System.out);
return;
}
// validate after help, the sub-command is by default mandatory, so this will
// throw an `ArgException` if no command was set.
parser.validate();
// run the actual program...
cmd.get().run();
}
}
SubCommandSet: A collection of sub-commands, activated via thewithSubCommandsmethod on theArgParser.Builder. It must be the last element in the parser, after which only sub-command definitions are accepted.SubCommand: Defines a single sub-command. It takes an instance factory that receives its ownArgParser.Builder, letting each sub-command declare independent options and arguments.
Generated Options [incubating]
You can automatically generate argument definitions from an object's fields and
methods using ArgParser.Builder#generateArgs(Object). It inspects the object
and creates flags for booleans, options for scalar and list fields, and
properties for map fields. Standard POJO setter conventions (set*, add*,
put*) are recognized automatically.
The generation rules are:
Fields — each public non-final field is inspected:
- boolean fields become flags.
- Map fields become properties.
- Collection fields become repeated options.
- Objects without a known parser are traversed recursively, using the field name
as an option-name prefix.
- Everything else becomes a single-valued option.
- Fields annotated with @ArgIgnore are skipped.
Methods — public methods following POJO naming (set*, add*, addTo*,
put*, putIn*) are inspected:
- Two-argument methods are treated as putters and become properties.
- Single-argument methods follow the same boolean/parseable/object rules as
fields. Methods starting with add or addTo are treated as repeated.
- Objects without a known parser are traversed recursively, including
getter-only methods.
- Methods annotated with @ArgIgnore are skipped.
Naming — the default option name is derived from the field or method name
(minus the setter prefix) in snake_case. Use @ArgOptions to override the
name or set short-option characters.
If any argument annotation (other than @ArgIgnore) is present on a field or
method that cannot be resolved into a valid argument, generateArgs throws an
exception.
Annotations
Annotations control how arguments are generated. If any annotation other than
@ArgIgnore is present on a field or method, the generator treats it as an
explicit declaration and will fail if it cannot produce a valid argument.
@ArgIgnore: Skip the annotated field or method entirely.@ArgOptions: Override the option name, set short-option characters, or provide a usage description.@ArgIsRepeated: Mark the argument as repeatable.@ArgIsRequired: Make the argument mandatory.@ArgIsHidden: Hide the argument from help output unless hidden options are explicitly requested.@ArgValueParser: Specify a custom value parser for the argument.@ArgKeyParser: Specify a custom key parser (only valid on properties).
Terminal IO
The terminal-io module provides a pure-Java way to interact with the terminal
for input, output, and cursor control, without depending on ncurses or similar
native libraries. It uses Unix tty CLI bindings for features like reading
terminal dimensions. An alternative library with broader scope is
Lanterna.
import net.morimekta.terminal.Terminal;
class Test {
public static void main(String... args) {
try (var terminal = new Terminal()) {
terminal.info("Starting");
terminal.pressToContinue("Continue?");
terminal.warn("Not sure about this...");
if (terminal.confirm("Are you really sure?", false)) {
terminal.fatal("Noooo!");
throw new UnsupportedOperationException();
} else {
terminal.error("Told you so.");
}
}
}
}
With output like this:
[info] Starting
Continue?
[warn] Not sure about this...
Are you really sure? [y/N]: Yes
[FATAL] Noooo!
Terminal: Manages terminal state (raw/cooked mode) and provides simple logging, input reading, and output writing.LineBuffer: Tracks a set of lines printed to the terminal and can efficiently update any line in place by moving the cursor, making it useful for progress bars and selection UIs.LinePrinter: A line-oriented printer interface.Terminalimplements it and can also produce aPrintStreamthat respects raw/cooked mode transitions.
Terminal Input
Utilities for reading structured input from the user in raw terminal mode.
InputConfirmation: Prompts for a simple yes/no answer.InputLine: Reads a line of text with inline editing, optional character and line validation, and tab completion.InputPassword: Same asInputLine, but hides the typed text by replacing each character with a fill character (or hiding it entirely).
Progress
Displays progress bars for long-running tasks. The example below uses Apache HTTP Client to download a file, showing a spinner and percentage bar that adapts to the terminal width.
import net.morimekta.terminal.args.ArgParser;
import net.morimekta.terminal.args.Argument;
import net.morimekta.terminal.args.Option;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.impl.client.HttpClientBuilder;
import java.nio.file.Files;
import java.nio.file.Path;
import java.io.BufferedOutputStream;
import java.util.ArrayList;
import static java.nio.file.StandardOpenOption.CREATE;
import static java.nio.file.StandardOpenOption.CREATE_NEW;
import static net.morimekta.terminal.args.ValueParser.outputFile;
class WGet {
private static String url;
private static Path file;
public static void main(String... arguments) {
ArgParser.argParser("wget", "0.1.0", "Get a URL and save to file")
.add(Argument.argument("url", "The URL to get", s -> url = s).required())
.add(Argument.argument("file", "The URL to get", outputFile(f -> file = f)).required())
.parse(arguments);
var get = new HttpGet(url);
System.out.println(url + " -> " + file);
// This sets up the progress line itself.
try (ProgressLine p = new ProgressLine(DefaultSpinners.ASCII, "GET")) {
try (var client = HttpClientBuilder.create().build()) {
try (var response = client.execute(get)) {
final var len = response.getEntity().getContentLength();
var dl = 0L;
try (var iStream = response.getEntity().getContent();
var fStream = Files.newOutputStream(file, CREATE, CREATE_NEW);
var oStream = new BufferedOutputStream(fStream)) {
byte[] buffer = new byte[1024];
int l;
while ((l = iStream.read(buffer)) > 0) {
dl += l;
oStream.write(buffer, 0, l);
// This line updates the progress.
p.onNext(new Progress(dl, len));
}
}
// This ensures output with 100% at the end...
p.onNext(new Progress(len, len));
}
}
}
}
}
Giving output like this:
http://my-url -> my-file
GET: [##################################### ] 73% v /
GET: [###################################################] 100% v @ 0.9 s
Progress: An immutable state snapshot (current value, total) passed to progress displays.ProgressLine: Renders a single progress bar with a spinner, updating in-place until the task completes. Single-threaded.ProgressManager: Coordinates multiple progress entries, advancing through them sequentially and displaying a rolling set of progress lines with log output above.Spinner: Interface for generating the animated spinner portion of a progress line.DefaultSpinners: Built-in spinner styles using ASCII characters or simple Unicode block elements.
Selection
Selection presents a navigable list of items in the terminal. Users can browse
with arrow keys, jump to entries by number, and trigger custom actions on the
highlighted item.
class GitBranch {
public static void main(String[] args) {
ArrayList<Branch> branches = Git.loadBranches();
Branch current = branches.stream().filter(i -> i.current()).findFirst();
try (Selection<Branch> selection = Selection
.newBuilder(branches)
.printer(b -> b.name() + (b.current() ? " *" : ""))
.initial(current)
.hiddenOn(Char.LF, SelectionReaction.SELECT)
.on('q', "quit", SelectionReaction.EXIT)
.on('d', "delete", b -> {
Git.delete(b);
branches.remove(b);
SelectionReaction.UPDATE_KEEP_POSITION;
})
.build()) {
Branch selected = selection.runSelection();
if (selected != null) {
Git.checkout(selected);
}
}
}
}
Select [q=quit, d=delete]
1 | main *
2 | my-branch
Your choice (1..2 or q,d):