Building a CLI with Quarkus, Kotlin and GraalVM

Building a CLI with Quarkus, Kotlin and GraalVM

Command-line tools are great for automation, but choosing the right technology stack to build them can be tricky. I recently set out to build a new command-line application to streamline some tasks, drawing from previous experiences where the tooling left me wanting more.

This time, I chose a different combination of technologies that better suits my needs: Kotlin, Quarkus, and GraalVM. In this blog, I’ll walk you through the setup and decisions behind this stack—so you can get up and running even faster when building your own CLI tools.

Background

A few years back, I started with a pet project named mcs (which is short for Maven Central Search). I wrote it in plain Java with PicoCLI, later added GraalVM so I could ship the project as a native executable, and recently added Dagger for dependency injection. While I liked the stack for its simplicity and minimalism, I had a hard time integrating all of these technologies. Integrating Dagger was particularly difficult, as neither Dagger nor PicoCLI had a hands-on guide for integrating the other.

Also, the feedback loop was not ideal: I could only test a change in the code by building the whole application and then running it.

A new technology stack

So when I decided to create a new CLI tool, I chose to select and verify the technology stack before getting started. I have worked with Quarkus in the past and loved its focus on developer joy with features like unified config, live coding and continuous testing. And you can run Quarkus in “Command Mode” which is great for building a CLI. Although Dagger’s dependency injection works well once set up, I had some trouble getting it to work. Since Quarkus also includes dependency injection, I could use that instead of Dagger. There’s a hands-on guide for integrating Quarkus with PicoCLI, so I expected this to be an easy path. For the code itself, I prefer to write that in Kotlin if possible. Luckily, Quarkus also has a guide for that. There’s no substitute for GraalVM so that would remain in the stack.

In the remainder of this post, I’ll report on how I glued these things together. Let’s get started!

Getting started

It all starts with bootstrapping the project by creating a ‘command mode’ Quarkus application. This is done with Apache Maven:

mvn io.quarkus.platform:quarkus-maven-plugin:3.24.4:create \
    -DprojectGroupId=it.mulders \
    -DprojectArtifactId=hello \
    -DnoCode

The -DnoCode option only creates the Maven-based build scripts, including the Maven Wrapper, but leaves out the actual Java code. It also includes some Dockerfiles, that we will not need, so we can remove them.

Looking at the project model inside pom.xml, there are a few things I don’t like. They’re not wrong, it’s just not my taste, so I decided to clean up the file a bit, but this is optional and opinionated. As an example, I don’t like to have a property to define a groupId value that only occurs twice in a file. I prefer to write them out where I need them, so I don’t need to look back in the <properties> section to find the actual value. Another example: although you can leave out the <groupId> of a Maven plugin when it is org.apache.maven.plugins, I prefer to include it for explicitness.

Adding Kotlin

The next step is to add Kotlin as the programming language. At this stage, there is no code yet (remember the -DnoCode switch?), so nothing needs to be rewritten. For the project to work with Kotlin, we need to have the Kotlin compiler and a dependency on Kotlin’s standard library.

Adding the Kotlin compiler

To be able to compile Kotlin code, Jetbrains offers a Maven plugin. Adding it to your project is pretty familiar if you’ve worked with Maven before. We must add the following to the project model in pom.xml, under projectbuildplugins:

<plugin>
    <groupId>org.jetbrains.kotlin</groupId>
    <artifactId>kotlin-maven-plugin</artifactId>
    <version>${kotlin.version}</version>
    <dependencies>
        <dependency>
            <groupId>org.jetbrains.kotlin</groupId>
            <artifactId>kotlin-maven-allopen</artifactId>
            <version>${kotlin.version}</version>
        </dependency>
    </dependencies>
    <extensions>true</extensions>
    <configuration>
        <compilerPlugins>
            <!-- enable the all-open plugin for the Kotlin compiler -->
            <plugin>all-open</plugin>
        </compilerPlugins>
        <pluginOptions>
            <!-- make classes and their members as open if the class is marked with any of the following annotations -->
            <option>all-open:annotation=jakarta.enterprise.context.ApplicationScoped</option>
            <option>all-open:annotation=io.quarkus.test.junit.QuarkusTest</option>
        </pluginOptions>
    </configuration>
</plugin>

The version of the Maven plugin, the Kotlin language version and the kotlin-maven-allopen plugin (more on that below) should be the same. That’s why it’s useful to add a new property to the project model in pom.xml, under projectproperties:

