Femtocli: A small but mighty CLI library for small CLI tools in < 45KB

TL;DR: I built femtocli, a small command-line parsing library with sub-commands, an annotation-based API, and support for parsing Java agent arguments.

Every command-line tool that I, and probably you, write needs a command-line interface (CLI). Writing simply, with one or two options manually, is possible, but becomes tedious fast. Especially when our tool grows, and we add more options and subcommands.

Usually, you would add a library like the fantastic picocli to your project and be fine. It’s a really easy-to-use CLI library with all the features that you would want. So you can write a simple CLI quite quickly and declaratively:

@Command(name = "demo", description = "A demo application")
public class Main implements Runnable {

    @CommandLine.Parameters(index = "0", description = "Some parameter")
    String parameter;

    @Option(names = "--some-flag")
    boolean someFlag;

    @Override
    public void run() { /* print */ }

    public static void main(String[] args) {
        new CommandLine(new Main()).execute(args);
    }
}

The only problem? While the application JAR itself is only around 3KB for these tiny examples, the application JAR with the Picocli 4.7.7 dependency is 407KB.

Is this a problem? It might not be for you, but it certainly can be for small tools like jstall, where the Picocli dependency accounted for over half the JAR size in the minimal version, which is kind of a lot just for the option parsing. Of course, in many situations you don’t care, as there are other libraries, e.g., for JSON parsing, that can easily add multiple megabytes. But for tiny CLI helper tools, it can matter.

I couldn’t find any maintained CLI library with a picocli-like declarative approach and sub-commands under 50KB, so I built one. And the best part: I was able to add all the features I liked (converters, custom footers, …) and ignore the ones I don’t usually need (GraalVM support, ANSI colors, …).

The new library is called femtocli, it requires Java 17 and is MIT licensed:

package me.bechberger.femtocli.examples;

import me.bechberger.femtocli.FemtoCli;
import me.bechberger.femtocli.annotations.Command;
import me.bechberger.femtocli.annotations.Option;

import java.util.concurrent.Callable;

@Command(name = "greet", description = "Greet a person")
class GreetCommand implements Callable<Integer> {
    @Option(names = {"-n", "--name"}, description = "Name to greet", required = true)
    String name;

    @Option(names = {"-c", "--count"}, description = "Count (default: ${DEFAULT-VALUE})", defaultValue = "1")
    int count;

    @Override
    public Integer call() {
        for (int i = 0; i < count; i++) System.out.println("Hello, " + name + "!");
        return 0;
    }
}

@Command(name = "myapp", description = "My CLI application", version = "1.0.0",
        subcommands = {GreetCommand.class})
public class QuickStart implements Runnable {
    public void run() {
        System.out.println("Use 'myapp greet --help'");
    }

    public static void main(String[] args) {
        FemtoCli.run(new QuickStart(), args);
    }
}

Try it:

> ./examples/run.sh QuickStart greet --name=World --count=1
Hello, World!

As you see, I designed the library to be almost a drop-in replacement for the basic use cases. The resulting JAR is currently below 55KB and 45KB when using the minimal version without Java debug information.

Before I show you all the cool features of this tiny, but mighty command-line library, I want to answer the crucial question:

Should you use it?

You should definitely not use it if the JAR size is not important to you or if you develop a Java agent. Libraries like Picocli offer much more functionality, are better tested, and have better documentation. They evolved over time. Femtocli has not. You can find a good list of libraries at Tim’s list and awesome-java.

But if you are adventurous, you might still find bugs and frequent releases, need a small library, and still want a good set of features, then give the CLI library a try.

Also, one of the benefits of having my own command-line parsing library is that I can add features that are important to me, but probably to many other people as well. One stand-out feature, besides the small size, is the ability to parse Java agent-like arguments, where a comma separates parameters and options:

Agent Mode

This is a feature that I found in no other command-line parsing library. As the avid reader of my blog knows, I quite like to develop Java agents, e.g., for my post Who instruments the instrumenters?, but these agents are passed a single string. It is a convention to pass arguments comma-separated. Usually I would write an ad-hoc library or ackwardly parse arguments and convert them to the normal style, to be parsed with picocli.

