Skip to content

parttimenerd/femtocli

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

49 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

femtocli

CI Maven Central Version

Powerful yet minimal command line interface framework for Java applications and Java agents.

A minimal (< 45KB) Java command line interface (CLI) framework for building small command line applications, using annotations to define (sub)commands, options, and positional parameters. It is designed for tools where minimizing dependencies and binary size is important.

While it's small, it should still cover most of the common use cases.

It's currently in early development, so expect some rough edges.

Requires Java 17 or higher.

Features

  • Define commands with @Command (classes and subcommand methods)
  • Options via @Option (short/long names, required, default values, param labels, split, per-option converter)
  • Positional parameters via @Parameters (index, arity, paramLabel, defaultValue)
  • Mixins (reusable option groups) via @Mixin
  • Nested subcommands (classes and methods)
  • Default subcommand: automatically route to a subcommand when no subcommand name is given
  • Parent command access: subcommands can access ancestor commands and their parsed options via Spec.getParent()
  • 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
  • Enum descriptions: show descriptive text for enum values in help with showEnumDescriptions = true
  • 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
  • Support for "agent args" mode, like Java agents
  • Helpful error messages with "did you mean" suggestions for mistyped options

Non-Goals

  • Replace any existing full-featured CLI library.
  • Include more advanced features like command completion, interactive prompts, or complex validation.
  • Support for localization or internationalization.
  • Extensive error handling or logging mechanisms.

Quick Start

This is the smallest realistic setup: one top-level command with a subcommand and a required option.

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!

Maven dependency

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

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

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

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

Examples

The examples below are generated from the examples/ subproject and show many of the important features in action.

Quick start (subcommands + required options) (source)

A tiny but realistic app: a top-level command with a greet subcommand, a required --name, and a defaulted --count.

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!

And its help screens:

> ./examples/run.sh QuickStart --help
Usage: myapp [-hV] [COMMAND]
My CLI application
  -h, --help       Show this help message and exit.
  -V, --version    Print version information and exit.
Commands:
  greet  Greet a person
> ./examples/run.sh QuickStart greet --help
Usage: myapp greet [-hV] --name=<name> [--count=<count>]
Greet a person
  -c, --count=<count>    Count (default: 1)
  -h, --help             Show this help message and exit.
  -n, --name=<name>      Name to greet (required)
  -V, --version          Print version information and exit.

Subcommands as methods (source)

Define a subcommand as a method annotated with @Command.

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

Default subcommand (source)

Use defaultSubcommand to automatically route to a subcommand when the next argument isn't a recognised subcommand name. This is useful for tools where a "default action" makes sense – e.g. app 1234 behaves like app status 1234.

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.util.concurrent.Callable;

/**
 * Example showing how to use {@code defaultSubcommand} to route unrecognised
 * positional arguments (like a PID) to a specific subcommand automatically.
 *
 * <p>With this setup:
 * <ul>
 *   <li>{@code app 1234}         → behaves like {@code app status 1234}</li>
 *   <li>{@code app status 1234}  → explicit, works as usual</li>
 *   <li>{@code app list}         → routes to the list subcommand</li>
 * </ul>
 */
@Command(name = "app",
        description = "Example app with a default subcommand",
        subcommands = {DefaultSubcommand.Status.class, DefaultSubcommand.ListCmd.class},
        defaultSubcommand = DefaultSubcommand.Status.class,
        mixinStandardHelpOptions = true)
public class DefaultSubcommand implements Runnable {

    @Command(name = "status", description = "Show status for a target")
    static class Status implements Callable<Integer> {
        @Parameters(description = "Target PID or name")
        String target;

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

        @Override
        public Integer call() {
            System.out.println("Status of " + target + (verbose ? " (verbose)" : ""));
            return 0;
        }
    }

    @Command(name = "list", description = "List all targets")
    static class ListCmd implements Runnable {
        @Override
        public void run() {
            System.out.println("Listing all targets...");
        }
    }

    @Override
    public void run() {
        System.out.println("Usage: app <command> [options]");
    }

    public static void main(String[] args) {
        FemtoCli.run(new DefaultSubcommand(), args);
    }
}
> ./examples/run.sh DefaultSubcommand 1234
Status of 1234
> ./examples/run.sh DefaultSubcommand status 1234 --verbose
Status of 1234 (verbose)
> ./examples/run.sh DefaultSubcommand list
Listing all targets...

Positional parameters (source)

Use @Parameters for values that are identified by position (instead of an option name).

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.

End-of-options marker (--) (source)

Use -- to stop option parsing. Any following tokens are treated as positional parameters, even if they start with -.

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.util.List;

/**
 * Demonstrates the end-of-options marker ({@code --}).
 * <p>
 * Everything after {@code --} is treated as a positional parameter, even if it starts with {@code -}.
 */
@Command(name = "end-of-options", mixinStandardHelpOptions = true)
public class EndOfOptionsMarker implements Runnable {

    @Option(names = "--name", description = "A normal option")
    String name;

    @Parameters(index = "0..*", paramLabel = "ARGS", description = "Arguments (may start with '-')")
    List<String> args;

    @Override
    public void run() {
        System.out.println("name=" + name);
        System.out.println("args=" + args);
    }

    public static void main(String[] args) {
        System.exit(FemtoCli.run(new EndOfOptionsMarker(), args));
    }
}
> ./examples/run.sh EndOfOptionsMarker --name test -- --not-an-option -also-not
name=test
args=[--not-an-option, -also-not]

Multi-value options (lists + split) (source)

Repeat an option (-I a -I b) or use split for delimited values (--tags=a,b).

package me.bechberger.femtocli.examples;

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

import java.nio.file.Path;
import java.util.List;

/**
 * Demonstrates how to use multi-value options. The option value is split by a separator and stored in a list.
 */
public class MultiValueOptions implements Runnable {
    @Option(names = "-I", description = "Include dirs")
    List<Path> includeDirs; // -I a -I b

    @Option(names = "--tags", split = ",", description = "Tags")
    List<String> tags; // --tags=a,b,c

    public void run() {
        System.out.println("Include Dirs: " + includeDirs);
        System.out.println("Tags: " + tags);
    }

    public static void main(String[] args) {
        FemtoCli.run(new MultiValueOptions(), args);
    }
}
> ./examples/run.sh MultiValueOptions --tags=a,b,c -I a -I b --tags d,e
Include Dirs: [a, b]
Tags: [a, b, c, d, e]

Arrays and lists (source)

Multi-value options and parameters can be modeled as arrays or List.

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.util.Arrays;
import java.util.List;

/**
 * Demonstrates multi-value options/parameters with both arrays and lists.
 */
@Command(name = "arrays-and-lists", mixinStandardHelpOptions = true)
public class ArraysAndLists implements Runnable {

    @Option(names = "--xs", split = ",", description = "Comma-separated values into a String[]")
    String[] xs;

    @Option(names = "--ys", split = ",", description = "Comma-separated values into a List<String>")
    List<String> ys;

    @Parameters(index = "0..*", paramLabel = "REST", description = "Remaining args")
    String[] rest;

    @Override
    public void run() {
        System.out.println("xs=" + Arrays.toString(xs));
        System.out.println("ys=" + ys);
        System.out.println("rest=" + Arrays.toString(rest));
    }

    public static void main(String[] args) {
        System.exit(FemtoCli.run(new ArraysAndLists(), args));
    }
}
> ./examples/run.sh ArraysAndLists --xs=a,b --ys=c,d rest1 rest2
xs=[a, b]
ys=[c, d]
rest=[rest1, rest2]

Mixins (reusable option groups) (source)

Use @Mixin to share common option groups across commands/subcommands.

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.

Hide commands and options from help output (source)

