Command-Line Programs on the JVM & Kotlin

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production; see the section bibliography for the reference material consulted while preparing these pages.

The JVM is a good home for command-line programs whose value is in the code they run (parsing, networking, databases, large libraries) and a poor one for programs that must start in a few milliseconds. This page covers what the Java class library gives a command-line program — the console, the process API, shutdown hooks — how to launch Java code without a build step, how to cut JVM startup cost, how to ship a runtime image, and the Kotlin side: Clikt and Mordant on the JVM, and Kotlin/Native executables that do not need a JVM at all. The running scenario is the Bookshelf: a shelf command, a shelf-report script that runs it and captures its output, and a shelfd background daemon.

The page was written against Java 25 (the JDK 22 to 25 differences are called out where they matter), Clikt 5.1.0, Mordant 3.1.0 and Mosaic 0.18.0. The Java snippets in the first half of the page were compiled and run on a JDK 17 (JetBrains Runtime 17.0.14) on macOS; behaviour that is new in JDK 22 or later is described from the official Javadoc and JEPs and was not run. The Kotlin snippets, the Gradle build files and the GraalVM, jlink and jpackage commands were not compiled or run while the page was written, because no Kotlin toolchain or GraalVM is installed on the machine used for it. They follow the official documentation of each library, which is linked after every example. Windows was not available either; Windows behaviour comes from the documentation.

Several topics are covered elsewhere and only linked here. The Java language is on Java, the JDK tools and Maven or Gradle builds on Build & Tooling, and file and stream I/O on I/O & Files. picocli, Spring Shell, JLine and the GraalVM Native Image setup for the docsctl example are on Java CLI Frameworks, and the rules for colour, terminals and non-interactive use on TTY, Colour & Interactivity. Quarkus command mode is on Command Mode & CLI Applications. The process and signal model that the JVM sits on is on POSIX Processes, Signals & I/O and Windows Console & Processes; choosing between the JVM and the other stacks is on The CLI Landscape & Choosing a Stack.

What the JVM adds, and what it hides

A Java program does not see the raw process model. The java launcher starts a JVM, which starts its own threads, and the class library offers a portable subset of the operating system’s facilities. That subset is the right default for portable code and the wrong one when a command-line program needs a terminal size, raw mode or a pseudo-terminal: the class library has none of them, and libraries such as JLine (through native access) fill the gap.

Topic Linux macOS Windows

Terminal detection

System.console(); from JDK 22 Console.isTerminal(), which on POSIX is documented as isatty of standard input and output

Same as Linux

Same API; the documented default implementation checks whether the standard handles are character devices

Arguments

String[] args excludes the program name; the shell has already expanded globs and removed quotes

Same as Linux

cmd.exe and PowerShell do not expand globs; the program receives the text typed. Quoting rules differ per shell

Signals

SIGINT, SIGTERM, SIGHUP run shutdown hooks; SIGKILL cannot be caught

Same as Linux

Ctrl+C, Ctrl+Break and system shutdown or logoff run shutdown hooks; there are no POSIX signals

Exit status of a signalled JVM

128 plus the signal number (143 for SIGTERM)

Same as Linux

The status the JVM chooses; there is no signal-based status

Writing to a closed pipe

SIGPIPE is ignored by the JVM; the write fails and PrintStream records the error

Same

The write fails the same way

The exit statuses in the table are the ones observed on macOS with the stand-in daemon shown below.

Reading the terminal: System.console and java.io.Console

System.console() returns the JVM’s unique Console, or null when there is none. A Console offers readLine, readPassword (echo disabled, returned as a char[] so it can be wiped), printf and format, a reader() and a writer(), flush(), and charset(). The Javadoc advises zeroing the array returned by readPassword after use, and says the read methods return null at end of input (Ctrl+D on Unix, Ctrl+Z on Windows).

