SwiftCLI

Build Status

一个强大的框架,用于在 Swift 中开发命令行界面(CLI),从最简单到最复杂。

import SwiftCLI

class GreetCommand: Command {
    let name = "greet"
    
    @Param var person: String

    func execute() throws {
        stdout <<< "Hello \(person)!"
    }
}

let greeter = CLI(name: "greeter")
greeter.commands = [GreetCommand()]
greeter.go()
~ > greeter greet world
Hello world!

使用 SwiftCLI,您将自动获得

目录

安装

Ice 包管理器

> ice add jakeheis/SwiftCLI

Swift 包管理器

将 SwiftCLI 添加为项目的依赖项

dependencies: [
    .package(url: "https://github.com/jakeheis/SwiftCLI", from: "6.0.0")
]

Carthage

github "jakeheis/SwiftCLI" ~> 5.2.2

CocoaPods

pod 'SwiftCLI', '~> 6.0.0'

创建命令行界面 (CLI)

创建 CLI 时,需要一个 name,而 versiondescription 都是可选的。

let myCli = CLI(name: "greeter", version: "1.0.0", description: "Greeter - a friendly greeter")

您可以通过 .commands 属性设置命令

myCli.commands = [myCommand, myOtherCommand]

最后,要运行 CLI,您可以调用 go 方法之一。

// Use go if you want program execution to continue afterwards
myCli.go() 

// Use goAndExit if you want your program to terminate after the CLI has finished
myCli.goAndExit()

// Use go(with:) if you want to control the arguments which the CLI runs with
myCli.go(with: ["arg1", "arg2"])

命令

为了创建一个命令,您必须实现 Command 协议。 所有需要做的是实现 name 属性和 execute 函数; Command 的其他属性是可选的(但强烈建议使用 shortDescription)。 可以创建如下一个简单的 hello world 命令

class GreetCommand: Command {

    let name = "greet"
    let shortDescription = "Says hello to the world"

    func execute() throws  {
        stdout <<< "Hello world!"
    }

}

参数

命令可以通过某些实例变量指定它接受哪些参数。 使用反射,SwiftCLI 将识别类型为 @Param@CollectedParam 的属性包装器。 这些属性应按照命令期望用户传递参数的顺序出现。 所有必需参数必须首先出现,然后是任何可选参数,最后最多只能有一个收集参数。

class GreetCommand: Command {
    let name = "greet"

    @Param var first: String
    @Param var second: String?
    @CollectedParam var remaining: [String]
}

在此示例中,如果用户运行 greeter greet Jack Jill up the hill,则 first 将包含值 Jacksecond 将包含值 Jill,而 remaining 将包含值 ["up", "the", "hill"]

@Param

单个参数采用属性包装器 @Param 的形式。 由 @Param 包装的属性可以是必需的或可选的。 如果没有给命令传递足够的参数来满足所有必需的参数,则命令将失败。

class GreetCommand: Command {
    let name = "greet"

    @Param var person: String
    @Param var followUp: String

    func execute() throws {
        stdout <<< "Hey there, \(person)!"
        stdout <<< followUp
    }
}
~ > greeter greet Jack

Usage: greeter greet <person> <followUp> [options]

Options:
  -h, --help      Show help information

Error: command requires exactly 2 arguments

~ > greeter greet Jack "What's up?"
Hey there, Jack!
What's up?

如果用户没有传递足够的参数来满足所有可选参数,则这些未满足参数的值将为 nil

class GreetCommand: Command {
    let name = "greet"

    @Param var person: String
    @Param var followUp: String? // Note: String? in this example, not String

    func execute() throws {
        stdout <<< "Hey there, \(person)!"
        if let followUpText = followUp {
            stdout <<< followUpText
        }
    }
}
~ > greeter greet Jack
Hey there, Jack!
~ > greeter greet Jack "What's up?"
Hello, Jack!
What's up?

@CollectedParam

命令在所有其他参数之后可以有一个单独的收集参数,称为 @CollectedParam。 此参数允许用户传递任意数量的参数,这些参数将被收集到由收集参数包装的数组中。 由 @CollectedParam 包装的属性**必须**是一个数组。 默认情况下,@CollectedParam 不要求用户传递任何参数。 可以使用 @CollectedParam(minCount:) 初始化程序来要求参数具有一定数量的值。

class GreetCommand: Command {
    let name = "greet"

    @CollectedParam(minCount: 1) var people: [String]

    func execute() throws {
        for person in people {
            stdout <<< "Hey there, \(person)!"
        }        
    }
}
~ > greeter greet Jack
Hey there, Jack!
~ > greeter greet Jack Jill Water
Hey there, Jack!
Hey there, Jill!
Hey there, Water!

参数的值类型

