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 |
|
Same as Linux |
Same API; the documented default implementation checks whether the standard handles are character devices |
Arguments |
|
Same as Linux |
|
Signals |
|
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 |
Same as Linux |
The status the JVM chooses; there is no signal-based status |
Writing to a closed pipe |
|
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
Source: Java SE 25 — java.io.Console
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 |
|---|---|
|
The program and its arguments, as separate strings. No shell is involved, so no globbing and no quoting is needed. |
|
Working directory and a mutable copy of the environment for the child. |
|
|
|
Shortcut for |
|
Merges standard error into standard output, so one reader suffices. |
|
Start one process or a pipeline in which each process’s output feeds the next, without a shell. |
|
Wait for, and read, the exit status. |
|
Terminate the process. On Unix |
|
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 withredirectErrorStream(true), redirect to a file, or useinheritIO(). The example above reads to end of stream first, which means a timeout onwaitForwould 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 calledshelf 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
PATHof the JVM’s environment on Unix. On Windows the process is created withCreateProcess, which runs executables; scripts such as.cmdand.batare normally started throughcmd /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 sendsSIGTERMon Unix, which a well-behaved child handles; on Windows there is no equivalent signal, which is whatsupportsNormalTermination()reports. Escalate todestroyForcibly()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
PIPEthe child sees a pipe and typically turns off colour and prompts; withinheritIO()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 |
|---|---|---|
|
the cleanup message |
143 |
|
the cleanup message |
129 |
|
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 ( |
The JDK ships an archive of its own core classes that is mapped at startup |
Nothing to do; covers only JDK classes |
All |
AppCDS ( |
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 ( |
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.
Shipping without a JVM installation: jlink and jpackage
A command-line tool that needs the user to install the right JDK is hard to distribute. Two JDK tools remove that requirement:
-
jlinkassembles a minimal runtime image from the modules a program needs (jdepstells you which), producing a directory with its ownbin/java. -
jpackagewraps 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:msiorexeon Windows,pkgordmgon macOS,deborrpmon Linux, so one build machine per operating system is needed. Thejpackagereference 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.
Source: Java SE 25 — jlink, Java SE 25 — jpackage
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.
Source: Clikt documentation, Gradle — The Application Plugin, Kotlin — Working with the command-line compiler
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.
Source: Mordant documentation, Mosaic on GitHub
Why kotlinx-cli is not recommended
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.
Source: kotlinx-cli on GitHub
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 ( |
No compile step to maintain; JDK required; dependencies need a class path |
Rich tool, many libraries, long-running |
JAR plus Clikt or picocli, |
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 |
JVM with shutdown hook, service manager integration |
Startup cost is paid once; hook flushes state on |
Interactive shell |
JLine (Java) or Mosaic (Kotlin) |
Terminal access the class library lacks |
Pitfalls
-
Assuming
System.console()means "terminal". It isnullfor redirected streams on older JDKs; on JDK 22 and later testisTerminal()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. UsereadPassword, 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
SIGTERMand Ctrl+C but not afterSIGKILL; make cleanup idempotent so a crash recovery path can finish the work. -
Ignoring a closed pipe. The JVM ignores
SIGPIPE; aPrintStreamhides the error. CallcheckError()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.
jpackageand Kotlin/Native Apple targets need the target operating system.