Use hidden=true on @Command and @Option if you want to keep functionality available but omit it from help output.

package me.bechberger.femtocli.examples;

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

/**
 * Demonstrates how to hide commands and options from help output.
 */
@Command(
        name = "hidden",
        description = "Hide commands and options in help",
        mixinStandardHelpOptions = true,
        subcommands = {
                HiddenCommandsAndOptions.Status.class,
                HiddenCommandsAndOptions.Internal.class
        }
)
public class HiddenCommandsAndOptions implements Runnable {

    @Option(names = "--verbose", description = "Verbose output")
    boolean verbose;

    @Option(names = "--secret", hidden = true, description = "A hidden option")
    String secret;

    @Override
    public void run() {
        System.out.println("verbose=" + verbose);
        System.out.println("secret=" + secret);
    }

    @Command(name = "status", description = "Show status", mixinStandardHelpOptions = true)
    public static class Status implements Runnable {
        @Override
        public void run() {
            System.out.println("OK");
        }
    }

    @Command(name = "internal", hidden = true, description = "Internal command")
    public static class Internal implements Runnable {
        @Override
        public void run() {
            System.out.println("INTERNAL");
        }
    }

    public static void main(String[] args) {
        System.exit(FemtoCli.run(new HiddenCommandsAndOptions(), args));
    }
}
> ./examples/run.sh HiddenCommandsAndOptions --help
Usage: hidden [-hV] [--verbose] [COMMAND]
Hide commands and options in help
  -h, --help       Show this help message and exit.
  -V, --version    Print version information and exit.
      --verbose    Verbose output
Commands:
  status  Show status

Help labels + defaults (source)

Control how values show up in help output (paramLabel) and document defaults with ${DEFAULT-VALUE}.

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.nio.file.Path;

@Command(name = "help-labels")
public class HelpLabelsAndDefaults implements Runnable {
    @Option(
            names = "--output",
            paramLabel = "FILE",
            defaultValue = "out.txt",
            description = "Write result to FILE (default: ${DEFAULT-VALUE})"
    )
    Path output;

    @Parameters(
            index = "0",
            paramLabel = "INPUT",
            description = "Input file"
    )
    Path input;

    @Parameters(
            index = "1",
            arity = "0..1",
            paramLabel = "LEVEL",
            defaultValue = "info",
            description = "Log level (default: ${DEFAULT-VALUE})"
    )
    String level;

    @Override
    public void run() {
        System.out.println("Input: " + input);
        System.out.println("Output: " + output);
        System.out.println("Level: " + level);
    }

    public static void main(String[] args) {
        FemtoCli.run(new HelpLabelsAndDefaults(), args);
    }
}
> ./examples/run.sh HelpLabelsAndDefaults in.txt
Input: in.txt
Output: out.txt
Level: info
> ./examples/run.sh HelpLabelsAndDefaults --help
Usage: help-labels [-hV] [--output=FILE] INPUT [LEVEL]
      INPUT        Input file
      [LEVEL]      Log level (default: ${DEFAULT-VALUE})
  -h, --help       Show this help message and exit.
      --output=FILE
                   Write result to FILE (default: out.txt)
  -V, --version    Print version information and exit.

Spec injection (access to runtime) (source)

Declare a plain Spec field to access configured streams and usage rendering.

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.

Parent command access (source)

Subcommands can access their parent command (and its parsed options) via Spec.getParent() or Spec.getParent(Class). Options are parsed at each level before descending into subcommands, so --verbose db --host myhost migrate parses --verbose into the root, --host into db, and migrate can access both ancestors.

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;

@Command(name = "cli", description = "Deep parent access example", subcommands = {DeepParentAccess.Database.class})
public class DeepParentAccess implements Runnable {
    @Option(names = {"-c", "--config"}, description = "Config file path", defaultValue = "default.conf")
    String config;

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

