├── .github └── workflows │ └── test.yml ├── LICENSE ├── Makefile ├── README.md ├── autocomplete.go ├── cli.go ├── cli_test.go ├── command.go ├── command_mock.go ├── command_mock_test.go ├── go.mod ├── go.sum ├── help.go ├── ui.go ├── ui_colored.go ├── ui_concurrent.go ├── ui_concurrent_test.go ├── ui_mock.go ├── ui_mock_test.go ├── ui_test.go ├── ui_writer.go └── ui_writer_test.go /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | on: [push, pull_request] 2 | name: Test 3 | jobs: 4 | test: 5 | strategy: 6 | matrix: 7 | go-version: [1.18.x] 8 | os: [ubuntu-latest] 9 | runs-on: ${{ matrix.os }} 10 | steps: 11 | - name: Install Go 12 | uses: actions/setup-go@v2 13 | with: 14 | go-version: ${{ matrix.go-version }} 15 | - name: Checkout code 16 | uses: actions/checkout@v2 17 | - name: Test 18 | run: go test ./... 19 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Mozilla Public License, version 2.0 2 | 3 | 1. Definitions 4 | 5 | 1.1. “Contributor” 6 | 7 | means each individual or legal entity that creates, contributes to the 8 | creation of, or owns Covered Software. 9 | 10 | 1.2. “Contributor Version” 11 | 12 | means the combination of the Contributions of others (if any) used by a 13 | Contributor and that particular Contributor’s Contribution. 14 | 15 | 1.3. “Contribution” 16 | 17 | means Covered Software of a particular Contributor. 18 | 19 | 1.4. “Covered Software” 20 | 21 | means Source Code Form to which the initial Contributor has attached the 22 | notice in Exhibit A, the Executable Form of such Source Code Form, and 23 | Modifications of such Source Code Form, in each case including portions 24 | thereof. 25 | 26 | 1.5. “Incompatible With Secondary Licenses” 27 | means 28 | 29 | a. that the initial Contributor has attached the notice described in 30 | Exhibit B to the Covered Software; or 31 | 32 | b. that the Covered Software was made available under the terms of version 33 | 1.1 or earlier of the License, but not also under the terms of a 34 | Secondary License. 35 | 36 | 1.6. “Executable Form” 37 | 38 | means any form of the work other than Source Code Form. 39 | 40 | 1.7. “Larger Work” 41 | 42 | means a work that combines Covered Software with other material, in a separate 43 | file or files, that is not Covered Software. 44 | 45 | 1.8. “License” 46 | 47 | means this document. 48 | 49 | 1.9. “Licensable” 50 | 51 | means having the right to grant, to the maximum extent possible, whether at the 52 | time of the initial grant or subsequently, any and all of the rights conveyed by 53 | this License. 54 | 55 | 1.10. “Modifications” 56 | 57 | means any of the following: 58 | 59 | a. any file in Source Code Form that results from an addition to, deletion 60 | from, or modification of the contents of Covered Software; or 61 | 62 | b. any new file in Source Code Form that contains any Covered Software. 63 | 64 | 1.11. “Patent Claims” of a Contributor 65 | 66 | means any patent claim(s), including without limitation, method, process, 67 | and apparatus claims, in any patent Licensable by such Contributor that 68 | would be infringed, but for the grant of the License, by the making, 69 | using, selling, offering for sale, having made, import, or transfer of 70 | either its Contributions or its Contributor Version. 71 | 72 | 1.12. “Secondary License” 73 | 74 | means either the GNU General Public License, Version 2.0, the GNU Lesser 75 | General Public License, Version 2.1, the GNU Affero General Public 76 | License, Version 3.0, or any later versions of those licenses. 77 | 78 | 1.13. “Source Code Form” 79 | 80 | means the form of the work preferred for making modifications. 81 | 82 | 1.14. “You” (or “Your”) 83 | 84 | means an individual or a legal entity exercising rights under this 85 | License. For legal entities, “You” includes any entity that controls, is 86 | controlled by, or is under common control with You. For purposes of this 87 | definition, “control” means (a) the power, direct or indirect, to cause 88 | the direction or management of such entity, whether by contract or 89 | otherwise, or (b) ownership of more than fifty percent (50%) of the 90 | outstanding shares or beneficial ownership of such entity. 91 | 92 | 93 | 2. License Grants and Conditions 94 | 95 | 2.1. Grants 96 | 97 | Each Contributor hereby grants You a world-wide, royalty-free, 98 | non-exclusive license: 99 | 100 | a. under intellectual property rights (other than patent or trademark) 101 | Licensable by such Contributor to use, reproduce, make available, 102 | modify, display, perform, distribute, and otherwise exploit its 103 | Contributions, either on an unmodified basis, with Modifications, or as 104 | part of a Larger Work; and 105 | 106 | b. under Patent Claims of such Contributor to make, use, sell, offer for 107 | sale, have made, import, and otherwise transfer either its Contributions 108 | or its Contributor Version. 109 | 110 | 2.2. Effective Date 111 | 112 | The licenses granted in Section 2.1 with respect to any Contribution become 113 | effective for each Contribution on the date the Contributor first distributes 114 | such Contribution. 115 | 116 | 2.3. Limitations on Grant Scope 117 | 118 | The licenses granted in this Section 2 are the only rights granted under this 119 | License. No additional rights or licenses will be implied from the distribution 120 | or licensing of Covered Software under this License. Notwithstanding Section 121 | 2.1(b) above, no patent license is granted by a Contributor: 122 | 123 | a. for any code that a Contributor has removed from Covered Software; or 124 | 125 | b. for infringements caused by: (i) Your and any other third party’s 126 | modifications of Covered Software, or (ii) the combination of its 127 | Contributions with other software (except as part of its Contributor 128 | Version); or 129 | 130 | c. under Patent Claims infringed by Covered Software in the absence of its 131 | Contributions. 132 | 133 | This License does not grant any rights in the trademarks, service marks, or 134 | logos of any Contributor (except as may be necessary to comply with the 135 | notice requirements in Section 3.4). 136 | 137 | 2.4. Subsequent Licenses 138 | 139 | No Contributor makes additional grants as a result of Your choice to 140 | distribute the Covered Software under a subsequent version of this License 141 | (see Section 10.2) or under the terms of a Secondary License (if permitted 142 | under the terms of Section 3.3). 143 | 144 | 2.5. Representation 145 | 146 | Each Contributor represents that the Contributor believes its Contributions 147 | are its original creation(s) or it has sufficient rights to grant the 148 | rights to its Contributions conveyed by this License. 149 | 150 | 2.6. Fair Use 151 | 152 | This License is not intended to limit any rights You have under applicable 153 | copyright doctrines of fair use, fair dealing, or other equivalents. 154 | 155 | 2.7. Conditions 156 | 157 | Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted in 158 | Section 2.1. 159 | 160 | 161 | 3. Responsibilities 162 | 163 | 3.1. Distribution of Source Form 164 | 165 | All distribution of Covered Software in Source Code Form, including any 166 | Modifications that You create or to which You contribute, must be under the 167 | terms of this License. You must inform recipients that the Source Code Form 168 | of the Covered Software is governed by the terms of this License, and how 169 | they can obtain a copy of this License. You may not attempt to alter or 170 | restrict the recipients’ rights in the Source Code Form. 171 | 172 | 3.2. Distribution of Executable Form 173 | 174 | If You distribute Covered Software in Executable Form then: 175 | 176 | a. such Covered Software must also be made available in Source Code Form, 177 | as described in Section 3.1, and You must inform recipients of the 178 | Executable Form how they can obtain a copy of such Source Code Form by 179 | reasonable means in a timely manner, at a charge no more than the cost 180 | of distribution to the recipient; and 181 | 182 | b. You may distribute such Executable Form under the terms of this License, 183 | or sublicense it under different terms, provided that the license for 184 | the Executable Form does not attempt to limit or alter the recipients’ 185 | rights in the Source Code Form under this License. 186 | 187 | 3.3. Distribution of a Larger Work 188 | 189 | You may create and distribute a Larger Work under terms of Your choice, 190 | provided that You also comply with the requirements of this License for the 191 | Covered Software. If the Larger Work is a combination of Covered Software 192 | with a work governed by one or more Secondary Licenses, and the Covered 193 | Software is not Incompatible With Secondary Licenses, this License permits 194 | You to additionally distribute such Covered Software under the terms of 195 | such Secondary License(s), so that the recipient of the Larger Work may, at 196 | their option, further distribute the Covered Software under the terms of 197 | either this License or such Secondary License(s). 198 | 199 | 3.4. Notices 200 | 201 | You may not remove or alter the substance of any license notices (including 202 | copyright notices, patent notices, disclaimers of warranty, or limitations 203 | of liability) contained within the Source Code Form of the Covered 204 | Software, except that You may alter any license notices to the extent 205 | required to remedy known factual inaccuracies. 206 | 207 | 3.5. Application of Additional Terms 208 | 209 | You may choose to offer, and to charge a fee for, warranty, support, 210 | indemnity or liability obligations to one or more recipients of Covered 211 | Software. However, You may do so only on Your own behalf, and not on behalf 212 | of any Contributor. You must make it absolutely clear that any such 213 | warranty, support, indemnity, or liability obligation is offered by You 214 | alone, and You hereby agree to indemnify every Contributor for any 215 | liability incurred by such Contributor as a result of warranty, support, 216 | indemnity or liability terms You offer. You may include additional 217 | disclaimers of warranty and limitations of liability specific to any 218 | jurisdiction. 219 | 220 | 4. Inability to Comply Due to Statute or Regulation 221 | 222 | If it is impossible for You to comply with any of the terms of this License 223 | with respect to some or all of the Covered Software due to statute, judicial 224 | order, or regulation then You must: (a) comply with the terms of this License 225 | to the maximum extent possible; and (b) describe the limitations and the code 226 | they affect. Such description must be placed in a text file included with all 227 | distributions of the Covered Software under this License. Except to the 228 | extent prohibited by statute or regulation, such description must be 229 | sufficiently detailed for a recipient of ordinary skill to be able to 230 | understand it. 231 | 232 | 5. Termination 233 | 234 | 5.1. The rights granted under this License will terminate automatically if You 235 | fail to comply with any of its terms. However, if You become compliant, 236 | then the rights granted under this License from a particular Contributor 237 | are reinstated (a) provisionally, unless and until such Contributor 238 | explicitly and finally terminates Your grants, and (b) on an ongoing basis, 239 | if such Contributor fails to notify You of the non-compliance by some 240 | reasonable means prior to 60 days after You have come back into compliance. 241 | Moreover, Your grants from a particular Contributor are reinstated on an 242 | ongoing basis if such Contributor notifies You of the non-compliance by 243 | some reasonable means, this is the first time You have received notice of 244 | non-compliance with this License from such Contributor, and You become 245 | compliant prior to 30 days after Your receipt of the notice. 246 | 247 | 5.2. If You initiate litigation against any entity by asserting a patent 248 | infringement claim (excluding declaratory judgment actions, counter-claims, 249 | and cross-claims) alleging that a Contributor Version directly or 250 | indirectly infringes any patent, then the rights granted to You by any and 251 | all Contributors for the Covered Software under Section 2.1 of this License 252 | shall terminate. 253 | 254 | 5.3. In the event of termination under Sections 5.1 or 5.2 above, all end user 255 | license agreements (excluding distributors and resellers) which have been 256 | validly granted by You or Your distributors under this License prior to 257 | termination shall survive termination. 258 | 259 | 6. Disclaimer of Warranty 260 | 261 | Covered Software is provided under this License on an “as is” basis, without 262 | warranty of any kind, either expressed, implied, or statutory, including, 263 | without limitation, warranties that the Covered Software is free of defects, 264 | merchantable, fit for a particular purpose or non-infringing. The entire 265 | risk as to the quality and performance of the Covered Software is with You. 266 | Should any Covered Software prove defective in any respect, You (not any 267 | Contributor) assume the cost of any necessary servicing, repair, or 268 | correction. This disclaimer of warranty constitutes an essential part of this 269 | License. No use of any Covered Software is authorized under this License 270 | except under this disclaimer. 271 | 272 | 7. Limitation of Liability 273 | 274 | Under no circumstances and under no legal theory, whether tort (including 275 | negligence), contract, or otherwise, shall any Contributor, or anyone who 276 | distributes Covered Software as permitted above, be liable to You for any 277 | direct, indirect, special, incidental, or consequential damages of any 278 | character including, without limitation, damages for lost profits, loss of 279 | goodwill, work stoppage, computer failure or malfunction, or any and all 280 | other commercial damages or losses, even if such party shall have been 281 | informed of the possibility of such damages. This limitation of liability 282 | shall not apply to liability for death or personal injury resulting from such 283 | party’s negligence to the extent applicable law prohibits such limitation. 284 | Some jurisdictions do not allow the exclusion or limitation of incidental or 285 | consequential damages, so this exclusion and limitation may not apply to You. 286 | 287 | 8. Litigation 288 | 289 | Any litigation relating to this License may be brought only in the courts of 290 | a jurisdiction where the defendant maintains its principal place of business 291 | and such litigation shall be governed by laws of that jurisdiction, without 292 | reference to its conflict-of-law provisions. Nothing in this Section shall 293 | prevent a party’s ability to bring cross-claims or counter-claims. 294 | 295 | 9. Miscellaneous 296 | 297 | This License represents the complete agreement concerning the subject matter 298 | hereof. If any provision of this License is held to be unenforceable, such 299 | provision shall be reformed only to the extent necessary to make it 300 | enforceable. Any law or regulation which provides that the language of a 301 | contract shall be construed against the drafter shall not be used to construe 302 | this License against a Contributor. 303 | 304 | 305 | 10. Versions of the License 306 | 307 | 10.1. New Versions 308 | 309 | Mozilla Foundation is the license steward. Except as provided in Section 310 | 10.3, no one other than the license steward has the right to modify or 311 | publish new versions of this License. Each version will be given a 312 | distinguishing version number. 313 | 314 | 10.2. Effect of New Versions 315 | 316 | You may distribute the Covered Software under the terms of the version of 317 | the License under which You originally received the Covered Software, or 318 | under the terms of any subsequent version published by the license 319 | steward. 320 | 321 | 10.3. Modified Versions 322 | 323 | If you create software not governed by this License, and you want to 324 | create a new license for such software, you may create and use a modified 325 | version of this License if you rename the license and remove any 326 | references to the name of the license steward (except to note that such 327 | modified license differs from this License). 328 | 329 | 10.4. Distributing Source Code Form that is Incompatible With Secondary Licenses 330 | If You choose to distribute Source Code Form that is Incompatible With 331 | Secondary Licenses under the terms of this version of the License, the 332 | notice described in Exhibit B of this License must be attached. 333 | 334 | Exhibit A - Source Code Form License Notice 335 | 336 | This Source Code Form is subject to the 337 | terms of the Mozilla Public License, v. 338 | 2.0. If a copy of the MPL was not 339 | distributed with this file, You can 340 | obtain one at 341 | http://mozilla.org/MPL/2.0/. 342 | 343 | If it is not possible or desirable to put the notice in a particular file, then 344 | You may include the notice in a location (such as a LICENSE file in a relevant 345 | directory) where a recipient would be likely to look for such a notice. 346 | 347 | You may add additional accurate notices of copyright ownership. 348 | 349 | Exhibit B - “Incompatible With Secondary Licenses” Notice 350 | 351 | This Source Code Form is “Incompatible 352 | With Secondary Licenses”, as defined by 353 | the Mozilla Public License, v. 2.0. 354 | 355 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | TEST?=./... 2 | 3 | default: test 4 | 5 | # test runs the test suite and vets the code 6 | test: 7 | go list $(TEST) | xargs -n1 go test -timeout=60s -parallel=10 $(TESTARGS) 8 | 9 | # testrace runs the race checker 10 | testrace: 11 | go list $(TEST) | xargs -n1 go test -race $(TESTARGS) 12 | 13 | # updatedeps installs all the dependencies to run and build 14 | updatedeps: 15 | go mod download 16 | 17 | .PHONY: test testrace updatedeps 18 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 🚨 This project is archived. 🚨 Learn More 3 |
4 | 5 |
6 | 7 | # Go CLI Library [![GoDoc](https://godoc.org/github.com/mitchellh/cli?status.png)](https://pkg.go.dev/github.com/mitchellh/cli) 8 | 9 | cli is a library for implementing command-line interfaces in Go. 10 | cli is the library that powers the CLI for 11 | [Packer](https://github.com/mitchellh/packer), 12 | [Consul](https://github.com/hashicorp/consul), 13 | [Vault](https://github.com/hashicorp/vault), 14 | [Terraform](https://github.com/hashicorp/terraform), 15 | [Nomad](https://github.com/hashicorp/nomad), and more. 16 | 17 | ## Features 18 | 19 | * Easy sub-command based CLIs: `cli foo`, `cli bar`, etc. 20 | 21 | * Support for nested subcommands such as `cli foo bar`. 22 | 23 | * Optional support for default subcommands so `cli` does something 24 | other than error. 25 | 26 | * Support for shell autocompletion of subcommands, flags, and arguments 27 | with callbacks in Go. You don't need to write any shell code. 28 | 29 | * Automatic help generation for listing subcommands. 30 | 31 | * Automatic help flag recognition of `-h`, `--help`, etc. 32 | 33 | * Automatic version flag recognition of `-v`, `--version`. 34 | 35 | * Helpers for interacting with the terminal, such as outputting information, 36 | asking for input, etc. These are optional, you can always interact with the 37 | terminal however you choose. 38 | 39 | * Use of Go interfaces/types makes augmenting various parts of the library a 40 | piece of cake. 41 | 42 | ## Example 43 | 44 | Below is a simple example of creating and running a CLI 45 | 46 | ```go 47 | package main 48 | 49 | import ( 50 | "log" 51 | "os" 52 | 53 | "github.com/mitchellh/cli" 54 | ) 55 | 56 | func main() { 57 | c := cli.NewCLI("app", "1.0.0") 58 | c.Args = os.Args[1:] 59 | c.Commands = map[string]cli.CommandFactory{ 60 | "foo": fooCommandFactory, 61 | "bar": barCommandFactory, 62 | } 63 | 64 | exitStatus, err := c.Run() 65 | if err != nil { 66 | log.Println(err) 67 | } 68 | 69 | os.Exit(exitStatus) 70 | } 71 | ``` 72 | 73 | -------------------------------------------------------------------------------- /autocomplete.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "github.com/posener/complete/cmd/install" 5 | ) 6 | 7 | // autocompleteInstaller is an interface to be implemented to perform the 8 | // autocomplete installation and uninstallation with a CLI. 9 | // 10 | // This interface is not exported because it only exists for unit tests 11 | // to be able to test that the installation is called properly. 12 | type autocompleteInstaller interface { 13 | Install(string) error 14 | Uninstall(string) error 15 | } 16 | 17 | // realAutocompleteInstaller uses the real install package to do the 18 | // install/uninstall. 19 | type realAutocompleteInstaller struct{} 20 | 21 | func (i *realAutocompleteInstaller) Install(cmd string) error { 22 | return install.Install(cmd) 23 | } 24 | 25 | func (i *realAutocompleteInstaller) Uninstall(cmd string) error { 26 | return install.Uninstall(cmd) 27 | } 28 | 29 | // mockAutocompleteInstaller is used for tests to record the install/uninstall. 30 | type mockAutocompleteInstaller struct { 31 | InstallCalled bool 32 | UninstallCalled bool 33 | } 34 | 35 | func (i *mockAutocompleteInstaller) Install(cmd string) error { 36 | i.InstallCalled = true 37 | return nil 38 | } 39 | 40 | func (i *mockAutocompleteInstaller) Uninstall(cmd string) error { 41 | i.UninstallCalled = true 42 | return nil 43 | } 44 | -------------------------------------------------------------------------------- /cli.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "io/ioutil" 7 | "os" 8 | "regexp" 9 | "sort" 10 | "strings" 11 | "sync" 12 | "text/template" 13 | 14 | "github.com/Masterminds/sprig/v3" 15 | "github.com/armon/go-radix" 16 | "github.com/posener/complete" 17 | ) 18 | 19 | // CLI contains the state necessary to run subcommands and parse the 20 | // command line arguments. 21 | // 22 | // CLI also supports nested subcommands, such as "cli foo bar". To use 23 | // nested subcommands, the key in the Commands mapping below contains the 24 | // full subcommand. In this example, it would be "foo bar". 25 | // 26 | // If you use a CLI with nested subcommands, some semantics change due to 27 | // ambiguities: 28 | // 29 | // * We use longest prefix matching to find a matching subcommand. This 30 | // means if you register "foo bar" and the user executes "cli foo qux", 31 | // the "foo" command will be executed with the arg "qux". It is up to 32 | // you to handle these args. One option is to just return the special 33 | // help return code `RunResultHelp` to display help and exit. 34 | // 35 | // * The help flag "-h" or "-help" will look at all args to determine 36 | // the help function. For example: "otto apps list -h" will show the 37 | // help for "apps list" but "otto apps -h" will show it for "apps". 38 | // In the normal CLI, only the first subcommand is used. 39 | // 40 | // * The help flag will list any subcommands that a command takes 41 | // as well as the command's help itself. If there are no subcommands, 42 | // it will note this. If the CLI itself has no subcommands, this entire 43 | // section is omitted. 44 | // 45 | // * Any parent commands that don't exist are automatically created as 46 | // no-op commands that just show help for other subcommands. For example, 47 | // if you only register "foo bar", then "foo" is automatically created. 48 | // 49 | type CLI struct { 50 | // Args is the list of command-line arguments received excluding 51 | // the name of the app. For example, if the command "./cli foo bar" 52 | // was invoked, then Args should be []string{"foo", "bar"}. 53 | Args []string 54 | 55 | // Commands is a mapping of subcommand names to a factory function 56 | // for creating that Command implementation. If there is a command 57 | // with a blank string "", then it will be used as the default command 58 | // if no subcommand is specified. 59 | // 60 | // If the key has a space in it, this will create a nested subcommand. 61 | // For example, if the key is "foo bar", then to access it our CLI 62 | // must be accessed with "./cli foo bar". See the docs for CLI for 63 | // notes on how this changes some other behavior of the CLI as well. 64 | // 65 | // The factory should be as cheap as possible, ideally only allocating 66 | // a struct. The factory may be called multiple times in the course 67 | // of a command execution and certain events such as help require the 68 | // instantiation of all commands. Expensive initialization should be 69 | // deferred to function calls within the interface implementation. 70 | Commands map[string]CommandFactory 71 | 72 | // HiddenCommands is a list of commands that are "hidden". Hidden 73 | // commands are not given to the help function callback and do not 74 | // show up in autocomplete. The values in the slice should be equivalent 75 | // to the keys in the command map. 76 | HiddenCommands []string 77 | 78 | // Name defines the name of the CLI. 79 | Name string 80 | 81 | // Version of the CLI. 82 | Version string 83 | 84 | // Autocomplete enables or disables subcommand auto-completion support. 85 | // This is enabled by default when NewCLI is called. Otherwise, this 86 | // must enabled explicitly. 87 | // 88 | // Autocomplete requires the "Name" option to be set on CLI. This name 89 | // should be set exactly to the binary name that is autocompleted. 90 | // 91 | // Autocompletion is supported via the github.com/posener/complete 92 | // library. This library supports bash, zsh and fish. To add support 93 | // for other shells, please see that library. 94 | // 95 | // AutocompleteInstall and AutocompleteUninstall are the global flag 96 | // names for installing and uninstalling the autocompletion handlers 97 | // for the user's shell. The flag should omit the hyphen(s) in front of 98 | // the value. Both single and double hyphens will automatically be supported 99 | // for the flag name. These default to `autocomplete-install` and 100 | // `autocomplete-uninstall` respectively. 101 | // 102 | // AutocompleteNoDefaultFlags is a boolean which controls if the default auto- 103 | // complete flags like -help and -version are added to the output. 104 | // 105 | // AutocompleteGlobalFlags are a mapping of global flags for 106 | // autocompletion. The help and version flags are automatically added. 107 | Autocomplete bool 108 | AutocompleteInstall string 109 | AutocompleteUninstall string 110 | AutocompleteNoDefaultFlags bool 111 | AutocompleteGlobalFlags complete.Flags 112 | autocompleteInstaller autocompleteInstaller // For tests 113 | 114 | // HelpFunc is the function called to generate the generic help 115 | // text that is shown if help must be shown for the CLI that doesn't 116 | // pertain to a specific command. 117 | HelpFunc HelpFunc 118 | 119 | // HelpWriter is used to print help text and version when requested. 120 | // Defaults to os.Stderr for backwards compatibility. 121 | // It is recommended that you set HelpWriter to os.Stdout, and 122 | // ErrorWriter to os.Stderr. 123 | HelpWriter io.Writer 124 | 125 | // ErrorWriter used to output errors when a command can not be run. 126 | // Defaults to the value of HelpWriter for backwards compatibility. 127 | // It is recommended that you set HelpWriter to os.Stdout, and 128 | // ErrorWriter to os.Stderr. 129 | ErrorWriter io.Writer 130 | 131 | //--------------------------------------------------------------- 132 | // Internal fields set automatically 133 | 134 | once sync.Once 135 | autocomplete *complete.Complete 136 | commandTree *radix.Tree 137 | commandNested bool 138 | commandHidden map[string]struct{} 139 | subcommand string 140 | subcommandArgs []string 141 | topFlags []string 142 | 143 | // These are true when special global flags are set. We can/should 144 | // probably use a bitset for this one day. 145 | isHelp bool 146 | isVersion bool 147 | isAutocompleteInstall bool 148 | isAutocompleteUninstall bool 149 | } 150 | 151 | // NewClI returns a new CLI instance with sensible defaults. 152 | func NewCLI(app, version string) *CLI { 153 | return &CLI{ 154 | Name: app, 155 | Version: version, 156 | HelpFunc: BasicHelpFunc(app), 157 | Autocomplete: true, 158 | } 159 | 160 | } 161 | 162 | // IsHelp returns whether or not the help flag is present within the 163 | // arguments. 164 | func (c *CLI) IsHelp() bool { 165 | c.once.Do(c.init) 166 | return c.isHelp 167 | } 168 | 169 | // IsVersion returns whether or not the version flag is present within the 170 | // arguments. 171 | func (c *CLI) IsVersion() bool { 172 | c.once.Do(c.init) 173 | return c.isVersion 174 | } 175 | 176 | // Run runs the actual CLI based on the arguments given. 177 | func (c *CLI) Run() (int, error) { 178 | c.once.Do(c.init) 179 | 180 | // If this is a autocompletion request, satisfy it. This must be called 181 | // first before anything else since its possible to be autocompleting 182 | // -help or -version or other flags and we want to show completions 183 | // and not actually write the help or version. 184 | if c.Autocomplete && c.autocomplete.Complete() { 185 | return 0, nil 186 | } 187 | 188 | // Just show the version and exit if instructed. 189 | if c.IsVersion() && c.Version != "" { 190 | c.HelpWriter.Write([]byte(c.Version + "\n")) 191 | return 0, nil 192 | } 193 | 194 | // Just print the help when only '-h' or '--help' is passed. 195 | if c.IsHelp() && c.Subcommand() == "" { 196 | c.HelpWriter.Write([]byte(c.HelpFunc(c.helpCommands(c.Subcommand())) + "\n")) 197 | return 0, nil 198 | } 199 | 200 | // If we're attempting to install or uninstall autocomplete then handle 201 | if c.Autocomplete { 202 | // Autocomplete requires the "Name" to be set so that we know what 203 | // command to setup the autocomplete on. 204 | if c.Name == "" { 205 | return 1, fmt.Errorf( 206 | "internal error: CLI.Name must be specified for autocomplete to work") 207 | } 208 | 209 | // If both install and uninstall flags are specified, then error 210 | if c.isAutocompleteInstall && c.isAutocompleteUninstall { 211 | return 1, fmt.Errorf( 212 | "Either the autocomplete install or uninstall flag may " + 213 | "be specified, but not both.") 214 | } 215 | 216 | // If the install flag is specified, perform the install or uninstall 217 | if c.isAutocompleteInstall { 218 | if err := c.autocompleteInstaller.Install(c.Name); err != nil { 219 | return 1, err 220 | } 221 | 222 | return 0, nil 223 | } 224 | 225 | if c.isAutocompleteUninstall { 226 | if err := c.autocompleteInstaller.Uninstall(c.Name); err != nil { 227 | return 1, err 228 | } 229 | 230 | return 0, nil 231 | } 232 | } 233 | 234 | // Attempt to get the factory function for creating the command 235 | // implementation. If the command is invalid or blank, it is an error. 236 | raw, ok := c.commandTree.Get(c.Subcommand()) 237 | if !ok { 238 | c.ErrorWriter.Write([]byte(c.HelpFunc(c.helpCommands(c.subcommandParent())) + "\n")) 239 | return 127, nil 240 | } 241 | 242 | command, err := raw.(CommandFactory)() 243 | if err != nil { 244 | return 1, err 245 | } 246 | 247 | // If we've been instructed to just print the help, then print it 248 | if c.IsHelp() { 249 | c.commandHelp(c.HelpWriter, command) 250 | return 0, nil 251 | } 252 | 253 | // If there is an invalid flag, then error 254 | if len(c.topFlags) > 0 { 255 | c.ErrorWriter.Write([]byte( 256 | "Invalid flags before the subcommand. If these flags are for\n" + 257 | "the subcommand, please put them after the subcommand.\n\n")) 258 | c.commandHelp(c.ErrorWriter, command) 259 | return 1, nil 260 | } 261 | 262 | code := command.Run(c.SubcommandArgs()) 263 | if code == RunResultHelp { 264 | // Requesting help 265 | c.commandHelp(c.ErrorWriter, command) 266 | return 1, nil 267 | } 268 | 269 | return code, nil 270 | } 271 | 272 | // Subcommand returns the subcommand that the CLI would execute. For 273 | // example, a CLI from "--version version --help" would return a Subcommand 274 | // of "version" 275 | func (c *CLI) Subcommand() string { 276 | c.once.Do(c.init) 277 | return c.subcommand 278 | } 279 | 280 | // SubcommandArgs returns the arguments that will be passed to the 281 | // subcommand. 282 | func (c *CLI) SubcommandArgs() []string { 283 | c.once.Do(c.init) 284 | return c.subcommandArgs 285 | } 286 | 287 | // subcommandParent returns the parent of this subcommand, if there is one. 288 | // If there isn't on, "" is returned. 289 | func (c *CLI) subcommandParent() string { 290 | // Get the subcommand, if it is "" alread just return 291 | sub := c.Subcommand() 292 | if sub == "" { 293 | return sub 294 | } 295 | 296 | // Clear any trailing spaces and find the last space 297 | sub = strings.TrimRight(sub, " ") 298 | idx := strings.LastIndex(sub, " ") 299 | 300 | if idx == -1 { 301 | // No space means our parent is root 302 | return "" 303 | } 304 | 305 | return sub[:idx] 306 | } 307 | 308 | func (c *CLI) init() { 309 | if c.HelpFunc == nil { 310 | c.HelpFunc = BasicHelpFunc("app") 311 | 312 | if c.Name != "" { 313 | c.HelpFunc = BasicHelpFunc(c.Name) 314 | } 315 | } 316 | 317 | if c.HelpWriter == nil { 318 | c.HelpWriter = os.Stderr 319 | } 320 | if c.ErrorWriter == nil { 321 | c.ErrorWriter = c.HelpWriter 322 | } 323 | 324 | // Build our hidden commands 325 | if len(c.HiddenCommands) > 0 { 326 | c.commandHidden = make(map[string]struct{}) 327 | for _, h := range c.HiddenCommands { 328 | c.commandHidden[h] = struct{}{} 329 | } 330 | } 331 | 332 | // Build our command tree 333 | c.commandTree = radix.New() 334 | c.commandNested = false 335 | for k, v := range c.Commands { 336 | k = strings.TrimSpace(k) 337 | c.commandTree.Insert(k, v) 338 | if strings.ContainsRune(k, ' ') { 339 | c.commandNested = true 340 | } 341 | } 342 | 343 | // Go through the key and fill in any missing parent commands 344 | if c.commandNested { 345 | var walkFn radix.WalkFn 346 | toInsert := make(map[string]struct{}) 347 | walkFn = func(k string, raw interface{}) bool { 348 | idx := strings.LastIndex(k, " ") 349 | if idx == -1 { 350 | // If there is no space, just ignore top level commands 351 | return false 352 | } 353 | 354 | // Trim up to that space so we can get the expected parent 355 | k = k[:idx] 356 | if _, ok := c.commandTree.Get(k); ok { 357 | // Yay we have the parent! 358 | return false 359 | } 360 | 361 | // We're missing the parent, so let's insert this 362 | toInsert[k] = struct{}{} 363 | 364 | // Call the walk function recursively so we check this one too 365 | return walkFn(k, nil) 366 | } 367 | 368 | // Walk! 369 | c.commandTree.Walk(walkFn) 370 | 371 | // Insert any that we're missing 372 | for k := range toInsert { 373 | var f CommandFactory = func() (Command, error) { 374 | return &MockCommand{ 375 | HelpText: "This command is accessed by using one of the subcommands below.", 376 | RunResult: RunResultHelp, 377 | }, nil 378 | } 379 | 380 | c.commandTree.Insert(k, f) 381 | } 382 | } 383 | 384 | // Setup autocomplete if we have it enabled. We have to do this after 385 | // the command tree is setup so we can use the radix tree to easily find 386 | // all subcommands. 387 | if c.Autocomplete { 388 | c.initAutocomplete() 389 | } 390 | 391 | // Process the args 392 | c.processArgs() 393 | } 394 | 395 | func (c *CLI) initAutocomplete() { 396 | if c.AutocompleteInstall == "" { 397 | c.AutocompleteInstall = defaultAutocompleteInstall 398 | } 399 | 400 | if c.AutocompleteUninstall == "" { 401 | c.AutocompleteUninstall = defaultAutocompleteUninstall 402 | } 403 | 404 | if c.autocompleteInstaller == nil { 405 | c.autocompleteInstaller = &realAutocompleteInstaller{} 406 | } 407 | 408 | // We first set c.autocomplete to a noop autocompleter that outputs 409 | // to nul so that we can detect if we're autocompleting or not. If we're 410 | // not, then we do nothing. This saves a LOT of compute cycles since 411 | // initAutoCompleteSub has to walk every command. 412 | c.autocomplete = complete.New(c.Name, complete.Command{}) 413 | c.autocomplete.Out = ioutil.Discard 414 | if !c.autocomplete.Complete() { 415 | return 416 | } 417 | 418 | // Build the root command 419 | cmd := c.initAutocompleteSub("") 420 | 421 | // For the root, we add the global flags to the "Flags". This way 422 | // they don't show up on every command. 423 | if !c.AutocompleteNoDefaultFlags { 424 | cmd.Flags = map[string]complete.Predictor{ 425 | "-" + c.AutocompleteInstall: complete.PredictNothing, 426 | "-" + c.AutocompleteUninstall: complete.PredictNothing, 427 | "-help": complete.PredictNothing, 428 | "-version": complete.PredictNothing, 429 | } 430 | } 431 | cmd.GlobalFlags = c.AutocompleteGlobalFlags 432 | 433 | c.autocomplete = complete.New(c.Name, cmd) 434 | } 435 | 436 | // initAutocompleteSub creates the complete.Command for a subcommand with 437 | // the given prefix. This will continue recursively for all subcommands. 438 | // The prefix "" (empty string) can be used for the root command. 439 | func (c *CLI) initAutocompleteSub(prefix string) complete.Command { 440 | var cmd complete.Command 441 | walkFn := func(k string, raw interface{}) bool { 442 | // Ignore the empty key which can be present for default commands. 443 | if k == "" { 444 | return false 445 | } 446 | 447 | // Keep track of the full key so that we can nest further if necessary 448 | fullKey := k 449 | 450 | if len(prefix) > 0 { 451 | // If we have a prefix, trim the prefix + 1 (for the space) 452 | // Example: turns "sub one" to "one" with prefix "sub" 453 | k = k[len(prefix)+1:] 454 | } 455 | 456 | if idx := strings.Index(k, " "); idx >= 0 { 457 | // If there is a space, we trim up to the space. This turns 458 | // "sub sub2 sub3" into "sub". The prefix trim above will 459 | // trim our current depth properly. 460 | k = k[:idx] 461 | } 462 | 463 | if _, ok := cmd.Sub[k]; ok { 464 | // If we already tracked this subcommand then ignore 465 | return false 466 | } 467 | 468 | // If the command is hidden, don't record it at all 469 | if _, ok := c.commandHidden[fullKey]; ok { 470 | return false 471 | } 472 | 473 | if cmd.Sub == nil { 474 | cmd.Sub = complete.Commands(make(map[string]complete.Command)) 475 | } 476 | subCmd := c.initAutocompleteSub(fullKey) 477 | 478 | // Instantiate the command so that we can check if the command is 479 | // a CommandAutocomplete implementation. If there is an error 480 | // creating the command, we just ignore it since that will be caught 481 | // later. 482 | impl, err := raw.(CommandFactory)() 483 | if err != nil { 484 | impl = nil 485 | } 486 | 487 | // Check if it implements ComandAutocomplete. If so, setup the autocomplete 488 | if c, ok := impl.(CommandAutocomplete); ok { 489 | subCmd.Args = c.AutocompleteArgs() 490 | subCmd.Flags = c.AutocompleteFlags() 491 | } 492 | 493 | cmd.Sub[k] = subCmd 494 | return false 495 | } 496 | 497 | walkPrefix := prefix 498 | if walkPrefix != "" { 499 | walkPrefix += " " 500 | } 501 | 502 | c.commandTree.WalkPrefix(walkPrefix, walkFn) 503 | return cmd 504 | } 505 | 506 | func (c *CLI) commandHelp(out io.Writer, command Command) { 507 | // Get the template to use 508 | tpl := strings.TrimSpace(defaultHelpTemplate) 509 | if t, ok := command.(CommandHelpTemplate); ok { 510 | tpl = t.HelpTemplate() 511 | } 512 | if !strings.HasSuffix(tpl, "\n") { 513 | tpl += "\n" 514 | } 515 | 516 | // Parse it 517 | t, err := template.New("root").Funcs(sprig.TxtFuncMap()).Parse(tpl) 518 | if err != nil { 519 | t = template.Must(template.New("root").Parse(fmt.Sprintf( 520 | "Internal error! Failed to parse command help template: %s\n", err))) 521 | } 522 | 523 | // Template data 524 | data := map[string]interface{}{ 525 | "Name": c.Name, 526 | "SubcommandName": c.Subcommand(), 527 | "Help": command.Help(), 528 | } 529 | 530 | // Build subcommand list if we have it 531 | var subcommandsTpl []map[string]interface{} 532 | if c.commandNested { 533 | // Get the matching keys 534 | subcommands := c.helpCommands(c.Subcommand()) 535 | keys := make([]string, 0, len(subcommands)) 536 | for k := range subcommands { 537 | keys = append(keys, k) 538 | } 539 | 540 | // Sort the keys 541 | sort.Strings(keys) 542 | 543 | // Figure out the padding length 544 | var longest int 545 | for _, k := range keys { 546 | if v := len(k); v > longest { 547 | longest = v 548 | } 549 | } 550 | 551 | // Go through and create their structures 552 | subcommandsTpl = make([]map[string]interface{}, 0, len(subcommands)) 553 | for _, k := range keys { 554 | // Get the command 555 | raw, ok := subcommands[k] 556 | if !ok { 557 | c.ErrorWriter.Write([]byte(fmt.Sprintf( 558 | "Error getting subcommand %q", k))) 559 | } 560 | sub, err := raw() 561 | if err != nil { 562 | c.ErrorWriter.Write([]byte(fmt.Sprintf( 563 | "Error instantiating %q: %s", k, err))) 564 | } 565 | 566 | // Find the last space and make sure we only include that last part 567 | name := k 568 | if idx := strings.LastIndex(k, " "); idx > -1 { 569 | name = name[idx+1:] 570 | } 571 | 572 | subcommandsTpl = append(subcommandsTpl, map[string]interface{}{ 573 | "Name": name, 574 | "NameAligned": name + strings.Repeat(" ", longest-len(k)), 575 | "Help": sub.Help(), 576 | "Synopsis": sub.Synopsis(), 577 | }) 578 | } 579 | } 580 | data["Subcommands"] = subcommandsTpl 581 | 582 | // Write 583 | err = t.Execute(out, data) 584 | if err == nil { 585 | return 586 | } 587 | 588 | // An error, just output... 589 | c.ErrorWriter.Write([]byte(fmt.Sprintf( 590 | "Internal error rendering help: %s", err))) 591 | } 592 | 593 | // helpCommands returns the subcommands for the HelpFunc argument. 594 | // This will only contain immediate subcommands. 595 | func (c *CLI) helpCommands(prefix string) map[string]CommandFactory { 596 | // If our prefix isn't empty, make sure it ends in ' ' 597 | if prefix != "" && prefix[len(prefix)-1] != ' ' { 598 | prefix += " " 599 | } 600 | 601 | // Get all the subkeys of this command 602 | var keys []string 603 | c.commandTree.WalkPrefix(prefix, func(k string, raw interface{}) bool { 604 | // Ignore any sub-sub keys, i.e. "foo bar baz" when we want "foo bar" 605 | if !strings.Contains(k[len(prefix):], " ") { 606 | keys = append(keys, k) 607 | } 608 | 609 | return false 610 | }) 611 | 612 | // For each of the keys return that in the map 613 | result := make(map[string]CommandFactory, len(keys)) 614 | for _, k := range keys { 615 | raw, ok := c.commandTree.Get(k) 616 | if !ok { 617 | // We just got it via WalkPrefix above, so we just panic 618 | panic("not found: " + k) 619 | } 620 | 621 | // If this is a hidden command, don't show it 622 | if _, ok := c.commandHidden[k]; ok { 623 | continue 624 | } 625 | 626 | result[k] = raw.(CommandFactory) 627 | } 628 | 629 | return result 630 | } 631 | 632 | func (c *CLI) processArgs() { 633 | for i, arg := range c.Args { 634 | if arg == "--" { 635 | break 636 | } 637 | 638 | // Check for help flags. 639 | if arg == "-h" || arg == "-help" || arg == "--help" { 640 | c.isHelp = true 641 | continue 642 | } 643 | 644 | // Check for autocomplete flags 645 | if c.Autocomplete { 646 | if arg == "-"+c.AutocompleteInstall || arg == "--"+c.AutocompleteInstall { 647 | c.isAutocompleteInstall = true 648 | continue 649 | } 650 | 651 | if arg == "-"+c.AutocompleteUninstall || arg == "--"+c.AutocompleteUninstall { 652 | c.isAutocompleteUninstall = true 653 | continue 654 | } 655 | } 656 | 657 | if c.subcommand == "" { 658 | // Check for version flags if not in a subcommand. 659 | if arg == "-v" || arg == "-version" || arg == "--version" { 660 | c.isVersion = true 661 | continue 662 | } 663 | 664 | if arg != "" && arg[0] == '-' { 665 | // Record the arg... 666 | c.topFlags = append(c.topFlags, arg) 667 | } 668 | } 669 | 670 | // If we didn't find a subcommand yet and this is the first non-flag 671 | // argument, then this is our subcommand. 672 | if c.subcommand == "" && arg != "" && arg[0] != '-' { 673 | c.subcommand = arg 674 | if c.commandNested { 675 | // If the command has a space in it, then it is invalid. 676 | // Set a blank command so that it fails. 677 | if strings.ContainsRune(arg, ' ') { 678 | c.subcommand = "" 679 | return 680 | } 681 | 682 | // Determine the argument we look to to end subcommands. 683 | // We look at all arguments until one is a flag or has a space. 684 | // This disallows commands like: ./cli foo "bar baz". An 685 | // argument with a space is always an argument. A blank 686 | // argument is always an argument. 687 | j := 0 688 | for k, v := range c.Args[i:] { 689 | if strings.ContainsRune(v, ' ') || v == "" || v[0] == '-' { 690 | break 691 | } 692 | 693 | j = i + k + 1 694 | } 695 | 696 | // Nested CLI, the subcommand is actually the entire 697 | // arg list up to a flag that is still a valid subcommand. 698 | searchKey := strings.Join(c.Args[i:j], " ") 699 | k, _, ok := c.commandTree.LongestPrefix(searchKey) 700 | if ok { 701 | // k could be a prefix that doesn't contain the full 702 | // command such as "foo" instead of "foobar", so we 703 | // need to verify that we have an entire key. To do that, 704 | // we look for an ending in a space or an end of string. 705 | reVerify := regexp.MustCompile(regexp.QuoteMeta(k) + `( |$)`) 706 | if reVerify.MatchString(searchKey) { 707 | c.subcommand = k 708 | i += strings.Count(k, " ") 709 | } 710 | } 711 | } 712 | 713 | // The remaining args the subcommand arguments 714 | c.subcommandArgs = c.Args[i+1:] 715 | } 716 | } 717 | 718 | // If we never found a subcommand and support a default command, then 719 | // switch to using that. 720 | if c.subcommand == "" { 721 | if _, ok := c.Commands[""]; ok { 722 | args := c.topFlags 723 | args = append(args, c.subcommandArgs...) 724 | c.topFlags = nil 725 | c.subcommandArgs = args 726 | } 727 | } 728 | } 729 | 730 | // defaultAutocompleteInstall and defaultAutocompleteUninstall are the 731 | // default values for the autocomplete install and uninstall flags. 732 | const defaultAutocompleteInstall = "autocomplete-install" 733 | const defaultAutocompleteUninstall = "autocomplete-uninstall" 734 | 735 | const defaultHelpTemplate = ` 736 | {{.Help}}{{if gt (len .Subcommands) 0}} 737 | 738 | Subcommands: 739 | {{- range $value := .Subcommands }} 740 | {{ $value.NameAligned }} {{ $value.Synopsis }}{{ end }} 741 | {{- end }} 742 | ` 743 | -------------------------------------------------------------------------------- /cli_test.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io" 7 | "os" 8 | "reflect" 9 | "sort" 10 | "strings" 11 | "testing" 12 | 13 | "github.com/posener/complete" 14 | ) 15 | 16 | // envComplete is the env var that the complete library sets to specify 17 | // it should be calculating an auto-completion. This isn't exported so we 18 | // reproduce it here. If it changes then we'll have to update this. 19 | const envComplete = "COMP_LINE" 20 | 21 | func TestCLIIsHelp(t *testing.T) { 22 | testCases := []struct { 23 | args []string 24 | isHelp bool 25 | }{ 26 | {[]string{"-h"}, true}, 27 | {[]string{"-help"}, true}, 28 | {[]string{"--help"}, true}, 29 | {[]string{"-h", "foo"}, true}, 30 | {[]string{"foo", "bar"}, false}, 31 | {[]string{"-v", "bar"}, false}, 32 | {[]string{"foo", "-h"}, true}, 33 | {[]string{"foo", "-help"}, true}, 34 | {[]string{"foo", "--help"}, true}, 35 | {[]string{"foo", "bar", "-h"}, true}, 36 | {[]string{"foo", "bar", "-help"}, true}, 37 | {[]string{"foo", "bar", "--help"}, true}, 38 | {[]string{"foo", "bar", "--", "zip", "-h"}, false}, 39 | {[]string{"foo", "bar", "--", "zip", "-help"}, false}, 40 | {[]string{"foo", "bar", "--", "zip", "--help"}, false}, 41 | } 42 | 43 | for _, testCase := range testCases { 44 | cli := &CLI{Args: testCase.args} 45 | result := cli.IsHelp() 46 | 47 | if result != testCase.isHelp { 48 | t.Errorf("Expected '%#v'. Args: %#v", testCase.isHelp, testCase.args) 49 | } 50 | } 51 | } 52 | 53 | func TestCLIIsVersion(t *testing.T) { 54 | testCases := []struct { 55 | args []string 56 | isVersion bool 57 | }{ 58 | {[]string{"--", "-v"}, false}, 59 | {[]string{"--", "-version"}, false}, 60 | {[]string{"--", "--version"}, false}, 61 | {[]string{"-v"}, true}, 62 | {[]string{"-version"}, true}, 63 | {[]string{"--version"}, true}, 64 | {[]string{"-v", "foo"}, true}, 65 | {[]string{"foo", "bar"}, false}, 66 | {[]string{"-h", "bar"}, false}, 67 | {[]string{"foo", "-v"}, false}, 68 | {[]string{"foo", "-version"}, false}, 69 | {[]string{"foo", "--version"}, false}, 70 | {[]string{"foo", "--", "zip", "-v"}, false}, 71 | {[]string{"foo", "--", "zip", "-version"}, false}, 72 | {[]string{"foo", "--", "zip", "--version"}, false}, 73 | } 74 | 75 | for _, testCase := range testCases { 76 | cli := &CLI{Args: testCase.args} 77 | result := cli.IsVersion() 78 | 79 | if result != testCase.isVersion { 80 | t.Errorf("Expected '%#v'. Args: %#v", testCase.isVersion, testCase.args) 81 | } 82 | } 83 | } 84 | 85 | func TestCLIRun(t *testing.T) { 86 | command := new(MockCommand) 87 | cli := &CLI{ 88 | Args: []string{"foo", "-bar", "-baz"}, 89 | Commands: map[string]CommandFactory{ 90 | "foo": func() (Command, error) { 91 | return command, nil 92 | }, 93 | }, 94 | } 95 | 96 | exitCode, err := cli.Run() 97 | if err != nil { 98 | t.Fatalf("err: %s", err) 99 | } 100 | 101 | if exitCode != command.RunResult { 102 | t.Fatalf("bad: %d", exitCode) 103 | } 104 | 105 | if !command.RunCalled { 106 | t.Fatalf("run should be called") 107 | } 108 | 109 | if !reflect.DeepEqual(command.RunArgs, []string{"-bar", "-baz"}) { 110 | t.Fatalf("bad args: %#v", command.RunArgs) 111 | } 112 | } 113 | 114 | func TestCLIRun_blank(t *testing.T) { 115 | command := new(MockCommand) 116 | cli := &CLI{ 117 | Args: []string{"", "foo", "-bar", "-baz"}, 118 | Commands: map[string]CommandFactory{ 119 | "foo": func() (Command, error) { 120 | return command, nil 121 | }, 122 | }, 123 | } 124 | 125 | exitCode, err := cli.Run() 126 | if err != nil { 127 | t.Fatalf("err: %s", err) 128 | } 129 | 130 | if exitCode != command.RunResult { 131 | t.Fatalf("bad: %d", exitCode) 132 | } 133 | 134 | if !command.RunCalled { 135 | t.Fatalf("run should be called") 136 | } 137 | 138 | if !reflect.DeepEqual(command.RunArgs, []string{"-bar", "-baz"}) { 139 | t.Fatalf("bad args: %#v", command.RunArgs) 140 | } 141 | } 142 | 143 | func TestCLIRun_prefix(t *testing.T) { 144 | buf := new(bytes.Buffer) 145 | command := new(MockCommand) 146 | cli := &CLI{ 147 | Args: []string{"foobar"}, 148 | Commands: map[string]CommandFactory{ 149 | "foo": func() (Command, error) { 150 | return command, nil 151 | }, 152 | 153 | "foo bar": func() (Command, error) { 154 | return command, nil 155 | }, 156 | }, 157 | ErrorWriter: buf, 158 | } 159 | 160 | exitCode, err := cli.Run() 161 | if err != nil { 162 | t.Fatalf("err: %s", err) 163 | } 164 | 165 | if exitCode != 127 { 166 | t.Fatalf("bad: %d", exitCode) 167 | } 168 | 169 | if command.RunCalled { 170 | t.Fatalf("run should not be called") 171 | } 172 | } 173 | 174 | func TestCLIRun_subcommandSuffix(t *testing.T) { 175 | buf := new(bytes.Buffer) 176 | command := new(MockCommand) 177 | cli := &CLI{ 178 | Args: []string{"fooasdf", "-o=foo"}, 179 | Commands: map[string]CommandFactory{ 180 | "foo": func() (Command, error) { 181 | return command, nil 182 | }, 183 | 184 | "foo bar": func() (Command, error) { 185 | return command, nil 186 | }, 187 | }, 188 | ErrorWriter: buf, 189 | } 190 | 191 | exitCode, err := cli.Run() 192 | if err != nil { 193 | t.Fatalf("err: %s", err) 194 | } 195 | 196 | if exitCode != 127 { 197 | t.Fatalf("expected to get exit code 127, but got %d", exitCode) 198 | } 199 | 200 | if command.RunCalled { 201 | t.Fatalf("run should not be called") 202 | } 203 | } 204 | 205 | func TestCLIRun_default(t *testing.T) { 206 | commandBar := new(MockCommand) 207 | commandBar.RunResult = 42 208 | 209 | cli := &CLI{ 210 | Args: []string{"-bar", "-baz"}, 211 | Commands: map[string]CommandFactory{ 212 | "": func() (Command, error) { 213 | return commandBar, nil 214 | }, 215 | "foo": func() (Command, error) { 216 | return new(MockCommand), nil 217 | }, 218 | }, 219 | } 220 | 221 | exitCode, err := cli.Run() 222 | if err != nil { 223 | t.Fatalf("err: %s", err) 224 | } 225 | 226 | if exitCode != commandBar.RunResult { 227 | t.Fatalf("bad: %d", exitCode) 228 | } 229 | 230 | if !commandBar.RunCalled { 231 | t.Fatalf("run should be called") 232 | } 233 | 234 | if !reflect.DeepEqual(commandBar.RunArgs, []string{"-bar", "-baz"}) { 235 | t.Fatalf("bad args: %#v", commandBar.RunArgs) 236 | } 237 | } 238 | 239 | // GH-74: When using NewCLI with a default command only, Run would 240 | // stack overflow and crash. 241 | func TestCLIRun_defaultFromNew(t *testing.T) { 242 | commandBar := new(MockCommand) 243 | 244 | cli := NewCLI("test", "0.1.0") 245 | cli.Commands = map[string]CommandFactory{ 246 | "": func() (Command, error) { 247 | return commandBar, nil 248 | }, 249 | } 250 | 251 | exitCode, err := cli.Run() 252 | if err != nil { 253 | t.Fatalf("err: %s", err) 254 | } 255 | 256 | if exitCode != commandBar.RunResult { 257 | t.Fatalf("bad: %d", exitCode) 258 | } 259 | 260 | if !commandBar.RunCalled { 261 | t.Fatalf("run should be called") 262 | } 263 | } 264 | 265 | func TestCLIRun_helpNested(t *testing.T) { 266 | helpCalled := false 267 | buf := new(bytes.Buffer) 268 | cli := &CLI{ 269 | Args: []string{"--help"}, 270 | Commands: map[string]CommandFactory{ 271 | "foo sub42": func() (Command, error) { 272 | return new(MockCommand), nil 273 | }, 274 | }, 275 | HelpFunc: func(m map[string]CommandFactory) string { 276 | helpCalled = true 277 | 278 | var keys []string 279 | for k := range m { 280 | keys = append(keys, k) 281 | } 282 | sort.Strings(keys) 283 | 284 | expected := []string{"foo"} 285 | if !reflect.DeepEqual(keys, expected) { 286 | return fmt.Sprintf("error: contained sub: %#v", keys) 287 | } 288 | 289 | return "" 290 | }, 291 | ErrorWriter: buf, 292 | } 293 | 294 | code, err := cli.Run() 295 | if err != nil { 296 | t.Fatalf("Error: %s", err) 297 | } 298 | 299 | if code != 0 { 300 | t.Fatalf("Code: %d", code) 301 | } 302 | 303 | if !helpCalled { 304 | t.Fatal("help not called") 305 | } 306 | } 307 | 308 | func TestCLIRun_nested(t *testing.T) { 309 | command := new(MockCommand) 310 | cli := &CLI{ 311 | Args: []string{"foo", "bar", "-bar", "-baz"}, 312 | Commands: map[string]CommandFactory{ 313 | "foo": func() (Command, error) { 314 | return new(MockCommand), nil 315 | }, 316 | "foo bar": func() (Command, error) { 317 | return command, nil 318 | }, 319 | }, 320 | } 321 | 322 | exitCode, err := cli.Run() 323 | if err != nil { 324 | t.Fatalf("err: %s", err) 325 | } 326 | 327 | if exitCode != command.RunResult { 328 | t.Fatalf("bad: %d", exitCode) 329 | } 330 | 331 | if !command.RunCalled { 332 | t.Fatalf("run should be called") 333 | } 334 | 335 | if !reflect.DeepEqual(command.RunArgs, []string{"-bar", "-baz"}) { 336 | t.Fatalf("bad args: %#v", command.RunArgs) 337 | } 338 | } 339 | 340 | func TestCLIRun_nestedTopLevel(t *testing.T) { 341 | command := new(MockCommand) 342 | cli := &CLI{ 343 | Args: []string{"foo"}, 344 | Commands: map[string]CommandFactory{ 345 | "foo": func() (Command, error) { 346 | return command, nil 347 | }, 348 | "foo bar": func() (Command, error) { 349 | return new(MockCommand), nil 350 | }, 351 | }, 352 | } 353 | 354 | exitCode, err := cli.Run() 355 | if err != nil { 356 | t.Fatalf("err: %s", err) 357 | } 358 | 359 | if exitCode != command.RunResult { 360 | t.Fatalf("bad: %d", exitCode) 361 | } 362 | 363 | if !command.RunCalled { 364 | t.Fatalf("run should be called") 365 | } 366 | 367 | if !reflect.DeepEqual(command.RunArgs, []string{}) { 368 | t.Fatalf("bad args: %#v", command.RunArgs) 369 | } 370 | } 371 | 372 | func TestCLIRun_nestedMissingParent(t *testing.T) { 373 | buf := new(bytes.Buffer) 374 | cli := &CLI{ 375 | Args: []string{"foo"}, 376 | Commands: map[string]CommandFactory{ 377 | "foo bar": func() (Command, error) { 378 | return &MockCommand{SynopsisText: "hi!"}, nil 379 | }, 380 | }, 381 | ErrorWriter: buf, 382 | } 383 | 384 | exitCode, err := cli.Run() 385 | if err != nil { 386 | t.Fatalf("err: %s", err) 387 | } 388 | 389 | if exitCode != 1 { 390 | t.Fatalf("bad exit code: %d", exitCode) 391 | } 392 | 393 | if buf.String() != testCommandNestedMissingParent { 394 | t.Fatalf("bad: %#v", buf.String()) 395 | } 396 | } 397 | 398 | func TestCLIRun_nestedNoArgs(t *testing.T) { 399 | command := new(MockCommand) 400 | cli := &CLI{ 401 | Args: []string{"foo", "bar"}, 402 | Commands: map[string]CommandFactory{ 403 | "foo": func() (Command, error) { 404 | return new(MockCommand), nil 405 | }, 406 | "foo bar": func() (Command, error) { 407 | return command, nil 408 | }, 409 | }, 410 | } 411 | 412 | exitCode, err := cli.Run() 413 | if err != nil { 414 | t.Fatalf("err: %s", err) 415 | } 416 | 417 | if exitCode != command.RunResult { 418 | t.Fatalf("bad: %d", exitCode) 419 | } 420 | 421 | if !command.RunCalled { 422 | t.Fatalf("run should be called") 423 | } 424 | 425 | if !reflect.DeepEqual(command.RunArgs, []string{}) { 426 | t.Fatalf("bad args: %#v", command.RunArgs) 427 | } 428 | } 429 | 430 | func TestCLIRun_nestedBlankArg(t *testing.T) { 431 | command := new(MockCommand) 432 | cli := &CLI{ 433 | Args: []string{"foo", "", "bar", "-baz"}, 434 | Commands: map[string]CommandFactory{ 435 | "foo": func() (Command, error) { 436 | return command, nil 437 | }, 438 | "foo bar": func() (Command, error) { 439 | return new(MockCommand), nil 440 | }, 441 | }, 442 | } 443 | 444 | exitCode, err := cli.Run() 445 | if err != nil { 446 | t.Fatalf("err: %s", err) 447 | } 448 | 449 | if exitCode != command.RunResult { 450 | t.Fatalf("bad: %d", exitCode) 451 | } 452 | 453 | if !command.RunCalled { 454 | t.Fatalf("run should be called") 455 | } 456 | 457 | if !reflect.DeepEqual(command.RunArgs, []string{"", "bar", "-baz"}) { 458 | t.Fatalf("bad args: %#v", command.RunArgs) 459 | } 460 | } 461 | 462 | func TestCLIRun_nestedQuotedCommand(t *testing.T) { 463 | command := new(MockCommand) 464 | cli := &CLI{ 465 | Args: []string{"foo bar"}, 466 | Commands: map[string]CommandFactory{ 467 | "foo": func() (Command, error) { 468 | return new(MockCommand), nil 469 | }, 470 | "foo bar": func() (Command, error) { 471 | return command, nil 472 | }, 473 | }, 474 | } 475 | 476 | exitCode, err := cli.Run() 477 | if err != nil { 478 | t.Fatalf("err: %s", err) 479 | } 480 | 481 | if exitCode != 127 { 482 | t.Fatalf("bad: %d", exitCode) 483 | } 484 | } 485 | 486 | func TestCLIRun_nestedQuotedArg(t *testing.T) { 487 | command := new(MockCommand) 488 | cli := &CLI{ 489 | Args: []string{"foo", "bar baz"}, 490 | Commands: map[string]CommandFactory{ 491 | "foo": func() (Command, error) { 492 | return command, nil 493 | }, 494 | "foo bar": func() (Command, error) { 495 | return new(MockCommand), nil 496 | }, 497 | }, 498 | } 499 | 500 | exitCode, err := cli.Run() 501 | if err != nil { 502 | t.Fatalf("err: %s", err) 503 | } 504 | 505 | if exitCode != command.RunResult { 506 | t.Fatalf("bad: %d", exitCode) 507 | } 508 | 509 | if !command.RunCalled { 510 | t.Fatalf("run should be called") 511 | } 512 | 513 | if !reflect.DeepEqual(command.RunArgs, []string{"bar baz"}) { 514 | t.Fatalf("bad args: %#v", command.RunArgs) 515 | } 516 | } 517 | 518 | func TestCLIRun_printHelp(t *testing.T) { 519 | testCases := [][]string{ 520 | {"-h"}, 521 | {"--help"}, 522 | } 523 | 524 | for _, testCase := range testCases { 525 | buf := new(bytes.Buffer) 526 | helpText := "foo" 527 | 528 | cli := &CLI{ 529 | Args: testCase, 530 | Commands: map[string]CommandFactory{ 531 | "foo": func() (Command, error) { 532 | return new(MockCommand), nil 533 | }, 534 | }, 535 | HelpFunc: func(map[string]CommandFactory) string { 536 | return helpText 537 | }, 538 | HelpWriter: buf, 539 | } 540 | 541 | code, err := cli.Run() 542 | if err != nil { 543 | t.Errorf("Args: %#v. Error: %s", testCase, err) 544 | continue 545 | } 546 | 547 | if code != 0 { 548 | t.Errorf("Args: %#v. Code: %d", testCase, code) 549 | continue 550 | } 551 | 552 | if !strings.Contains(buf.String(), helpText) { 553 | t.Errorf("Args: %#v. Text: %v", testCase, buf.String()) 554 | } 555 | } 556 | } 557 | 558 | func TestCLIRun_printHelpIllegal(t *testing.T) { 559 | testCases := []struct { 560 | args []string 561 | exit int 562 | }{ 563 | {nil, 127}, 564 | {[]string{"i-dont-exist"}, 127}, 565 | {[]string{"-bad-flag", "foo"}, 1}, 566 | } 567 | 568 | for _, testCase := range testCases { 569 | buf := new(bytes.Buffer) 570 | helpText := "foo" 571 | 572 | cli := &CLI{ 573 | Args: testCase.args, 574 | Commands: map[string]CommandFactory{ 575 | "foo": func() (Command, error) { 576 | return &MockCommand{HelpText: helpText}, nil 577 | }, 578 | "foo sub42": func() (Command, error) { 579 | return new(MockCommand), nil 580 | }, 581 | }, 582 | HelpFunc: func(m map[string]CommandFactory) string { 583 | var keys []string 584 | for k := range m { 585 | keys = append(keys, k) 586 | } 587 | sort.Strings(keys) 588 | 589 | expected := []string{"foo"} 590 | if !reflect.DeepEqual(keys, expected) { 591 | return fmt.Sprintf("error: contained sub: %#v", keys) 592 | } 593 | 594 | return helpText 595 | }, 596 | ErrorWriter: buf, 597 | } 598 | 599 | code, err := cli.Run() 600 | if err != nil { 601 | t.Errorf("Args: %#v. Error: %s", testCase, err) 602 | continue 603 | } 604 | 605 | if code != testCase.exit { 606 | t.Errorf("Args: %#v. Code: %d", testCase, code) 607 | continue 608 | } 609 | 610 | if strings.Contains(buf.String(), "error") { 611 | t.Errorf("Args: %#v. Text: %v", testCase, buf.String()) 612 | } 613 | 614 | if !strings.Contains(buf.String(), helpText) { 615 | t.Errorf("Args: %#v. Text: %v", testCase, buf.String()) 616 | } 617 | } 618 | } 619 | 620 | func TestCLIRun_printCommandHelp(t *testing.T) { 621 | testCases := [][]string{ 622 | {"--help", "foo"}, 623 | {"-h", "foo"}, 624 | } 625 | 626 | for _, args := range testCases { 627 | command := &MockCommand{ 628 | HelpText: "donuts", 629 | } 630 | 631 | buf := new(bytes.Buffer) 632 | cli := &CLI{ 633 | Args: args, 634 | Commands: map[string]CommandFactory{ 635 | "foo": func() (Command, error) { 636 | return command, nil 637 | }, 638 | }, 639 | HelpWriter: buf, 640 | } 641 | 642 | exitCode, err := cli.Run() 643 | if err != nil { 644 | t.Fatalf("err: %s", err) 645 | } 646 | 647 | if exitCode != 0 { 648 | t.Fatalf("bad exit code: %d", exitCode) 649 | } 650 | 651 | if buf.String() != (command.HelpText + "\n") { 652 | t.Fatalf("bad: %#v", buf.String()) 653 | } 654 | } 655 | } 656 | 657 | func TestCLIRun_printCommandHelpNested(t *testing.T) { 658 | testCases := [][]string{ 659 | {"--help", "foo", "bar"}, 660 | {"-h", "foo", "bar"}, 661 | } 662 | 663 | for _, args := range testCases { 664 | command := &MockCommand{ 665 | HelpText: "donuts", 666 | } 667 | 668 | buf := new(bytes.Buffer) 669 | cli := &CLI{ 670 | Args: args, 671 | Commands: map[string]CommandFactory{ 672 | "foo bar": func() (Command, error) { 673 | return command, nil 674 | }, 675 | }, 676 | HelpWriter: buf, 677 | } 678 | 679 | exitCode, err := cli.Run() 680 | if err != nil { 681 | t.Fatalf("err: %s", err) 682 | } 683 | 684 | if exitCode != 0 { 685 | t.Fatalf("bad exit code: %d", exitCode) 686 | } 687 | 688 | if buf.String() != (command.HelpText + "\n") { 689 | t.Fatalf("bad: %#v", buf.String()) 690 | } 691 | } 692 | } 693 | 694 | func TestCLIRun_printCommandHelpSubcommands(t *testing.T) { 695 | testCases := [][]string{ 696 | {"--help", "foo"}, 697 | {"-h", "foo"}, 698 | } 699 | 700 | for _, args := range testCases { 701 | command := &MockCommand{ 702 | HelpText: "donuts", 703 | } 704 | 705 | buf := new(bytes.Buffer) 706 | cli := &CLI{ 707 | Args: args, 708 | Commands: map[string]CommandFactory{ 709 | "foo": func() (Command, error) { 710 | return command, nil 711 | }, 712 | "foo bar": func() (Command, error) { 713 | return &MockCommand{SynopsisText: "hi!"}, nil 714 | }, 715 | "foo zip": func() (Command, error) { 716 | return &MockCommand{SynopsisText: "hi!"}, nil 717 | }, 718 | "foo zap": func() (Command, error) { 719 | return &MockCommand{SynopsisText: "hi!"}, nil 720 | }, 721 | "foo banana": func() (Command, error) { 722 | return &MockCommand{SynopsisText: "hi!"}, nil 723 | }, 724 | "foo longer": func() (Command, error) { 725 | return &MockCommand{SynopsisText: "hi!"}, nil 726 | }, 727 | "foo longer longest": func() (Command, error) { 728 | return &MockCommand{SynopsisText: "hi!"}, nil 729 | }, 730 | }, 731 | HelpWriter: buf, 732 | } 733 | 734 | exitCode, err := cli.Run() 735 | if err != nil { 736 | t.Fatalf("err: %s", err) 737 | } 738 | 739 | if exitCode != 0 { 740 | t.Fatalf("bad exit code: %d", exitCode) 741 | } 742 | 743 | if buf.String() != testCommandHelpSubcommandsOutput { 744 | t.Fatalf("bad: %#v\n\n'%#v'\n\n'%#v'", args, buf.String(), testCommandHelpSubcommandsOutput) 745 | } 746 | } 747 | } 748 | 749 | func TestCLIRun_printCommandHelpSubcommandsNestedTwoLevel(t *testing.T) { 750 | testCases := [][]string{ 751 | {"--help", "L1"}, 752 | {"-h", "L1"}, 753 | } 754 | 755 | for _, args := range testCases { 756 | command := &MockCommand{ 757 | HelpText: "donuts", 758 | } 759 | 760 | buf := new(bytes.Buffer) 761 | cli := &CLI{ 762 | Args: args, 763 | Commands: map[string]CommandFactory{ 764 | "L1": func() (Command, error) { 765 | return command, nil 766 | }, 767 | "L1 L2A": func() (Command, error) { 768 | return &MockCommand{SynopsisText: "hi!"}, nil 769 | }, 770 | "L1 L2B": func() (Command, error) { 771 | return &MockCommand{SynopsisText: "hi!"}, nil 772 | }, 773 | "L1 L2A L3A": func() (Command, error) { 774 | return &MockCommand{SynopsisText: "hi!"}, nil 775 | }, 776 | "L1 L2A L3B": func() (Command, error) { 777 | return &MockCommand{SynopsisText: "hi!"}, nil 778 | }, 779 | }, 780 | HelpWriter: buf, 781 | } 782 | 783 | exitCode, err := cli.Run() 784 | if err != nil { 785 | t.Fatalf("err: %s", err) 786 | } 787 | 788 | if exitCode != 0 { 789 | t.Fatalf("bad exit code: %d", exitCode) 790 | } 791 | 792 | if buf.String() != testCommandHelpSubcommandsTwoLevelOutput { 793 | t.Fatalf("bad: %#v\n\n%s\n\n%s", args, buf.String(), testCommandHelpSubcommandsOutput) 794 | } 795 | } 796 | } 797 | 798 | // Test that the root help only prints the root level. 799 | func TestCLIRun_printHelpRootSubcommands(t *testing.T) { 800 | testCases := [][]string{ 801 | {"--help"}, 802 | {"-h"}, 803 | } 804 | 805 | for _, args := range testCases { 806 | buf := new(bytes.Buffer) 807 | cli := &CLI{ 808 | Args: args, 809 | Commands: map[string]CommandFactory{ 810 | "bar": func() (Command, error) { 811 | return &MockCommand{SynopsisText: "hi!"}, nil 812 | }, 813 | "foo": func() (Command, error) { 814 | return &MockCommand{SynopsisText: "hi!"}, nil 815 | }, 816 | "foo bar": func() (Command, error) { 817 | return &MockCommand{SynopsisText: "hi!"}, nil 818 | }, 819 | "foo zip": func() (Command, error) { 820 | return &MockCommand{SynopsisText: "hi!"}, nil 821 | }, 822 | }, 823 | HelpWriter: buf, 824 | } 825 | 826 | exitCode, err := cli.Run() 827 | if err != nil { 828 | t.Fatalf("err: %s", err) 829 | } 830 | 831 | if exitCode != 0 { 832 | t.Fatalf("bad exit code: %d", exitCode) 833 | } 834 | 835 | expected := `Usage: app [--version] [--help] [] 836 | 837 | Available commands are: 838 | bar hi! 839 | foo hi! 840 | 841 | ` 842 | if buf.String() != expected { 843 | t.Fatalf("bad: %#v\n\n'%#v'\n\n'%#v'", args, buf.String(), expected) 844 | } 845 | } 846 | } 847 | 848 | func TestCLIRun_printCommandHelpTemplate(t *testing.T) { 849 | testCases := [][]string{ 850 | {"--help", "foo"}, 851 | {"-h", "foo"}, 852 | } 853 | 854 | for _, args := range testCases { 855 | command := &MockCommandHelpTemplate{ 856 | MockCommand: MockCommand{ 857 | HelpText: "donuts", 858 | }, 859 | 860 | HelpTemplateText: "hello {{.Help}}", 861 | } 862 | 863 | buf := new(bytes.Buffer) 864 | cli := &CLI{ 865 | Args: args, 866 | Commands: map[string]CommandFactory{ 867 | "foo": func() (Command, error) { 868 | return command, nil 869 | }, 870 | }, 871 | HelpWriter: buf, 872 | } 873 | 874 | exitCode, err := cli.Run() 875 | if err != nil { 876 | t.Fatalf("err: %s", err) 877 | } 878 | 879 | if exitCode != 0 { 880 | t.Fatalf("bad exit code: %d", exitCode) 881 | } 882 | 883 | if buf.String() != "hello "+command.HelpText+"\n" { 884 | t.Fatalf("bad: %#v", buf.String()) 885 | } 886 | } 887 | } 888 | 889 | func TestCLIRun_helpHiddenRoot(t *testing.T) { 890 | helpCalled := false 891 | buf := new(bytes.Buffer) 892 | cli := &CLI{ 893 | Args: []string{"--help"}, 894 | HiddenCommands: []string{"bar"}, 895 | Commands: map[string]CommandFactory{ 896 | "foo": func() (Command, error) { 897 | return &MockCommand{}, nil 898 | }, 899 | "bar": func() (Command, error) { 900 | return &MockCommand{}, nil 901 | }, 902 | }, 903 | HelpFunc: func(m map[string]CommandFactory) string { 904 | helpCalled = true 905 | 906 | if _, ok := m["foo"]; !ok { 907 | t.Fatal("should have foo") 908 | } 909 | if _, ok := m["bar"]; ok { 910 | t.Fatal("should not have bar") 911 | } 912 | 913 | return "" 914 | }, 915 | ErrorWriter: buf, 916 | } 917 | 918 | code, err := cli.Run() 919 | if err != nil { 920 | t.Fatalf("Error: %s", err) 921 | } 922 | 923 | if code != 0 { 924 | t.Fatalf("Code: %d", code) 925 | } 926 | 927 | if !helpCalled { 928 | t.Fatal("help not called") 929 | } 930 | } 931 | 932 | func TestCLIRun_helpHiddenNested(t *testing.T) { 933 | command := &MockCommand{ 934 | HelpText: "donuts", 935 | } 936 | 937 | buf := new(bytes.Buffer) 938 | cli := &CLI{ 939 | Args: []string{"foo", "--help"}, 940 | Commands: map[string]CommandFactory{ 941 | "foo": func() (Command, error) { 942 | return command, nil 943 | }, 944 | "foo bar": func() (Command, error) { 945 | return &MockCommand{SynopsisText: "hi!"}, nil 946 | }, 947 | "foo zip": func() (Command, error) { 948 | return &MockCommand{SynopsisText: "hi!"}, nil 949 | }, 950 | "foo longer": func() (Command, error) { 951 | return &MockCommand{SynopsisText: "hi!"}, nil 952 | }, 953 | "foo longer longest": func() (Command, error) { 954 | return &MockCommand{SynopsisText: "hi!"}, nil 955 | }, 956 | }, 957 | HiddenCommands: []string{"foo zip", "foo longer longest"}, 958 | HelpWriter: buf, 959 | } 960 | 961 | exitCode, err := cli.Run() 962 | if err != nil { 963 | t.Fatalf("err: %s", err) 964 | } 965 | 966 | if exitCode != 0 { 967 | t.Fatalf("bad exit code: %d", exitCode) 968 | } 969 | 970 | if buf.String() != testCommandHelpSubcommandsHiddenOutput { 971 | t.Fatalf("bad: '%#v'\n\n'%#v'", buf.String(), testCommandHelpSubcommandsOutput) 972 | } 973 | } 974 | 975 | func TestCLIRun_autocompleteBoth(t *testing.T) { 976 | command := new(MockCommand) 977 | cli := &CLI{ 978 | Args: []string{ 979 | "-" + defaultAutocompleteInstall, 980 | "-" + defaultAutocompleteUninstall, 981 | }, 982 | Commands: map[string]CommandFactory{ 983 | "foo": func() (Command, error) { 984 | return command, nil 985 | }, 986 | }, 987 | 988 | Name: "foo", 989 | Autocomplete: true, 990 | autocompleteInstaller: &mockAutocompleteInstaller{}, 991 | } 992 | 993 | exitCode, err := cli.Run() 994 | if err == nil { 995 | t.Fatal("should error") 996 | } 997 | 998 | if exitCode != 1 { 999 | t.Fatalf("bad: %d", exitCode) 1000 | } 1001 | 1002 | if command.RunCalled { 1003 | t.Fatalf("run should not be called") 1004 | } 1005 | } 1006 | 1007 | func TestCLIRun_autocompleteInstall(t *testing.T) { 1008 | command := new(MockCommand) 1009 | installer := new(mockAutocompleteInstaller) 1010 | cli := &CLI{ 1011 | Args: []string{ 1012 | "-" + defaultAutocompleteInstall, 1013 | }, 1014 | Commands: map[string]CommandFactory{ 1015 | "foo": func() (Command, error) { 1016 | return command, nil 1017 | }, 1018 | }, 1019 | 1020 | Name: "foo", 1021 | Autocomplete: true, 1022 | autocompleteInstaller: installer, 1023 | } 1024 | 1025 | exitCode, err := cli.Run() 1026 | if err != nil { 1027 | t.Fatalf("err: %s", err) 1028 | } 1029 | 1030 | if exitCode != 0 { 1031 | t.Fatalf("bad: %d", exitCode) 1032 | } 1033 | 1034 | if command.RunCalled { 1035 | t.Fatalf("run should not be called") 1036 | } 1037 | 1038 | if !installer.InstallCalled { 1039 | t.Fatal("should call install") 1040 | } 1041 | } 1042 | 1043 | func TestCLIRun_autocompleteUninstall(t *testing.T) { 1044 | command := new(MockCommand) 1045 | installer := new(mockAutocompleteInstaller) 1046 | cli := &CLI{ 1047 | Args: []string{ 1048 | "-" + defaultAutocompleteUninstall, 1049 | }, 1050 | Commands: map[string]CommandFactory{ 1051 | "foo": func() (Command, error) { 1052 | return command, nil 1053 | }, 1054 | }, 1055 | 1056 | Name: "foo", 1057 | Autocomplete: true, 1058 | autocompleteInstaller: installer, 1059 | } 1060 | 1061 | exitCode, err := cli.Run() 1062 | if err != nil { 1063 | t.Fatalf("err: %s", err) 1064 | } 1065 | 1066 | if exitCode != 0 { 1067 | t.Fatalf("bad: %d", exitCode) 1068 | } 1069 | 1070 | if command.RunCalled { 1071 | t.Fatalf("run should not be called") 1072 | } 1073 | 1074 | if !installer.UninstallCalled { 1075 | t.Fatal("should call uninstall") 1076 | } 1077 | } 1078 | 1079 | func TestCLIRun_autocompleteNoName(t *testing.T) { 1080 | command := new(MockCommand) 1081 | installer := new(mockAutocompleteInstaller) 1082 | cli := &CLI{ 1083 | Args: []string{"foo"}, 1084 | Commands: map[string]CommandFactory{ 1085 | "foo": func() (Command, error) { 1086 | return command, nil 1087 | }, 1088 | }, 1089 | 1090 | Autocomplete: true, 1091 | autocompleteInstaller: installer, 1092 | } 1093 | 1094 | exitCode, err := cli.Run() 1095 | if err == nil { 1096 | t.Fatal("should error") 1097 | } 1098 | 1099 | if exitCode != 1 { 1100 | t.Fatalf("bad: %d", exitCode) 1101 | } 1102 | 1103 | if command.RunCalled { 1104 | t.Fatalf("run should not be called") 1105 | } 1106 | } 1107 | 1108 | // Test that running `-autocomplete-install` doesn't execute 1109 | // the autocomplete installer. This was a bug reported by Nomad. 1110 | func TestCLIRun_autocompleteInstallTab(t *testing.T) { 1111 | command := new(MockCommand) 1112 | installer := new(mockAutocompleteInstaller) 1113 | cli := &CLI{ 1114 | Args: []string{ 1115 | "-" + defaultAutocompleteInstall, 1116 | }, 1117 | Commands: map[string]CommandFactory{ 1118 | "foo": func() (Command, error) { 1119 | return command, nil 1120 | }, 1121 | }, 1122 | 1123 | Name: "foo", 1124 | Autocomplete: true, 1125 | autocompleteInstaller: installer, 1126 | } 1127 | 1128 | defer testAutocomplete(t, "foo -autocomplete-install")() 1129 | 1130 | exitCode, err := cli.Run() 1131 | if err != nil { 1132 | t.Fatalf("err: %s", err) 1133 | } 1134 | 1135 | if exitCode != 0 { 1136 | t.Fatalf("bad: %d", exitCode) 1137 | } 1138 | 1139 | if command.RunCalled { 1140 | t.Fatalf("run should not be called") 1141 | } 1142 | 1143 | if installer.InstallCalled { 1144 | t.Fatal("should not call install") 1145 | } 1146 | } 1147 | 1148 | func TestCLIRun_autocompleteHelpTab(t *testing.T) { 1149 | buf := new(bytes.Buffer) 1150 | command := new(MockCommand) 1151 | installer := new(mockAutocompleteInstaller) 1152 | cli := &CLI{ 1153 | Args: []string{ 1154 | "-help", 1155 | }, 1156 | Commands: map[string]CommandFactory{ 1157 | "foo": func() (Command, error) { 1158 | return command, nil 1159 | }, 1160 | }, 1161 | 1162 | Name: "foo", 1163 | ErrorWriter: buf, 1164 | Autocomplete: true, 1165 | autocompleteInstaller: installer, 1166 | } 1167 | 1168 | defer testAutocomplete(t, "foo -help")() 1169 | 1170 | exitCode, err := cli.Run() 1171 | if err != nil { 1172 | t.Fatalf("err: %s", err) 1173 | } 1174 | 1175 | if exitCode != 0 { 1176 | t.Fatalf("bad: %d", exitCode) 1177 | } 1178 | 1179 | if command.RunCalled { 1180 | t.Fatalf("run should not be called") 1181 | } 1182 | 1183 | if buf.String() != "" { 1184 | t.Fatal("help should be empty") 1185 | } 1186 | } 1187 | 1188 | func TestCLIAutocomplete_root(t *testing.T) { 1189 | cases := []struct { 1190 | Completed []string 1191 | Last string 1192 | Expected []string 1193 | }{ 1194 | {nil, "-v", []string{"-version"}}, 1195 | {nil, "-h", []string{"-help"}}, 1196 | {nil, "-a", []string{ 1197 | "-" + defaultAutocompleteInstall, 1198 | "-" + defaultAutocompleteUninstall, 1199 | }}, 1200 | 1201 | {nil, "f", []string{"foo"}}, 1202 | {nil, "n", []string{"nodes", "noodles"}}, 1203 | {nil, "noo", []string{"noodles"}}, 1204 | {nil, "su", []string{"sub"}}, 1205 | {nil, "h", nil}, 1206 | 1207 | // Make sure global flags work on subcommands 1208 | {[]string{"sub"}, "-v", nil}, 1209 | {[]string{"sub"}, "o", []string{"one"}}, 1210 | {[]string{"sub"}, "su", []string{"sub2"}}, 1211 | {[]string{"sub", "sub2"}, "o", []string{"one"}}, 1212 | {[]string{"deep", "deep2"}, "a", []string{"a1"}}, 1213 | } 1214 | 1215 | for _, tc := range cases { 1216 | t.Run(tc.Last, func(t *testing.T) { 1217 | command := new(MockCommand) 1218 | cli := &CLI{ 1219 | Commands: map[string]CommandFactory{ 1220 | "foo": func() (Command, error) { return command, nil }, 1221 | "nodes": func() (Command, error) { return command, nil }, 1222 | "noodles": func() (Command, error) { return command, nil }, 1223 | "hidden": func() (Command, error) { return command, nil }, 1224 | "sub one": func() (Command, error) { return command, nil }, 1225 | "sub two": func() (Command, error) { return command, nil }, 1226 | "sub sub2 one": func() (Command, error) { return command, nil }, 1227 | "sub sub2 two": func() (Command, error) { return command, nil }, 1228 | "deep deep2 a1": func() (Command, error) { return command, nil }, 1229 | "deep deep2 b2": func() (Command, error) { return command, nil }, 1230 | }, 1231 | HiddenCommands: []string{"hidden"}, 1232 | 1233 | Autocomplete: true, 1234 | } 1235 | 1236 | // Setup the autocomplete line 1237 | var input bytes.Buffer 1238 | input.WriteString("cli ") 1239 | if len(tc.Completed) > 0 { 1240 | input.WriteString(strings.Join(tc.Completed, " ")) 1241 | input.WriteString(" ") 1242 | } 1243 | input.WriteString(tc.Last) 1244 | defer testAutocomplete(t, input.String())() 1245 | 1246 | // Setup the output so that we can read it. We don't need to 1247 | // reset os.Stdout because testAutocomplete will do that for us. 1248 | r, w, err := os.Pipe() 1249 | if err != nil { 1250 | t.Fatalf("err: %s", err) 1251 | } 1252 | defer r.Close() // Only defer reader since writer is closed below 1253 | os.Stdout = w 1254 | 1255 | // Run 1256 | exitCode, err := cli.Run() 1257 | w.Close() 1258 | if err != nil { 1259 | t.Fatalf("err: %s", err) 1260 | } 1261 | 1262 | if exitCode != 0 { 1263 | t.Fatalf("bad: %d", exitCode) 1264 | } 1265 | 1266 | // Copy the output and get the autocompletions. We trim the last 1267 | // element if we have one since we usually output a final newline 1268 | // which results in a blank. 1269 | var outBuf bytes.Buffer 1270 | io.Copy(&outBuf, r) 1271 | actual := strings.Split(outBuf.String(), "\n") 1272 | if len(actual) > 0 { 1273 | actual = actual[:len(actual)-1] 1274 | } 1275 | if len(actual) == 0 { 1276 | // If we have no elements left, make the value nil since 1277 | // this is what we use in tests. 1278 | actual = nil 1279 | } 1280 | 1281 | sort.Strings(actual) 1282 | sort.Strings(tc.Expected) 1283 | if !reflect.DeepEqual(actual, tc.Expected) { 1284 | t.Fatalf("bad:\n\n%#v\n\n%#v", actual, tc.Expected) 1285 | } 1286 | }) 1287 | } 1288 | } 1289 | 1290 | func TestCLIAutocomplete_rootGlobalFlags(t *testing.T) { 1291 | cases := []struct { 1292 | Completed []string 1293 | Last string 1294 | Expected []string 1295 | }{ 1296 | {nil, "-v", []string{"-version"}}, 1297 | {nil, "-t", []string{"-tubes"}}, 1298 | } 1299 | 1300 | for _, tc := range cases { 1301 | t.Run(tc.Last, func(t *testing.T) { 1302 | command := new(MockCommand) 1303 | cli := &CLI{ 1304 | Commands: map[string]CommandFactory{ 1305 | "foo": func() (Command, error) { return command, nil }, 1306 | }, 1307 | 1308 | Autocomplete: true, 1309 | AutocompleteGlobalFlags: map[string]complete.Predictor{ 1310 | "-tubes": complete.PredictNothing, 1311 | }, 1312 | } 1313 | 1314 | // Setup the autocomplete line 1315 | var input bytes.Buffer 1316 | input.WriteString("cli ") 1317 | if len(tc.Completed) > 0 { 1318 | input.WriteString(strings.Join(tc.Completed, " ")) 1319 | input.WriteString(" ") 1320 | } 1321 | input.WriteString(tc.Last) 1322 | defer testAutocomplete(t, input.String())() 1323 | 1324 | // Setup the output so that we can read it. We don't need to 1325 | // reset os.Stdout because testAutocomplete will do that for us. 1326 | r, w, err := os.Pipe() 1327 | if err != nil { 1328 | t.Fatalf("err: %s", err) 1329 | } 1330 | defer r.Close() // Only defer reader since writer is closed below 1331 | os.Stdout = w 1332 | 1333 | // Run 1334 | exitCode, err := cli.Run() 1335 | w.Close() 1336 | if err != nil { 1337 | t.Fatalf("err: %s", err) 1338 | } 1339 | 1340 | if exitCode != 0 { 1341 | t.Fatalf("bad: %d", exitCode) 1342 | } 1343 | 1344 | // Copy the output and get the autocompletions. We trim the last 1345 | // element if we have one since we usually output a final newline 1346 | // which results in a blank. 1347 | var outBuf bytes.Buffer 1348 | io.Copy(&outBuf, r) 1349 | actual := strings.Split(outBuf.String(), "\n") 1350 | if len(actual) > 0 { 1351 | actual = actual[:len(actual)-1] 1352 | } 1353 | if len(actual) == 0 { 1354 | // If we have no elements left, make the value nil since 1355 | // this is what we use in tests. 1356 | actual = nil 1357 | } 1358 | 1359 | sort.Strings(actual) 1360 | sort.Strings(tc.Expected) 1361 | if !reflect.DeepEqual(actual, tc.Expected) { 1362 | t.Fatalf("bad:\n\n%#v\n\n%#v", actual, tc.Expected) 1363 | } 1364 | }) 1365 | } 1366 | } 1367 | 1368 | func TestCLIAutocomplete_rootDisableDefaultFlags(t *testing.T) { 1369 | cases := []struct { 1370 | Completed []string 1371 | Last string 1372 | Expected []string 1373 | }{ 1374 | {nil, "-v", nil}, 1375 | {nil, "-h", nil}, 1376 | {nil, "-auto", nil}, 1377 | {nil, "-t", []string{"-tubes"}}, 1378 | } 1379 | 1380 | for _, tc := range cases { 1381 | t.Run(tc.Last, func(t *testing.T) { 1382 | command := new(MockCommand) 1383 | cli := &CLI{ 1384 | Commands: map[string]CommandFactory{ 1385 | "foo": func() (Command, error) { return command, nil }, 1386 | }, 1387 | 1388 | Autocomplete: true, 1389 | AutocompleteNoDefaultFlags: true, 1390 | AutocompleteGlobalFlags: map[string]complete.Predictor{ 1391 | "-tubes": complete.PredictNothing, 1392 | }, 1393 | } 1394 | // Setup the autocomplete line 1395 | var input bytes.Buffer 1396 | input.WriteString("cli ") 1397 | if len(tc.Completed) > 0 { 1398 | input.WriteString(strings.Join(tc.Completed, " ")) 1399 | input.WriteString(" ") 1400 | } 1401 | input.WriteString(tc.Last) 1402 | defer testAutocomplete(t, input.String())() 1403 | 1404 | // Setup the output so that we can read it. We don't need to 1405 | // reset os.Stdout because testAutocomplete will do that for us. 1406 | r, w, err := os.Pipe() 1407 | if err != nil { 1408 | t.Fatalf("err: %s", err) 1409 | } 1410 | defer r.Close() // Only defer reader since writer is closed below 1411 | os.Stdout = w 1412 | 1413 | // Run 1414 | exitCode, err := cli.Run() 1415 | w.Close() 1416 | if err != nil { 1417 | t.Fatalf("err: %s", err) 1418 | } 1419 | 1420 | if exitCode != 0 { 1421 | t.Fatalf("bad: %d", exitCode) 1422 | } 1423 | 1424 | // Copy the output and get the autocompletions. We trim the last 1425 | // element if we have one since we usually output a final newline 1426 | // which results in a blank. 1427 | var outBuf bytes.Buffer 1428 | io.Copy(&outBuf, r) 1429 | actual := strings.Split(outBuf.String(), "\n") 1430 | if len(actual) > 0 { 1431 | actual = actual[:len(actual)-1] 1432 | } 1433 | if len(actual) == 0 { 1434 | // If we have no elements left, make the value nil since 1435 | // this is what we use in tests. 1436 | actual = nil 1437 | } 1438 | 1439 | sort.Strings(actual) 1440 | sort.Strings(tc.Expected) 1441 | if !reflect.DeepEqual(actual, tc.Expected) { 1442 | t.Fatalf("bad:\n\n%#v\n\n%#v", actual, tc.Expected) 1443 | } 1444 | }) 1445 | } 1446 | } 1447 | 1448 | func TestCLIAutocomplete_subcommandArgs(t *testing.T) { 1449 | cases := []struct { 1450 | Completed []string 1451 | Last string 1452 | Expected []string 1453 | }{ 1454 | {[]string{"foo"}, "RE", []string{"README.md"}}, 1455 | {[]string{"foo", "-go"}, "asdf", []string{"yo"}}, 1456 | } 1457 | 1458 | for _, tc := range cases { 1459 | t.Run(tc.Last, func(t *testing.T) { 1460 | command := new(MockCommandAutocomplete) 1461 | command.AutocompleteArgsValue = complete.PredictFiles("*") 1462 | command.AutocompleteFlagsValue = map[string]complete.Predictor{ 1463 | "-go": complete.PredictFunc(func(complete.Args) []string { 1464 | return []string{"yo"} 1465 | }), 1466 | } 1467 | 1468 | cli := &CLI{ 1469 | Commands: map[string]CommandFactory{ 1470 | "foo": func() (Command, error) { 1471 | return command, nil 1472 | }, 1473 | }, 1474 | 1475 | Autocomplete: true, 1476 | } 1477 | 1478 | // We need to initialize the autocomplete environment so that 1479 | // the cli doesn't no-op the autocomplete init 1480 | defer testAutocomplete(t, "must be non-empty")() 1481 | 1482 | // Initialize 1483 | cli.init() 1484 | 1485 | // Test the autocompleter 1486 | actual := cli.autocomplete.Command.Predict(complete.Args{ 1487 | Completed: tc.Completed, 1488 | Last: tc.Last, 1489 | LastCompleted: tc.Completed[len(tc.Completed)-1], 1490 | }) 1491 | sort.Strings(actual) 1492 | 1493 | if !reflect.DeepEqual(actual, tc.Expected) { 1494 | t.Fatalf("bad prediction: %#v", actual) 1495 | } 1496 | }) 1497 | } 1498 | } 1499 | 1500 | func TestCLISubcommand(t *testing.T) { 1501 | testCases := []struct { 1502 | args []string 1503 | subcommand string 1504 | }{ 1505 | {[]string{"bar"}, "bar"}, 1506 | {[]string{"foo", "-h"}, "foo"}, 1507 | {[]string{"-h", "bar"}, "bar"}, 1508 | {[]string{"foo", "bar", "-h"}, "foo"}, 1509 | } 1510 | 1511 | for _, testCase := range testCases { 1512 | cli := &CLI{Args: testCase.args} 1513 | result := cli.Subcommand() 1514 | 1515 | if result != testCase.subcommand { 1516 | t.Errorf("Expected %#v, got %#v. Args: %#v", 1517 | testCase.subcommand, result, testCase.args) 1518 | } 1519 | } 1520 | } 1521 | 1522 | func TestCLISubcommand_nested(t *testing.T) { 1523 | testCases := []struct { 1524 | args []string 1525 | subcommand string 1526 | }{ 1527 | {[]string{"bar"}, "bar"}, 1528 | {[]string{"foo", "-h"}, "foo"}, 1529 | {[]string{"-h", "bar"}, "bar"}, 1530 | {[]string{"foo", "bar", "-h"}, "foo bar"}, 1531 | {[]string{"foo", "bar", "baz", "-h"}, "foo bar"}, 1532 | {[]string{"foo", "bar", "-h", "baz"}, "foo bar"}, 1533 | {[]string{"-h", "foo", "bar"}, "foo bar"}, 1534 | } 1535 | 1536 | for _, testCase := range testCases { 1537 | cli := &CLI{ 1538 | Args: testCase.args, 1539 | Commands: map[string]CommandFactory{ 1540 | "foo bar": func() (Command, error) { 1541 | return new(MockCommand), nil 1542 | }, 1543 | }, 1544 | } 1545 | result := cli.Subcommand() 1546 | 1547 | if result != testCase.subcommand { 1548 | t.Errorf("Expected %#v, got %#v. Args: %#v", 1549 | testCase.subcommand, result, testCase.args) 1550 | } 1551 | } 1552 | } 1553 | 1554 | // testAutocomplete sets up the environment to behave like a was 1555 | // pressed in a shell to autocomplete a command. 1556 | func testAutocomplete(t *testing.T, input string) func() { 1557 | // This env var is used to trigger autocomplete 1558 | os.Setenv(envComplete, input) 1559 | 1560 | // Change stdout/stderr since the autocompleter writes directly to them. 1561 | oldStdout := os.Stdout 1562 | oldStderr := os.Stderr 1563 | 1564 | r, w, err := os.Pipe() 1565 | if err != nil { 1566 | t.Fatalf("err: %s", err) 1567 | } 1568 | 1569 | os.Stdout = w 1570 | os.Stderr = w 1571 | 1572 | return func() { 1573 | // Reset our env 1574 | os.Unsetenv(envComplete) 1575 | 1576 | // Reset stdout, stderr 1577 | os.Stdout = oldStdout 1578 | os.Stderr = oldStderr 1579 | 1580 | // Close our pipe 1581 | r.Close() 1582 | w.Close() 1583 | } 1584 | } 1585 | 1586 | const testCommandNestedMissingParent = `This command is accessed by using one of the subcommands below. 1587 | 1588 | Subcommands: 1589 | bar hi! 1590 | ` 1591 | 1592 | const testCommandHelpSubcommandsOutput = `donuts 1593 | 1594 | Subcommands: 1595 | banana hi! 1596 | bar hi! 1597 | longer hi! 1598 | zap hi! 1599 | zip hi! 1600 | ` 1601 | 1602 | const testCommandHelpSubcommandsHiddenOutput = `donuts 1603 | 1604 | Subcommands: 1605 | bar hi! 1606 | longer hi! 1607 | ` 1608 | 1609 | const testCommandHelpSubcommandsTwoLevelOutput = `donuts 1610 | 1611 | Subcommands: 1612 | L2A hi! 1613 | L2B hi! 1614 | ` 1615 | -------------------------------------------------------------------------------- /command.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "github.com/posener/complete" 5 | ) 6 | 7 | const ( 8 | // RunResultHelp is a value that can be returned from Run to signal 9 | // to the CLI to render the help output. 10 | RunResultHelp = -18511 11 | ) 12 | 13 | // A command is a runnable sub-command of a CLI. 14 | type Command interface { 15 | // Help should return long-form help text that includes the command-line 16 | // usage, a brief few sentences explaining the function of the command, 17 | // and the complete list of flags the command accepts. 18 | Help() string 19 | 20 | // Run should run the actual command with the given CLI instance and 21 | // command-line arguments. It should return the exit status when it is 22 | // finished. 23 | // 24 | // There are a handful of special exit codes this can return documented 25 | // above that change behavior. 26 | Run(args []string) int 27 | 28 | // Synopsis should return a one-line, short synopsis of the command. 29 | // This should be less than 50 characters ideally. 30 | Synopsis() string 31 | } 32 | 33 | // CommandAutocomplete is an extension of Command that enables fine-grained 34 | // autocompletion. Subcommand autocompletion will work even if this interface 35 | // is not implemented. By implementing this interface, more advanced 36 | // autocompletion is enabled. 37 | type CommandAutocomplete interface { 38 | // AutocompleteArgs returns the argument predictor for this command. 39 | // If argument completion is not supported, this should return 40 | // complete.PredictNothing. 41 | AutocompleteArgs() complete.Predictor 42 | 43 | // AutocompleteFlags returns a mapping of supported flags and autocomplete 44 | // options for this command. The map key for the Flags map should be the 45 | // complete flag such as "-foo" or "--foo". 46 | AutocompleteFlags() complete.Flags 47 | } 48 | 49 | // CommandHelpTemplate is an extension of Command that also has a function 50 | // for returning a template for the help rather than the help itself. In 51 | // this scenario, both Help and HelpTemplate should be implemented. 52 | // 53 | // If CommandHelpTemplate isn't implemented, the Help is output as-is. 54 | type CommandHelpTemplate interface { 55 | // HelpTemplate is the template in text/template format to use for 56 | // displaying the Help. The keys available are: 57 | // 58 | // * ".Help" - The help text itself 59 | // * ".Subcommands" 60 | // 61 | HelpTemplate() string 62 | } 63 | 64 | // CommandFactory is a type of function that is a factory for commands. 65 | // We need a factory because we may need to setup some state on the 66 | // struct that implements the command itself. 67 | type CommandFactory func() (Command, error) 68 | -------------------------------------------------------------------------------- /command_mock.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "github.com/posener/complete" 5 | ) 6 | 7 | // MockCommand is an implementation of Command that can be used for tests. 8 | // It is publicly exported from this package in case you want to use it 9 | // externally. 10 | type MockCommand struct { 11 | // Settable 12 | HelpText string 13 | RunResult int 14 | SynopsisText string 15 | 16 | // Set by the command 17 | RunCalled bool 18 | RunArgs []string 19 | } 20 | 21 | func (c *MockCommand) Help() string { 22 | return c.HelpText 23 | } 24 | 25 | func (c *MockCommand) Run(args []string) int { 26 | c.RunCalled = true 27 | c.RunArgs = args 28 | 29 | return c.RunResult 30 | } 31 | 32 | func (c *MockCommand) Synopsis() string { 33 | return c.SynopsisText 34 | } 35 | 36 | // MockCommandAutocomplete is an implementation of CommandAutocomplete. 37 | type MockCommandAutocomplete struct { 38 | MockCommand 39 | 40 | // Settable 41 | AutocompleteArgsValue complete.Predictor 42 | AutocompleteFlagsValue complete.Flags 43 | } 44 | 45 | func (c *MockCommandAutocomplete) AutocompleteArgs() complete.Predictor { 46 | return c.AutocompleteArgsValue 47 | } 48 | 49 | func (c *MockCommandAutocomplete) AutocompleteFlags() complete.Flags { 50 | return c.AutocompleteFlagsValue 51 | } 52 | 53 | // MockCommandHelpTemplate is an implementation of CommandHelpTemplate. 54 | type MockCommandHelpTemplate struct { 55 | MockCommand 56 | 57 | // Settable 58 | HelpTemplateText string 59 | } 60 | 61 | func (c *MockCommandHelpTemplate) HelpTemplate() string { 62 | return c.HelpTemplateText 63 | } 64 | -------------------------------------------------------------------------------- /command_mock_test.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestMockCommand_implements(t *testing.T) { 8 | var _ Command = new(MockCommand) 9 | } 10 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/mitchellh/cli 2 | 3 | go 1.11 4 | 5 | require ( 6 | github.com/Masterminds/sprig/v3 v3.2.1 7 | github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310 8 | github.com/bgentry/speakeasy v0.1.0 9 | github.com/fatih/color v1.7.0 10 | github.com/google/uuid v1.1.2 // indirect 11 | github.com/hashicorp/go-multierror v1.0.0 // indirect 12 | github.com/huandu/xstrings v1.3.2 // indirect 13 | github.com/mattn/go-colorable v0.0.9 // indirect 14 | github.com/mattn/go-isatty v0.0.3 15 | github.com/posener/complete v1.1.1 16 | github.com/stretchr/testify v1.6.1 // indirect 17 | golang.org/x/crypto v0.0.0-20200820211705-5c72a883971a // indirect 18 | ) 19 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= 2 | github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= 3 | github.com/Masterminds/semver/v3 v3.1.1 h1:hLg3sBzpNErnxhQtUy/mmLR2I9foDujNK030IGemrRc= 4 | github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs= 5 | github.com/Masterminds/sprig/v3 v3.2.1 h1:n6EPaDyLSvCEa3frruQvAiHuNp2dhBlMSmkEr+HuzGc= 6 | github.com/Masterminds/sprig/v3 v3.2.1/go.mod h1:UoaO7Yp8KlPnJIYWTFkMaqPUYKTfGFPhxNuwnnxkKlk= 7 | github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310 h1:BUAU3CGlLvorLI26FmByPp2eC2qla6E1Tw+scpcg/to= 8 | github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= 9 | github.com/bgentry/speakeasy v0.1.0 h1:ByYyxL9InA1OWqxJqqp2A5pYHUrCiAL6K3J+LKSsQkY= 10 | github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= 11 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 12 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 13 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 14 | github.com/fatih/color v1.7.0 h1:DkWD4oS2D8LGGgTQ6IvwJJXSL5Vp2ffcQg58nFV38Ys= 15 | github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= 16 | github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 17 | github.com/google/uuid v1.1.2 h1:EVhdT+1Kseyi1/pUmXKaFxYsDNy9RQYkMWRH68J/W7Y= 18 | github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 19 | github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA= 20 | github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= 21 | github.com/hashicorp/go-multierror v1.0.0 h1:iVjPR7a6H0tWELX5NxNe7bYopibicUzc7uPribsnS6o= 22 | github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= 23 | github.com/huandu/xstrings v1.3.1/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= 24 | github.com/huandu/xstrings v1.3.2 h1:L18LIDzqlW6xN2rEkpdV8+oL/IXWJ1APd+vsdYy4Wdw= 25 | github.com/huandu/xstrings v1.3.2/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= 26 | github.com/imdario/mergo v0.3.11 h1:3tnifQM4i+fbajXKBHXWEH+KvNHqojZ778UH75j3bGA= 27 | github.com/imdario/mergo v0.3.11/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA= 28 | github.com/mattn/go-colorable v0.0.9 h1:UVL0vNpWh04HeJXV0KLcaT7r06gOH2l4OW6ddYRUIY4= 29 | github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= 30 | github.com/mattn/go-isatty v0.0.3 h1:ns/ykhmWi7G9O+8a448SecJU3nSMBXJfqQkl0upE1jI= 31 | github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= 32 | github.com/mitchellh/copystructure v1.0.0 h1:Laisrj+bAB6b/yJwB5Bt3ITZhGJdqmxquMKeZ+mmkFQ= 33 | github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw= 34 | github.com/mitchellh/reflectwalk v1.0.0 h1:9D+8oIskB4VJBN5SFlmc27fSlIBZaov1Wpk/IfikLNY= 35 | github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= 36 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 37 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 38 | github.com/posener/complete v1.1.1 h1:ccV59UEOTzVDnDUEFdT95ZzHVZ+5+158q8+SJb2QV5w= 39 | github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= 40 | github.com/shopspring/decimal v1.2.0 h1:abSATXmQEYyShuxI4/vyW3tV1MrKAJzCZ/0zLUXYbsQ= 41 | github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= 42 | github.com/spf13/cast v1.3.1 h1:nFm6S0SMdyzrzcmThSipiEubIDy8WEXKNZ0UOgiRpng= 43 | github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= 44 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 45 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 46 | github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= 47 | github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0= 48 | github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 49 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 50 | golang.org/x/crypto v0.0.0-20200414173820-0848c9571904/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 51 | golang.org/x/crypto v0.0.0-20200820211705-5c72a883971a h1:vclmkQCjlDX5OydZ9wv8rBCcS0QyQY66Mpf/7BZbInM= 52 | golang.org/x/crypto v0.0.0-20200820211705-5c72a883971a/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 53 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 54 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 55 | golang.org/x/sys v0.0.0-20190412213103-97732733099d h1:+R4KGOnez64A81RvjARKc4UT5/tI9ujCIVX+P5KiHuI= 56 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 57 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 58 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 59 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 60 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 61 | gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU= 62 | gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 63 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= 64 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 65 | -------------------------------------------------------------------------------- /help.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "log" 7 | "sort" 8 | "strings" 9 | ) 10 | 11 | // HelpFunc is the type of the function that is responsible for generating 12 | // the help output when the CLI must show the general help text. 13 | type HelpFunc func(map[string]CommandFactory) string 14 | 15 | // BasicHelpFunc generates some basic help output that is usually good enough 16 | // for most CLI applications. 17 | func BasicHelpFunc(app string) HelpFunc { 18 | return func(commands map[string]CommandFactory) string { 19 | var buf bytes.Buffer 20 | buf.WriteString(fmt.Sprintf( 21 | "Usage: %s [--version] [--help] []\n\n", 22 | app)) 23 | buf.WriteString("Available commands are:\n") 24 | 25 | // Get the list of keys so we can sort them, and also get the maximum 26 | // key length so they can be aligned properly. 27 | keys := make([]string, 0, len(commands)) 28 | maxKeyLen := 0 29 | for key := range commands { 30 | if len(key) > maxKeyLen { 31 | maxKeyLen = len(key) 32 | } 33 | 34 | keys = append(keys, key) 35 | } 36 | sort.Strings(keys) 37 | 38 | for _, key := range keys { 39 | commandFunc, ok := commands[key] 40 | if !ok { 41 | // This should never happen since we JUST built the list of 42 | // keys. 43 | panic("command not found: " + key) 44 | } 45 | 46 | command, err := commandFunc() 47 | if err != nil { 48 | log.Printf("[ERR] cli: Command '%s' failed to load: %s", 49 | key, err) 50 | continue 51 | } 52 | 53 | key = fmt.Sprintf("%s%s", key, strings.Repeat(" ", maxKeyLen-len(key))) 54 | buf.WriteString(fmt.Sprintf(" %s %s\n", key, command.Synopsis())) 55 | } 56 | 57 | return buf.String() 58 | } 59 | } 60 | 61 | // FilteredHelpFunc will filter the commands to only include the keys 62 | // in the include parameter. 63 | func FilteredHelpFunc(include []string, f HelpFunc) HelpFunc { 64 | return func(commands map[string]CommandFactory) string { 65 | set := make(map[string]struct{}) 66 | for _, k := range include { 67 | set[k] = struct{}{} 68 | } 69 | 70 | filtered := make(map[string]CommandFactory) 71 | for k, f := range commands { 72 | if _, ok := set[k]; ok { 73 | filtered[k] = f 74 | } 75 | } 76 | 77 | return f(filtered) 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /ui.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "bufio" 5 | "errors" 6 | "fmt" 7 | "io" 8 | "os" 9 | "os/signal" 10 | "strings" 11 | 12 | "github.com/bgentry/speakeasy" 13 | "github.com/mattn/go-isatty" 14 | ) 15 | 16 | // Ui is an interface for interacting with the terminal, or "interface" 17 | // of a CLI. This abstraction doesn't have to be used, but helps provide 18 | // a simple, layerable way to manage user interactions. 19 | type Ui interface { 20 | // Ask asks the user for input using the given query. The response is 21 | // returned as the given string, or an error. 22 | Ask(string) (string, error) 23 | 24 | // AskSecret asks the user for input using the given query, but does not echo 25 | // the keystrokes to the terminal. 26 | AskSecret(string) (string, error) 27 | 28 | // Output is called for normal standard output. 29 | Output(string) 30 | 31 | // Info is called for information related to the previous output. 32 | // In general this may be the exact same as Output, but this gives 33 | // Ui implementors some flexibility with output formats. 34 | Info(string) 35 | 36 | // Error is used for any error messages that might appear on standard 37 | // error. 38 | Error(string) 39 | 40 | // Warn is used for any warning messages that might appear on standard 41 | // error. 42 | Warn(string) 43 | } 44 | 45 | // BasicUi is an implementation of Ui that just outputs to the given 46 | // writer. This UI is not threadsafe by default, but you can wrap it 47 | // in a ConcurrentUi to make it safe. 48 | type BasicUi struct { 49 | Reader io.Reader 50 | Writer io.Writer 51 | ErrorWriter io.Writer 52 | } 53 | 54 | func (u *BasicUi) Ask(query string) (string, error) { 55 | return u.ask(query, false) 56 | } 57 | 58 | func (u *BasicUi) AskSecret(query string) (string, error) { 59 | return u.ask(query, true) 60 | } 61 | 62 | func (u *BasicUi) ask(query string, secret bool) (string, error) { 63 | if _, err := fmt.Fprint(u.Writer, query+" "); err != nil { 64 | return "", err 65 | } 66 | 67 | // Register for interrupts so that we can catch it and immediately 68 | // return... 69 | sigCh := make(chan os.Signal, 1) 70 | signal.Notify(sigCh, os.Interrupt) 71 | defer signal.Stop(sigCh) 72 | 73 | // Ask for input in a go-routine so that we can ignore it. 74 | errCh := make(chan error, 1) 75 | lineCh := make(chan string, 1) 76 | go func() { 77 | var line string 78 | var err error 79 | if secret && isatty.IsTerminal(os.Stdin.Fd()) { 80 | line, err = speakeasy.Ask("") 81 | } else { 82 | r := bufio.NewReader(u.Reader) 83 | line, err = r.ReadString('\n') 84 | } 85 | if err != nil { 86 | errCh <- err 87 | return 88 | } 89 | 90 | lineCh <- strings.TrimRight(line, "\r\n") 91 | }() 92 | 93 | select { 94 | case err := <-errCh: 95 | return "", err 96 | case line := <-lineCh: 97 | return line, nil 98 | case <-sigCh: 99 | // Print a newline so that any further output starts properly 100 | // on a new line. 101 | fmt.Fprintln(u.Writer) 102 | 103 | return "", errors.New("interrupted") 104 | } 105 | } 106 | 107 | func (u *BasicUi) Error(message string) { 108 | w := u.Writer 109 | if u.ErrorWriter != nil { 110 | w = u.ErrorWriter 111 | } 112 | 113 | fmt.Fprint(w, message) 114 | fmt.Fprint(w, "\n") 115 | } 116 | 117 | func (u *BasicUi) Info(message string) { 118 | u.Output(message) 119 | } 120 | 121 | func (u *BasicUi) Output(message string) { 122 | fmt.Fprint(u.Writer, message) 123 | fmt.Fprint(u.Writer, "\n") 124 | } 125 | 126 | func (u *BasicUi) Warn(message string) { 127 | u.Error(message) 128 | } 129 | 130 | // PrefixedUi is an implementation of Ui that prefixes messages. 131 | type PrefixedUi struct { 132 | AskPrefix string 133 | AskSecretPrefix string 134 | OutputPrefix string 135 | InfoPrefix string 136 | ErrorPrefix string 137 | WarnPrefix string 138 | Ui Ui 139 | } 140 | 141 | func (u *PrefixedUi) Ask(query string) (string, error) { 142 | if query != "" { 143 | query = fmt.Sprintf("%s%s", u.AskPrefix, query) 144 | } 145 | 146 | return u.Ui.Ask(query) 147 | } 148 | 149 | func (u *PrefixedUi) AskSecret(query string) (string, error) { 150 | if query != "" { 151 | query = fmt.Sprintf("%s%s", u.AskSecretPrefix, query) 152 | } 153 | 154 | return u.Ui.AskSecret(query) 155 | } 156 | 157 | func (u *PrefixedUi) Error(message string) { 158 | if message != "" { 159 | message = fmt.Sprintf("%s%s", u.ErrorPrefix, message) 160 | } 161 | 162 | u.Ui.Error(message) 163 | } 164 | 165 | func (u *PrefixedUi) Info(message string) { 166 | if message != "" { 167 | message = fmt.Sprintf("%s%s", u.InfoPrefix, message) 168 | } 169 | 170 | u.Ui.Info(message) 171 | } 172 | 173 | func (u *PrefixedUi) Output(message string) { 174 | if message != "" { 175 | message = fmt.Sprintf("%s%s", u.OutputPrefix, message) 176 | } 177 | 178 | u.Ui.Output(message) 179 | } 180 | 181 | func (u *PrefixedUi) Warn(message string) { 182 | if message != "" { 183 | message = fmt.Sprintf("%s%s", u.WarnPrefix, message) 184 | } 185 | 186 | u.Ui.Warn(message) 187 | } 188 | -------------------------------------------------------------------------------- /ui_colored.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "github.com/fatih/color" 5 | ) 6 | 7 | const ( 8 | noColor = -1 9 | ) 10 | 11 | // UiColor is a posix shell color code to use. 12 | type UiColor struct { 13 | Code int 14 | Bold bool 15 | } 16 | 17 | // A list of colors that are useful. These are all non-bolded by default. 18 | var ( 19 | UiColorNone UiColor = UiColor{noColor, false} 20 | UiColorRed = UiColor{int(color.FgHiRed), false} 21 | UiColorGreen = UiColor{int(color.FgHiGreen), false} 22 | UiColorYellow = UiColor{int(color.FgHiYellow), false} 23 | UiColorBlue = UiColor{int(color.FgHiBlue), false} 24 | UiColorMagenta = UiColor{int(color.FgHiMagenta), false} 25 | UiColorCyan = UiColor{int(color.FgHiCyan), false} 26 | ) 27 | 28 | // ColoredUi is a Ui implementation that colors its output according 29 | // to the given color schemes for the given type of output. 30 | type ColoredUi struct { 31 | OutputColor UiColor 32 | InfoColor UiColor 33 | ErrorColor UiColor 34 | WarnColor UiColor 35 | Ui Ui 36 | } 37 | 38 | func (u *ColoredUi) Ask(query string) (string, error) { 39 | return u.Ui.Ask(u.colorize(query, u.OutputColor)) 40 | } 41 | 42 | func (u *ColoredUi) AskSecret(query string) (string, error) { 43 | return u.Ui.AskSecret(u.colorize(query, u.OutputColor)) 44 | } 45 | 46 | func (u *ColoredUi) Output(message string) { 47 | u.Ui.Output(u.colorize(message, u.OutputColor)) 48 | } 49 | 50 | func (u *ColoredUi) Info(message string) { 51 | u.Ui.Info(u.colorize(message, u.InfoColor)) 52 | } 53 | 54 | func (u *ColoredUi) Error(message string) { 55 | u.Ui.Error(u.colorize(message, u.ErrorColor)) 56 | } 57 | 58 | func (u *ColoredUi) Warn(message string) { 59 | u.Ui.Warn(u.colorize(message, u.WarnColor)) 60 | } 61 | 62 | func (u *ColoredUi) colorize(message string, uc UiColor) string { 63 | if uc.Code == noColor { 64 | return message 65 | } 66 | 67 | attr := []color.Attribute{color.Attribute(uc.Code)} 68 | if uc.Bold { 69 | attr = append(attr, color.Bold) 70 | } 71 | 72 | return color.New(attr...).SprintFunc()(message) 73 | } 74 | -------------------------------------------------------------------------------- /ui_concurrent.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "sync" 5 | ) 6 | 7 | // ConcurrentUi is a wrapper around a Ui interface (and implements that 8 | // interface) making the underlying Ui concurrency safe. 9 | type ConcurrentUi struct { 10 | Ui Ui 11 | l sync.Mutex 12 | } 13 | 14 | func (u *ConcurrentUi) Ask(query string) (string, error) { 15 | u.l.Lock() 16 | defer u.l.Unlock() 17 | 18 | return u.Ui.Ask(query) 19 | } 20 | 21 | func (u *ConcurrentUi) AskSecret(query string) (string, error) { 22 | u.l.Lock() 23 | defer u.l.Unlock() 24 | 25 | return u.Ui.AskSecret(query) 26 | } 27 | 28 | func (u *ConcurrentUi) Error(message string) { 29 | u.l.Lock() 30 | defer u.l.Unlock() 31 | 32 | u.Ui.Error(message) 33 | } 34 | 35 | func (u *ConcurrentUi) Info(message string) { 36 | u.l.Lock() 37 | defer u.l.Unlock() 38 | 39 | u.Ui.Info(message) 40 | } 41 | 42 | func (u *ConcurrentUi) Output(message string) { 43 | u.l.Lock() 44 | defer u.l.Unlock() 45 | 46 | u.Ui.Output(message) 47 | } 48 | 49 | func (u *ConcurrentUi) Warn(message string) { 50 | u.l.Lock() 51 | defer u.l.Unlock() 52 | 53 | u.Ui.Warn(message) 54 | } 55 | -------------------------------------------------------------------------------- /ui_concurrent_test.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestConcurrentUi_impl(t *testing.T) { 8 | var _ Ui = new(ConcurrentUi) 9 | } 10 | -------------------------------------------------------------------------------- /ui_mock.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "fmt" 7 | "io" 8 | "strings" 9 | "sync" 10 | ) 11 | 12 | // NewMockUi returns a fully initialized MockUi instance 13 | // which is safe for concurrent use. 14 | func NewMockUi() *MockUi { 15 | m := new(MockUi) 16 | m.once.Do(m.init) 17 | return m 18 | } 19 | 20 | // MockUi is a mock UI that is used for tests and is exported publicly 21 | // for use in external tests if needed as well. Do not instantite this 22 | // directly since the buffers will be initialized on the first write. If 23 | // there is no write then you will get a nil panic. Please use the 24 | // NewMockUi() constructor function instead. You can fix your code with 25 | // 26 | // sed -i -e 's/new(cli.MockUi)/cli.NewMockUi()/g' *_test.go 27 | type MockUi struct { 28 | InputReader io.Reader 29 | ErrorWriter *syncBuffer 30 | OutputWriter *syncBuffer 31 | 32 | once sync.Once 33 | } 34 | 35 | func (u *MockUi) Ask(query string) (string, error) { 36 | u.once.Do(u.init) 37 | 38 | var result string 39 | fmt.Fprint(u.OutputWriter, query) 40 | r := bufio.NewReader(u.InputReader) 41 | line, err := r.ReadString('\n') 42 | if err != nil { 43 | return "", err 44 | } 45 | result = strings.TrimRight(line, "\r\n") 46 | 47 | return result, nil 48 | } 49 | 50 | func (u *MockUi) AskSecret(query string) (string, error) { 51 | return u.Ask(query) 52 | } 53 | 54 | func (u *MockUi) Error(message string) { 55 | u.once.Do(u.init) 56 | 57 | fmt.Fprint(u.ErrorWriter, message) 58 | fmt.Fprint(u.ErrorWriter, "\n") 59 | } 60 | 61 | func (u *MockUi) Info(message string) { 62 | u.Output(message) 63 | } 64 | 65 | func (u *MockUi) Output(message string) { 66 | u.once.Do(u.init) 67 | 68 | fmt.Fprint(u.OutputWriter, message) 69 | fmt.Fprint(u.OutputWriter, "\n") 70 | } 71 | 72 | func (u *MockUi) Warn(message string) { 73 | u.once.Do(u.init) 74 | 75 | fmt.Fprint(u.ErrorWriter, message) 76 | fmt.Fprint(u.ErrorWriter, "\n") 77 | } 78 | 79 | func (u *MockUi) init() { 80 | u.ErrorWriter = new(syncBuffer) 81 | u.OutputWriter = new(syncBuffer) 82 | } 83 | 84 | type syncBuffer struct { 85 | sync.RWMutex 86 | b bytes.Buffer 87 | } 88 | 89 | func (b *syncBuffer) Write(data []byte) (int, error) { 90 | b.Lock() 91 | defer b.Unlock() 92 | return b.b.Write(data) 93 | } 94 | 95 | func (b *syncBuffer) Read(data []byte) (int, error) { 96 | b.RLock() 97 | defer b.RUnlock() 98 | return b.b.Read(data) 99 | } 100 | 101 | func (b *syncBuffer) Reset() { 102 | b.Lock() 103 | b.b.Reset() 104 | b.Unlock() 105 | } 106 | 107 | func (b *syncBuffer) String() string { 108 | return string(b.Bytes()) 109 | } 110 | 111 | func (b *syncBuffer) Bytes() []byte { 112 | b.RLock() 113 | data := b.b.Bytes() 114 | b.RUnlock() 115 | return data 116 | } 117 | -------------------------------------------------------------------------------- /ui_mock_test.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "io" 5 | "testing" 6 | ) 7 | 8 | func TestMockUi_implements(t *testing.T) { 9 | var _ Ui = new(MockUi) 10 | } 11 | 12 | func TestMockUi_Ask(t *testing.T) { 13 | tests := []struct { 14 | name string 15 | query, input string 16 | expectedResult string 17 | }{ 18 | {"EmptyString", "Middle Name?", "\n", ""}, 19 | {"NonEmptyString", "Name?", "foo bar\nbaz\n", "foo bar"}, 20 | } 21 | 22 | for _, tc := range tests { 23 | t.Run(tc.name, func(t *testing.T) { 24 | in_r, in_w := io.Pipe() 25 | defer in_r.Close() 26 | defer in_w.Close() 27 | 28 | ui := &MockUi{ 29 | InputReader: in_r, 30 | } 31 | 32 | go in_w.Write([]byte(tc.input)) 33 | 34 | result, err := ui.Ask(tc.query) 35 | if err != nil { 36 | t.Fatalf("err: %s", err) 37 | } 38 | 39 | if result != tc.expectedResult { 40 | t.Fatalf("bad: %#v", result) 41 | } 42 | }) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /ui_test.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | "testing" 7 | ) 8 | 9 | func TestBasicUi_implements(t *testing.T) { 10 | var _ Ui = new(BasicUi) 11 | } 12 | 13 | func TestBasicUi_Ask(t *testing.T) { 14 | tests := []struct { 15 | name string 16 | query, input string 17 | expectedQuery, expectedResult string 18 | }{ 19 | {"EmptyString", "Middle Name?", "\n", "Middle Name? ", ""}, 20 | {"NonEmptyString", "Name?", "foo bar\nbaz\n", "Name? ", "foo bar"}, 21 | } 22 | 23 | for _, tc := range tests { 24 | t.Run(tc.name, func(t *testing.T) { 25 | in_r, in_w := io.Pipe() 26 | defer in_r.Close() 27 | defer in_w.Close() 28 | 29 | writer := new(bytes.Buffer) 30 | ui := &BasicUi{ 31 | Reader: in_r, 32 | Writer: writer, 33 | } 34 | 35 | go in_w.Write([]byte(tc.input)) 36 | 37 | result, err := ui.Ask(tc.query) 38 | if err != nil { 39 | t.Fatalf("err: %s", err) 40 | } 41 | 42 | if writer.String() != tc.expectedQuery { 43 | t.Fatalf("bad: %#v", writer.String()) 44 | } 45 | 46 | if result != tc.expectedResult { 47 | t.Fatalf("bad: %#v", result) 48 | } 49 | }) 50 | } 51 | } 52 | 53 | func TestBasicUi_AskSecret(t *testing.T) { 54 | in_r, in_w := io.Pipe() 55 | defer in_r.Close() 56 | defer in_w.Close() 57 | 58 | writer := new(bytes.Buffer) 59 | ui := &BasicUi{ 60 | Reader: in_r, 61 | Writer: writer, 62 | } 63 | 64 | go in_w.Write([]byte("foo bar\nbaz\n")) 65 | 66 | result, err := ui.AskSecret("Name?") 67 | if err != nil { 68 | t.Fatalf("err: %s", err) 69 | } 70 | 71 | if writer.String() != "Name? " { 72 | t.Fatalf("bad: %#v", writer.String()) 73 | } 74 | 75 | if result != "foo bar" { 76 | t.Fatalf("bad: %#v", result) 77 | } 78 | } 79 | 80 | func TestBasicUi_Error(t *testing.T) { 81 | writer := new(bytes.Buffer) 82 | ui := &BasicUi{Writer: writer} 83 | ui.Error("HELLO") 84 | 85 | if writer.String() != "HELLO\n" { 86 | t.Fatalf("bad: %s", writer.String()) 87 | } 88 | } 89 | 90 | func TestBasicUi_Error_ErrorWriter(t *testing.T) { 91 | writer := new(bytes.Buffer) 92 | ewriter := new(bytes.Buffer) 93 | ui := &BasicUi{Writer: writer, ErrorWriter: ewriter} 94 | ui.Error("HELLO") 95 | 96 | if ewriter.String() != "HELLO\n" { 97 | t.Fatalf("bad: %s", ewriter.String()) 98 | } 99 | } 100 | 101 | func TestBasicUi_Output(t *testing.T) { 102 | writer := new(bytes.Buffer) 103 | ui := &BasicUi{Writer: writer} 104 | ui.Output("HELLO") 105 | 106 | if writer.String() != "HELLO\n" { 107 | t.Fatalf("bad: %s", writer.String()) 108 | } 109 | } 110 | 111 | func TestBasicUi_Warn(t *testing.T) { 112 | writer := new(bytes.Buffer) 113 | ui := &BasicUi{Writer: writer} 114 | ui.Warn("HELLO") 115 | 116 | if writer.String() != "HELLO\n" { 117 | t.Fatalf("bad: %s", writer.String()) 118 | } 119 | } 120 | 121 | func TestPrefixedUi_implements(t *testing.T) { 122 | var _ Ui = new(PrefixedUi) 123 | } 124 | 125 | func TestPrefixedUiError(t *testing.T) { 126 | ui := new(MockUi) 127 | p := &PrefixedUi{ 128 | ErrorPrefix: "foo", 129 | Ui: ui, 130 | } 131 | 132 | p.Error("bar") 133 | if ui.ErrorWriter.String() != "foobar\n" { 134 | t.Fatalf("bad: %s", ui.ErrorWriter.String()) 135 | } 136 | } 137 | 138 | func TestPrefixedUiInfo(t *testing.T) { 139 | ui := new(MockUi) 140 | p := &PrefixedUi{ 141 | InfoPrefix: "foo", 142 | Ui: ui, 143 | } 144 | 145 | p.Info("bar") 146 | if ui.OutputWriter.String() != "foobar\n" { 147 | t.Fatalf("bad: %s", ui.OutputWriter.String()) 148 | } 149 | } 150 | 151 | func TestPrefixedUiOutput(t *testing.T) { 152 | ui := new(MockUi) 153 | p := &PrefixedUi{ 154 | OutputPrefix: "foo", 155 | Ui: ui, 156 | } 157 | 158 | p.Output("bar") 159 | if ui.OutputWriter.String() != "foobar\n" { 160 | t.Fatalf("bad: %s", ui.OutputWriter.String()) 161 | } 162 | } 163 | 164 | func TestPrefixedUiWarn(t *testing.T) { 165 | ui := new(MockUi) 166 | p := &PrefixedUi{ 167 | WarnPrefix: "foo", 168 | Ui: ui, 169 | } 170 | 171 | p.Warn("bar") 172 | if ui.ErrorWriter.String() != "foobar\n" { 173 | t.Fatalf("bad: %s", ui.ErrorWriter.String()) 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /ui_writer.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | // UiWriter is an io.Writer implementation that can be used with 4 | // loggers that writes every line of log output data to a Ui at the 5 | // Info level. 6 | type UiWriter struct { 7 | Ui Ui 8 | } 9 | 10 | func (w *UiWriter) Write(p []byte) (n int, err error) { 11 | n = len(p) 12 | if n > 0 && p[n-1] == '\n' { 13 | p = p[:n-1] 14 | } 15 | 16 | w.Ui.Info(string(p)) 17 | return n, nil 18 | } 19 | -------------------------------------------------------------------------------- /ui_writer_test.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "io" 5 | "testing" 6 | ) 7 | 8 | func TestUiWriter_impl(t *testing.T) { 9 | var _ io.Writer = new(UiWriter) 10 | } 11 | 12 | func TestUiWriter(t *testing.T) { 13 | ui := new(MockUi) 14 | w := &UiWriter{ 15 | Ui: ui, 16 | } 17 | 18 | w.Write([]byte("foo\n")) 19 | w.Write([]byte("bar\n")) 20 | 21 | if ui.OutputWriter.String() != "foo\nbar\n" { 22 | t.Fatalf("bad: %s", ui.OutputWriter.String()) 23 | } 24 | } 25 | 26 | func TestUiWriter_empty(t *testing.T) { 27 | ui := new(MockUi) 28 | w := &UiWriter{ 29 | Ui: ui, 30 | } 31 | 32 | w.Write([]byte("")) 33 | 34 | if ui.OutputWriter.String() != "\n" { 35 | t.Fatalf("bad: %s", ui.OutputWriter.String()) 36 | } 37 | } 38 | --------------------------------------------------------------------------------