In this guide, we will be building a CLI tool from scratch. No fancy frameworks or libraries -- instead, we are building our own highly minimal framework loosely based on Cobra.
Here's a taste of what we're building: š gupi
ā”ļø Disclaimer: Popular CLI frameworks exist for every language. In most cases, you're better off picking your favorite flavor and going with that. |
---|
If you have ever wondered how CLI frameworks do what they do, then read on. There are plenty of reasonable use cases for DIY tools. For a practical example, look at Hashicorp (Terraform, Nomad). Most, if not all their CLI products use github.com/mitchellh/cli, a Go CLI Framework developed by the founder.
Cobra, which I briefly name dropped above, is arguably the best and most popular CLI framework for Go. It is trusted by the Kubernetes and Docker community (Kubectl & Docker CLI), among many more notable projects: Helm, Etcd, Istio...
Cobra has a deceptively simple API. For example, here is a snippet directly from its developer page:
This defines a single command hello, accepts a list of strings as arguments, and defines the logic to execute the command. The goal for this guide is to create a CLI with a similar look and feel.
Here is the agenda for building š Gupi:
- Defining the requirements
- Initializing the CLI
- Creating the Command struct
- Wiring up components
- Adding subcommands: Add, Edit, List, Delete, Create, Version
- Putting it all together
Step 0: Make sure you have Go installed. See the official docs for the latest install instructions - Go Install.
You view the full source while following along at š Gupi.
Defining the requirements
Keep in mind that what we are building does not matter as much as how we are building it. It could easily be adapted to do whatever you need.
š Gupi will be able to add, edit, list, delete, and create templates. The commands or features will be defined as follows:
- Add: Adds an existing template to the collection via a local file
$ gupi add -f /path/to/template.md TEMPLATE_NAME
Template 'TEMPLATE_NAME' was added
- Edit: Uses the default text editor to modify a stored template
$ gupi edit TEMPLATE_NAME
Template 'TEMPLATE_NAME' was edited
- List: Lists all stored templates
$ gupi list
NAME CREATED SIZE
todo-list 2 weeks ago 482
design-doc 3 days ago 300
- Create: Generates an instance of a template in the current directory
$ gupi create TEMPLATE_NAME
Created 'TEMPLATE_NAME' in '/path/to/TEMPLATE_NAME'
- Delete: Removes a stored template
$ gupi delete TEMPLATE_NAME
Template 'TEMPLATE_NAME' was deleted
- Version: Prints version info to the console
$ gupi version
gupi version: v0.1, build: 893b04957563cd7120f817dd654ba745075cfe6b
Initializing the CLI
The first thing we need to do is create the project directory and initialize it as a Git repository. I'll call this project gupi
:
$ mkdir gupi
$ cd gupi
$ git init
Go has its own way to manage dependencies by using a go.mod
file. You can easily enable this on a new project by running:
$ go mod init github.com/USERNAME/gupi
The full path github.com/USERNAME/gupi
is what others would use to download if you packaged and distributed this module. This is also helpful for importing local files from subfolders.
Next, let's create our main entry point called: main.go
$ touch main.go
Add a basic print statement to make sure the environment is working:
// main.go
package main
import "fmt"
func main() {
fmt.Println("Working CLI!")
}
We can test this app by running:
$ go run main.go
Working CLI!
We just confirmed we have a working Go project but have no time to celebrate since this doesn't feel like a CLI tool.
One thing that stands out from CLIs like Docker and Kubectl is the informative and friendly user manuals that are displayed by calling the base command.
Essentially what I want is:
$ gupi
Our awesome CLI with instructions on how to quickly get started using it.
Options:
Commands:
* Wicked Feature 1 - One wicked feature
* Cool Feature 2 - Coolest feature by far
* Practical Feature 3 - The daily driver of features
New users should be able to understand what the tool does and how to actually use it. We already clearly defined the main features of our tool, so putting together this usage text will be easy.
Based on the features defined above, I came up with this; feel free to modify as you see fit:
var usage = `Usage: gupi command [options]
A simple tool to generate and manage custom templates
Options:
Commands:
add Adds a template to the collection from a local file
edit Uses the default text editor to modify a stored template
list Lists all stored templates
create Generates an instance of a template in the current directory
delete Removes a stored template
version Prints version info to console
`
Now that we have a base usage message, we can update our main function print this to get a feel for the CLI.
// main.go
func main() {
fmt.Println(usage)
}
If we test this app again, it will slowly start feeling like a real CLI.
$ go run main.go
We need to add one more thing before moving another. An important aspect of any CLI tool is exit/status codes. Any CLI needs to return 0 when successful and anything greater when an error occurs.
We'll create a method called usageAndExit
which prints the top level usage message along with an optional status message.
// main.go
func usageAndExit(msg string) {
if msg != "" {
fmt.Fprint(os.Stderr, msg)
fmt.Fprintf(os.Stderr, "\n")
}
flag.Usage()
os.Exit(0)
}
If we update our main method as follows, then test:
// main.go
func main() {
usageAndExit("")
}
You should get this cryptic message after running:
$ go run main.go
Usage of /var/folders/47/jlb0vvyx0px72mykl96x41xm0000gp/T/go-build235122164/b001/exe/main:
Not to worry, as this just means we are trying to access flag.Usage()
before setting a usage message. If we update the main method again:
// main.go
func main() {
flag.Usage = func() {
fmt.Fprint(os.Stderr, fmt.Sprint(usage))
}
usageAndExit("")
}
Now it will behave as expected returning the usage message and the correct exit code.
You can confirm by running:
$ echo $?
0
Now that we have defined our commands, we can work on actually creating them.
Creating the Command struct
First thing we want to define is the base Command Struct. Each command needs to be able to have its own set of flags and a function defining what to do with those flags. We'll define this struct in a new file commands.go
// command.go
type Command struct {
flags *flag.FlagSet
Execute func(cmd *Command, args []string)
}
The flag.Flagset
is just what it sounds like: a collection of flags. These will be defined for each subcommand. Execute
is an anonymous function that accepts a Command and an array of arguments. Note: Go treats functions as first class citizens, so we can pass functions as parameters or when defining an instance of a struct. Next, we need a few functions on the Command struct. It needs to:
- Parse and assign flags to its flagset
- Tell if flags have been parsed
- Execute a given function
What those turn into is:
- Init()
- Called()
- Run()
The full version will look like this:
// command.go
package command
import (
"flag"
)
type Command struct {
flags *flag.FlagSet
Execute func(cmd *Command, args []string)
}
func (c *Command) Init(args []string) error {
return c.flags.Parse(args)
}
func (c *Command) Called() bool {
return c.flags.Parsed()
}
func (c *Command) Run() {
c.Execute(c, c.flags.Args())
}
This way each new subcommand we add will just be of type Command. This will simplify things for the next step as we modify our main method to allow subcommands to be easily added.
Wiring up components
The main method will be the entry point for all subcommands. We'll achieve this by leveraging to parts: subcommands are Command types and a switch statement.
We need to initialize an empty command object, as this will be the container for incoming subcommands. New subcommands will be initialized based on the case statement. After the command is initialized, we parse the flags and execute the function using the same methods defined earlier. What that begins to look like is this:
// main.go
func main() {
...
var cmd *command.Command
switch os.Args[1] {
case "version":
cmd = command.NewVersionCommand()
default:
usageAndExit(fmt.Sprintf("gupi: '%s' is not a gupi command.\n", os.Args[1]))
}
cmd.Init(os.Args[2:])
cmd.Run()
}
Our subcommands haven't been defined yet, but this is essentially what it'll look like, however, each new command will get its own case statement.
We are now at a good point to start building out our subcommands.
Adding subcommands
We have six subcommands to add, but it'll go quickly since each command will follow the same steps:
- Define a friendly usage message
- Create the specific new command method
- Build out the core logic for the function
- Add a new case statement
We already added the version
case to the main method, so let's start there.
Add Version command
First, we need to define what the usage message should be. Based on the requirements, I put this together:
NOTE: I didn't mention this before, but the Version command will accept a single flag --short
, if added the short version of the build info will be printed.
// version.go
var versionUsage = `Print the app version and build info for the current context.
Usage: gupi version [options]
Options:
--short If true, print just the version number. Default false.
`
Next we need to create the NewVersionCommand()
, which will return a pointer to a Command object. This is where we will attach the usage message, define the flags, and specify the core logic:
// version.go
func NewVersionCommand() *Command {
cmd := &Command{
flags: flag.NewFlagSet("version", flag.ExitOnError),
Execute: versionFunc,
}
cmd.flags.BoolVar(&short, "short", false, "")
cmd.flags.Usage = func() {
fmt.Fprintln(os.Stderr, versionUsage)
}
return cmd
}
As defined in the usage message, this command should print the version number, build info, and accept a -short
flag.
// version.go
var (
build = "???"
version = "???"
short = false
)
var versionFunc = func(cmd *Command, args []string) {
if short {
fmt.Printf("brief version: v%s", version)
} else {
fmt.Printf("brief version: v%s, build: %s", version, build)
}
os.Exit(0)
}
Nothing surprising about the versionFunc
, it prints a message and exits with an all-clear exit code. What might've caught your eye is the ??? for the build and version variables. Those are meant as placeholder variables that will be overwritten during the build process. This allows us to specify version numbers in a config or dynamically include commit hashes on each build. We'll be using ldflags to do this, I'll go over this near the end. For now, if you want to read more check out using ldflags to set version flags.
One point I glanced over is the func(cmd *Command, args []string)
type for the Execute
field. That means Execute
is an anonymous function that can be passed in at any time or extracted into a separate variable as we did for versionFunc
. Extracting is not necessary, but is helpful for building readable code.
The final step would be adding a case statement to our main method. Since we already took care of that, we're ready for this command.
Now we can accept subcommands and start testing with and without the --short
flag:
$ go run main.go version --short
brief version: v???
$ go run main.go version
brief version: v???, build: ???
For now the ???
is fine. We will replace those during the build process later on. At this point, we've touched on all the core components of creating a CLI
- Friendly messaging and instructions
- Adding new subcommands
- Adding subcommand specific flags
- Wiring up subcommands together
The next few steps will move faster since we're just repeating the same steps to add more functionality to our CLI.
Add our Add Command
Next, we'll introduce the functionality to store templates for use. To keep this simple we'll just be keeping a copy of a given template in a specific folder.
As I mentioned we'll just repeat a few similar steps for each new function. I'll start focusing on the repetitive steps and more on the differences. We'll start by defining the usage messaging:
// add.go
var addUsage = `Add a template from a file path or URL.
Usage: brief add [OPTIONS] TEMPLATE
Options:
--file path to an existing template file
`
Next by creating a new add command method which takes a single flag --file
:
// add.go
func NewAddCommand() *Command {
cmd := &Command{
flags: flag.NewFlagSet("add", flag.ExitOnError),
Execute: addFunc,
}
cmd.flags.StringVar(&filePath, "file", "", "")
cmd.flags.Usage = func() {
fmt.Fprintln(os.Stderr, addUsage)
}
return cmd
}
Now we can focus on the functionality. The first thing we need to do is some error handling and validation. We need to confirm the file we're using as a template actually exists. We also need to confirm if the directory for saved templates exists, otherwise, we'll go ahead and create it.
The os package has several useful methods for handling files, paths, and folders. We'll use os.Stat() to determine if a file/folder exists.
The os
package has several use methods for handling files, paths and folders. We'll use os.Stat()
to determine if a file/folder exists. Then we'll use the os.UserHomeDir()
to get the home directory. This will be the base path for our saved templates. The full path will be USER_HOME/.gupi/templates/
.
// add.go
var addFunc = func(cmd *Command, args []string) {
if len(filePath) == 0 {
... // file error handling
if _, err := os.Stat(filePath); err != nil {
... // file path error handling
homeDir, err := os.UserHomeDir()
... // home dir error handling
fileDir := filepath.Join(homeDir, ".gupi", "template")
... // file path error handling; create folder if not exists
f, err := ioutil.ReadFile(filePath)
... // file error handling
file_name := filepath.Base(filePath)
fileOutPath := filepath.Join(fileDir, file_name)
out, err := os.Create(fileOutPath)
... // file error handling
defer out.Close()
out.WriteString(string(f))
fmt.Printf("gupi: Template '%s' was added\n", file_name)
}
We will use the method errAndExit()
to handle any errors and return a helpful error message.
// command.go
func errAndExit(msg string) {
fmt.Fprint(os.Stderr, msg, "\n")
os.Exit(1)
}
The final step is to add an add
case to the switch statement
// main.go
...
case "add":
cmd = command.NewAddCommand()
...
You will need to create a simple template file to test this subcommand. I'll create test.md
:
this is a test template
Now you save the template using this subcommand:
$ go run main.go add --file test.md
We should now be able to save templates. But how can we confirm it's actually saved? This is where the next command list
comes into play.
Add List Command
This command will list all the available templates in the template folder. With this, we can validate that the test.md
template was added successfully. Similar to the add command, we are relying on the os
package to manage files and folder paths.
Jumping back to our steps, we'll first define the usage message:
// list.go
var listUsage = `List all currently avaible templates.
Usage: brief list
Options:
`
Then create the new list command. This will be simpler than others since it does not use any specific flags.
// list.go
func NewListCommand() *Command {
cmd := &Command{
flags: flag.NewFlagSet("list", flag.ExitOnError),
Execute: listFunc,
}
...
So this command needs to list all templates in the specific folder in the user home directory.
We'll use os.UserHomeDir()
to grab the user-specific home directory then look for the template folder in the app subdirectory.
Finally, we use some basic string formatting for a friendly output.
// list.go
var listFunc = func(cmd *Command, args []string) {
homeDir, err := os.UserHomeDir()
... // file error handling
fileDir := filepath.Join(homeDir, ".gupi", "template")
file, err := os.Open(fileDir)
... // file error handling
filelist, err := file.Readdir(0)
... // file error handling
fmt.Printf("NAME\t\tSIZE\t\tMODIFIED")
for _, files := range filelist {
fmt.Printf("\n%-15s %-15v %v\n", files.Name(), files.Size(), files.ModTime().Format("2006-01-02 15:04:05"))
}
}
The last step is to add another case in the switch statement
// main.go
case "list":
cmd = command.NewListCommand()
Now we can test this command by running:
$ go run main.go list
NAME SIZE MODIFIED
test.md 24 2021-12-02 00:44:10
The next command will allow us to edit any existing templates in terminal.
Add Edit Command
This next command is fun to use and surprisingly simple to put together. We'll add the ability to edit our saved templates directly from the terminal using your editor of choice. I'll be using Vim as my editor, but this could easily be modified into a flag or config variable to be passed in.
The first thing to do is more familiar with error handling, we know where templates live and this command requires a template name so we just have to make sure it all exists as expected.
Then we will define what command will be executed to start the editor. In this case, we will run vim FILE_NAME
. Before we actually execute the command we need to configure the IO to be sent to our terminal. Along with that some simple error handling in case anything goes wrong along the way.
// edit.go
var editFunc = func(cmd *Command, args []string) {
...
if _, err := os.Stat(file_path); err == nil {
command := exec.Command("vim", file_path)
command.Stdout = os.Stdout
command.Stdin = os.Stdin
command.Stderr = os.Stderr
err := command.Run()
if err != nil {
os.Exit(1)
}
}
fmt.Printf("gupi: Template '%s' was edit\n", file_name)
}
Now we can edit the test.md
template by running:
$ go run main.go edit test.md
gupi: Template 'test.md' was edited
Now let's try to create an instance of this template.
Add Create Command
This is the most important step. Everything we have done so far has been leading up to this command. Several parts will look familiar, especially around file manipulation. One new addition is the use of the template
package, which enables us to parse templates and inject data into the template. If you've used any sort of templating framework such as Handlebars, Mustache, or Jinja, you'll feel right at home.
As before, we first need to do some error handling to make sure all files exist as expected. If it all checks out we will use template.ParseFiles(FILE)
to create a Template object from the actual file. Then, we'll create an empty file with the same name. Finally, we'll use template.Execute()
to write the data to the newly created open file.
// create.go
var createFunc = func(cmd *Command, args []string) {
file_name := args[0]
homeDir, err := os.UserHomeDir()
... // file error handling
path := filepath.Join(homeDir, ".gupi", "template", tmp)
if _, err := os.Stat(path); err == nil {
date := time.Now()
data := getDates(date)
t, err := template.ParseFiles(path)
... // file error handling
file_name := args[0]
f, err := os.Create(file_name)
... // file error handling
err = t.Execute(f, data)
... // file error handling
currDir, _ := os.Getwd()
fmt.Printf("Created '%s' in '%s'", file_name, filepath.Join(currDir, file_name))
}
}
You can now create files using templates!
$ go run main.go create test.md
Created 'test.md' in '/Users/rodrigomoran/Workspace/gupi/test.md'
A final command we'll include is the ability to delete existing templates.
Add Delete Command
Similar to a few other commands, we need to validate that the template we plan to remove actually exists. If it checks out, then we'll use os.Remove()
to delete the file from the folder.
// delete.go
var deleteFunc = func(cmd *Command, args []string) {
if len(args) == 0 {
... // file error handling
file_name := args[0]
homeDir, err := os.UserHomeDir()
... // file error handling
filePath := filepath.Join(homeDir, ".gupi", "template", file_name)
if _, err := os.Stat(filePath); err == nil {
os.Remove(filePath)
fmt.Printf("gupi: Template '%s' was deleted\n", file_name)
}
}
Now we can delete our test template by running:
$ go run main.go delete test.md
gupi: Template 'test.md' was deleted
Putting it all together
Now that we have all the subcommands, we can put them all together into a standalone binary. For that, we'll use the Makefile. If you're not familiar with them, Makefiles were initially used to simplify compiling C programs but they can be used for any language.
The Makefile below includes commands to build, test, and clean.
.PHONY: all test clean
user = phantompunk
binary = gupi
version = 0.1.1
build = $(shell git rev-parse HEAD)
ldflags = -ldflags "-X 'github.com/$(user)/$(binary)/command.version=$(version)'
ldflags += -X 'github.com/$(user)/$(binary)/command.build=$(build)'"
all:
go build -o $(binary) $(ldflags)
test:
go test ./... -cover -coverprofile c.out
go tool cover -html=c.out -o coverage.html
clean:
rm -rf $(binary) c.out coverage.html
Before building the binary, be sure to update the user variable for your GitHub username.
To build the binary run: $ make
$ make
go build -o gupi -ldflags "-X 'github.com/phantompunk/gupi/command.version=0.0.1' -X 'github.com/phantompunk/gupi/command.build=98b2bf861904efb87b59422f5279735667f65093'"
To add the binary to your path, run:
$ go install
Make sure your $GOPATH
is added to your. $PATH
You should then just be able to run:
$ gupi
š Congrats!
You just built a CLI from scratch and hopefully learned a few things along the way.
Here is a brief walkthrough of what you can do with š Gupi
Full source code: https://github.com/phantompunk/gupi
Where to go from here
At this point we have a fully functioning CLI but its far from perfect or complete. Feel free to improve and experiment by customizing or extending functionality. A few ideas to get you going could be to:
- allow importing templates from a URL based on a flag
- allow for configurable settings from a configuration file
- support more functions in templates