    @Override
    public void run() {
        System.out.println("Root command executed with config: " + config);
    }

    @Command(name = "db", description = "Database operations", subcommands = {Migrate.class})
    public static class Database implements Runnable {
        Spec spec;

        @Option(names = {"-h", "--host"}, description = "Database host", defaultValue = "localhost")
        String host;

        @Option(names = {"-p", "--port"}, description = "Database port", defaultValue = "5432")
        int port;

        @Override
        public void run() {
            DeepParentAccess root = spec.getParent(DeepParentAccess.class);
            System.out.println("Database: Connecting to " + host + ":" + port);
            System.out.println("  Config: " + root.config);
            System.out.println("  Verbose: " + root.verbose);
        }
    }

    @Command(name = "migrate", description = "Run database migrations")
    public static class Migrate implements Runnable {
        Spec spec;

        @Option(names = {"-d", "--direction"}, description = "Migration direction (up/down)", defaultValue = "up")
        String direction;

        @Override
        public void run() {
            // getParent() returns the direct parent
            Database db = (Database) spec.getParent();
            // getParent(Class) searches the entire ancestor chain
            DeepParentAccess root = spec.getParent(DeepParentAccess.class);

            System.out.println("Migration " + direction);
            System.out.println("  Host: " + db.host + ":" + db.port);
            System.out.println("  Config: " + root.config + ", verbose: " + root.verbose);
        }
    }

    public static void main(String[] args) {
        FemtoCli.run(new DeepParentAccess(), args);
    }
}
> ./examples/run.sh DeepParentAccess --config prod.conf --verbose db --host db.example.com migrate --direction down
Migration down
  Host: db.example.com:5432
  Config: prod.conf, verbose: true

Agent args mode (comma-separated arguments) (source)

Useful when you can only pass a single string (e.g., Java agent arguments) and still want subcommands/options.

Why this is special: Java agents typically only get a single -javaagent:...=ARGSTRING argument, and that string is commonly encoded as comma-separated key/value pairs. To my knowledge, there isn’t another Java CLI parsing library that supports this “agent args” style parsing out of the box (including escaping/quoting edge cases) while still giving you subcommands, help/version, and type conversion.

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

Agent invocations (single comma-separated string):

> ./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
> ./examples/run.sh AgentCli stop,help
Usage: agent-cli,stop,[hV],output=<output>,[verbose],<mode>
Options:
      <mode>
  h, help            Show this help message and exit.
      output=<output>
                     Output file (required)
  v, verbose         Verbose
  V, version         Print version information and exit.

Custom type converters (source)

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:

Register type converters globally, or declare a per-option converter (class or method).

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

Enums + completion candidates placeholder (source)

Enum options automatically list completion candidates in help output.

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.

Enums with descriptions (source)

For enums with more complex values, you can provide descriptions by adding a getDescription() method and setting showEnumDescriptions = true in the @Option annotation.

You can customize how enum values are separated in the help output using the joiner syntax:

  • ${COMPLETION-CANDIDATES} or ${COMPLETION-CANDIDATES:, } - comma-separated (default)
  • ${COMPLETION-CANDIDATES:\n} - newline-separated with proper indentation
package me.bechberger.femtocli.examples;

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

@Command(name = "enumwithdesc")
public class EnumWithDescription implements Runnable {

    enum Mode {
        FAST("fast", "optimized for speed"),
        SAFE("safe", "optimized for safety");

        private final String name;
        private final String description;

        Mode(String name, String description) {
            this.name = name;
            this.description = description;
        }

        public String getDescription() { return description; }

        @Override
        public String toString() { return name; }
    }

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

    @Option(names = "--verbose-mode", defaultValue = "safe",
            description = "Mode (verbose listing):\n${COMPLETION-CANDIDATES:\\n}, default: ${DEFAULT-VALUE}",
            showEnumDescriptions = true)
    Mode verboseMode;

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