The question that decides how a program behaves is "when is the console null?". The Javadoc says only that a console exists if the JVM is started from an interactive command line without redirecting the standard streams, and that it may be absent when the JVM is started automatically, for example by a job scheduler. For JDK 17 to 21 the practical rule is that System.console() is null as soon as standard input or output is redirected. JDK 22 added Console.isTerminal(), which reports whether the console is a terminal, so code that must decide whether to prompt or colour its output should check both: a non-null console and isTerminal() on JDK 22 and later. The repository’s TTY, Colour & Interactivity page describes the same JDK 22 change from the point of view of non-interactive use; the Javadoc does not say exactly which redirections leave a non-null console on JDK 22 and later, so test the JDK you ship. Two other points matter: the Javadoc says reads and writes use the stdin.encoding and stdout.encoding charsets (see the Windows page for how the console code page affects them), and every console method is synchronized, so a thread blocked in readLine blocks other console writes.

import java.io.Console;
import java.util.Arrays;

public class Login {
    public static void main(String[] args) {
        Console console = System.console();
        if (console == null) {
            System.err.println("shelf: no console; pass --token-file or set SHELF_TOKEN");
            System.exit(2);
        }
        String user = console.readLine("User: ");
        char[] password = console.readPassword("Password for %s: ", user);
        try {
            console.printf("Read %d characters%n", password.length);
        } finally {
            Arrays.fill(password, '\0');   // wipe the secret as soon as it has been used
        }
    }
}

Run with redirected input on JDK 17, the program takes the no-console branch and exits with status 2:

$ echo "" | java -cp out Login; echo "exit=$?"
shelf: no console; pass --token-file or set SHELF_TOKEN
exit=2

A String password cannot be wiped and may stay in memory until the garbage collector reuses it, which is why the API returns a char[]. Prefer reading a secret from a file, an environment variable or a credential store over a prompt when the program may run unattended; the design rules are on Configuration & Auth.

Running Java without a build step: the source-file launcher

Since JDK 11, java Hello.java compiles the file in memory and runs it (JEP 330). JDK 22 extended this to programs split over several files (JEP 458): the launcher compiles the launch file and any classes it needs from the source files in the same directory tree, in memory, with no class files written. JEP 458 states that making external library dependencies easy is not a goal, so a script that needs Clikt or picocli must still be given a class path or be built with Maven or Gradle. JEP 512, delivered in JDK 25, adds compact source files and instance main methods, which shortens the boilerplate of a script (no class declaration needed); check the JEP for the exact rules before relying on them.

For a script that is run as a command, a file with a shebang line works without the .java extension. The file needs --source to tell the launcher the Java release, and env -S (on Linux and macOS) to pass it:

#!/usr/bin/env -S java --source 17

import java.util.List;

public class ShelfReport {
    public static void main(String[] args) {
        System.out.println("args=" + List.of(args));
    }
}
$ chmod +x shelf-report
$ ./shelf-report a b
args=[a, b]
$ java shelf-report x       # without --source the launcher looks for a class named shelf-report
Error: Could not find or load main class shelf-report

Shebang scripts are a Unix convention. On Windows the equivalent is java ShelfReport.java called from a .cmd or PowerShell wrapper. The launcher pays the compile cost on every run (the program above is tiny), so it suits one-off scripts and tests, not commands that run many times. For those, compile once and use a JAR or a runtime image, as described below.

Running other programs: ProcessBuilder and ProcessHandle

ProcessBuilder configures and starts an operating-system process; Process represents it; ProcessHandle (JDK 9 and later) gives the process identifier, parent, children, descendants, command line information and an onExit future for any process, not only ones this JVM started.

API What it does

new ProcessBuilder(String...) / command(...)

The program and its arguments, as separate strings. No shell is involved, so no globbing and no quoting is needed.

directory(File), environment()

Working directory and a mutable copy of the environment for the child.

redirectInput/Output/Error(Redirect)

Redirect.PIPE (default), INHERIT (use the parent’s standard stream), DISCARD, from(file), to(file), appendTo(file).

inheritIO()

Shortcut for INHERIT on all three streams; the child talks to the same terminal.

redirectErrorStream(true)

Merges standard error into standard output, so one reader suffices.

start() / startPipeline(List)

Start one process or a pipeline in which each process’s output feeds the next, without a shell.

Process.waitFor(), waitFor(timeout, unit), exitValue()

Wait for, and read, the exit status.

Process.destroy() / destroyForcibly()

Terminate the process. On Unix destroy sends SIGTERM; destroyForcibly sends SIGKILL. supportsNormalTermination() reports whether the platform has a gentle form.

Process.toHandle(), ProcessHandle.current(), descendants(), info()

Navigate the process tree and read its metadata.

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.List;

public class ShelfRunner {
    public static void main(String[] args) throws IOException, InterruptedException {
        // 1. Capture output: stderr merged into stdout, one reader, no deadlock.
        ProcessBuilder pb = new ProcessBuilder("shelf", args.length > 0 ? args[0] : "list");
        pb.redirectErrorStream(true);
        pb.environment().put("NO_COLOR", "1");
        Process process = pb.start();
        String output = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
        int exit = process.waitFor();
        System.out.print(output);
        System.out.println("exit status: " + exit);

        // 2. Hand the terminal to the child: inherit stdin, stdout and stderr.
        int status = new ProcessBuilder("shelf", "list").inheritIO().start().waitFor();
        System.out.println("inherited run exit status: " + status);

        // 3. ProcessHandle: inspect this process and its descendants, then stop a child.
        ProcessHandle self = ProcessHandle.current();
        System.out.println("pid=" + self.pid() + " command=" + self.info().command().orElse("?"));
        Process sleeper = new ProcessBuilder("sleep", "30").start();
        System.out.println("descendants: " + self.descendants().count());
        sleeper.destroy();
        sleeper.waitFor();
        System.out.println("sleeper exit value: " + sleeper.exitValue());

        // 4. A pipeline without a shell: shelf list | sort
        List<Process> pipeline = ProcessBuilder.startPipeline(List.of(
            new ProcessBuilder("shelf", "list").redirectError(ProcessBuilder.Redirect.INHERIT),
            new ProcessBuilder("sort").redirectOutput(ProcessBuilder.Redirect.INHERIT)));
        for (Process p : pipeline) {
            p.waitFor();
        }
    }
}

With a stand-in shelf shell script on the PATH (it prints two books and exits 0, or prints an error and exits 3 for shelf fail), the run on macOS printed:

$ PATH=$PWD/bin:$PATH java -cp out ShelfRunner
1984	George Orwell
Dune	Frank Herbert
exit status: 0
1984	George Orwell
Dune	Frank Herbert
inherited run exit status: 0
pid=87696 command=/path/to/jdk/bin/java
descendants: 1
sleeper exit value: 143
1984	George Orwell
Dune	Frank Herbert
$ PATH=$PWD/bin:$PATH java -cp out ShelfRunner fail | head -2
no such book
exit status: 3

An exit value of 143 is 128 plus 15: a child ended by SIGTERM is reported by the JVM as 128 + signal, the same convention shells use. The rules for exit statuses as an interface are on Streams & Exit Codes.

Pitfalls

  • Unread output blocks the child. The pipes between the processes have a limited buffer. A program that calls waitFor() before reading a chatty child’s output can deadlock. Read the output first, merge streams with redirectErrorStream(true), redirect to a file, or use inheritIO(). The example above reads to end of stream first, which means a timeout on waitFor would only start after the child closed its output; to enforce a timeout, read on a second thread or redirect the output to a file.

  • There is no shell. new ProcessBuilder("shelf list") looks for a program called shelf list. Pass arguments as separate strings. To use shell features, run the shell explicitly (sh -c, cmd /c, pwsh -Command) and quote with care; never build the string from untrusted input.

  • Program lookup differs per platform. The child is found through the PATH of the JVM’s environment on Unix. On Windows the process is created with CreateProcess, which runs executables; scripts such as .cmd and .bat are normally started through cmd /c. Check the quoting of arguments that contain spaces or quotes on Windows, where the argument vector is rebuilt into a single command-line string (see Windows Console & Processes).

  • destroy() is not graceful everywhere. It sends SIGTERM on Unix, which a well-behaved child handles; on Windows there is no equivalent signal, which is what supportsNormalTermination() reports. Escalate to destroyForcibly() after a timeout.

  • The environment is inherited. A child receives every variable of the JVM, including secrets. Clear and rebuild the map (environment().clear()) when launching an untrusted program.

  • Running a terminal program as a child does not give it a pseudo-terminal. With PIPE the child sees a pipe and typically turns off colour and prompts; with inheritIO() it sees the parent’s terminal. A pseudo-terminal needs a library that uses the native API; see Terminals, TTYs & Pseudo-Terminals.