使用所有这些参数属性包装器,只要类型符合 ConvertibleFromString,就可以使用任何类型。 大多数原始类型(例如 Int)已经符合 ConvertibleFromString,具有原始值为原始类型的枚举也是如此。

class GreetCommand: Command {
    let name = "greet"

    @Param var number: Int

    func execute() throws {
        stdout <<< "Hey there, number \(number)!"     
    }
}
~ > greeter greet Jack

Usage: greeter greet <number> [options]

Options:
  -h, --help      Show help information

Error: invalid value passed to 'number'; expected Int

~ > greeter greet 4
Hey there, number 4!

具有符合 CaseIterable 的枚举类型的参数具有其他专门的行为。 在错误消息中,将详细说明该参数允许的值。

class GreetCommand: Command {
    
    let name = "greet"
    
    enum Volume: String, ConvertibleFromString, CaseIterable {
        case loud
        case quiet
    }
    
    @Param var volume: Volume
    
    func execute() throws {
        let greeting = "Hello world!"
        
        switch volume {
        case .loud: stdout <<< greeting.uppercased()
        case .quiet: stdout <<< greeting.lowercased()
        }
        
    }
}
~ > greeter greet Jack

Usage: greeter greet <volume> [options]

Options:
  -h, --help      Show help information

Error: invalid value passed to 'volume'; expected one of: loud, quiet

~ > greet greet loud
HELLO WORLD!

要使自定义类型符合 ConvertibleFromString,只需实现一个函数

extension MyType: ConvertibleFromString {
    init?(input: String) {
        // Construct an instance of MyType from the String, or return nil if not possible
        ...
    }
}

选项

命令支持两种类型的选项:flag 选项和 keyed 选项。 两种类型的选项都可以用短划线后跟一个字母(例如 git commit -a)或两个短划线后跟选项名称(例如 git commit --all)来表示。 单个字母选项可以级联成一个短划线后跟所有想要的选项:git commit -am "message" == git commit -a -m "message"

选项用命令类上的属性包装器指定,就像参数一样

class ExampleCommand: Command {
    ...
    @Flag("-a", "--all")
    var flag: Bool

    @Key("-t", "--times")
    var key: Int?
    ...
}

Flags

Flags 是充当布尔开关的简单选项。 例如,如果要实现 git commit,则 -a 将是一个 flag 选项。 它们采用由 @Flag 包装的布尔值的形式。

GreetCommand 可以采用一个 “loudly” flag

class GreetCommand: Command {

    ...

    @Flag("-l", "--loudly", description: "Say the greeting loudly")
    var loudly: Bool

    func execute() throws {
        if loudly {
             ...
        } else {
            ...
        }
    }

}

相关的选项类型是 @CounterFlag,它计算用户传递相同 flag 的次数。 @CounterFlag 只能包装类型为 Int 的属性。 例如,使用像这样的 flag 声明

class GreetCommand: Command {
    ...
    @CounterFlag("-s", "--softly", description: "Say the greeting softly")
    var softly: Int
    ...
}

用户可以写 greeter greet -s -s,并且 softly.value 将是 2

Keys

Keys 是具有关联值的选项。 以 "git commit" 为例,"-m" 将是一个 keyed 选项,因为它具有关联的值 - 提交消息。 它们采用由 '@Key` 包装的变量的形式。

GreetCommand 可以采用一个 “number of times” 选项

class GreetCommand: Command {

    ...

    @Key("-n", "--number-of-times", description: "Say the greeting a certain number of times")
    var numberOfTimes: Int?

    func execute() throws {
        for i in 0..<(numberOfTimes ?? 1) {
            ...
        }
    }

}

@Key 包装的变量可以是符合上述 ConvertibleFromString 的任何类型。 它**必须**是可选的,否则 Swift 编译器将崩溃。

相关的选项类型是 VariadicKey,它允许用户使用不同的值多次传递相同的 key。 例如,使用像这样的 key 声明

class GreetCommand: Command {
    ...
    @VariadicKey("-l", "--location", description: "Say the greeting in a certain location")
    var locations: [String]
    ...
}

用户可以写 greeter greet -l Chicago -l NYC,并且 locations.value 将是 ["Chicago", "NYC"]。 由 @VariadicKey 包装的变量必须是符合 ConvertibleFromString 的类型的数组。

选项组

多个选项之间的关系可以通过选项组来指定。 选项组允许命令指定用户必须传递一个组的最多一个选项(传递多个是一个错误),必须传递一个组的恰好一个选项(传递零个或多个于一个是错误),或者必须传递一个组的一个或多个选项(传递零个是一个错误)。

要添加选项组,Command 应该实现属性 optionGroups。 选项组通过 $ 语法引用选项。 例如,如果 GreetCommand 具有 loudly flag 和 whisper flag,但不希望用户能够同时传递两者,则可以使用 OptionGroup