    public static void main(String[] args) {
        FemtoCli.run(new EnumWithDescription(), args);
    }
}
> ./examples/run.sh EnumWithDescription --help
Usage: enumwithdesc [-hV] [--mode=<mode>] [--verbose-mode=<verboseMode>]
  -h, --help                      Show this help message and exit.
      --mode=<mode>               Mode: fast (optimized for speed), safe
                                  (optimized for safety), default: safe
  -V, --version                   Print version information and exit.
      --verbose-mode=<verboseMode>
                                  Mode (verbose listing):
                                  fast (optimized for speed)
                                  safe (optimized for safety), default: safe
> ./examples/run.sh EnumWithDescription --mode fast
Mode: fast (optimized for speed)
Verbose Mode: safe (optimized for safety)

Custom header, footer and synopsis (source)

Customize the help screen with a header and a fully custom synopsis.

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, synopsis and footer.
 * 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

Ignore options (inheritance / mixins) (source)

Filter inherited options (or mixin-provided options) from the effective command surface.

package me.bechberger.femtocli.examples;

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

/**
 * Example for {@code @IgnoreOptions}:
 * <ul>
 *   <li>remove inherited options from a base command</li>
 *   <li>remove options contributed by a {@code @Mixin}</li>
 * </ul>
 */
public class IgnoreOptionsExample {

    @IgnoreOptions(exclude = "--m")
    static class MixinOpts {
        @Option(names = "--m", description = "Mixin option")
        int m;
    }

    static class Base implements Runnable {
        @Option(names = "--a", description = "Inherited option A")
        int a;

        @Option(names = "--b", description = "Inherited option B")
        int b;

        @Mixin
        MixinOpts mixin;

        @Override
        public void run() {
        }
    }

    /**
     * One command that extends a base command and has a mixin.
     * <p>
     * - {@code --a} is inherited from {@link Base} but ignored here
     * - {@code --m} comes from the mixin but is ignored on the mixin class
     */
    @IgnoreOptions(exclude = "--a")
    static class Cmd extends Base {
        @Override
        public void run() {
            System.out.println("b=" + b);
            System.out.println("(mixin is present, but its option is ignored)");
        }
    }

    public static void main(String[] args) {
        // try:
        //   --a 1      (unknown option, ignored from base)
        //   --m 2      (unknown option, ignored from mixin)
        //   --b 3
        FemtoCli.run(new Cmd(), args);
    }
}
> ./examples/run.sh IgnoreOptionsExample --help
Usage: cmd [-hV] [--b=<b>]
      --b=<b>      Inherited option B
  -h, --help       Show this help message and exit.
  -V, --version    Print version information and exit.

Custom verifiers (source)

Validate values and produce user-friendly errors.

package me.bechberger.femtocli.examples;

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

class Helpers {
    static void checkPort(int p) {
        if (p < 1 || p > 65535) throw new VerifierException("port out of range");
    }
}

@Command(name = "verifiers")
public class CustomTypeVerifiers implements Runnable {

    @Option(names = "--port", verifierMethod = "Helpers#checkPort")
    int port;

    @Override
    public void run() {
        System.out.println("port=" + port);
    }

    public static void main(String[] args) {
        FemtoCli.run(new CustomTypeVerifiers(), args);
    }
}
> ./examples/run.sh CustomTypeVerifiers --port 0
Error: port out of range
Usage: verifiers [-hV] [--port=<port>]
  -h, --help       Show this help message and exit.
      --port=<port>
  -V, --version    Print version information and exit.

Global configuration (source)

Set global defaults like version strings and usage formatting.

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);
    }
}
> ./examples/run.sh GlobalConfiguration --version
1.2.3

"Did you mean" suggestions (source)

When users mistype option names, femtocli suggests similar valid options using Levenshtein distance.

package me.bechberger.femtocli.examples;

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

/**
 * Demonstrates "did you mean" suggestions for mistyped options.
 *
 * <p>When you mistype an option name, femtocli suggests similar options.
 */