<kotlin.version>2.1.21</kotlin.version>

Running your builds on a modern (version 17 or higher) Java Virtual Machine (JVM), requires two more changes to the build setup, according to the Maven documentation of Kotlin:

  1. To instruct the Kotlin compiler to target a Java 21 runtime, add the following to the project model in pom.xml, under projectproperties:

    <kotlin.compiler.jvmTarget>21</kotlin.compiler.jvmTarget>
    
  2. And to configure the Java Virtual Machine (JVM) that runs the build properly, add the following to .mvn/jvm.config:

    --add-opens=java.base/java.lang=ALL-UNNAMED
    --add-opens=java.base/java.io=ALL-UNNAMED
    

Finally, to tell the Kotlin compiler where to find the source code of the application, update the project model under projectbuild with these two lines:

<sourceDirectory>src/main/kotlin</sourceDirectory>
<testSourceDirectory>src/test/kotlin</testSourceDirectory>

The All-open Kotlin compiler plugin

In Kotlin, classes and their members are final by default. Many frameworks, including Quarkus and Spring, can’t deal with this properly as they often define a dynamic proxy class. For this to work, Kotlin classes should declared open. Because you can easily forget this, there is an All-open Kotlin compiler plugin. This plugin marks classes that have a specific annotation and their members open, even if open is not in the source code. Remember, laziness is a virtue…

The Kotlin Standard Library

Kotlin is not only a language; it also comes with a large standard library of useful APIs. This library must be on the classpath of a project in order to write or run Kotlin code. As with the compiler, adding it to your project is pretty familiar if you’ve worked with Maven before. Add the following to the project model in pom.xml, under projectdependencies:

<dependency>
    <groupId>org.jetbrains.kotlin</groupId>
    <artifactId>kotlin-stdlib</artifactId>
</dependency>

The Kotlin Standard Library comes in multiple flavours and it depends on your situation which one you need. The Maven documentation of Kotlin explains which options you have and when you need them:

  • if you want your program to work on a Java 7 runtime, use kotlin-stdlib-jdk7.
  • if you want your program to work on a Java 8 runtime, use kotlin-stdlib-jdk8.
  • else, use kotlin-stdlib.

Since I only want to distribute my application as a native executable (more on that later), I can resort to the kotlin-stdlib.

Finally, in order for Quarkus to play nicely with Kotlin, add another dependency to the project:

<dependency>
    <groupId>io.quarkus</groupId>
    <artifactId>quarkus-kotlin</artifactId>
</dependency>

Note that there is no version number for this dependency. It is managed by the Quarkus Platform that is already imported in the <dependencyManagement> section of my project model.

Adding PicoCLI

Great, so the project would now compile Kotlin code rather than Java code! But there is no code yet — let’s change that.

To begin with, it would be great if the CLI could just print “Hello, world”. Since we might want to add more functionality later, we will only have it print this text when the user invokes a “sub command”, let’s say hello.

First, add a dependency on the PicoCLI library. PicoCLI lets us declare commands, subcommands, arguments, and parameters in a declarative manner, and parse the user input according to that specification. Again, do this in pom.xml, under projectdependencies:

<dependency>
    <groupId>io.quarkus</groupId>
    <artifactId>quarkus-picocli</artifactId>
</dependency>

Note that this is not a direct dependency on PicoCLI itself, but rather a dependency on the Quarkus integration with PicoCLI. The version is managed by the Quarkus Platform, so we can rest assured that it will pull in a compatible version of PicoCLI.

With all these preparations in place, we can finally start to write some code!

The first thing we need to do is provide an QuarkusApplication implementation. It’s the entry point of the Quarkus application and responsible for bootstrapping PicoCLI.

import io.quarkus.runtime.QuarkusApplication
import io.quarkus.runtime.annotations.QuarkusMain
import jakarta.inject.Inject
import picocli.CommandLine

@CommandLine.Command(
    // Add standard '-h/--help' and '-V/--version' options to the application.
    mixinStandardHelpOptions = true,
    // The suggested name for this application, useful for the '-h/--help' output.
    name = "hello"
)
// Mark this class as the entry point for a command-mode Quarkus application.
@QuarkusMain
class HelloApplication @Inject constructor(val factory: CommandLine.IFactory) : QuarkusApplication {
    // Bootstrap PicoCLI and pass all command line arguments to it.
    override fun run(vararg args: String): Int = CommandLine(this, factory).execute(*args)
}

