Building a CLI tool using Swift and Vapor's Console module Jul 30 2019

Building a command-line interface (CLI) tools is a complicated task. We work on the logic of our application and also have to deal with other details like parsing parameters, handling the correct display directives depending on the TTY, etcetera. Vapor, the web framework, uses a module called Console (called ConsoleKit on V4) to build their CLI.

Vapor’s command-line tool provides a lot of functionality, and at the same time, it looks quite smooth. I would like my CLI tools to look more like their command-line tool.

The good news is that we can also make use of the Console module in our CLI projects. In this post, I’ll explain how to use Console to build a better command-line experience for your CLI tools.

First, we are going to have a glance at Vapor’s approach. This will give us context on how to set up our CLI tool using Console. Let’s begin.

How Vapor works

I’ll do a significant oversimplification of how Vapor works. This post is not an in-depth study of Vapor, but what I’ll explain will help us understand how to incorporate Vapor’s Console module on our CLIs. Let’s get to it.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Container
+-------------------------------------------------+
| +---------------------------------------------+ |
| |               |               |             | |
| |               |               |             | |
| |               |               |             | |
| |               |               |             | |
| |   Service     |   Service     |   Service   | |
| |               |               |             | |
| |               |               |             | |
| |               |               |             | |
| |               |               |             | |
| |               |               |             | |
| +---------------+---------------+-------------+ |
|  CommandConfig                                  |
+-------------------------------------------------+
|                                                 |
|                   EVENT LOOP                    |
|                                                 |
+-------------------------------------------------+

A Vapor application provides services, that is the main idea. Those services are grouped together into a Container object. The Container includes a collection of services, their configurations, an environment property (i.e. development, staging, production, testing, etc.) and an Event Loop.

The services provide functionality. The CommandConfig keeps track of registered services and their settings.

The environment property helps us make decisions on the intent of the current running services. For example, we can decide only to log “debug” messages when in development, and only log error messages when on staging and production.

The EventLoop is, in simple terms, a loop that process inputs and outputs, managing the state of the session. For example, it could be a loop that listens on port 80 for all incoming REST messages, or it could be a loop waiting for STDIN input and displaying messages to STDOUT. “Things” that have access to an EventLoop are called Workers, so every Container is a Worker.

In general, we have a container where the services run.

Let’s put all those pieces together and build a command-line tool.

Building a CLI tool to display system information

We’ll start with something simple, a CLI that uses basic system commands to get the information. We will use the following commands: pmset, ifconfig, and df (battery information, network information, and disk space). We will call our application Estatus (naming is hard).

Our application workflow

Our app will work in the following way. First, if we run our application without any parameters, it’ll show us the percentage of our battery, that will be the default behaviour. Then we can send it additional parameters to indicate which “service” we want. For example, ifconfig to get network information, disk for disk space information and batt for our battery information.

Let’s begin.

Creating our app

Using the Swift Package Manager (SPM), we are going to create an application:

1
2
3
4
$ mkdir estatus && cd $_
# We could have mkdir Estatus and then type cd estatus
# but I always like to share this fun tricks
$ swift package init --type executable

Now let’s add our dependencies we are going to use Console, so we need that dependency. Also, we will need to add Command which comes as part of Console. Our Package.swift file will look like this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// swift-tools-version:5.0
// The swift-tools-version declares the minimum version of Swift required to build this package.

import PackageDescription

let package = Package(
    name: "Estatus",
    dependencies: [
      .package(url: "https://github.com/vapor/console.git", from: "3.0.0"),
    ],
    targets: [
        .target(
            name: "Estatus",
            dependencies: ["Console", "Command"]),
        .testTarget(
            name: "EstatusTests",
            dependencies: ["Estatus"]),
    ]
)

That sets our dependencies. Now we’ll start by creating our default service, battery status. If you are on a desktop computer you can use any other command, maybe display your computer’s uname. Create the file BatteryCommand.swift inside the Sources/Estatus directory, and add the following content:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
import Foundation
import Command

struct BatteryCommand: Command {

    var arguments: [CommandArgument] {
        return []
    }

    var options: [CommandOption] {
        return []
    }
    var help: [String] {
      return ["Usage:", "Estatus batt"]
    }

    func run(using context: CommandContext) throws -> Future<Void> {
      let pmset = Process()
      let pipe = Pipe()
      if #available(OSX 13, *) {
        pmset.executableURL = URL(fileURLWithPath: "/usr/bin/env")
      } else {
        pmset.launchPath = "/usr/bin/env"
      }
      pmset.arguments = ["pmset", "-g", "batt"]
      pmset.standardOutput = pipe
      do {
      if #available(OSX 13, *) {
        try pmset.run()
      } else {
        pmset.launch()
      }
        pmset.waitUntilExit()
        let data = pipe.fileHandleForReading.readDataToEndOfFile()
        if let output = String(data: data, encoding: String.Encoding.utf8) {
          context.console.print(output)
        }
      }
      return .done(on: context.container)
    }
}