@Command(name = "didyoumean", description = "Example showing helpful error suggestions")
public class DidYouMean implements Runnable {

    @Option(names = "--input-file", description = "Input file to process")
    String inputFile;

    @Option(names = "--output-file", description = "Output file destination")
    String outputFile;

    @Option(names = "--verbose", description = "Enable verbose output")
    boolean verbose;

    @Override
    public void run() {
        System.out.println("Processing " + inputFile + " -> " + outputFile);
        if (verbose) {
            System.out.println("Verbose mode enabled");
        }
    }

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

Example with a typo using underscore instead of hyphen:

> ./examples/run.sh DidYouMean --input_file test.txt
Error: Unknown option: --input_file

  tip: a similar argument exists: '--input-file'
Usage: didyoumean [-hV] [--input-file=<inputFile>] [--output-file=<outputFile>]
                  [--verbose]
Example showing helpful error suggestions
  -h, --help                    Show this help message and exit.
      --input-file=<inputFile>  Input file to process
      --output-file=<outputFile>
                                Output file destination
  -V, --version                 Print version information and exit.
      --verbose                 Enable verbose output

Example with an option that's wildly off (no similar suggestions):

> ./examples/run.sh DidYouMean --no-this-is-not-an-option test
Error: Unknown option: --no-this-is-not-an-option

Usage: didyoumean [-hV] [--input-file=<inputFile>] [--output-file=<outputFile>]
                  [--verbose]
Example showing helpful error suggestions
  -h, --help                    Show this help message and exit.
      --input-file=<inputFile>  Input file to process
      --output-file=<outputFile>
                                Output file destination
  -V, --version                 Print version information and exit.
      --verbose                 Enable verbose output

You can disable this feature via CommandConfig:

FemtoCli.builder()
    .commandConfig(c -> c.suggestSimilarOptions = false)
    .run(new DidYouMean(), args);

Or configure the suggestion string:

FemtoCli.builder()
    .commandConfig(c -> c.similarOptionSuggestion = "Did you mean: %s?")
    .run(new DidYouMean(), args);

Boolean options with explicit values (source)

Allow boolean options to accept explicit values (for cases where a pure flag isn’t enough).

package me.bechberger.femtocli.examples;

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

/**
 * Demonstrates boolean options as flags and with explicit values.
 *
 * <p>Supported forms:</p>
 * <ul>
 *   <li>{@code --prim} (flag style, sets to true)</li>
 *   <li>{@code --prim false} (explicit value as separate token)</li>
 *   <li>{@code --boxed=false} (explicit value with equals)</li>
 * </ul>
 */
@Command(name = "bools", description = "Boolean option parsing example")
public class BooleanExplicitValues implements Runnable {

    @Option(names = "--boxed", description = "Boxed boolean (Boolean)")
    Boolean boxed;

    @Option(names = "--prim", description = "Primitive boolean (boolean)")
    boolean prim;

    @Override
    public void run() {
        System.out.println("boxed=" + boxed);
        System.out.println("prim=" + prim);
    }

    public static void main(String[] args) {
        System.exit(FemtoCli.run(new BooleanExplicitValues(), args));
    }
}
> ./examples/run.sh BooleanExplicitValues --boxed=false --prim false
boxed=false
prim=false
> ./examples/run.sh BooleanExplicitValues --help
Usage: bools [-hV] [--boxed] [--prim]
Boolean option parsing example
      --boxed      Boxed boolean (Boolean)
  -h, --help       Show this help message and exit.
      --prim       Primitive boolean (boolean)
  -V, --version    Print version information and exit.

Support, Feedback, Contributing

This project is open to feature requests/suggestions, bug reports etc. via GitHub issues. Contribution and feedback are encouraged and always welcome.

License

MIT, Copyright 2026 SAP SE or an SAP affiliate company, Johannes Bechberger and contributors