Kotlin CLI Tool Project — Build a Command-Line Application
In this tutorial, you will learn about Kotlin CLI Tool Project. We cover key concepts, practical examples, and best practices to help you master this topic.
A Kotlin CLI tool project demonstrates practical application development with argument Parsing, file system operations, Coroutine-based async processing, and native-image compilation for fast command-line execution.
What You'll Learn
- Structure a CLI project with Kotlin
- Parse command-line arguments with kotlinx-cli
- Read and write files
- Use coroutines for concurrent file processing
- Handle errors gracefully with user-friendly messages
- Compile to native binary with GraalVM
- Publish to package managers
Why It Matters
CLI tools automate repetitive tasks: file processing, data transformation, system administration, and build automation. Kotlin is excellent for CLI tools because it has Java's library ecosystem, coroutines for async operations, and GraalVM for native compilation that starts instantly.
Real-World Use
DodaTech uses a Kotlin CLI tool for batch processing malware signature files, converting between formats, and validating signature integrity before uploading to the server. The native binary distributes as a single executable.
Learning Path
flowchart LR A[Dependency Injection] --> B[CLI Tool Project\nYou are here] B --> C[Android App Project] style B fill:#f90,color:#fff
Project Setup
// build.gradle.kts
plugins {
kotlin("jvm") version "2.0.21"
kotlin("plugin.serialization") version "2.0.21"
id("org.jetbrains.kotlinx.binary-compatibility-validator") version "0.16.3"
}
dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-cli:0.3.6")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.1")
implementation("com.github.ajalt.mordant:mordant:2.4.0") // Terminal styling
}
application {
mainClass.set("com.example.cli.MainKt")
}
// For GraalVM native image
tasks.register<JavaExec>("nativeImage") {
// Uses GraalVM native-image tool
}
Argument Parsing with kotlinx-cli
import kotlinx.cli.*
class FileProcessorArgs(parser: ArgParser) {
val inputFile by parser.option(
ArgType.String,
description = "Input file path"
).required()
val outputFile by parser.option(
ArgType.String,
description = "Output file path"
).default("output.txt")
val verbose by parser.option(
ArgType.Boolean,
description = "Enable verbose output"
).default(false)
val mode by parser.option(
ArgType.Choice<ProcessingMode>(),
description = "Processing mode"
).default(ProcessingMode.COUNT)
val threads by parser.option(
ArgType.Int,
description = "Number of threads"
).default(4)
val files by parser.addArgument(
ArgType.String,
description = "Files to process",
vararg = true
).default(listOf())
}
enum class ProcessingMode {
COUNT, EXTRACT, VALIDATE, TRANSFORM
}
Main Application
import kotlinx.cli.*
import kotlinx.coroutines.*
import java.io.File
import kotlin.system.exitProcess
fun main(args: Array<String>) {
val parser = ArgParser("file-processor")
val arguments = FileProcessorArgs(parser)
try {
parser.parse(args)
} catch (e: Exception) {
System.err.println("Error: ${e.message}")
parser.printHelp()
exitProcess(1)
}
val app = FileProcessorApp(arguments)
app.run()
}
class FileProcessorApp(private val args: FileProcessorArgs) {
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
fun run() {
println("File Processor v1.0")
println("Mode: ${args.mode}")
println("Input: ${args.inputFile}")
try {
when (args.mode) {
ProcessingMode.COUNT -> countLines()
ProcessingMode.EXTRACT -> extractLines()
ProcessingMode.VALIDATE -> validateFile()
ProcessingMode.TRANSFORM -> transformFile()
}
} catch (e: Exception) {
System.err.println("Fatal error: ${e.message}")
if (args.verbose) {
e.printStackTrace()
}
exitProcess(1)
}
}
private fun countLines() {
val file = File(args.inputFile)
if (!file.exists()) {
System.err.println("File not found: ${args.inputFile}")
exitProcess(1)
}
val totalLines = file.useLines { it.count() }
val nonEmptyLines = file.useLines { it.count { line -> line.isNotBlank() } }
val commentLines = file.useLines { it.count { line -> line.trimStart().startsWith("//") } }
println("File: ${file.name}")
println("Total lines: $totalLines")
println("Non-empty lines: $nonEmptyLines")
println("Comment lines: $commentLines")
println("Code lines: ${nonEmptyLines - commentLines}")
}
private fun extractLines() {
val file = File(args.inputFile)
val outputFile = File(args.outputFile)
val pattern = Regex("""\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b""")
val matches = file.useLines { lines ->
lines.flatMap { line ->
pattern.findAll(line).map { it.value }
}.distinct().toList()
}
outputFile.writeText(matches.joinToString("\n"))
println("Extracted ${matches.size} unique email addresses to ${args.outputFile}")
}
private fun validateFile() {
val file = File(args.inputFile)
val errors = mutableListOf<String>()
file.useLines { lines ->
lines.forEachIndexed { index, line ->
if (line.isBlank()) return@forEachIndexed
if (line.length > 1000) {
errors.add("Line ${index + 1}: Exceeds 1000 characters (${line.length})")
}
if (line.contains("\t")) {
errors.add("Line ${index + 1}: Contains tab character (use spaces)")
}
}
}
if (errors.isEmpty()) {
println("Validation passed: No issues found")
} else {
println("Validation found ${errors.size} issues:")
errors.forEach { println(" - $it") }
}
}
private suspend fun processFileAsync(job: ProcessJob): ProcessResult = withContext(Dispatchers.IO) {
// Simulated async processing
delay(500)
ProcessResult(job.path, true, "Processed successfully")
}
private fun transformFile() {
val file = File(args.inputFile)
val outputFile = File(args.outputFile)
val transformed = file.useLines { lines ->
lines.map { line ->
line
.trimEnd()
.replace(Regex("\\s+"), " ")
}.filter { it.isNotBlank() }
.mapIndexed { index, line -> "${index + 1}: $line" }
.toList()
}
outputFile.writeText(transformed.joinToString("\n"))
println("Transformed ${transformed.size} lines to ${args.outputFile}")
}
}
data class ProcessJob(val path: String, val priority: Int = 0)
data class ProcessResult(val path: String, val success: Boolean, val message: String)
Concurrent File Processing with Coroutines
private fun processFilesConcurrently(files: List<String>) = runBlocking {
val semaphore = Semaphore(args.threads)
val results = mutableListOf<ProcessResult>()
files.map { filePath ->
async {
semaphore.withPermit {
processFileAsync(ProcessJob(filePath))
}
}
}.awaitAll().forEach { result ->
results.add(result)
if (args.verbose) {
println("${result.path}: ${result.message}")
}
}
val successCount = results.count { it.success }
val failCount = results.count { !it.success }
println("\nSummary:")
println(" Processed: ${results.size}")
println(" Succeeded: $successCount")
println(" Failed: $failCount")
}
Error Handling and User Experience
class CliException(message: String, val exitCode: Int = 1) : Exception(message)
fun handleCliError(error: CliException) {
when (error.exitCode) {
1 -> System.err.println("Error: ${error.message}")
2 -> System.err.println("Usage error: ${error.message}")
3 -> System.err.println("File error: ${error.message}")
}
exitProcess(error.exitCode)
}
Building a Native Binary
# Build the fat JAR
./gradlew shadowJar
# Run with Java
java -jar build/libs/file-processor-all.jar --help
# With GraalVM native-image (after installing GraalVM)
native-image -jar build/libs/file-processor-all.jar file-processor
# Run the native binary
./file-processor --input-file data.txt --mode count
Common Mistakes
Not handling file encoding: Default encoding varies by platform. Always specify encoding:
file.readText(Charsets.UTF_8).Blocking the main thread: Even in CLI tools, use coroutines for I/O operations to allow cancellation and progress reporting.
Poor error messages: Users need to know what went wrong and how to fix it. Include file paths, line numbers, and suggestions.
Not using exit codes: Use exit codes (0 for success, non-zero for errors) to enable scripting and piping.
Forgetting --help: All CLI tools should provide a help message. kotlinx-cli generates this automatically.
Large file handling: Reading entire files into memory causes OOM. Use lines sequences or buffered readers.
Practice Questions
- How does kotlinx-cli handle required versus optional arguments?
Answer: Required arguments use .required() on the option. Optional arguments specify .default() or .optional(). The parser throws if required arguments are missing.
- How do you read a large file line by line without loading it entirely into memory?
Answer: Use File.useLines { lines -> lines.map { Process(it) }.toList() }. The useLines function reads lines lazily and closes the reader automatically.
- What exit code should a CLI tool return on success?
Answer: 0. Non-zero exit codes indicate specific errors (1 for general, 2 for usage errors).
- How do you build a native binary with GraalVM?
Answer: Install GraalVM, build a fat JAR with shadowJar, then run native-image -jar <jar> <name> to produce a native executable.
- Challenge: Extend the CLI tool with a --watch mode that monitors a directory for new files and processes them automatically. Use Kotlin's WatchService and coroutines.
Answer:
import java.nio.file.*
fun watchDirectory(path: Path) = runBlocking {
val watchService = FileSystems.getDefault().newWatchService()
path.register(
watchService,
StandardWatchEventKinds.ENTRY_CREATE,
StandardWatchEventKinds.ENTRY_MODIFY
)
println("Watching $path for new files...")
while (isActive) {
val key = watchService.poll(1, TimeUnit.SECONDS)
key?.pollEvents()?.forEach { event ->
val fileName = event.context() as Path
println("New file detected: $fileName")
launch {
processFileAsync(ProcessJob(path.resolve(fileName).toString()))
}
}
key?.reset()
}
}
Mini Project
Build a log analyzer CLI tool. Requirements:
- Parse log files with timestamps, levels (INFO, WARN, ERROR), and messages
- Count occurrences by level
- Filter by date range, level, or keyword
- Output summary statistics
- Export results to JSON or CSV
- Watch mode for live log monitoring
- Color-coded output for ERROR/WARN/INFO levels
- Concurrent processing for multiple log files
- Native binary build with GraalVM
This project applies all CLI tool patterns in a practical and useful application.
FAQ
What's Next
After building the CLI tool, build an Android app to apply mobile development skills. You can also explore Ktor for server-side development.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro