Articles, podcasts and news about Swift development, by John Sundell.

Building a command line tool using the Swift Package Manager

Published on 15 Apr 2017

This week, I’d like to provide a step-by-step tutorial for the setup that I use to build command line tools using the Swift Package Manager.

I personally really like the Swift Package Manager (which I will from now on refer to as ‘SPM’ to save me some typing 😅) and how easy it is to use once you get up and running. But it does have somewhat of a learning curve. So hopefully this post will make it easier for anyone looking to build their own tooling using Swift.

Getting started

To start building a command line tool, make a new directory and initialize it using SPM:

$ mkdir CommandLineTool
$ cd CommandLineTool
$ swift package init --type executable

The type executable above tells SPM that you want to build a command line tool, rather than a framework.

What’s in the box?

After initializing your directory, it will have the following contents:

Splitting your code up into a framework and an executable

One thing I’d recommend that you do right away is to create two modules for your sources — one framework and one executable. This will make testing a lot easier, and will also (and this is really cool) enable your command line tool to also be used as a dependency in other tools.

To make this happen, first, create two folders in Sources, one for the executable and one for the framework, like this:

$ cd Sources
$ mkdir CommandLineToolCore

One really nice aspect of SPM is that it uses the file system as its “source of truth”, so simply creating new folders enables you to define new modules.

Next, update Package.swift to define two targets — one for the CommandLineTool module and one for CommandLineToolCore:

import PackageDescription

let package = Package(
    name: "CommandLineTool",
    targets: [
        .target(
            name: "CommandLineTool",
            dependencies: ["CommandLineToolCore"]
        ),
        .target(name: "CommandLineToolCore")
    ]
)

As you can see above, we make the executable depend on the framework.

Using Xcode

In order to get proper code completion and be able to easily run/debug your command line tool — you probably want to use Xcode. The good news is that SPM can easily generate an Xcode project for you based on the file system. And, since the Xcode project is git ignored, you don’t have to deal with conflicts and updates to it — you simply just re-generate it when needed.

To generate an Xcode project, run the following command in the root folder:

$ swift package generate-xcodeproj

You’ll get a warning when running the above, but just ignore that for now, we’re about to fix it! 🙂

Defining a programmatic entry point

In order to be able to easily run your tool both from the command line and from your tests, it’s a good idea to not put too much functionality into main.swift, and instead enable your tool to be programmatically invoked.

To do this, create a CommandLineTool.swift file in your framework module (Sources/CommandLineToolCore) and add the following contents:

import Foundation

public final class CommandLineTool {
    private let arguments: [String]

    public init(arguments: [String] = CommandLine.arguments) { 
        self.arguments = arguments
    }

    public func run() throws {
        print("Hello world")
    }
}

Next, simply call the above run() method from main.swift:

import CommandLineToolCore

let tool = CommandLineTool()

do {
    try tool.run()
} catch {
    print("Whoops! An error occurred: \(error)")
}

Hello world

Let’s take our command line tool for a spin! First, we’ll need to compile it. This is done by calling swift build in the root folder of our package. After that, we can call swift run to run it:

$ swift build
$ swift run
> Hello world

We could’ve just called swift run above, since that’ll compile our project if needed, but it’s always useful to learn how the underlying commands work as well.

Adding dependencies

Unless you are building something a bit more trivial, you’re probably going to find yourself in need of adding some dependencies to your command line tool. Any Swift package can be added as a dependency, simply by specifying it in Package.swift:

import PackageDescription

let package = Package(
    name: "CommandLineTool",
    dependencies: [
        .package(
            url: "https://github.com/johnsundell/files.git",
            from: "4.0.0"
        )
    ],
    targets: [
        .target(
            name: "CommandLineTool",
            dependencies: ["CommandLineToolCore"]
        ),
        .target(
            name: "CommandLineToolCore",
            dependencies: ["Files"]
        )
    ]
)

Above I add Files, which is a framework that enables you to easily handle files and folders in Swift. We will use Files to enable our command line tool to create a file in the current folder.

Installing dependencies

Once you’ve declared any new dependencies, simply ask SPM to resolve your dependencies and install them, and then re-generate the Xcode project:

$ swift package update
$ swift package generate-xcodeproj

Accepting arguments

Let’s modify CommandLineTool.swift, to instead of printing Hello world, now creating a file with the name taken from the arguments passed to the command line tool:

import Foundation
import Files

public final class CommandLineTool {
    private let arguments: [String]

    public init(arguments: [String] = CommandLine.arguments) { 
        self.arguments = arguments
    }

    public func run() throws {
        guard arguments.count > 1 else {
            throw Error.missingFileName
        }
        
        // The first argument is the execution path
        let fileName = arguments[1]

        do {
            try Folder.current.createFile(at: fileName)
        } catch {
            throw Error.failedToCreateFile
        }
    }
}

public extension CommandLineTool {
    enum Error: Swift.Error {
        case missingFileName
        case failedToCreateFile
    }
}

As you can see above, we wrap the call to Folder.current.createFile() in our own do, try, catch in order to provide a unified error API to our users.

Writing tests

We’re almost ready to ship our amazing new command line tool, but before we do — let’s ensure that it actually works as intended by writing some tests.

The good news is that, since we split up our tool into a framework and an executable early, testing it becomes very easy. All we have to do is to run it programmatically, and assert that it created a file with the name that was specified.

First add a test module to your Package.swift file by adding the following in your targets array:

.testTarget(
    name: "CommandLineToolTests",
    dependencies: ["CommandLineToolCore", "Files"]
)

Finally, re-generate your Xcode project:

$ swift package generate-xcodeproj

Open the Xcode project again, jump over to CommandLineToolTests.swift and add the following contents:

import Foundation
import XCTest
import Files
import CommandLineToolCore

class CommandLineToolTests: XCTestCase {
    func testCreatingFile() throws {
        // Setup a temp test folder that can be used as a sandbox
        let tempFolder = Folder.temporary
        let testFolder = try tempFolder.createSubfolderIfNeeded(
            withName: "CommandLineToolTests"
        )

        // Empty the test folder to ensure a clean state
        try testFolder.empty()

        // Make the temp folder the current working folder
        let fileManager = FileManager.default
        fileManager.changeCurrentDirectoryPath(testFolder.path)

        // Create an instance of the command line tool
        let arguments = [testFolder.path, "Hello.swift"]
        let tool = CommandLineTool(arguments: arguments)

        // Run the tool and assert that the file was created
        try tool.run()
        XCTAssertNotNil(try? testFolder.file(named: "Hello.swift"))
    }
}

It’s also a good idea to add tests to verify that the proper errors were thrown when a file name wasn’t given, or if the file creation failed.

To run your tests, simply run swift test on the command line.

Installing your command line tool

Now that we’ve built and tested our command line tool, let’s install it to enable it to be run from anywhere on the command line. To do that, build the tool using the release configuration, and then move the compiled binary to /usr/local/bin:

$ swift build -c release
$ cd .build/release
$ cp -f CommandLineTool /usr/local/bin/commandlinetool

Done! 🎉

I hope you enjoyed this post. It’s the first “tutorial-ish” post that I’ve ever written, so would love your feedback on it. Also, let me know if you have any comments or questions, along with requests for any future weekly blog posts on Twitter @johnsundell.

Thanks for reading! 🚀