Shutdown hooks, signals and exit statuses

Runtime.addShutdownHook(Thread) registers a thread that the JVM starts when it begins to shut down. The Runtime Javadoc lists the triggers: the last non-daemon thread ending, System.exit, and external events such as the user typing Ctrl+C or a system-wide event such as logoff or shutdown. Hooks are started concurrently in an unspecified order, so each hook must be thread-safe and must not depend on another hook. A hook does not run if the JVM is killed with SIGKILL or calls Runtime.halt, and a hook that blocks forever stops the shutdown. Hooks must be short, must not call System.exit (that blocks, because shutdown is already in progress), and should not rely on services, such as the logging framework, that may be shutting down.

import java.util.concurrent.CountDownLatch;

public class Shelfd {
    public static void main(String[] args) throws InterruptedException {
        Thread cleanup = new Thread(() -> {
            // Runs on normal exit, System.exit, Ctrl+C, SIGTERM and SIGHUP.
            // Never runs after SIGKILL or Runtime.halt.
            System.err.println("shelfd: flushing loans and removing socket");
        }, "shelfd-cleanup");
        Runtime.getRuntime().addShutdownHook(cleanup);

        System.err.println("shelfd: ready (pid " + ProcessHandle.current().pid() + ")");
        new CountDownLatch(1).await();   // stand-in for the accept loop
    }
}

Sending three different signals to this program on macOS gave:

Signal Output Exit status

SIGTERM

the cleanup message

143

SIGHUP

the cleanup message

129

SIGKILL

nothing; the JVM was killed at once

137

Ctrl+C (SIGINT) is documented as a hook trigger too; it was not exercised in this run because a background job in a non-interactive shell ignores SIGINT, which is a pitfall in itself when testing a daemon from a script. A service manager normally stops a daemon with SIGTERM, then SIGKILL after a timeout, so the hook is the place to flush state and remove the socket file; how to wire this to systemd, launchd and Windows services is on Long-Running Processes & Services. The -Xrs option of the java tool reduces the JVM’s own use of signals, and with it the hook triggers, which matters when embedding the JVM in a program that wants to handle signals itself. There is no standard Java API to handle an individual signal; the sun.misc.Signal class lives in the jdk.unsupported module and is not a supported interface, so prefer shutdown hooks.

SIGPIPE and PrintStream

The JVM ignores SIGPIPE, so a Java program that writes to a closed pipe does not die the way a C program does. System.out is a PrintStream, which never throws IOException: it records the failure and checkError() reports it. Piping a program that prints 200,000 lines into head -1:

$ java -cp out Many | head -1
book 0
done, checkError=true

The program kept running to the end and exited successfully; the error was visible only because the program asked. A filter-style command should call System.out.checkError() periodically, or write through a FileOutputStream or BufferedWriter, which throw IOException, and stop quietly when the reader goes away; the POSIX background is on POSIX Processes, Signals & I/O.

Startup time: AppCDS, AOT cache, CRaC and GraalVM

A JVM spends its first milliseconds loading and verifying classes, then interprets bytecode until the JIT compiler warms up. For a command that runs for 100 ms and is called thousands of times, that overhead dominates. On the macOS machine used for this page, a trivial program on JDK 17 started in about 40 ms, so the cost to remove is the class loading and warm-up of a real program with frameworks, not the bare runtime. The options form a ladder from low effort to a different runtime model.

Technique What it does Cost and limits Platforms

Default CDS (-Xshare:auto)

The JDK ships an archive of its own core classes that is mapped at startup

Nothing to do; covers only JDK classes

All

AppCDS (-XX:ArchiveClassesAtExit, -XX:SharedArchiveFile)

Archives the application’s classes too, so they are not parsed and verified at each start

An archive per JDK and per class path; on JDK 17 the class path must contain JAR files, not directories

All (JDK 13 or later for the dynamic archive)

Automatic archive (-XX:+AutoCreateSharedArchive)

Creates or refreshes the dynamic archive on its own

