Create a Swifty Command-Line Tool With ArgumentParser

Save your notes as files!

David Piper
Better Programming

--

Photo by Volodymyr Hryshchenko on Unsplash

As iOS developers, we use the terminal every day. It’s hard to imagine programming without command-line tools like Git or CocoaPods.

Apple makes it super easy to create your own command-line tools using Swift. In this tutorial, let’s dive into Apple’s ArgumentParser framework and learn how to create a command-line tool yourself.

We’ll look at a simple example and understand all parts needed to create a great command-line tool. Ultimately, we’ll talk about how this will benefit you as an iOS developer.

Getting Started

In this tutorial, we’ll build a command-line tool enabling you to write notes from the terminal. It’ll look like this:

clinotes write --file /path/to/save/note/to "This is a note" --force

You can check out the finished project on my GitHub page:

Alternatively, you can follow along and build the project yourself. Before diving into code, we need to create a new project for our command-line tool. Open Xcode, create a new project, and select the Swift package:

Click on Next. In the next step, name the project CLINotes and click on Create. This generates a new package with the given name.

Open Package.swift, and replace its content with the following code:

This defines an executable with the name CLINotes as the product of our package. It also adds the dependency ArgumentParser to our project.

Finally, create a new file called main.swift inside the group CLINotes inside the group Sources and leave it empty for now. We’ll take a look at it later.

Your project should look similar to this now:

clinotes, readme, package, sources, clinotes, clinotes, main, tests, package.resolved

Now that everything is set up, we’ll start by defining our first command and subcommand in the next two sections.

Defining a Command

Apple’s ArgumentParser framework allows us to define our command-line interface as simple Swift types. All commands and subcommands are defined as structs, which can have properties and methods.

They need to conform to ParsableCommand, this protocol is the base for all commands. Let’s start with defining our first command! Open CLINotes.swift and replace its content with the following code:

Here is what’s going on:

// 1 — We start by defining our top-level command. This is a struct called CLINotes, and it conforms to ParsableCommand.

// 2 — We’ll add the file extension .clinote to our files so that it’s possible to recognise notes as created by our command-line tool. As we are going to use this in multiple places, we store it in a property fileExtension.

// 3 — Next, we create a CommandConfiguration. It defines a lot of information about your command. It can have many different properties, let’s take a look at them:

  • commandName: This is the name you enter into the command line to execute the command. If not set, a name will be automatically generated based on the structs name. Default value is nil.
  • abstract: A short description should be one line. For a longer description, you can use discussion. The default value is an empty string.
  • usage: This describes how to use the command. An automatically generated usage description is used for the help message if it's not set. Default value is nil.
  • discussion: If you have more to say about your command than the one-line abstract, you can do so via this property. The default value is an empty string.
  • version: It’s possible to version your commands. Define in which versions of your command-line tool this command is available. The default value is an empty string.
  • shouldDisplay: Show or hide the command in the help message. The default value is true, so if not changed, a command is shown.
  • subcommands: A list of subcommands. Use subcommands to specify your commands and make them easier to use. We’ll take a look at how to use them after looking at the CommandConfiguration. The default value is an empty array.
  • defaultSubcommand: It’s possible to define a subcommand when a user doesn’t enter it. The default value is nil.
  • helpNames: This defines the name of flags (a type of input, we’ll look at different types in a later section) to request help. The default values are -h and --help. So, if a user enters your command name with, for example, -h, they will see the help message.

All have a reasonable default value. Thus, none are required, and you will rarely use all these properties. Often commandName, abstract and — if needed — subcommands is enough to provide all the information a user needs.

Adding a Subcommand

Let’s add a subcommand to specify our command-line tool’s functionality further. Still, in CLINotes.swift, replace its content with the following code:

// 1 — We don’t need many of the properties of CommandConfiguration. In our example commandName, abstract and discussion provide useful information to the user of our command-line tool. Thus, we keep them and remove the rest. Additionally, we add the new command we are currently adding to subcommands.

// 2 —A subcommand is again a struct conforming to ParsableCommand, just like the top-level command we created before. We place the new Write command inside our CLINotes command. A CommandConfiguration provides more information.

Building and Running the Command-Line Tool

Now that we have our commands ready, let’s build and run our program.

But there is one last thing to do. Open main.swift, and replace its content with the following code:

main.swift is the main entry point for executing our command-line tool. By calling main() on our top-level command, we start parsing and executing the command entered into the command line.

Next, open the Terminal, move to the project directory and execute the following command:

swift build -c release

This builds the command-line tool and creates an executable in the build directory. We could call it from there, but it’s pretty inconvenient to use this path each time we want to use our command-line tool. Thus, let’s first move it to a different place that’s easier to access. Do so by executing this command:

cp -f .build/release/CLINotes /usr/local/bin/CLINotes

This places the executable in the binary directory and makes it accessible from everywhere.

The output of these two commands should look similar to this:

Still being in the terminal, test your command-line tool by entering the following command:

clinotes

You will see the following output:

You can recognise a lot of what’s shown here from the CommandConfiguration:

  • OVERVIEW shows what we’ve set in abstract.
  • Below it is the discussion.
  • Because we didn’t override the property usage, it shows the default in USAGE.
  • ~ shows one option to get help because we didn’t change the default value of helpNames it’s -h and --help.
  • SUBCOMMANDS lists one subcommand, which is our Write subcommand.

We can also execute the write subcommand, which results in this output:

We can’t do much with it yet. Let’s change this in the next section by adding input and implementing a run method to save our notes.

Adding Input

