summary: Basics of writing a Go CLI tool id: codelab categories: golang tags: cli status: Published authors: Nicolas Lepage feedback link: https://github.com/nlepage/catption/issues
In this codelab you will learn the basics of writing a Go CLI tool.
- Setup a development environment
- Discover
os,os/execandflagpackages - Discover
github.com/spf13/cobraCLI library - Create commands and subcommands
- Read command flags and args
- Discover
github.com/spf13/viperconfig library - Read and write a config file
- Put
cobraandvipertogether - Read environment variables
- Discover
github.com/sirupsen/logruslogging library - Use build time variable injection
- Use conditional compilation and build tags
The steps marked with a π are optional.
- Setup a development environment
- Read args (package
os) - Bonus: Read flags (package
flag)
In order to go through this codelab, you are going to need a working Go development environment.
The minimum required version is Go 1.13.
Positive
: Already have Go installed?
Make sure you are running a version >= 1.13 by running go version.
If it is the case you may proceed to the next step.
Negative
: Do not use apt (old versions of Go)
Run:
sudo snap install go --classicFollow the instructions at https://golang.org/doc/install#tarball
Run:
brew install goDownload the package file at https://golang.org/dl/, open it, and follow the prompts.
Download the MSI file at https://golang.org/dl/, open it, and follow the prompts.
Positive
: Check your installation by running go version and go env.
There are two ways of downloading the codelab contents.
The prefered way is git, which will allow you to keep track of your work and revert things if needed.
Run:
git clone https://github.com/nlepage/catption.gitDownload https://github.com/nlepage/catption/archive/master.zip and unzip it.
Positive : Each chapter of the codelab has its own directory:
π catption
|-π codelab
| |-π chapter1
| |-π chapter2
Run cd catption/codelab/chapter1 to go to chapter 1.
The last thing you need is a Go friendly IDE.
If you don't already have one, here are some popular IDEs for Go:
- Visual Studio Code + vscode-go
- Goland
- vim + vim-go
Now open the codelab contents and you are ready π·, let's Go!
In πcatption/codelab/chapter1 you will find a classic hello.go:
package main
import (
"fmt"
)
func main() {
fmt.Println("Hello World!")
}β¨ Execute this program by running go run hello.go.
We would like to replace World by a variable in our message.
β¨ Create a new string variable:
var recipient = "Gopher"β¨ Use fmt.Printf() to format the message with recipient.
Negative
: Unlike fmt.Println(), fmt.Printf() does not add a new line at the end of the string.
You must add it by appending \n at the end of the message.
As you can see the main function of a Go program has no parameters.
The command line arguments are available in the Args variable of the os package.
Positive
: os.Args has the type []string (slice of string).
A slice is a variable length array.
β¨ Use os.Args to fill the recipient variable.
Positive
: strings.Join concatenates the elements of a slice of strings.
Positive
: To extract a subset of a slice, use the slice operator.
Having var ii = []int{1, 2, 3, 4}, ii[2:] will give you the slice [3, 4]
Flags allow to change the behavior of commands, like the -r flag of rm which enables recursive removal.
The flag package allows to parse the flags contained in os.Args.
We would like our command to have a -u flag which uppercases the message:
$ hello -u capslock
HELLO CAPSLOCK!β¨ Explore the flag package and parse the -u flag in hello.go.
Positive
: flag.Args returns the non-flag command-line arguments.
Positive
: strings.ToUpper returns an upper case copy of a string.
Positive : `fmt.Sprintf returns a formatted string
π Congratulations! You have completed chapter 1.
- Setup a development environment
- Read args (package
os) - π Read flags (package
flag)
- Discover
github/spf13/cobra - Create a cobra command
- π Validate arguments
Cobra is a library for creating powerful modern CLI applications.
Cobra provides:
- Easy subcommand-based CLIs:
app server,app fetch, etc. - Fully POSIX-compliant flags (including short & long versions)
- Nested subcommands
- Global, local and cascading flags
- Easy generation of applications & commands with
cobra init appname&cobra add cmdname - Intelligent suggestions (
app srver... did you meanapp server?) - Automatic help generation for commands and flags
- Automatic help flag recognition of
-h,--help, etc. - Automatically generated bash autocomplete for your application
- Automatically generated man pages for your application
- Command aliases so you can change things without breaking them
- The flexibility to define your own help, usage, etc.
- Optional tight integration with viper for 12-factor apps
π Explore cobra's documentation and API.
Let's see how to recreate our hello command using Cobra.
In πcatption/codelab/chapter2 you will find a new hello.go with the skeleton of a cobra app:
package main
import (
"fmt"
"os"
"strings"
"github.com/spf13/cobra"
)
var cmd = &cobra.Command{
RunE: func(_ *cobra.Command, args []string) error {
return nil
},
}
func main() {
if err := cmd.Execute(); err != nil {
os.Exit(1)
}
}
func sayHello(args []string) error {
if _, err := fmt.Printf("Hello %s!\n", strings.Join(args, " ")); err != nil {
return err
}
return nil
}β¨ Fill the Use and Long fields of the cmd Command struct, then execute go run hello.go -h to see the result.
β¨ Call sayHello in the RunE function of cmd in order to have a working hello command, execute go run hello.go cobra to see the result.
Negative
: sayHello may return an error, you may forward this error to the caller of RunE.
β¨ Finally fill the Version field of cmd, then execute go run hello-go --version to see the result.
Our hello command needs at least one command line argument.
β¨ Fill the Args field of cmd with the correct value in order to raise an error if hello doesn't receive any arguments.
Positive
: The type of Args is cobra.PositionalArgs, which is a function type.
You could implement your own command-line arguments validator (this is not the goal here).
π Congratulations! You have completed chapter 2.
- Discover
github/spf13/cobra - Create a cobra command
- π Validate arguments
- Interpret flags
- π Flag shorthand
Enough of hello messages, let's start writing our cat caption CLI π±
In πcatption/codelab/chapter3 you will find a catption.go with a new command:
var (
top, bottom string
size, fontSize, margin float64
cmd = &cobra.Command{
Use: "catption",
Long: "Cat caption generator CLI",
Args: cobra.ExactArgs(1),
Version: "chapter3",
RunE: func(_ *cobra.Command, args []string) error {
var name = args[0]
cat, err := catption.LoadJPG(name)
if err != nil {
return err
}
cat.Top, cat.Bottom = top, bottom
cat.Size, cat.FontSize, cat.Margin = size, fontSize, margin
return cat.SaveJPG("out.jpg")
},
}
)This command does 3 things:
- Create a catption by loading a JPEG file
- Setup the catption's parameters
- Write the catption to
out.jpg
However the variables used to setup the catption have not been initialized.
β¨ In the init function, setup cmd's flags:
topandbottomstring flagssize,fontSizeandmarginfloat flags (Usecatption.DefaultSize,catption.DefaultFontSizeandcatption.DefaultMarginas default values)
Positive
: Command.Flags returns the FlagSet of a command.
The FlagSet allows to setup the flags of a command.
Positive
: Some methods of FlagSet, such as IntVar, expect a pointer as first argument.
Having var i = 42, use &i to get a pointer to i, &i has the type *int.
β¨ Play around with your new command, some pictures are available in πcats/
Flags shorthands allow users to type more concise commands.
β¨ Add some shorthands to cmd:
-tfor--top-bfor--bottom-sfor--size
Positive
: All FlagSet methods have a shorthand variant.
To add a shorthand to an int flag, use IntVarP instead of IntVar.
π Congratulations! You have completed chapter 3.
- Interpret flags
- π Flag shorthand
- Discover
github.com/spf13/viper - Read a config file
- π Access user's config dir
Viper is a complete configuration solution for Go applications including 12-Factor apps. It is designed to work within an application, and can handle all types of configuration needs and formats.
It supports:
- setting defaults
- reading from JSON, TOML, YAML, HCL, envfile and Java properties config files
- live watching and re-reading of config files (optional)
- reading from environment variables
- reading from remote config systems (etcd or Consul), and watching changes
- reading from command line flags
- reading from buffer
- setting explicit values
π Explore viper's documentation and API.
Specifying the full path to the input JPEG file is not very userfriendly...
Let's use a config file to define directories where catption should look for JPEG files.
In πcatption/codelab/chapter4 the catption command now has a PreRunE function:
PreRunE: func(_ *cobra.Command, _ []string) error {
viper.SetConfigName("catption")
viper.AddConfigPath(".")
if err := viper.ReadInConfig(); err != nil {
if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
return err
}
}
return nil
},This function tries to load a catption.* config file in the current directory.
β¨ Before the call to ReadInConfig, define the default value for the "dirs" config key (use the value of the dirs var).
Positive
: viper.SetDefault allows to define default values for config keys.
β¨ After the call to ReadInConfig, set the value of the dirs var using the "dirs" config key.
Positive
: viper has all kinds of getters for reading config keys.
viper.GetIntSlice reads a config key into a slice of ints ([]int).
β¨ Create a catption.* config file with the directories where you want catption to look for JPEG files.
Example catption.yaml:
dirs:
- "."
- "../../cats"You can now try your configuration: go run catption.go -t "Hello" -b "World" dinner.jpg
Many applications read there config file from the user's config directory ($HOME/Library/Application Support on macOS for example).
β¨ Call viper.AddConfigPath a second time to read catption's config file from the user's config directory, in addition of current the directory.
Positive
: Package os has some useful helpers such as UserHomeDir to read platform dependent environment variables.
π Congratulations! You have completed chapter 4.
- Discover
github.com/spf13/viper - Read a config file
- π Access user's config dir
- Connect cobra and viper
- π Read environment variables
Some of our users don't want to use config files.
We would like to offer them the possibility to override the dirs config key with a flag.
Luckily viper has the ability to read config values from cobra!
Negative : When connecting cobra and viper, you must read config values from viper. viper reads values from cobra, but not the other way around.
β¨ Create a new dir flag with the type slice of strings.
β¨ Bind the dir flag to viper's dirs config key.
Positive
: FlagSet.Lookup returns the *pflag.Flag for a previously created flag's name.
Positive
: viper.BindPFlag binds a config key to a *pflag.Flag.
Try it out: go run catption.go -t "Hello" -b "World" --dir "../../cats" --dir "." dinner.jpg
One of our users would like to deploy catption on a kubernetes cluster.
The easiest way for him/her to specify the input files directories is to use an environment variable.
β¨ Use viper's API to read the dirs config key from a CATPTION_DIRS environment variable.
Try it out: CATPTION_DIRS="../../cats" go run catption.go -t "Hello" -b "World" dinner.jpg
π Congratulations! You have completed chapter 5.
- Connect cobra and viper
- π Read environment variables
- Create a subcommand
- π Inject compile time variables
Some of our users don't know how to create a config file and add directories to it.
Let's help them by adding a new dir subcommand to catption, which will add a directory to the config file.
In πcatption/codelab/chapter6 we now have a dirCmd command, and a addDir function which implements adding a new directory to the config file.
β¨ Fill the fields of dirCmd: Use, Long, Args and RunE
β¨ In the init function, add dirCmd as a subcommand to cmd
Positive
: cmd's RunE function is now a PersistentPreRunE.
It will be executed for cmd and it's subcommands.
Positive
: Command.AddComand adds a subcommand to a parent command
Using a constant value for cmd's Version field is not very useful.
It would be nice to set this variable at compile time, with a git tag or commit hash.
β¨ Create a version variable at package level, and set cmd.Version's value with this variable.
β¨ Try changing the binary's version with build flags: go build -ldflags "-X main.version=1.0.0"
π Congratulations! You have completed chapter 6.
- Create a subcommand
- π Inject compile time variables
- Interpret custom flags
- π Discover
github.com/sirupsen/logrus
We've added some logs to catption using a library called logrus.
However we would like to be able to set the log level using a flag.
In πcatption/codelab/chapter7 we now have a logLevel variable used to set the log level.
This variable has the type logrus.Level.
In order to create a flag with a custom type, you must implement pflag's Value interface.
This is already done by the type logLevelValue:
type logLevelValue logrus.Level
var _ pflag.Value = new(logLevelValue)
func (l *logLevelValue) Set(value string) error {
lvl, err := logrus.ParseLevel(value)
if err != nil {
return err
}
*l = logLevelValue(lvl)
return nil
}
func (l *logLevelValue) String() string {
return logrus.Level(*l).String()
}
func (l *logLevelValue) Type() string {
return "string"
}β¨ In the init function, create a new --logLevel flag for the logLevel variable.
Positive
: Command.PersistentFlags returns a FlagSet used for the current command and its subcommands.
Positive
: FlagSet.Var defines a custom typed flag.
It is possible to perform a type cast between pointer types, here is an example:
type Celsius float64
func example() {
var temperature float64
measureTemperature((*Celsius)(&temperature))
fmt.Println("temp:", temperature)
}
// measureTemperature stores a new measure in the t pointer
func measureTemperature(t *Celsius)π Have a look at logrus's documentation and API
β¨ Add some new logs in catption.
π Congratulations! You have completed chapter 7.
- Interpret custom flags
- π Discover
github.com/sirupsen/logrus
- Discover
os/execpackage - Use conditional compilation
- π Use build tags
We would like catption to open an image viewer as soon as the image has been written to disk.
Most operating systems have commands to open the appropriate viewer for a file:
- The
xdg-opencommand on π§ Linux - The
opencommand on π macOS - The
startcommand on π Windows
β¨ Use the os/exec package to execute the appropriate command for your OS and display the image.
Positive
: Cmd.Run starts a command and waits for it to complete.
Some users don't have the same OS as you.
We would like to cross-compile catption to other systems, but the command for opening a viewer is system dependent!
The go compiler is able to include/exclude source files, based on their suffix.
source_darwin.go will only be compiled when targeting macOS systems.
β¨ Create 3 files with each an openCmd string const:
open_linux.gofor Linuxopen_darwin.gofor macOSopen_windows.gofor Windows
β¨ Use openCmd to call exec.Command
One of our users would like to run catption on a FreeBSD system.
xdg-open is also available on this system, it would be nice to use the same openCmd const for Linux and FreeBSD.
β¨ Rename open_linux.go to open_xdg.go.
β¨ Add build tags to open_xdg.go in order to target Linux and FreeBSD.
π Congratulations! You have completed chapter 8.
- Discover
os/execpackage - Use conditional compilation
- π Use build tags
π Congratulations! You have completed the codelab!
You now know the basics to build you own CLI with Go.
- Setup a development environment
- Discover
os,os/execandflagpackages - Discover
github.com/spf13/cobraCLI library - Create commands and subcommands
- Read command flags and args
- Discover
github.com/spf13/viperconfig library - Read and write a config file
- Put
cobraandvipertogether - Read environment variables
- Discover
github.com/sirupsen/logruslogging library - Use build time variable injection
- Use conditional compilation and build tags
The fully working catption CLI source is available at the repositories root.