JDK 19 or later

All

AOT cache (JEP 483, and follow-ups JEP 514 and JEP 515)

Loads and links classes ahead of time, and in later releases also records method profiles, as part of Project Leyden

Newer JDKs; read the JEPs for the exact command-line flags and what each JDK release contains

All

CRaC (Coordinated Restore at Checkpoint)

Checkpoints a warm process and restores it later

Needs a JDK build that includes it and, on Linux, checkpoint support from the operating system; not part of every JDK

Linux

GraalVM Native Image

Compiles the program ahead of time to a native executable

Closed-world analysis: reflection, resources and dynamic proxies must be declared (picocli-codegen generates them); longer builds; one executable per target platform

Linux, macOS and Windows

An AppCDS archive made on JDK 17 for a JAR and then used:

$ jar --create --file hello.jar --main-class Hello -C out Hello.class
$ java -XX:ArchiveClassesAtExit=hello.jsa -jar hello.jar
$ ls -la hello.jsa
-r--r--r--  1 user  wheel  278528 Oct 25 14:43 hello.jsa
$ java -XX:SharedArchiveFile=hello.jsa -jar hello.jar

For a program that loads only a handful of classes the archive made no measurable difference (both runs took about 39 ms), so measure with your real program, not a hello-world, before adopting the technique. Tools such as hyperfine are suited to this (see Testing Terminal Apps & TUIs). The archive is tied to the exact JDK build that created it, so regenerate it in the build, not by hand. A JVM can also be tuned for short runs with options such as -XX:TieredStopAtLevel=1, which limits the JIT to its fast compiler; the java tool reference describes every flag and warns that some options are not supported across releases.

GraalVM Native Image gives the fastest start and a self-contained executable, at the price of a closed-world build; the docsctl example on Java CLI Frameworks shows the picocli-codegen setup and a measured JVM versus native comparison, and Native Binaries & Cross-Compilation compares it with the native toolchains of the other languages. The page you are reading did not run Native Image.

A command-line tool that needs the user to install the right JDK is hard to distribute. Two JDK tools remove that requirement:

  • jlink assembles a minimal runtime image from the modules a program needs (jdeps tells you which), producing a directory with its own bin/java.

  • jpackage wraps a runtime image and the application into an app image or a native package. The package types are tied to the platform that builds them: msi or exe on Windows, pkg or dmg on macOS, deb or rpm on Linux, so one build machine per operating system is needed. The jpackage reference lists the prerequisites of each type.

$ jdeps --print-module-deps --ignore-missing-deps build/libs/shelf.jar
$ jlink --add-modules java.base,java.logging --strip-debug --no-header-files --no-man-pages --output build/runtime
$ jpackage --type app-image --name shelf --input build/libs --main-jar shelf.jar --runtime-image build/runtime

These commands were not run while the page was written. The module list printed by jdeps replaces the two modules shown. An application on the class path rather than the module path is supported by jpackage. Signing and notarization of the macOS and Windows results are not specific to Java and are covered on Signing, Notarization & OS Trust and Native Packages & Installers; the Java Reference page Build & Tooling covers the jlink and jpackage basics.

Argument parsing, REPLs and TUIs on the JVM

Argument parsing in Java is normally delegated to picocli, which is annotation driven and has a Native Image code generator. Spring Shell builds interactive shells for Spring applications, and JLine (4.4.8 at the time of writing, which requires Java 11, with an optional foreign-function provider that needs Java 22 or later) provides line editing, history, completion and terminal access, including the raw mode and window size that the class library lacks. All three are demonstrated on Java CLI Frameworks, so this page does not repeat them. Full-screen libraries for the JVM — Lanterna 3.1.5 and the newer TamboUI 0.6.0 — and Mosaic for Kotlin are compared on Other TUI Libraries: JVM, Kotlin, Node.js & Swift; the concepts they share are on TUI Architecture & Concepts.

Kotlin on the JVM: Clikt

Kotlin on the JVM compiles to the same bytecode as Java (see Kotlin & the JVM), so everything above applies unchanged, and every Java library — picocli included — can be called from Kotlin. The idiomatic Kotlin parser is Clikt, which models a command as a class whose properties are delegated to options and arguments.