It’s time to make it possible to add input to our command. To do so, we’ll add properties to our commands, which can be arguments, options, or flags.

Arguments are positional inputs, meaning they have a fixed place in the command, and you can’t put them in any other order.

Options are named inputs. You need to type in their name before the value. This can look something like --file /path/to/file. This requires you to remember the input names and makes a command more verbose but more flexible and easier to understand.

Flags are binary input. They can either be added to the command or not. An example is the option --help from the screenshot above. You can see that it’s either present or not.

Looking at the input to your command-line tool as a tuple of name and value, you can think of the three different types of input this way:

  • Argument: Only Value
  • Option: Name and Value
  • Flag: Only Name

You need to use a name for options and flags. There are five different options to name them:

  • short: Use only the first character for the name of your input. When using this option for helpNames, for example, in the CommandConfiguration, the name of the flag to show help would be -h. Another good example would be -v for enabling verbose output.
  • long: This requires you to use the whole name of a property. For help, it’s --help. It’s converted to lowercase and hyphens to separate longer names.
    A property called filePath has the long name --file-path.
  • shortAndLong: That’s the default value for helpNames, and it allows you to use both the short and the long format, so -h and --help in that example.
  • customShort and customLong: These two options allow you to customize the name of your input.

Now it’s time to add some properties to our Write command. Add the following code underneath its property configuration:

// 1 — First, we add the input filePath, which is the path the note will be saved at. We use customLong to define its name as file. The short name (-f) would conflict with the last input we’ll add because force also starts with an f. Thus, we only use a long version. The default long name is --file-path, and to make it more convenient to enter the path. We provide a custom name.

// 2 — Because arguments are positional input, we don’t need to define a name option. Instead, we only add help to explain to a user how to use this input.

// 3 — To not accidentally override an existing file, it’s needed to use -f or --force when writing to a file path that already exists.

Think of a good subcommand structure and use Arguments, Options, and Flags to create a usable command.

Build and run the command-line tool again. Entering clinotes write --help, you will see this output:

But even when providing values for all the properties, nothing will happen yet. We still need to add the code to actually save the file. We’ll do so in the next section.

Executing the Command

We can’t do much with our command-line tool yet. To change this, add this method to our Write struct below the property force:

run is executed automatically after parsing the command. Here is what’s happening:

// 1 — We want to add a common file extension to the notes created by our command-line tool. Thus, before writing something to the file system, we need to append it to the file path the user entered.

// 2 — We write the note to the given file path using write(toFile:automatically:encoding) on Strings. We use try? to silence any error for now. This will change in the next section when looking at how to handle errors.

Build the executable and run the command. We are now able to create files for our notes from the terminal. But let’s not stop here. There is another important part we need to look at — validation and handling errors.

Validating Input

Next, let’s explore how we can validate the input. Thinking about validation is an important part when defining your command-line tool. Although you can try to make it as sound as possible to use your program, there will probably be some way to enter invalid input.

A lot of errors are already handled by Swift’s type system. Try to enter a char for an option of type int, and you’ll see an error. But for other invalid input, you need to do the validation yourself. Let’s now take a look at how you can do this.

There are two types of errors that may occur: validation errors and runtime errors. Validation errors are thrown in validate and prevent run from being executed. But if all input provided by the user is valid, you might still run into problems when executing run. These are runtime errors.

Add this method to the Write struct above the run method:

// 1 — Implement the method validate. It will be called automatically when executing the command-line tool to validate the input. If anything is not as expected, you can throw an error.

// 2 — We don’t want the user of our command-line tool to overwrite a file by mistake. Thus, before running the command, we want to make sure the flag --force was added. A new note should be saved if there is a file in the path.

// 3 — If the user tries to overwrite an existing file, we throw a ValidationError containing a message explaining the error to the user.

Build the command-line tool like before and try to overwrite an existing note. You will see something similar to this:

Pretty handy, right?

Next, let’s take a look at runtime errors. Start by adding this new struct:

It represents an error happening at runtime.

Now, replace the run method with the following code:

// 1 — Instead of using try? to silence errors when writing a note to a file, we now use do and catch.

// 2 —When anything goes wrong, and the note can’t be saved, we throw a RuntimeError and let the user know that the operation failed.

Congratulations, the command-line tool is finished! 🥳

Debugging Your Command-Line Tool

As no software is immune to bugs, we need a way to debug a command-line tool like any other app. Since we run the executable in the command line, it won’t break at breakpoints or print anything to Xcode’s console.

We need a little trick to do so. First, we need to edit the scheme. Click on the target CLINotes and select Edit Scheme… in the dropdown.

Next, switch to Run (1) in the menu to the left and select the tab Arguments (2). In Arguments Passed On Launch, add the following command:

write --file /Desktop/testnote "This is a test note."

This executes the write subcommand with the given parameters when running the command-line tool in Xcode.

Now, add a breakpoint to validate in the Write struct.

Build and run the app in Xcode. It will execute the command you just defined and stop at the breakpoint.

That’s how you can debug your command-line tool. You can find the full project on my GitHub:

How To Benefit as an iOS Developer?

Although we looked at a rather easy and small example, you now know everything to build more sophisticated command-line tools.

There are many examples where you can create your own tools to facilitate your development workflow. Managing your app’s resources like images and strings, for example.

Another good use case is creating a command-line tool to control and configure your iOS app. To learn more about how to communicate with your app at runtime via the terminal, check out my talk at the iOS Global Developers Summit 2022, where I showcase how we at Scalable Capital created and use a command-line tool:

Resources

Thanks for reading. Stay tuned for more.

--

--