SwiftCLI
A powerful framework that can be used to develop a CLI, from the simplest to the most complex, in Swift.
import SwiftCLI
class GreetCommand: Command {
let name = "greet"
let person = Parameter()
func execute() throws {
print("Hello \(person.value)!")
}
}
let greeter = CLI(name: "greeter")
greeter.commands = [GreetCommand()]
greeter.go()
~ > greeter greet world
Hello world!
With SwiftCLI, you get for free:
- Command routing
- Option parsing
- Help messages
- Usage statements
- Error messages when commands are used incorrectly
- Zsh completions
Table of Contents
- Installation
- Creating a CLI
- Commands
- Command groups
- Routing commands
- Shell completions
- Special commands
- Input
- Customization
- Running your CLI
- Example
Installation
Ice Package Manager
> ice add jakeheis/Ice
Swift Package Manager
Add SwiftCLI as a dependency to your project:
dependencies: [
.package(url: "https://github.com/jakeheis/SwiftCLI", from: "4.0.0")
]
4.0.0
has a number of breaking changes. Use 3.1.0
if you wish to use the new features without the breaking changes.
Creating a CLI
When creating a CLI
, a name
is required, and a version
and description
are both optional.
let myCli = CLI(name: "greeter", version: "1.0.0", description: "Greeter - your own personal greeter")
You set commands through the .commands
property:
myCli.commands = [myCommand, myOtherCommand]
Finally, to actually start the CLI, you call one of the go
methods. In a production app, go()
or goAndExit()
should be used. These methods use the arguments passed to your CLI on launch.
// Use go if you want program execution to continue afterwards
myCli.go()
// Use exitAndGo if you want your program to terminate after CLI has finished
myCli.exitAndGo()
When you are creating and debugging your app, you can use debugGo(with:)
which makes it easier to pass an argument string to your app during development.
myCli.debugGo(with: "greeter greet")
Commands
In order to create a command, you must implement the Command
protocol. All that's required is to implement a name
property and an execute
function; the other properties of Command
are optional (though a shortDescription
is highly recommended). A simple hello world command could be created as such:
class GreetCommand: Command {
let name = "greet"
let shortDescription = "Says hello to the world"
func execute() throws {
print("Hello world!")
}
}
Parameters
A command can specify what parameters it accepts through certain instance variables. Using reflection, SwiftCLI will identify instance variables of type Parameter
, OptionalParameter
, CollectedParameter
, and OptionalCollectedParameter
. These instance variables should appear in the order that the command expects the user to pass the arguments:
class GreetCommand: Command {
let name = "greet"
let firstParam = Parameter()
let secondParam = Parameter()
}
In this example, if the user runs greeter greet Jack Jill
, firstParam
will be updated to have the value Jack
and secondParam
will be updated to have the value Jill
. The values of these parameters can be accessed in func execute()
by calling firstParam.value
, etc.
Required parameters
Required parameters take the form of the type Parameter
. If the command is not passed enough arguments to satisfy all required parameters, the command will fail.
class GreetCommand: Command {
let name = "greet"
let person = Parameter()
let greeting = Parameter()
func execute() throws {
print("\(greeting.value), \(person.value)!")
}
}
~ > greeter greet Jack
Expected 2 arguments, but got 1.
~ > greeter greet Jack Hello
Hello, Jack!
Optional parameters
Optional parameters take the form of the type OptionalParameter
. Optional parameters must come after all required parameters. If the user does not pass enough arguments to satisfy all optional parameters, the .value
of these unsatisfied parameters will be nil
.
class GreetCommand: Command {
let name = "greet"
let person = Parameter()
let greeting = OptionalParameter()
func execute() throws {
let greet = greeting.value ?? "Hey there"
print("\(greet), \(person.value)!")
}
}
~ > greeter greet Jack
Hey there, Jack!
~ > greeter greet Jack Hello
Hello, Jack!
Collected parameters
Commands may have a single collected parameter, a CollectedParameter
or a OptionalCollectedParameter
. These parameters allow the user to pass any number of arguments, and these arguments will be collected into the value
array of the collected parameter.
class GreetCommand: Command {
let name = "greet"
let people = CollectedParameter()
func execute() throws {
let peopleString = people.value.joined(separator: ", ")
print("Hey there, \(peopleString)!")
}
}
~ > greeter greet Jack
Hey there, Jack!
~ > greeter greet Jack Jill
Hey there, Jack, Jill!
~ > greeter greet Jack Jill Hill
Hey there, Jack, Jill, Hill!
Options
Commands have support for two types of options: flag options and keyed options. Both types of options can either be denoted by a dash followed by a single letter (e.g. git commit -a
) or two dashes followed by the option name (e.g. git commit --all
). Single letter options can be cascaded into a single dash followed by all the desired options: git commit -am "message"
== git commit -a -m "message"
.
Options are specified as instance variables on the command class, just like parameters:
class ExampleCommand: Command {
...
let flag = Flag("-a", "--all")
let key = Key<Int>("-t", "--times")
...
}
Flag options
Flag options are simple options that act as boolean switches. For example, if you were to implement git commit
, -a
would be a flag option. They take the form of variables of the type Flag
.
The GreetCommand
could be modified to take a "loudly" flag:
class GreetCommand: Command {
...
let loudly = Flag("-l", "--loudly", description: "Say the greeting loudly")
func execute() throws {
if loudly.value {
...
} else {
...
}
}
}
Keyed options
Keyed options are options that have an associated value. Using "git commit" as an example, "-m" would be a keyed option, as it has an associated value - the commit message. They take the form of variables of the generic type Key<T>
, where T
is the type of the option.
The GreetCommand
could be modified to take a "number of times" option:
class GreetCommand: Command {
...
let numberOfTimes = Key<Int>("-n", "--number-of-times", usage: "Say the greeting a certain number of times")
func execute() throws {
for i in 0..<(numberOfTimes ?? 1) {
...
}
}
}
Option groups
The relationship between multiple options can be specified through option groups. Option groups allow a command to specify that the user must pass at most one option of a group (passing more than one is an error), must pass exactly one option of a group (passing zero or more than one is an error), or must pass one or more options of a group (passing zero is an error).
To add option groups, a Command
should implement the property optionGroups
. For example, if the GreetCommand
had a loudly
flag and a whisper
flag but didn't want the user to be able to pass both, an OptionGroup
could be used:
class GreetCommand: Command {
...
let loudly = Flag("-l", "--loudly", description: "Say the greeting loudly")
let whisper = Flag("-w", "--whisper", description: "Whisper the greeting")
var optionGroups: [OptionGroup] {
let volume = OptionGroup(options: [loudly, whisper], restriction: .atMostOne)
return [volume]
}
func execute() throws {
if loudly.value {
...
} else {
...
}
}
}
Global options
Global options can be used to specify that every command should have a certain option. This is how the -h
flag is implemented for all commands. Simply add an option to CLI's .globalOptions
array (and optionally extend Command
to make the option easy to access in your commands):
private let verboseFlag = Flag("-v")
extension Command {
var verbose: Flag {
return verboseFlag
}
}
myCli.globalOptions.append(verboseFlag)
With this, every command now has a verbose
flag.
Usage of options
As seen in the above examples, Flag()
and Key()
both take an optional description
parameter. A concise description of what the option does should be included here. This allows the UsageStatementGenerator
to generate a fully informative usage statement for the command.
A command's usage statement is shown in two situations:
- The user passed an option that the command does not support --
greeter greet -z
- The command's help was invoked --
greeter greet -h
~ > greeter greet -h
Usage: greeter greet <person> [options]
-l, --loudly Say the greeting loudly
-n, --number-of-times <value> Say the greeting a certain number of times
-h, --help Show help information for this command
Command groups
Command groups provide a way for related commands to be nested under a certain namespace. Groups can themselves contain other groups.
class ConfigGroup: CommandGroup {
let name = "config"
let children = [GetCommand(), SetCommand()]
}
class GetCommand: Command {
let name = "get"
func execute() throws {}
}
class SetCommand: Command {
let name = "set"
func execute() throws {}
}
You can add a command group to your CLI's .commands
array just as add a normal command:
greeter.commands = [ConfigGroup()]
> greeter config
Usage: greeter config <command> [options]
Commands:
get
set
> greeter config set
> greeter config get
Routing commands
Command routing is done by an object implementing Router
, which is just one simple method:
func route(routables: [Routable], arguments: ArgumentList) -> RouteResult
SwiftCLI supplies a default implementation of Router
with DefaultRouter
. DefaultRouter
finds commands based on the first passed argument (or, in the case of command groups, the first several arguments). For example, greeter greet
would search for commands with the name
of "greet".
SwiftCLI also supplies an implementation of Router
called SingleCommandRouter
which should be used if your command is only a single command. For example, if you were implementing the ln
command, you would say CLI.router = SingleCommandRouter(command: LinkCommand())
.
If a command is not found, CLI
outputs a help message.
~ > greeter
Greeter - your own personal greeter
Available commands:
- greet Greets the given person
- help Prints this help information
Aliases
Aliases can be made through the call CommandAliaser.alias(from:to:)
. Router
will take these aliases into account while routing to the matching command. For example, if this call is made:
CommandAliaser.alias(from: "-c", to: "command")
And the user makes the call myapp -c
, the router will search for a command with the name "command" because of the alias, not a command with the name "-c".
Shell completions
Zsh completions can be automatically generated for your CLI (bash completions coming soon).
let myCli = CLI(...)
let generator = ZshCompletionGenerator(cli: myCli)
generator.writeCompletions()
Special commands
CLI
has two special commands: HelpCommand
and VersionCommand
.
Help Command
The HelpCommand
can be invoked with myapp help
or myapp -h
. The HelpCommand
first prints the app description (if any was given during CLI.setup()
). It then iterates through all available commands, printing their name and their short description.
~ > greeter help
Greeter - your own personal greeter
Available commands:
- greet Greets the given person
- help Prints this help information
Version Command
The VersionCommand
can be invoked with myapp version
or myapp -v
. The VersionCommand prints the version of the app given during init CLI(name:version:)
. If no version is given, the command is not available.
~ > greeter -v
Version: 1.0
Input
The Input
class wraps the handling of input from stdin. Several methods are available:
// Simple input:
public static func awaitInput(message: String?) -> String {}
public static func awaitInt(message: String?) -> Int {}
public static func awaitYesNoInput(message: String = "Confirm?") -> Bool {}
// Complex input (if the simple input methods are not sufficient):
public static func awaitInputWithValidation(message: String?, validation: (input: String) -> Bool) -> String {}
public static func awaitInputWithConversion<T>(message: String?, conversion: (input: String) -> T?) -> T {}
Customization
SwiftCLI was designed with sensible defaults but also the ability to be customized at every level. CLI
has six properties that can be changed from the default implementations to customized implementations.
Given a call like
~> baker bake cake -qt frosting
the flow of the CLI is as such:
User calls "baker bake cake -qt frosting"
Command: ?
Parameters: ?
Options: ?
Arguments: Node(bake) -> Node(cake) -> Node(-qt) -> Node(frosting)
ArgumentListManipulators() (including CommandAliaser() and OptionSplitter()) manipulate the nodes
Command: ?
Parameters: ?
Options: ?
Arguments: Node(bake) -> Node(cake) -> Node(-q) -> Node(-t) -> Node(frosting)
Router() uses the argument nodes to find the appropriate command
Command: bake
Parameters: ?
Options: ?
Arguments: Node(cake) -> Node(-q) -> Node(-t) -> Node(frosting)
OptionRecognizer() recognizes the options present within the argument nodes
Command: bake
Parameters: ?
Options: quietly, topped with frosting
Arguments: Node(cake)
ParameterFiller() fills the parameters of the routed command with the remaining arguments
Command: bake
Parameters: cake
Options: quietly, topped with frosting
Arguments: (none)
All four of these steps can be customized:
public static var argumentListManipulators: [ArgumentListManipulator] = [CommandAliaser(), OptionSplitter()]
public static var router: Router = DefaultRouter()
public static var optionRecognizer: OptionRecognizer = DefaultOptionRecognizer()
public static var parameterFiller: ParameterFiller = DefaultParameterFiller()
The messages formed by SwiftCLI can also be customized:
public var helpMessageGenerator: HelpMessageGenerator = DefaultHelpMessageGenerator()
See the individual files of each of these protocols in order to see how to provide a custom implementation.
Running your CLI
Simply call swift run
. In order to ensure your CLI
gets the arguments passed on the command line, make sure to call CLI.go()
, not CLI.debugGo(with: "")
.
Example
An example of a CLI developed with SwfitCLI can be found at https://github.com/jakeheis/Baker.