// build.gradle.kts
plugins {
    kotlin("jvm") version "KOTLIN_VERSION"   // use the current Kotlin release
    application
}

repositories { mavenCentral() }

dependencies {
    implementation("com.github.ajalt.clikt:clikt:5.1.0")
}

application {
    mainClass.set("shelf.MainKt")
    applicationName = "shelf"
}
package shelf

import com.github.ajalt.clikt.core.CliktCommand
import com.github.ajalt.clikt.core.Context
import com.github.ajalt.clikt.core.ProgramResult
import com.github.ajalt.clikt.core.main
import com.github.ajalt.clikt.core.subcommands
import com.github.ajalt.clikt.parameters.arguments.argument
import com.github.ajalt.clikt.parameters.options.flag
import com.github.ajalt.clikt.parameters.options.option
import com.github.ajalt.clikt.parameters.options.required

class Shelf : CliktCommand() {
    override fun help(context: Context) = "Manage the Bookshelf"
    override fun run() = Unit
}

class Add : CliktCommand() {
    private val json by option("--json", help = "Print JSON").flag()

    override fun help(context: Context) = "Add a book"

    override fun run() {
        if (json) echo("""{"title":"1984","author":"George Orwell"}""") else echo("added 1984")
    }
}

class Loan : CliktCommand() {
    private val title by argument(help = "Title of the book")
    private val member by option("--member", "-m", help = "Member name").required()

    override fun help(context: Context) = "Lend a book to a member"

    override fun run() {
        if (title.isBlank()) {
            echo("shelf: empty title", err = true)
            throw ProgramResult(2)
        }
        echo("$member borrowed $title")
    }
}

fun main(args: Array<String>) = Shelf().subcommands(Add(), Loan()).main(args)

Build and run through the Gradle application plugin, which also produces a distribution with start scripts for Unix (bin/shelf) and Windows (bin/shelf.bat):

$ ./gradlew installDist
$ build/install/shelf/bin/shelf loan --member ada "Dune"

Clikt derives each subcommand’s name from its class name (Add becomes add). It takes care of --help, error messages for missing or invalid values, and the option parsing rules. Two things to decide explicitly: how usage errors map to exit statuses (Clikt reports them through exceptions, and ProgramResult ends the program with a chosen status), and how your command behaves when its output is not a terminal. The Clikt 5 API changed from earlier releases — help text, for instance, is provided by overriding a function, not by a constructor argument — so follow the Clikt documentation for the release you use. These Kotlin snippets were not compiled while the page was written; check imports and signatures against the documentation of the version you depend on.

Mordant and Mosaic

Clikt uses Mordant for terminal output, and Mordant can be used alone. It detects the capabilities of the terminal (colour depth, interactivity) and renders styled text, tables, progress bars and prompts that degrade to plain text; the detection can be overridden when you construct the Terminal.

import com.github.ajalt.mordant.rendering.TextColors.red
import com.github.ajalt.mordant.table.table
import com.github.ajalt.mordant.terminal.Terminal

fun printShelf(terminal: Terminal) {
    terminal.println(table {
        header { row("Title", "Author") }
        body {
            row("1984", "George Orwell")
            row("Dune", "Frank Herbert")
        }
    })
    terminal.println(red("2 books on loan"))
}

Inside a Clikt command the terminal is available from currentContext.terminal, so options such as a colour override are handled in one place. The rules for deciding when to colour (NO_COLOR, redirected output, TERM=dumb) are the ones on TTY, Colour & Interactivity, and the escape sequences that Mordant produces are explained on Escape Sequences & Terminal Capabilities. The Mordant documentation does not, in the pages consulted, guarantee which environment variables it honours, so test the behaviour you depend on with the version you ship. This snippet was not compiled.

Mosaic (0.18.0, by Jake Wharton) takes a different approach: it applies the Jetpack Compose runtime to the terminal to build a declarative, state-driven full-screen UI. It is a library for interactive programs, not for argument parsing, and is described on Other TUI Libraries: JVM, Kotlin, Node.js & Swift.

