1 The Problem
We want a real command-line tool: tasks add "Buy milk", tasks list, tasks done 2 — commands and arguments, just like git or npm. It teaches argparse, the proper way to build CLI tools that feel professional, not like a toy menu.
2 How to Think About It
Think about commands and arguments, before any code:
add, list, done. → 2. Each takes arguments (the task text, or which number). → 3. args: Array<String> carries each subcommand’s own arguments, parsed by hand — Kotlin has no built-in flag parser, so a real CLI tool typically reaches for a small library once the argument list grows. → 4. Tasks persist in a file between commands. This is how real CLI tools are structured.
3 The Build — explained part by part
Here is the complete task manager. Read each part’s note below — you should understand the whole thing from the notes alone.
import java.io.File
/** One task: a permanent id, done or not, plus its text. */
data class Task(val id: Int, val done: Boolean, val text: String)
/** The saved state: the tasks, plus the id the next "add" should use. */
data class State(val nextId: Int, val tasks: List<Task>)
/** A parsed command line. A sealed class lets the compiler prove every case below is handled. */
sealed class Command {
data class Add(val text: String) : Command()
object List : Command()
data class Complete(val id: Int) : Command()
data class Remove(val id: Int) : Command()
data class Unknown(val raw: String) : Command()
}
fun parse(args: Array<String>): Command {
if (args.isEmpty()) return Command.Unknown("")
return when (args[0]) {
"add" -> Command.Add(args.drop(1).joinToString(" "))
"list" -> Command.List
"complete" -> args.getOrNull(1)?.toIntOrNull()?.let { Command.Complete(it) }
?: Command.Unknown("complete")
"remove" -> args.getOrNull(1)?.toIntOrNull()?.let { Command.Remove(it) }
?: Command.Unknown("remove")
else -> Command.Unknown(args[0])
}
}
/**
* Reads the saved counter from the file's first line, so an id is never reused even
* after the task holding it is removed — only the list's current max would miss that.
*/
fun load(file: File): State {
if (!file.exists()) return State(1, emptyList())
val lines = file.readLines().filter { it.isNotBlank() }
if (lines.isEmpty()) return State(1, emptyList())
val tasks = lines.drop(1).map { line ->
val (id, flag, text) = line.split("|", limit = 3)
Task(id.toInt(), flag == "1", text)
}
return State(lines[0].toInt(), tasks)
}
fun save(file: File, state: State) {
val lines = listOf(state.nextId.toString()) +
state.tasks.map { "${it.id}|${if (it.done) "1" else "0"}|${it.text}" }
file.writeText(lines.joinToString("\n"))
}
/** Applying one command: the updated state, plus a message to print. */
data class Result(val state: State, val message: String)
/** Applies [cmd] to [state] — a pure function, directly testable with no file I/O involved. */
fun apply(cmd: Command, state: State): Result = when (cmd) {
is Command.Add -> {
val id = state.nextId
val updated = state.tasks + Task(id, false, cmd.text)
Result(State(id + 1, updated), "Added task #$id.")
}
is Command.List -> {
val listing = state.tasks.joinToString("\n") { "#${it.id} [${if (it.done) "x" else " "}] ${it.text}" }
Result(state, listing)
}
is Command.Complete -> {
val found = state.tasks.any { it.id == cmd.id }
val updated = state.tasks.map { if (it.id == cmd.id) it.copy(done = true) else it }
Result(state.copy(tasks = updated), if (found) "Completed #${cmd.id}." else "No task #${cmd.id}.")
}
is Command.Remove -> {
val updated = state.tasks.filter { it.id != cmd.id }
val found = updated.size != state.tasks.size
Result(state.copy(tasks = updated), if (found) "Removed #${cmd.id}." else "No task #${cmd.id}.")
}
is Command.Unknown -> Result(state, "Unknown command: ${cmd.raw}")
}
fun main(args: Array<String>) {
val file = File("tasks.db")
val state = load(file)
val result = apply(parse(args), state)
save(file, result.state)
println(result.message)
}kotlinc on your own machine instead; the “Run It” section explains exactly how.Add/List/Complete/Remove/Unknown — sealing the class closes the hierarchy to exactly these cases, so a when over a Command with no else branch is checked exhaustively by the compiler: add a new subcommand and every unhandled when becomes a compile error, not a silent gap.data class State(val nextId: Int, val tasks: List<Task>) — the saved counter travels alongside the tasks, not recomputed from them. That distinction matters: see “If it breaks” below for a real bug this design choice avoids.
fun apply(cmd: Command, state: State): Result — the entire command-handling logic as one pure function, exactly like the REST API project's
handle: a Command and a State in, a new State and a message out, with no file I/O anywhere nearby.args.getOrNull(1)?.toIntOrNull()?.let { Command.Complete(it) } ?: Command.Unknown("complete") — a chain of nullable operations: get the second argument if it exists, parse it as a number if it parses, and if either step fails, fall through to the Elvis operator's right-hand side — no nested
if statements required.
(tasks.maxOfOrNull { it.id } ?: 0) + 1 from the current task list.State.nextId) rather than recomputing it from whatever tasks happen to remain — see the test below that specifically guards against this.complete or remove with an id that does not exist, and getting a crash instead of a clear message.args.drop(1).joinToString(" ") rejoins every word after the subcommand with a single space, recovering "Buy milk" from the separate arguments ["Buy", "milk"] an unquoted command line actually produces.4 Test & Prove Each Part
How do we know this works? We pull the real logic into small, plain functions and check each one against cases we already know the answer to.
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
import kotlin.test.assertIs
class CliTaskManagerTest {
@Test
fun parsesAddWithMultiWordText() {
val cmd = parse(arrayOf("add", "Buy", "milk"))
assertIs<Command.Add>(cmd)
assertEquals("Buy milk", cmd.text)
}
@Test
fun parsesCompleteWithAnId() {
val cmd = parse(arrayOf("complete", "3"))
assertEquals(Command.Complete(3), cmd)
}
@Test
fun missingArgumentIsUnknown() {
assertEquals(Command.Unknown("remove"), parse(arrayOf("remove")))
}
@Test
fun addingAssignsTheNextId() {
val result = apply(Command.Add("Buy milk"), State(1, emptyList()))
assertEquals(listOf(Task(1, false, "Buy milk")), result.state.tasks)
assertEquals("Added task #1.", result.message)
}
@Test
fun idsAreNeverReusedAfterARemoval() {
val afterAdd = apply(Command.Add("a"), State(1, emptyList())).state
val afterRemove = apply(Command.Remove(1), afterAdd).state
val afterSecondAdd = apply(Command.Add("b"), afterRemove).state
assertEquals(2, afterSecondAdd.tasks.single().id)
}
@Test
fun completingAnUnknownIdSaysSo() {
val result = apply(Command.Complete(99), State(1, emptyList()))
assertTrue(result.message.contains("No task"))
}
@Test
fun aFreshFileStartsCountingAtOne() {
val state = load(java.io.File("/tmp/__kt_cli_task_manager_test_missing__.db"))
assertEquals(1, state.nextId)
assertEquals(emptyList(), state.tasks)
}
}Compile with kotlinc cliTaskManager.kt cliTaskManagerTest.kt -include-runtime -d cliTaskManager.jar and run with JUnit's own runner. parse and apply take plain arguments and a State, so the tests never touch tasks.db or real command-line arguments at all — including the id-reuse test that exposed the bug described above.
5 The Interface
What it expects
go run tasks.go add "Buy milk"What it returns
1. [ ] Buy milk
2. [x] Walk dog6 Run It & Automate It
Save the code as cliTaskManager.kt and compile it with kotlinc cliTaskManager.kt -include-runtime -d cliTaskManager.jar, then run the result with extra arguments after it.
kotlinc cliTaskManager.kt -include-runtime -d cliTaskManager.jar && java -jar cliTaskManager.jar add "Buy milk"Each command (add / list / complete / remove) is a separate invocation, just like a real CLI tool.
A CI tool like Jenkins compiles and tests automatically whenever the code changes — every line below has a plain explanation.
$ java -jar cliTaskManager.jar add "Buy milk"
Added task #1.
$ java -jar cliTaskManager.jar add "Walk dog"
Added task #2.
$ java -jar cliTaskManager.jar list
#1 [ ] Buy milk
#2 [ ] Walk dog
$ java -jar cliTaskManager.jar remove 2
Removed #2.
$ java -jar cliTaskManager.jar add "Read book"
Added task #3.
$ java -jar cliTaskManager.jar list
#1 [ ] Buy milk
#3 [ ] Read bookparse only recognises add, list, complete, and remove exactly, all lowercase.tasks.db's first line (the saved counter) got out of sync with the tasks below it — check that save(file, result.state) runs after every command, writing both the counter and the tasks together.// Jenkinsfile — compiles and tests automatically every time the code changes.
pipeline {
agent any // run on any available machine
stages {
stage('Get the code') {
steps { checkout scm } // download the latest code
}
stage('Set up Kotlin') {
steps {
sh 'kotlinc -version' // confirm the compiler is installed
}
}
stage('Compile and test') {
steps {
sh 'kotlinc cliTaskManager.kt cliTaskManagerTest.kt -include-runtime -d build.jar' // one real JVM jar, no build tool required
sh 'java -cp build.jar:kotlin-test-junit.jar:junit.jar org.junit.runner.JUnitCore CliTaskManagerTest'
}
}
}
post {
success { echo 'All tests passed.' }
failure { echo 'A test failed — look above.' }
}
}
You have a working cli task manager. Extend it:
- Validate the id more strictly. Print a specific error for a non-numeric argument to
complete/removeinstead of falling through to Unknown. (Teaches: distinguishing “wrong shape” from “just not recognised”.) - Add an
undocommand. Keep the previousStatearound for one step back. (Teaches: a small history stack.) - Support flags. Let
list --doneshow only completed tasks. (Teaches: scanning the trailing arguments for a flag.) - Colour the output. Print completed tasks in green using ANSI escape codes. (Teaches: terminal escape sequences, no library required.)
sealed class for an exhaustively-checked set of commands, why a persisted counter beats recomputing a “max so far” from data that can shrink, and chaining nullable operations with ?.let { } and ?: instead of nested if statements. Related reference: Sealed Classes in Kotlin, Data Classes.