Skip to Content
Course content

178: Building CLIs with Scala and decline

Click on the "Edit" button in the top corner of the screen to edit your slide content.

Imagine you're designing a high-end vending machine. You don't want the customer to just shove a random piece of paper into a slot and hope the machine knows what they want. Instead, you provide a clear interface: buttons for specific snacks, a coin slot that only accepts certain denominations, and a small screen that tells the user exactly why their transaction failed if they didn't provide enough change. You've essentially built a contract between the user and the machine.

Building a Command Line Interface (CLI) is exactly the same. Most of us start by manually hacking away at the args: Array[String] passed into the main method, using a bunch of if/else blocks to check for --verbose or -f. It's messy, it's error-prone, and you have to manually write the help text. That's where decline comes in. It lets you define that "vending machine contract" as a Scala case class, and it handles the parsing, validation, and help-menu generation for you.

Defining the Contract with Case Classes

In decline, your CLI options aren't just strings; they are typed values. Let's say we're building a tool to scan a directory for oversized log files and archive them. We need a path to the folder, a maximum file size, and a flag to determine if we should actually move the files or just list them (dry run).

import com.monovore.decline._
import cats.imprints._

case class LogArchiveConfig(
  path: String,
  maxSizeMb: Int,
  dryRun: Boolean
)

By putting these in a case class, we've created a single source of truth for what our program needs to run. Now we just need to tell decline how to map command-line strings into this class.

Mapping Flags to Values

Here is where the magic happens. We create an Opts object. I like to think of this as the "wiring" phase. We map specific flags (like -s or --size) directly to the fields in our case class.

val archiveOpts = Opts.apply {
  (
    path: String,
    maxSize: Int,
    dryRun: Boolean
  ) => LogArchiveConfig(path, maxSize, dryRun)
}.withArgs(
  Opts.argument[String]("path", help = "The directory containing logs to scan"),
  Opts.option[Int]("size", short = "s", help = "Max file size in MB before archiving").withDefault(100),
  Opts.flag("dry-run", help = "List files without moving them")
)

Notice a few things here. The path is a positional argument—it's required and doesn't need a flag. The size is an option with a default value of 100, and dry-run is a simple boolean flag. If a user tries to pass "abc" instead of a number for the size, decline will automatically stop the program and print a helpful error message without you writing a single try-catch block.

Running the Parser

Once the wiring is done, you just need to execute the parser. I usually wrap this in a small main object. The parse method takes the raw args and returns an Either. If it's a Left, decline has already formatted a beautiful error message or help screen for the user; you just need to print it and exit.

object LogArchiver extends Command {
  def main(args: Array[String]): Unit = {
    archiveOpts.parse(args) match {
      case Left(error) => 
        System.err.println(error)
        sys.exit(1)
      case Right(config) => 
        println(s"Scanning ${config.path} for files larger than ${config.maxSizeMb}MB...")
        if (config.dryRun) println("Dry run enabled: no files will be moved.")
        // Your business logic goes here
    }
  }
}

I've spent way too many hours of my career writing custom while loops to parse Array[String]. Using a library like decline isn't just about saving keystrokes; it's about ensuring that your tool behaves predictably. When you provide a professional-looking --help output and strict type validation, your tools feel like first-class software rather than a collection of scripts.




📋 Practical Task

Build a "Project Dependency Auditor" CLI

Your task is to build a CLI tool using decline that mimics a dependency auditor. The tool should accept the following inputs:

  • A positional argument: projectPath (String) - The path to the project root.
  • An option: --severity or -s (String) - A filter for vulnerability severity (e.g., "High", "Critical"). This should have a default value of "Low".
  • A flag: --json - A boolean flag that, when present, tells the program to output results in JSON format instead of plain text.

Requirements:

  1. Create a case class AuditorConfig to hold these three values.
  2. Define the Opts parser to map the command-line arguments to that case class.
  3. Implement the main method to parse the arguments and print a confirmation message like: "Auditing project at [path] for [severity] vulnerabilities. Format: [JSON/Text]".
  4. Ensure that if the user provides an invalid argument, the program prints the decline error and exits with a non-zero status code.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.