That is our first service, it calls the pmset -g batt command. The program structure is very self descriptive. Our service doesn’t receive any arguments or options, so they are empty. The help array includes the text that is going to be shown when the –help option is received. And finally, the run function is where our service logic is defined.

Now let’s work on our main.swift and set up our BatteryCommand.

Setting up Console

Open the main.swift file, it is located inside the Estatus/Sources/Estatus/ directory. We are going to use Console and Command modules so import them:

1
2
import Console
import Command

Our Services run inside a container. So we are going to create a Container, Vapor includes a Basic implementation of Container, we’ll use that:

1
let container = BasicContainer(config: Config(), environment: Environment.production, services: Services(), on: EmbeddedEventLoop())

EmbeddedEventLoop, as the Swift NIO documentation explains, is a basic loop, with no proper event mechanism (the most basic event-loop). We are going to create the CommandConfig, where we configure our services.

1
2
var commandConfig = CommandConfig()
commandConfig.use(Batterycommand(), as: "batt", isDefault: true)

Notice that we passed the parameter isDefault to be true because BatteryCommand is going to be our default command. Now we have to register the services on our Container:

1
container.services.register(commandConfig)

We are going to use the Terminal object provided by Console to run the container.

The run function of Terminal (Terminal is an implementation of Console) needs a runnable object, we can get that from our CommandConfig resolve function. So lets get that object:

1
let group = try commandConfig.resolve(for: container).group()

That is the runnable object we are going to send to our Terminal object for running. Another data needed for the Terminal object to run is the arguments received by the script. Let’s obtain the arguments.

1
var commandInput = CommandInput(arguments: arguments)

Now we are ready to create the Terminal object and run the command.

1
2
let terminal = Terminal()
try terminal.run(group, input: &commandInput, on: container).wait()

And that would be it, our whole main file will look like this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import Console
import Command

let container = BasicContainer(config: Config(), environment: Environment.production, services: Services(), on: EmbeddedEventLoop())

var commandConfig = CommandConfig()
commandConfig.use(BatteryCommand(), as: "batt", isDefault: true)
container.services.register(commandConfig)

let group = try commandConfig.resolve(for: container).group()

var commandInput = CommandInput(arguments: CommandLine.arguments)
let terminal = Terminal()
try terminal.run(group, input: &commandInput, on: container).wait()

Now we can build and run our application:

1
2
3
4
$ swift build
$ swift run
# or we could run it directly
#./.build/x86_64-apple-macosx/debug/Estatus

You should see the output of the default command, something like:

1
2
3
Running default command: ./.build/x86_64-apple-macosx/debug/Estatus batt
Now drawing from 'AC Power'
 -InternalBattery-0 (id=4063331)        100%; charged; 0:00 remaining present: true

Now let’s add more services.

Adding more services

We are going to add two more services, one to show how to use optional parameters, and another that uses required arguments. Let’s start with the one using optional parameters.

A service that uses optional parameters

We are going to create a wrapper for ifconfig, it will accept an optional parameter for the interface name. The service can either be called by long name --interface INTERFACE_NAME or short name -i INTERFACE_NAME. Create the file IfconfigCommand.swift inside Sources/Estatus/, and add the following content:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
import Foundation
import Command

struct IfconfigCommand: Command {

    var arguments: [CommandArgument] {
        return []
    }

    var options: [CommandOption] {
        return [
          .value(name: "interface", short: "i")
        ]
    }
    var help: [String] {
      return ["Usage:", "Estatus ifconfig [--interface|-i INTERFACE_NAME]"]
    }

    func run(using context: CommandContext) throws -> Future<Void> {
      let pmset = Process()
      let pipe = Pipe()
      if #available(OSX 13, *) {
        pmset.executableURL = URL(fileURLWithPath: "/usr/bin/env")
      } else {
        pmset.launchPath = "/usr/bin/env"
      }
      pmset.arguments = ["ifconfig"]
      if let interface = context.options["interface"] {
      pmset.arguments?.append(interface)
      }
      pmset.standardOutput = pipe
      do {
      if #available(OSX 13, *) {
        try pmset.run()
      } else {
        pmset.launch()
      }
        pmset.waitUntilExit()
        let data = pipe.fileHandleForReading.readDataToEndOfFile()
        if let output = String(data: data, encoding: String.Encoding.utf8) {
          context.console.print(output)
        }
      }
      return .done(on: context.container)
    }
}

As you can see the command doesn’t expect any mandatory arguments, but it has an optional argument interface.

Let’s add the service to our main.swift. Your main.swift should look like this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import Console
import Command

let container = BasicContainer(config: Config(), environment: Environment.production, services: Services(), on: EmbeddedEventLoop())

var commandConfig = CommandConfig()
commandConfig.use(BatteryCommand(), as: "batt", isDefault: true)
commandConfig.use(IfconfigCommand(), as: "ifconfig")
container.services.register(commandConfig)