With femtocli, this is no longer necessary:

package me.bechberger.femtocli.examples;

import me.bechberger.femtocli.FemtoCli;
import me.bechberger.femtocli.annotations.Command;
import me.bechberger.femtocli.annotations.Option;
import me.bechberger.femtocli.annotations.Parameters;

import java.time.Duration;
import java.util.concurrent.Callable;

/**
 * Example showcasing FemtoCli agent args mode (comma-separated arguments).
 * <p>
 * Example invocations:
 * <ul>
 *   <li>{@code start,interval=1ms}</li>
 *   <li>{@code stop,output=file.jfr,verbose}</li>
 *   <li>{@code help}</li>
 *   <li>{@code version}</li>
 * </ul>
 */
@Command(
        name = "agent-cli",
        description = "Demo CLI for agent args mode",
        version = "1.0.0",
        subcommands = {AgentCli.Start.class, AgentCli.Stop.class},
        mixinStandardHelpOptions = true
)
public class AgentCli implements Runnable {

    @Override
    public void run() {
        // default action
        System.out.println("Try: start,interval=1ms or stop,output=file.jfr,verbose");
    }

    @Command(name = "start", description = "Start recording", mixinStandardHelpOptions = true)
    public static class Start implements Callable<Integer> {

        @Option(names = "--interval", defaultValue = "1ms", description = "Sampling interval")
        Duration interval;

        @Override
        public Integer call() {
            System.out.println("start: interval=" + interval);
            return 0;
        }
    }

    @Command(name = "stop", description = "Stop recording", mixinStandardHelpOptions = true)
    public static class Stop implements Callable<Integer> {
        @Parameters
        String mode;

        @Option(names = "--output", required = true, description = "Output file")
        String output;

        @Option(names = {"-v", "--verbose"}, description = "Verbose")
        boolean verbose;

        @Override
        public Integer call() {
            System.out.println("stop: mode=" + mode + ", output=" + output + ", verbose=" + verbose);
            return 0;
        }
    }

    public static void main(String[] args) {
        // Demonstrate agent mode if a single agent-args string is passed,
        // otherwise fall back to normal argv parsing.
        if (args.length == 1) {
            System.exit(FemtoCli.runAgent(new AgentCli(), args[0]));
        }
        System.exit(FemtoCli.run(new AgentCli(), args));
    }
}
> ./examples/run.sh AgentCli --help
Usage: agent-cli,[hV],[COMMAND]
Options:
  h, help         Show this help message and exit.
  V, version      Print version information and exit.
Commands:
  start  Start recording
  stop   Stop recording
> ./examples/run.sh AgentCli start,interval=1ms
start: interval=PT0.001S
> ./examples/run.sh AgentCli stop,jfr,output=file.jfr,verbose
stop: mode=jfr, output=file.jfr, verbose=true

But femtocli has many more features.

Femtocli’s Features

Femtocli has the following features:

  • Define commands with @Command (classes and subcommand methods)
  • Options via @Option (short/long names, required, default values, param labels, split, per-option converter, and verifiers)
  • Positional parameters via @Parameters (index, arity, paramLabel, defaultValue, …)
  • Mixins (reusable option groups) via @Mixin
  • Nested subcommands (classes and methods)
  • Multi-value options: arrays and List (repeat option or use split delimiter)
  • Built-in type conversion for primitive types, Path, Duration, enums, and support for custom converters
  • Automatic -h/--help and -V/--version flags
  • End-of-options marker (--)
  • Description placeholders (${DEFAULT-VALUE}, ${COMPLETION-CANDIDATES})
  • Custom header, customSynopsis, and footer in help output
  • Ability to hide commands and options from help output and to omit options defined in the class, in a parent class, or a mixin

A few more might follow, but the hard size limit of 55 KB (45 KB for the minimal build) is restrictive. But, well, I increased it slightly before incorporating important features. Is this a slippery slope? Maybe. But in the end, I want to have a library that solves my problems.

Usage

Add the library as a dependency in your project (< 55KB):

<dependency>
  <groupId>me.bechberger.util</groupId>
  <artifactId>femtocli</artifactId>
  <version>0.2.0</version>