At this point, if you would start the application, it wouldn’t do anything useful. Worse even, it would crash. We need to provide at least one “sub command” that performs an actual task.

Implementing a sub command

To add a subcommand, create a new package to gather all code related to the command-line interface of the application. I typically call that package .cli. Inside that package, we create a new class for each subcommand. The first subcommand is going to be “hello” and it will only print “Hello, world”.

Aside: This results in a slightly awkward interaction for end users. They will have to type hello hello to run the program. Usually, the program name would be something other than ‘hello’, but I’m leaving this for now.

The code for a sub command looks like this:

import org.slf4j.Logger
import org.slf4j.LoggerFactory
import picocli.CommandLine
import kotlin.jvm.java

// Marks this class as a (sub) command with a name and
// a short description of what it does.
@CommandLine.Command(name = "hello", description = ["Greet world"])
class HelloCommand : Runnable {
    override fun run() {
        // Impressive business logic!
        log.info("Hello, World!")
    }

    companion object {
        val log: Logger = LoggerFactory.getLogger(HelloCommand::class.java)
    }
}

To register this command with the application, we must update the @CommandLine.Command on the HelloApplication class:

@CommandLine.Command(
    mixinStandardHelpOptions = true,
    name = "hello",
    subcommands = [HelloCommand::class]
)

Running the application

Now it’s time to enjoy some of that Quarkus “developer joy”! After opening a terminal, navigate to the folder where the project lives. On macOS and Linux, run ./mvnw quarkus:dev -Dquarkus.args=‘hello’, or mvnw.cmd quarkus:dev -Dquarkus.args=‘hello’ on Windows. After downloading all plugins and dependencies, the application starts in ‘dev mode’ with the extra arguments specified by -Dquarkus.args.

Et voilà, after a while, the terminal shows this:

__  ____  __  _____   ___  __ ____  ______
 --/ __ \/ / / / _ | / _ \/ //_/ / / / __/
 -/ /_/ / /_/ / __ |/ , _/ ,< / /_/ /\ \
--\___\_\____/_/ |_/_/|_/_/|_|\____/___/
2025-07-24 21:20:05,472 INFO  [io.quarkus] (Quarkus Main Thread) hello 1.0.0-SNAPSHOT on JVM (powered by Quarkus 3.24.5) started in 0.707s.

2025-07-24 21:20:05,474 INFO  [io.quarkus] (Quarkus Main Thread) Profile dev activated. Live Coding activated.
2025-07-24 21:20:05,475 INFO  [io.quarkus] (Quarkus Main Thread) Installed features: [cdi, kotlin, picocli]
2025-07-24 21:20:05,514 INFO  [it.mul.hel.cli.HelloCommand] (Quarkus Main Thread) Hello, World!
2025-07-24 21:20:05,517 INFO  [io.quarkus] (Quarkus Main Thread) hello stopped in 0.002s

--
Tests paused
Press [space] to restart, [e] to edit command line args (currently 'hello'), [r] to resume testing, [o] Toggle test output, [:] for the terminal, [h] for more options>

Imagine we don’t like the capital ‘W’, all we have to do is change the Kotlin code and hit space in the terminal. The code is recompiled and the application starts again, this time with improved output! The whole process of recompiling and restarting took almost no time, so this makes for a very quick feedback loop. Pressing q quits the ‘dev mode’.

Building as a native executable

As a user of a CLI tool, I want it to start and run as quick as possible. As such, I want to avoid the overhead of starting a Java Virtual Machine (JVM). All the advanced features of the JVM (e.g., garbage collection or just-in-time compilation) are not necessary for the short runtime of a CLI. Luckily, with GraalVM, we can convert JVM bytecode to a native executable.

Building the application as a native executable is handled completely by Quarkus. Because Quarkus delegates this to GraalVM, it requires a recent GraalVM to be installed on your system. As I already have multiple versions of GraalVM installed, I can skip that part for now. Refer to the GraalVM website for downloads and instructions.

After that, it’s a matter of invoking the Maven Wrapper again, this time with ./mvnw verify -Pnative, or mvnw.cmd verify -P native on Windows. The last bit activates a Maven profile that in turn triggers building the native executable. It will take a while, but when this completes, you can invoke your application using ./target/hello-1.0.0-SNAPSHOT-runner hello on Linux and macOS - or target\hello-1.0.0-SNAPSHOT-runner.exe hello if you’re on Windows. The output probably looks something like this:

__  ____  __  _____   ___  __ ____  ______
 --/ __ \/ / / / _ | / _ \/ //_/ / / / __/
 -/ /_/ / /_/ / __ |/ , _/ ,< / /_/ /\ \
--\___\_\____/_/ |_/_/|_/_/|_|\____/___/
2025-07-24 21:44:13,402 INFO  [io.quarkus] (main) hello 1.0.0-SNAPSHOT native (powered by Quarkus 3.24.5) started in 0.006s.
2025-07-24 21:44:13,402 INFO  [io.quarkus] (main) Profile prod activated.
2025-07-24 21:44:13,402 INFO  [io.quarkus] (main) Installed features: [cdi, kotlin, picocli]
2025-07-24 21:44:13,403 INFO  [it.mul.hel.cli.HelloCommand] (main) Hello, world!
2025-07-24 21:44:13,403 INFO  [io.quarkus] (main) hello stopped in 0.000s

Note how the recorded startup time is down from 0.7 seconds in development mode to 0.006 seconds in native mode!

For more information, check the Quarkus guide on ‘Building a Native Executable’, which has a lot more details.

Cleaning up the output

The next thing I want to address is the superfluous output. For development mode, it’s OK to have some extra information (such as active Quarkus profiles, features, startup times), but I don’t want to see this in the production builds.

Luckily, this can be fully achieved using the Quarkus configuration mechanism. To do this, edit the application.properties file (in the src/main/resources directory) and make the following changes:

# Completely disable the Quarkus banner.
quarkus.banner.enabled=false

# Only print log messages from Quarkus and other libraries that are warning or more severe.
quarkus.log.level=WARN
# But for my own code, also print informational messages,
quarkus.log.category."it.mulders.hello".level=INFO
# excluding the timestamp, thread name, priority, and other details irrelevant for end users.
quarkus.log.console.format=%m%n

# However, in development mode, print informational and more severe messages from Quarkus and other libraries.
%dev.quarkus.log.level=INFO
# For my own code, also include debug logging.
%dev.quarkus.log.category."it.mulders.hello".level=DEBUG
# Print all logging in the "original" Quarkus format.
%dev.quarkus.log.console.format=%d{yyyy-MM-dd HH:mm:ss,SSS} %-5p [%c{3.}] (%t) %s%e%n

The special %dev prefix is a way to mark the remainder of that line as “only inside the ‘dev’ profile”. This profile is active when running ./mvnw quarkus:dev resp. mvnw.cmd quarkus:dev, but in a production build, it’s not activated.

After building the native executable again and running it, the output is now reduced to:

Hello, World!

Neat!

Processing arguments and options

A command-line application that always produces the same output is not particularly useful. So let’s add an argument and an option so we can change the behaviour of the “hello” subcommand.

For starters, we might not want to greet the whole world, only one particular person. This is a good use case for an optional argument “name”, with default value “World” if omitted. Our command class changes to this:

@CommandLine.Command(name = "hello", description = ["Greet someone"], mixinStandardHelpOptions = true)
class HelloCommand : Runnable {
    @CommandLine.Parameters(
        // this argument can occur at most once
        arity = "0..1",
        description = ["Who to greet"],
        defaultValue = "World",
        // name of the optional argument
        paramLabel = "name"
    )
    lateinit var name: String

    override fun run() {
        // Of course, the 'business logic' must change accordingly!
        log.info("Hello, $name!")
    }
}

Adding mixinStandardHelpOptions to the @CommandLine.Command annotation ensures that users can get help on arguments specific to this subcommand by invoking hello hello -h.

As a second example, we might want to print the message in capital letters. This is a nice example where an option comes in handy. Declaring this to PicoCLI looks a bit like before, except we use @CommandLine.Option instead of @CommandLine.Parameters:

@CommandLine.Option(
    // this option can occur at most once
    arity = "0..1",
    description = ["Output in all-capitals"],
    names = ["-c", "--capitals"]
)
var capitals: Boolean = false

Updating the business logic is left as an exercise to you, the reader.

Wrapping Up

I now have a working command-line tool that doesn’t do anything useful. But the great part is that I can use it to bootstrap any new CLI. While it’s time for me to implement all the automated tasks, you might feel enthusiastic about building one yourself too. If that’s the case, check out my GitHub repository quarkus-kotlin-cli. It has everything I described so far, and provides a convenient starting point to start building your own CLI.

In the meantime, I’d love to hear your thoughts on the above.

comments powered by Disqus