class GreetCommand: Command {

    ...

    @Flag("-l", "--loudly", description: "Say the greeting loudly")
    var loudly: Bool

    @Flag("-w", "--whisper", description: "Whisper the greeting")
    var whisper: Bool
    
    var optionGroups: [OptionGroup] {
        return [.atMostOne($loudly, $whipser)] // Note: $loudly and $whisper, not loudly and whisper
    }

    func execute() throws {
        if loudly {
            ...
        } else if whisper {
            ...
        } else {
            ...
        }
    }

}

全局选项

全局选项可用于指定每个命令应具有的特定选项。 这是为所有命令实现 -h flag 的方式。 只需将选项添加到 CLI 的 .globalOptions 数组中(并可选择扩展 Command 以使选项易于在您的命令中访问)

private let verboseFlag = Flag("-v")
extension Command {
    var verbose: Bool {
        return verboseFlag.value
    }
}

myCli.globalOptions.append(verboseFlag)

默认情况下,每个命令都有一个 -h flag,用于打印帮助信息。 您可以通过将 CLI helpFlag 设置为 nil 来关闭此功能

myCli.helpFlag = nil

选项用法

如以上示例所示,@Flag@Key 都采用可选的 description 参数。 此处应包含选项作用的简明描述。 这允许 HelpMessageGenerator 为命令生成完全信息性的用法说明。

在三种情况下显示命令的用法说明

~ > greeter greet -h

Usage: greeter greet <person> [options]

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

命令组

命令组提供了一种将相关命令嵌套在特定命名空间下的方法。 组本身可以包含其他组。

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 {}
}

您可以将命令组添加到 CLI 的 .commands 数组中,就像添加普通命令一样

greeter.commands = [ConfigGroup()]
> greeter config

Usage: greeter config <command> [options]

Commands:
  get
  set

> greeter config set
> greeter config get

Shell 补全

可以为您的 CLI 自动生成 Zsh 补全。

let myCli = CLI(...)

let generator = ZshCompletionGenerator(cli: myCli)
generator.writeCompletions()

将自动为命令名称和选项生成补全。 可以指定参数补全模式

@Param(completion: .none)
var noCompletions: String

@Param(completion: .filename)
var aFile: String

@Param(completion: .values([
    ("optionA", "the first available option"),
    ("optionB", "the second available option")
]))
var aValue: String

@Param(completion: .function("_my_custom_func"))
var aFunction: String

默认参数补全模式是 .filename。 如果您使用 .function 指定自定义函数,则在创建补全生成器时必须提供该函数

class MyCommand {
    ...
    @Param(completion: .function("_list_processes"))
    var pid: String
    ...
}

let myCLI = CLI(...)
myCLI.commands [MyCommand()]
let generator = ZshCompletionGenerator(cli: myCli, functions: [
    "_list_processes": """
        local pids
        pids=( $(ps -o pid=) )
        _describe '' pids
        """
])

内置命令

CLI 有两个内置命令:HelpCommandVersionCommand

Help 命令

可以使用 myapp help 调用 HelpCommandHelpCommand 首先打印应用程序描述(如果在 CLI.init 期间给出了任何描述)。 然后,它遍历所有可用的命令,打印它们的名称和简短描述。

~ > greeter help

Usage: greeter <command> [options]

Greeter - your own personal greeter

Commands:
  greet        Greets the given person
  help         Prints this help information

如果您不希望自动包含此命令,请将 helpCommand 属性设置为 nil

myCLI.helpCommand = nil

Version 命令

可以使用 myapp versionmyapp --version 调用 VersionCommand。 VersionCommand 打印在 init 期间给出的应用程序版本 CLI(name:version:)。 如果未给出版本,则该命令不可用。

~ > greeter --version
Version: 1.0

如果您不希望自动包含此命令,请将 versionCommand 属性设置为 nil

myCLI.versionCommand = nil

输入

Input 类使从 stdin 读取输入变得容易。 有几种方法可用

let str = Input.readLine()
let int = Input.readInt()
let double = Input.readDouble()
let bool = Input.readBool()

所有 read 方法都有四个可选参数

例如,您可以编写

let percentage = Input.readDouble(
    prompt: "Percentage:",
    validation: [.within(0...100)],
    errorResponse: { (input, reason) in
        Term.stderr <<< "'\(input)' is invalid; must be a number between 0 and 100"
    }
)

这将导致如下交互

Percentage: asdf
'asdf' is invalid; must be a number between 0 and 100
Percentage: 104
'104' is invalid; must be a number between 0 and 100
Percentage: 43.6

外部任务

SwiftCLI 使执行外部任务变得容易

// Execute a command and print output:
try Task.run("echo", "hello")
try Task.run(bash: "while true; do echo hi && sleep 1; done")

// Execute a command and capture the output:
let currentDirectory = try Task.capture("pwd").stdout
let sorted = try Task.capture(bash: "cat Package.swift | sort").stdout

您也可以使用 Task 类来获得更自定义的行为

let input = PipeStream()
let output = PipeStream()
let task = Task(executable: "sort", currentDirectory: "~/Ice", stdout: output, stdin: input)
task.runAsync()

input <<< "beta"
input <<< "alpha"
input.closeWrite()

output.readAll() // will be alpha\nbeta\n

有关 Task 的完整文档,请参见 Sources/SwiftCLI/Task.swift

单命令命令行界面 (CLI)

如果您的 CLI 仅包含一个命令,您可能希望仅通过调用 cli 而不是 cli command 来执行该命令。 在这种情况下,您可以按如下方式创建您的 CLI

class Ln: Command {
    let name = "ln"
    func execute() throws { ... }
}

let ln = CLI(singleCommand: Ln())
ln.go()

在这种情况下,如果用户编写 ln myFile newLocation,而不是搜索名称为 “myFile” 的命令,SwiftCLI 将执行 Ln 命令并将 “myFile” 作为该命令的第一个参数传递。

请记住,在创建单个命令的 CLI 时,您将失去默认的 VersionCommand。这意味着 cli -v 将不会自动工作。如果您想打印 CLI 版本,您需要在单个命令上手动实现一个 Flag("-v")

自定义

SwiftCLI 在设计时考虑了合理的默认值,但也具备在各个层级进行自定义的能力。CLI 具有三个属性,可以从默认实现更改为自定义实现。

parser (解析器)

Parser 遍历参数以找到相应的命令,更新其参数值,并识别选项。每个 CLI 都有一个 parser 属性,该属性具有三个可变属性:routeBehaviorparseOptionsAfterCollectedParameterresponders

routeBehavior 具有三个可能的值。默认值 .search 会遍历用户传递的参数,并尝试查找具有匹配名称的命令。如果失败,则会打印帮助消息。git 是一个以这种方式运行的程序的示例。第二个选项 .searchWithFallback(Command) 也首先尝试查找具有与用户传递的参数匹配的名称的命令,但如果失败,它不会打印帮助消息,而是回退到某个特定命令。'bundle' 是一个以这种方式运行的程序的示例。最后一个选项是 .automatically(Command)。在这种路由行为中,CLI 会自动路由到给定的命令,而不考虑参数。'ln' 是一个以这种方式运行的程序的示例。

parseOptionsAfterCollectedParameter 控制在遇到收集的参数后是否识别选项。它默认为 false。给定以下命令

class Run: Command {
    let name = "run"

    @Param var executable: String
    @CollectedParam var arguments: [String]

    @Flag("-a") var all: Bool
    ...
}

如果用户调用 swift run myExec -aparseOptionsAfterCollectedParameter 为 false,则 executable 的值为 "myExec",arguments 的值为 ["-a"]all 的值为 false。 如果 parseOptionsAfterCollectedParameter 在这种情况下为 true,则 executable 的值为 "myExec",arguments 的值为 []all 的值为 true。

responders 允许完全自定义解析器。有关其默认功能以及如何以任何方式进行自定义的更多信息,请查看 Parser.swift

aliases (别名)

可以通过 CLI 上的 aliases 属性创建别名。在路由到匹配的命令时,Parser 会考虑这些别名。例如,如果您编写

myCLI.aliases["-c"] = "command"

并且用户调用 myapp -c,由于别名,解析器将搜索名称为 "command" 的命令,而不是名称为 "-c" 的命令。

默认情况下,"--version" 是 "version" 的别名,但如果需要,您可以删除它

myCLI.aliases["--version"] = nil

argumentListManipulators (参数列表操作器)

ArgumentListManipulators 在 Parser 开始之前执行。 它们接收用户给出的参数,并可以稍微更改它们。 默认情况下,唯一使用的参数列表操作器是 OptionSplitter,它将诸如 -am 之类的选项拆分为 -a -m

您可以在您自己的类型上实现 ArgumentListManipulator 并更新 CLI 的属性

cli.argumentListManipulators.append(MyManipulator())

helpMessageGenerator (帮助消息生成器)

SwiftCLI 生成的消息也可以自定义

cli.helpMessageGenerator = MyHelpMessageGenerator()

运行您的命令行界面 (CLI)

只需调用 swift run。 为了确保您的 CLI 获得命令行上传递的参数,请务必调用 CLI.go()而不是 CLI.go(with: [])

使用 SwiftCLI 构建的 CLI