</dependency>

And for the minimal version without debug metadata (< 45KB):

<dependency>
  <groupId>me.bechberger.util</groupId>
  <artifactId>femtocli-minimal</artifactId>
  <version>0.2.0</version>
</dependency>

I would recommend using the minimal version for releases mostly, as it’s harder to debug the CLI library when something goes wrong.

Be aware that the library is under active development, so the version numbers mentioned here are just a snapshot at the time of writing.

In the following, I’ll show you more examples of what femtocli is capable of. You’ll find an up-to-date example section in the project’s README. All examples can be built and run in the examples folder of the main repository. So give it a try.

Subcommands as Methods

You saw in the introduction that femtocli supports subcommand classes, but it also supports subcommand methods for convenience:

package me.bechberger.femtocli.examples;

import me.bechberger.femtocli.FemtoCli;
import me.bechberger.femtocli.annotations.Command;

@Command(name = "myapp")
public class SubcommandMethod implements Runnable {
    @Command(name = "status", description = "Show status")
    int status() {
        System.out.println("OK");
        return 0;
    }

    @Override
    public void run() {
    }

    public static void main(String[] args) {
        FemtoCli.run(new SubcommandMethod(), args);
    }
}
> ./examples/run.sh SubcommandMethod status
OK

Positional Parameters

Femtocli also supports parameters identified by their position and arity, with optional parameter labels:

package me.bechberger.femtocli.examples;

import me.bechberger.femtocli.FemtoCli;
import me.bechberger.femtocli.annotations.Parameters;

import java.util.List;

/**
 * Shows how to use positional parameters.
 * Positional parameters are defined by their index and are not prefixed by an option name.
 */
public class PositionalParameters implements Runnable {
    @Parameters(index = "0", paramLabel = "FILE", description = "Input file")
    String file;

    @Parameters(index = "1..*", paramLabel = "ARGS", description = "Extra arguments")
    List<String> args;

    @Override
    public void run() {
        System.out.println("File: " + file);
        System.out.println("Args: " + args);
    }

    public static void main(String[] args) {
        FemtoCli.run(new PositionalParameters(), args);
    }
}
> ./examples/run.sh PositionalParameters in.txt arg1 arg2
File: in.txt
Args: [arg1, arg2]
> ./examples/run.sh PositionalParameters --help
Usage: positionalparameters [-hV] FILE [ARGS...]
      FILE         Input file
      [ARGS...]    Extra arguments
  -h, --help       Show this help message and exit.
  -V, --version    Print version information and exit.

Mixins

Like any good command-line parsing library, femtocli also supports mixins to share options between commands:

package me.bechberger.femtocli.examples;

import me.bechberger.femtocli.FemtoCli;
import me.bechberger.femtocli.annotations.Command;
import me.bechberger.femtocli.annotations.Mixin;
import me.bechberger.femtocli.annotations.Option;

/**
 * Shows how to use mixins to share options between subcommands. Run with "a -v" or "b -v" to see the effect.
 */
@Command(name = "mixins", subcommands = {MixinsAndSubcommands.A.class, MixinsAndSubcommands.B.class})
public class MixinsAndSubcommands implements Runnable {
    /** Example how to use mixins to share options between commands */
    static class Common {
        @Option(names = {"-v", "--verbose"})
        boolean verbose;
    }

    @Command(name = "a")
    static class A implements Runnable {
        @Mixin
        Common common;

        public void run() {
            System.out.println("Verbose: " + common.verbose);
        }
    }

    @Command(name = "b")
    static class B implements Runnable {
        @Mixin
        Common common;

        public void run() {
            System.out.println("Verbose: " + common.verbose);
        }
    }

    @Override
    public void run() {
    }

    public static void main(String[] args) {
        FemtoCli.run(new MixinsAndSubcommands(), args);
    }
}
> ./examples/run.sh MixinsAndSubcommands a
Verbose: false
> ./examples/run.sh MixinsAndSubcommands --help
Usage: mixins [-hV] [COMMAND]
  -h, --help       Show this help message and exit.
  -V, --version    Print version information and exit.
