:codable (karma)

A mobile engineer trying to figure out how to be a good father

Building a swift cli tool using Swift Package Manager

Command line tools are a useful part of a developers life with automating common tasks.

As a mobile developer we often use many command line tools either separately in terminal (Github CLI, Fastlane, SwiftFormat / Lint) to utilising them within our IDEs, within Xcodes run scripts for instance.

Something we don't do very often is build them ourselves, yet when you have a task that hasn't yet been automated it can be helpful.

So where do you begin?

Swift Package Manager (SPM) can make creating these tools easier for iOS developers. So you can build tools for your teams that not only can they understand, but they can also contribute to and expand on.

For this post we're going to create a cli tool which decodes a file.

The following creates our project folder and then tell swift to create a package of an executable type.

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

You'll then see the following output as the package gets created

Creating executable package: FileDecoder
Creating Package.swift
Creating README.md
Creating .gitignore
Creating Sources/
Creating Sources/FileDecoder/main.swift
Creating Tests/
Creating Tests/FileDecoderTests/
Creating Tests/FileDecoderTests/FileDecoderTests.swift

This is telling us that the swift package file structure has been created, the manifest file (Package.swift), main code structure (Sources/) and the testing structure (Tests/). It has also created some git files for the package, README.md and .gitignore which has been prepopulated with files and folder structures for working on a Swift Package.
To test the setup for the tool run the following

$ swift build
$ swift run
Hello, world!

And there you have it, your CLI tool - albeit not the most helpful.

Adding arguments to the tool

Opening up the package.swift file, and add the ArgumentParser package

let package = Package(
    name: "FileDecoder",
    dependencies: [
        .package(url: "https://github.com/apple/swift-argument-parser", from: "0.0.1"),
    ],
    targets: [
        .executableTarget(
            name: "FileDecoder",
            dependencies: [
                .product(name: "ArgumentParser", package: "swift-argument-parser")
            ]
        ),
        .testTarget(
            name: "FileDecoderTests",
            dependencies: ["FileDecoder"]),
    ]
)

ArgumentParser is an Apple tool which allows us to use Property Wrappers to declare subcommands and variables which our tool can then act on.

So lets open up main.swift and declare our main command!

import ArgumentParser

struct FileDecoder: ParsableCommand {
    static let configuration = CommandConfiguration(
        abstract: "A Swift command-line tool to decode files",
        subcommands: [Decode.self]
    )

    init() { }
}

FileDecoder.main()

This declares our "top-level" command of FileDecoder. At this point you can pass it an array of subcommands (in this instance we have one subcommand, "Decode").

Lets create the Decode command

import ArgumentParser

struct Decode: ParsableCommand {
    
    public static let configuration = CommandConfiguration(abstract: "Decode file at given path")
    
    @Argument(help: "Path to file")
    private var path: String
    
    @Option(name: .shortAndLong, help: "output name to use used")
    private var outputName: String?
    
    @Flag(name: .short, help: "Show extra logging for debugging purposes")
    private var verbose: Int
    
    func run() throws {
        do {
            if verbose > 0 {
                print("Decoding file at \(path)")
            }
            
            let outputPath: String
            if let output = outputName {
                outputPath = output
            } else {
                outputPath = path
            }
            print("File changes being output to \(outputPath)")
        }
    }
}

ArgumentParser allows us to decorate our subcommand with three main Property Wrappers:

  • Argument: This is a required value which is needed to run the subcommand
  • Option: This is an optional value which can be left out or used for the subcommand
  • Flag: These are extra values which do not take input but effect the flow of operations

As you can see from the example each of these wrappers takes a help string which highlights the functionality of that value when --help is run. Along with that Option and Flag also take a name enum which specifies what variations in that value are allowed (-v vs --verbose).

Running swift run FileDecoder decode --help will let you see how this looks for our Decode subcommand.

OVERVIEW: Decode file at given path

USAGE: file-decoder decode <path> [--output-name <output-name>] [-v ...]

ARGUMENTS:
  <path>                  Path to file

OPTIONS:
  -o, --output-name <output-name>
                          output name to use used
  -v                      Show extra logging for debugging purposes
  -h, --help              Show help information.

Testing our CLI tool

You should now be able to play around with the tool that has been created!

For instance, try running

$ swift run FileDecoder

or

$ swift run FileDecoder decode --help

Having a look at the outputs and adding / changing values for the options we've predefined.

Releasing our CLI tool

So, you've created an initial Command-line interface tool. You've added arguments and options to allow for greater customisation. You've tested it to make sure it all works.

Now what?

Now, you release. Releasing our tool is another process done through terminal.

$ swift build -c release
$ cp -f .build/release/FileDecoder /usr/local/bin/FileDecoder

These command will build your tool using the build in release configuration for swift, and then copies over that release executable over to the user bin directory. This means that you'll be able to run your tool as it's meant to be!

So you can run:

$ FileDecoder
OVERVIEW: A Swift command-line tool to decode files

USAGE: file-decoder <subcommand>

OPTIONS:
  -h, --help              Show help information.

SUBCOMMANDS:
  decode                  Decode file at given path

  See 'file-decoder help <subcommand>' for detailed help.
Tagged with: