Building a CLI tool using Swift and Vapor's Console module Jul 30 2019 Latest Update: Jul 7 2020
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.
**Note: If you want to read about other argument parser options I've written about:
- Command-line argument parsing using Swift Package Manager's TSCUtility module
- Understanding the Swift Argument Parser and working with STDIN
Table of Contents
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)
}
}
- If you want to learn more about
Process
check my post about Swift Scripting.
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
- All the code for the command-line tool can be found here Estatus
- Vapor Documentation for Console API all the functions have a link to GitHub, check the source code to understand what is going on behind the scenes.* I think that the comments in the code are quite good to understand better how things work on Vapor, so have a look at the following files for specific elements:
- Command API
- If you want to read more about the Swift Package Manager
- My post on Using Swift for Scripting
- Vapor web framework website
- Command-line argument parsing using Swift Package Manager's TSCUtility module
- Understanding the Swift Argument Parser and working with STDIN