Terminal Utilities

GitLab Docs Pipeline Coverage License
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 a SubCommandSet builder for sub-command support.
  • Option: A named option that accepts a value, specified as --name value or --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 a Putter, 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 the withSubCommands method on the ArgParser.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 own ArgParser.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. Terminal implements it and can also produce a PrintStream that 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 as InputLine, 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):