kotlinx-cli is the argument parser published under the Kotlin organization on GitHub. Its README marks the library as obsolete, so new code should pick Clikt (or picocli from Kotlin) instead. If you inherit code that uses it, it continues to work but should be expected to receive no new features; check the repository README for the current status before deciding.

Kotlin/Native executables

Kotlin/Native compiles Kotlin to a native executable through LLVM, with no JVM, no warm-up and a start time comparable to other native languages. The Kotlin Multiplatform plugin declares one target per platform: linuxX64 and linuxArm64 for Linux, macosArm64 and macosX64 for macOS, and mingwX64 for Windows. The Kotlin documentation lists which targets can be built on which host; in general Apple targets need a Mac, so a three-platform release normally uses one CI runner per operating system, as described on Native Binaries & Cross-Compilation.

// build.gradle.kts
plugins {
    kotlin("multiplatform") version "KOTLIN_VERSION"   // use the current Kotlin release
}

repositories { mavenCentral() }

kotlin {
    listOf(linuxX64(), macosArm64(), mingwX64()).forEach { target ->
        target.binaries.executable {
            baseName = "shelf"
        }
    }
}
// src/nativeMain/kotlin/main.kt
import kotlinx.cinterop.toKString
import platform.posix.STDOUT_FILENO
import platform.posix.getenv
import platform.posix.isatty

fun main(args: Array<String>) {
    val colour = getenv("NO_COLOR") == null && isatty(STDOUT_FILENO) != 0
    val title = if (colour) "\u001B[1mShelf\u001B[0m" else "Shelf"
    println(title)
    println("arguments: ${args.joinToString()}")
    println("HOME = ${getenv("HOME")?.toKString() ?: "(unset)"}")
}

platform.posix exposes the C library of the target through the Kotlin/Native interop tooling, so calls such as getenv, isatty, write and signal are available with C semantics and pointers (kotlinx.cinterop types). The set of functions depends on the target’s C headers: mingwX64 follows the Windows environment and lacks POSIX-only functions, so platform-specific code goes into expect and actual declarations in the Kotlin Multiplatform source sets, with a Windows implementation built on the Win32 API (see Windows Console & Processes). Callbacks passed to C, such as a signal handler, are created with staticCFunction, which cannot capture variables; a handler may only do the async-signal-safe work described in POSIX Processes, Signals & I/O. The Gradle task linkReleaseExecutableMacosArm64 (and its siblings for the other targets) builds a release executable under build/bin/; the file has the extension .kexe on Linux and macOS and .exe on Windows.

None of this was compiled while the page was written. Kotlin/Native has fewer libraries than the JVM: Clikt supports Kotlin Multiplatform targets, but check the documentation for the targets you need before assuming a library you use on the JVM is available.

Choosing between the options

Need Choose Why

Quick script, no build

Source-file launcher (java File.java)

No compile step to maintain; JDK required; dependencies need a class path

Rich tool, many libraries, long-running

JAR plus Clikt or picocli, jlink or jpackage image

Full JVM ecosystem; startup cost accepted

Short-lived command called often

GraalVM Native Image, or Kotlin/Native

Start in milliseconds; closed-world limits or smaller library set

Daemon like shelfd

JVM with shutdown hook, service manager integration

Startup cost is paid once; hook flushes state on SIGTERM

Interactive shell

JLine (Java) or Mosaic (Kotlin)

Terminal access the class library lacks

Pitfalls

  • Assuming System.console() means "terminal". It is null for redirected streams on older JDKs; on JDK 22 and later test isTerminal() as well.

  • Blocking on waitFor() before reading output. A full pipe makes the child wait for the parent, and the parent waits for the child.

  • Passwords in String. Use readPassword, wipe the array and keep secrets out of command-line arguments, which other users can read in the process list.

  • Trusting shutdown hooks. They run after SIGTERM and Ctrl+C but not after SIGKILL; make cleanup idempotent so a crash recovery path can finish the work.

  • Ignoring a closed pipe. The JVM ignores SIGPIPE; a PrintStream hides the error. Call checkError() in filters.

  • Measuring startup with hello-world. Archive and AOT techniques help programs that load many classes; measure the real program on every platform you ship.

  • Building native packages on one machine. jpackage and Kotlin/Native Apple targets need the target operating system.