let group = try commandConfig.resolve(for: container).group()

var commandInput = CommandInput(arguments: CommandLine.arguments)
let terminal = Terminal()
try terminal.run(group, input: &commandInput, on: container).wait()

Now we can build and run our project.

1
2
3
4
5
$ swift build
$ swift run Estatus ifconfig
# returns all the interfaces
$ swift run Estatus ifconfig -i en0
#only shows info for en0

Good, you should see the information for your network interfaces. Now let’s build the last service.

A service with a mandatory argument

Let’s now build a wrapper around df to show storage status. We want always to receive an argument that tells our command how to display the storage. The style could be in human (M for Megabytes, GB for Gigabytes, etcetera) or bytes. We are going to create our own Error to identify when we receive an invalid style for the command. Create the file EstatusError.swift inside Sources/Estatus/, with the following content:

1
2
3
4
enum EstatusError: Error {
    case invalidArgument
    case unknownError
}

Now create the file DFCommand.swift inside Sources/Estatus to define our new service. Add the following content:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
import Foundation
import Command

struct DFCommand: Command {

    var arguments: [CommandArgument] {
        return [.argument(name: "style")]
    }

    var options: [CommandOption] {
        return []
    }
    var help: [String] {
      return ["Usage:", "Estatus disk [style=human|bytes]"]
    }

    func run(using context: CommandContext) throws -> Future<Void> {
      let pmset = Process()
      let pipe = Pipe()
      if #available(OSX 13, *) {
        pmset.executableURL = URL(fileURLWithPath: "/usr/bin/env")
      } else {
        pmset.launchPath = "/usr/bin/env"
      }
      pmset.arguments = ["df"]

      let style = try context.argument("style")
      switch style {
      case "human":
        pmset.arguments?.append("-h")
      case "bytes":
        pmset.arguments?.append("-b")
      default:
        throw EstatusError.invalidArgument
      }

      pmset.standardOutput = pipe
      do {
      if #available(OSX 13, *) {
        try pmset.run()
      } else {
        pmset.launch()
      }
        pmset.waitUntilExit()
        let data = pipe.fileHandleForReading.readDataToEndOfFile()
        if let output = String(data: data, encoding: String.Encoding.utf8) {
          context.console.print(output)
        }
      }
      return .done(on: context.container)
    }
}

This time we are defining our command to receive one argument, and we are going to identify the argument on our code by the name style. Now we need to add our service to our main.swift. You can probably register the service on your own now. In the end, your main.swift should look like this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import Console
import Command

let container = BasicContainer(config: Config(), environment: Environment.production, services: Services(), on: EmbeddedEventLoop())

var commandConfig = CommandConfig()
commandConfig.use(BatteryCommand(), as: "batt", isDefault: true)
commandConfig.use(IfconfigCommand(), as: "ifconfig")
commandConfig.use(DFCommand(), as: "disk")
container.services.register(commandConfig)

let group = try commandConfig.resolve(for: container).group()

var commandInput = CommandInput(arguments: CommandLine.arguments)
let terminal = Terminal()
try terminal.run(group, input: &commandInput, on: container).wait()

With our service registered, we can build and run our project.

1
2
3
4
5
6
7
$ swift build
$ swift run Estatus disk
# shows error
$ swift run Estatus disk human
# displays storage info in human style
$ swift run Estatus  disk bytes
# display storage info in bytes

Calling our tool with the service disk should show your system’s storage information. As you can see, once you understand how Console works, defining services is straightforward.

Final Thoughts

In this post, we saw how to create a CLI tool using Console. We didn’t explore the capabilities of Console for requesting user input, how to show progress bars or any of the other features that the module provides. It would be too long a post if I went into all those details. I think that with the information here you have a good starting point. You can check the official documentation on how to do all those additional settings, or if you would like me to write about it let me know. Using Vapor’s Console, you can focus more on writing your services and less time in boilerplate. Console provides a lot of utilities to build great tools for the command line, it is no fun to have to type something like:

1
print("\u{001B}[0;32mHello, world")

to print colours on the terminal, when you can do it more cleanly with Console.

1
context.console.output("Hello, world".consoleText(color: .green))

Less error-prone, and we don’t need to reset the default colour after. That example shows how Console help us focus on our services logic and leave the rest to them. It’s a cool project if you are looking to help have a look at their git repo Console. You’ll notice that the Vapor repository shows ConsoleKit, that is the new version of Console for Vapor v4 (which is in alpha). In the examples, I used Version 3, which is the stable version.

There is still much more to explore when building CLI. The examples in this post were basic and didn’t handle errors gracefully. If you are creating a tool that will be used in production, you should look into all those details.

Ok, hope you enjoyed the post. Let me know what you think and if you have any questions, send them my way.

Related topics/notes of interest


** There is no comment system yet, but you can send me a message on twitter @rderik or send me an email: derik[at]rderik[dot]com.