Commands:
  a  
  b  
> ./examples/run.sh MixinsAndSubcommands a --help
Usage: mixins a [-hV] [--verbose]
  -h, --help       Show this help message and exit.
  -v, --verbose
  -V, --version    Print version information and exit.

Spec Injection

Spec fields allow you to access the current CLI session at runtime, to, e.g., access the command output stream or print the usage information:

package me.bechberger.femtocli.examples;

import me.bechberger.femtocli.FemtoCli;
import me.bechberger.femtocli.Spec;
import me.bechberger.femtocli.annotations.Command;
import me.bechberger.femtocli.annotations.Option;

import java.time.Duration;

/**
 * Example showcasing injection of the {@link Spec} object.
 * <p>
 * The Spec object contains the configured input and output streams,
 * as well as a method to print usage help with the same formatting as the current FemtoCli run.
 */
@Command(name = "inspect", description = "Example that uses Spec", mixinStandardHelpOptions = true)
public class SpecInjection implements Runnable {
    Spec spec; // injected

    @Option(names = {"-i", "--interval"},
            defaultValue = "10ms",
            description = "Sampling interval (default: ${DEFAULT-VALUE})")
    Duration interval;

    @Override
    public void run() {
        // Use the configured streams
        spec.out.println("interval = " + interval.toMillis());
        // Print usage with the same formatting as the current FemtoCli run
        spec.usage();
    }

    public static void main(String[] args) {
        FemtoCli.run(new SpecInjection(), args);
    }
}
> ./examples/run.sh SpecInjection --interval 10ms
interval = 10
Usage: inspect [-hV] [--interval=<interval>]
Example that uses Spec
  -h, --help                   Show this help message and exit.
  -i, --interval=<interval>    Sampling interval (default: 10ms)
  -V, --version                Print version information and exit.

Custom Type Converters

Femtocli supports parsing the primitive types and their boxing wrappers, as well as Duration and Path, but if you want more, you can bring your own converters (and you can override existing ones):

package me.bechberger.femtocli.examples;

import me.bechberger.femtocli.FemtoCli;
import me.bechberger.femtocli.TypeConverter;
import me.bechberger.femtocli.annotations.Command;
import me.bechberger.femtocli.annotations.Option;

import java.time.Duration;

/**
 * Example showcasing custom type converters.
 * <p>
 * Example invocation:
 * <pre>{@code
 * java CustomTypeConverters --name=hello --timeout=PT30S
 * }</pre>
 */
@Command(name = "convert")
public class CustomTypeConverters implements Runnable {

    /** Custom type converter that converts a string to uppercase. */
    public static class Upper implements TypeConverter<String> {
        public String convert(String value) {
            return value.toUpperCase();
        }
    }

    static boolean parseOnOff(String value) {
        if (value.equalsIgnoreCase("on")) return true;
        if (value.equalsIgnoreCase("off")) return false;
        throw new IllegalArgumentException("Expected 'on' or 'off'");
    }

    @Option(names = "--name", converter = Upper.class)
    String name;

    @Option(names = "--turn", converterMethod = "parseOnOff")
    boolean turn;

    @Option(names = "--timeout")
    Duration timeout;

    @Override
    public void run() {
        System.out.println("Name: " + name);
        System.out.println("Turn: " + turn);
        System.out.println("Timeout: " + timeout);
    }

    public static void main(String[] args) {
        FemtoCli.builder()
                .registerType(java.time.Duration.class, java.time.Duration::parse)
                .run(new CustomTypeConverters(), args);
    }
}
> ./examples/run.sh CustomTypeConverters --name=max --turn on --timeout=PT10S
Name: MAX
Turn: true
Timeout: PT10S

Enum Support

Of course, femtocli also supports enums and Picocli, like ${COMPLETION-CANDIDATES} placeholders in descriptions:

package me.bechberger.femtocli.examples;

import me.bechberger.femtocli.FemtoCli;
import me.bechberger.femtocli.annotations.Command;
import me.bechberger.femtocli.annotations.Option;

@Command(name = "enums")
public class EnumsAndCompletionCandidates implements Runnable {
    enum Mode { fast, safe }

    @Option(names = "--mode",
            defaultValue = "safe",
            description = "Mode (${COMPLETION-CANDIDATES}), default: ${DEFAULT-VALUE}")
    Mode mode;

    public void run() {
        System.out.println("Mode: " + mode);
    }

    public static void main(String[] args) {
        FemtoCli.run(new EnumsAndCompletionCandidates(), args);
    }
}
> ./examples/run.sh EnumsAndCompletionCandidates
Mode: safe
> ./examples/run.sh EnumsAndCompletionCandidates --mode fast
Mode: fast
> ./examples/run.sh EnumsAndCompletionCandidates --help
Usage: enums [-hV] [--mode=<mode>]
  -h, --help       Show this help message and exit.
      --mode=<mode>
                   Mode (fast, safe), default: safe
  -V, --version    Print version information and exit.

Custom Header, Footer, and Synopsis

You can customize the help messages a tiny bit, not as deeply as with other libraries, but probably deep enough:

package me.bechberger.femtocli.examples;

import me.bechberger.femtocli.FemtoCli;
import me.bechberger.femtocli.annotations.Command;
import me.bechberger.femtocli.annotations.Option;

/**
 * A command with a custom header and synopsis.
 * The header is printed above the usage message, and the synopsis replaces the default usage line.
 */
@Command(
        name = "mytool",
        header = {"My Tool", "Copyright 2026"},
        customSynopsis = {"Usage: mytool [OPTIONS] <file>"},
        description = "Process files",
        footer = """
                Examples:
                  mytool --flag
                """
)
public class CustomHeaderAndSynopsis implements Runnable {

    @Option(names = "--flag")
    boolean flag = false;

    public void run() {
    }

    public static void main(String[] args) {
        FemtoCli.run(new CustomHeaderAndSynopsis(), args);
    }
}
> ./examples/run.sh CustomHeaderAndSynopsis --help
My Tool
Copyright 2026
Usage: mytool [OPTIONS] <file>
Process files
      --flag
  -h, --help       Show this help message and exit.
  -V, --version    Print version information and exit.

Examples:
  mytool --flag

Global Configuration

Femtocli allows you to configure a few settings globally, like the shown version, making it easier to maintain consistency:

package me.bechberger.femtocli.examples;

import me.bechberger.femtocli.FemtoCli;

public class GlobalConfiguration implements Runnable {

    @Override
    public void run() {
    }

    public static void main(String[] args) {
        FemtoCli.builder()
                .commandConfig(c -> {
                    c.version = "1.2.3";
                })
                .run(new GlobalConfiguration(), args);
    }
}

And a few more features…

Conclusion

Making a tailor-made command-line library for my everyday use cases as a builder of small tools is really fun and, honestly, quite a bit of work. But I’m pretty happy with the results. It’s a small library with an API not too dissimilar to Picocli, but with a few nice additions. I might not use this library for everything, but it’s good enough that I already use it in tools like jstall (Quickly Inspect your Java Application with JStall), where it serves me well. I hope this library is helpful for you, too. It’s MIT-licensed, and I’m open to any suggestions, bug reports, or pull requests.

Thanks for coming along with me, and see you next week with a blog post on redacting heap dump files.

This blog post is part of my work in the SapMachine team at SAP, making profiling easier for everyone.

P.S.: I had a great time last week at JFokus, meeting so many awesome people.

Author

  • Johannes Bechberger

    Johannes Bechberger is a JVM developer working on profilers and their underlying technology in the SapMachine team at SAP. This includes improvements to async-profiler and its ecosystem, a website to view the different JFR event types, and improvements to the FirefoxProfiler, making it usable in the Java world. His work today comprises many open-source contributions and his blog, where he regularly writes on in-depth profiling and debugging topics. He also works on hello-ebpf, the first eBPF library for Java. His most recent contribution is the new CPU Time Profiler in JDK 25.

    View all posts

New posts like these come out at least every two weeks, to get notified about new posts, follow me on BlueSky, Twitter, Mastodon, or LinkedIn, or join the newsletter:

Leave a Reply

Your email address will not be published. Required fields are marked *