How to create, release & distribute a CLI in Golang?
From design to packaging & releasing, provide users with a direct interface to execute commands and perform various tasks.
Table of content
- Planning & designing your CLI
- Building a CLI in Golang using Cobra
- Setting up the CLI
- Adding commands to the CLI
- Implementing features & handling errors
- Packaging & releasing the CLI
- Using GoReleaser to package & release the CLI
- Publishing the CLI to Homebrew with GoReleaser
- Using GitHub actions for continuous integration & release
- Conclusion
Command-line interfaces (CLIs) have long been a popular and efficient way to interact with software applications. They provide users with a direct, text-based interface to execute commands and perform various tasks.
In this blog post, you'll explore the process of creating, releasing, and distributing a CLI in Golang.
You will deep dive into the tools & key considerations when working on a CLI, whether you’re building it to interact with a REST API, create automations or a utility tool.
Planning & designing your CLI
Before diving into the development of your CLI, it's important to determine its purpose, target audience and core functionalities:
- What problems does it solve?
- What specific tasks or operations will it enable users to perform?
- What technical vocabulary do they use to describe these tasks?
By determining the common workflows users are likely to perform, you’ll design a command structure and user interface that is intuitive.
Break down these functionalities into logical command structures and subcommands. This organization will make your CLI easier to navigate and use. Consider grouping related commands together and ensuring a consistent naming convention for a cohesive and user-friendly experience.
Check out this GopherCon conference from Carolyn Van Slyck on “Designing command-line interfaces people love” or Kubernetes CLI’s documentation to learn more about designing great CLIs.
Building a CLI in Golang using Cobra
While the Golang standard library offers all the tools you need to build a CLI, some common features like flags, validating arguments or documenting commands can quickly become a nightmare.
Cobra is a widely used package to bootstrap a CLI from prototype to complex applications like kubectl
or GitHub CLI.
Setting up the CLI
Start by creating a new Go project & install Cobra:
mkdir my-cli
cd my-cli
go mod init github.com/username/my-cli
go get -u github.com/spf13/cobra
Most Go projects are structured using cmd
, pkg
& internal
folders, as explained in this repository. Let’s use a similar structure for your CLI:
/my-cli
/cmd
/name # Your CLI command
/main.go # CLI entrypoint
/internal # Reusable code used by commands
/github # All code related to interacting with GitHub API
/api
/format
A minimal Cobra application has a root command which describes the CLI’s functionalities & available commands. It supports a version & help flag by default.
// cmd/name/main.go
package main
import (
"os"
"github.com/spf13/cobra"
)
var rootCmd = &cobra.Command{
Use: "Name",
Version: "0.1.0",
Short: "How engineers learn about CLIs",
}
func main() {
err := rootCmd.Execute()
if err != nil {
os.Exit(1)
}
}
You can run your CLI during development using the following command:
go run cmd/name/main.go
Adding commands to the CLI
Now that your CLI has a root command, you can add as many commands or subcommands.
To add a new command, create a new cobra.Command
struct in a new file:
// cmd/name/get.go
package main
import (
"fmt"
"github.com/MakeNowJust/heredoc/v2"
"github.com/spf13/cobra"
)
var getCmd = &cobra.Command{
Use: "get <detail>",
Short: "Display one or many repositories details",
Example: heredoc.Doc(`
Get stars count for a given repository
$ name get stars -r golang/go
`),
Args: func(cmd *cobra.Command, args []string) error {
return nil
},
RunE: func(cmd *cobra.Command, args []string) error {
fmt.Println("Hello from get command")
return nil
},
}
The cobra.Command
struct has a rich API to create powerful commands. Let’s go over what’s happening here:
Use
is a one-line usage message.Short
is a short description of the command. It is displayed when running the command with the--help
flag.Example
is a multiline message that lists use cases for that command. Using the heredoc package, you ensure proper indentation is kept.Args
allows you to define a function to validate arguments passed by users. If you returnnil
, arguments are considered valid and theRunE
function is executed.RunE
contains the main logic of your command. You’re free to do whatever you want in it, with the possibility to return an error to make Cobra exit with the right exit code.
To register your new command, you need to add it to your root command:
// cmd/name/main.go
func main() {
rootCmd.AddCommand(getCmd)
err := rootCmd.Execute()
if err != nil {
os.Exit(1)
}
}
Each time the main
function is executed, Cobra parses user’s input and determines which command needs to be called.
Implementing features & handling errors
While your CLI already has one command, it doesn’t do much. It’s considered best practice to not have all your logic inlined in the RunE
function.
Let’s create a REST API client to get how many stars a public GitHub repository has. You’ll also create a utility function to validate repository names.
// internal/github/api/api.go
package api
import (
"encoding/json"
"io/ioutil"
"net/http"
)
type RepositoryResponse struct {
StargazersCount int64 `json:"stargazers_count"`
}
func GetStarsCount(repositoryName string) (*int64, error) {
response, err := http.Get("https://api.github.com/repos/" + repositoryName)
if err != nil {
return nil, err
}
body, err := ioutil.ReadAll(response.Body)
if err != nil {
return nil, err
}
var responseStruct RepositoryResponse
err = json.Unmarshal(body, &responseStruct)
if err != nil {
return nil, err
}
return &responseStruct.StargazersCount, nil
}
// internal/github/format/repository.go
package format
import "strings"
func IsRepositoryValid(name string) bool {
if len(name) == 0 {
return false
}
parts := strings.Split(name, "/")
if len(parts) != 2 {
return false
}
return true
}
By having these pieces of code in internal
, you can easily reuse them in other commands. Let’s start using them in your get
command:
// cmd/name/get.go
package main
import (
"errors"
"fmt"
"github.com/MakeNowJust/heredoc/v2"
"github.com/username/my-cli/internal/github/api"
"github.com/username/my-cli/internal/github/format"
"github.com/spf13/cobra"
)
var getCmd = &cobra.Command{
Use: "get <detail>",
Short: "Display one or many repositories details",
Example: heredoc.Doc(`
Get stars count for a given repository
$ name get stars -r golang/go
`),
Args: func(cmd *cobra.Command, args []string) error {
if len(args) < 1 {
return errors.New("Requires a detail argument")
}
possibleDetails := []string{"stars"}
validResource := false
for _, resource := range possibleDetails {
if args[0] == resource {
validResource = true
break
}
}
if validResource == false {
return errors.New(fmt.Sprintf(`Detail "%s" is invalid`, args[0]))
}
return nil
},
RunE: func(cmd *cobra.Command, args []string) error {
if !format.IsRepositoryValid(repository) {
return errors.New("Repository flag is required")
}
detail := args[0]
switch detail {
case "stars":
{
starsCount, err := api.GetStarsCount(repository)
if err != nil {
return errors.New("Could not get stars count for this repository: " + err.Error())
}
fmt.Println(repository + " has " + fmt.Sprint(*starsCount) + " stars")
}
}
return nil
},
}
The Args
function has been updated to validate which details can be returned for any public GitHub repository. In the future, you could allow users to get the license, programming language, etc.
The RunE
function validates the repository flag passed by users using your utility function before calling the GitHub REST API using your GetStarsCount
function.
An important part of designing great CLIs is error messages: technical users need precise indication of what went wrong, if the operation can be retried with the same arguments and error codes they can be communicated when debugging issues.
Depending on the use case, the output of a command can be used by another system (eg. continuous integration pipeline) or a human (eg. development workflow). Completely different output formats are used to satisfy these needs, which is another consideration to take into account during the design phase.
Once your CLI is ready, it's time to package and release it to the world. This process involves choosing a package management tool, creating executable binaries, and preparing documentation and usage instructions.
Packaging & releasing the CLI
Packaging & releasing software is boring and error prone. This sentence is not from me but the tool that makes this process less painful: GoReleaser.
GoReleaser lets you define a configuration file to build, archive, package, sign and publish artifacts. In your case, these artifacts are multiple versions of your CLI for multiple platforms & package managers.
Using GoReleaser to package & release the CLI
GoReleaser can be installed following their installation guide.
Once installed, running the goreleaser init
creates a .goreleaser.yaml
which holds all the necessary configuration.
Let’s see what a simple .goreleaser.yaml
contains:
# .goreleaser.yaml
before:
hooks:
- go mod tidy
- go generate ./...
builds:
- <<: &build_defaults
binary: bin/name
main: ./cmd/name
id: macos
goos: [darwin]
goarch: [amd64]
- <<: *build_defaults
id: linux
goos: [linux]
goarch: [386, arm, amd64, arm64]
env:
- CGO_ENABLED=0
- <<: *build_defaults
id: windows
goos: [windows]
goarch: [386, amd64, arm64]
archives:
- id: nix
builds: [macos, linux]
<<: &archive_defaults
name_template: 'name_{{ .Version }}_{{ .Os }}_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}'
wrap_in_directory: true
replacements:
darwin: macOS
format: tar.gz
files:
- LICENSE
- id: windows
builds: [windows]
<<: *archive_defaults
wrap_in_directory: false
format: zip
files:
- LICENSE
release:
prerelease: auto
checksum:
name_template: 'checksums.txt'
snapshot:
name_template: '{{ incpatch .Version }}-next'
changelog:
sort: asc
filters:
exclude:
- '^docs:'
- '^test:'
In the builds
section, you specify that the CLI needs to be compiled to target MacOS, Windows & Linux for different architectures. The archives
section is used to customize generated archives.
In order to test the release process locally, you can run the following command:
goreleaser release --snapshot --clean
You can adjust the generated artifacts following GoReleaser’s documentation & ensure your configuration file is valid using the following command:
goreleaser check
Publishing the CLI to Homebrew with GoReleaser
Homebrew is a widely used package manager on MacOS. Most developers are used to install software through it. With GoReleaser, you can create a formula to install your CLI on MacOS computers.
First, create an empty public GitHub repository. It will used to store your Homebrew tap & make it accessible to users. Taps must be prefixed by homebrew-
to be correctly cloned by users when running brew tap
. For example, if you want users to use brew tap username/my-cli
, create a repository at github.com/username/homebrew-my-cli
.
Second, add a brews
section in your .goreleaser.yaml
configuration file:
# .goreleaser.yaml
brews:
- name: name
description: How engineers learn about CLIs
homepage: https://github.com/username/cli
tap:
owner: username
name: homebrew-cli
commit_author:
name: github_handle
email: email@example.com
folder: Formula
When running goreleaser release
, GoReleaser will commit to this repository in order to update the tap definition. This way, when Homebrew users use it, they’ll have an up-to-date formula to install your CLI. The generated formula is a Ruby class that contains links to platform-specific artifacts of your CLI.
Be aware that the machine on which the goreleaser release
command is executed needs to have git
configured with credentials that match the commit author section.
Let’s now explore how to set up a CI/CD pipeline for your CLI. This enables a smooth release management, ensuring a seamless development workflow.
Using GitHub actions for continuous integration & release
While every engineer in your team (or yourself) could release new versions of your CLI manually, this process is a tedious task & is error prone. By running it in a CI/CD pipeline, a predefined workflow can be run every time an event happens on your repository.
With GitHub actions, you can for example release a new version of your CLI every time a new tag is pushed to your repository. By adopting semantic versioning, these tags can be used as the version of your CLI.
Let’s see how such a GitHub actions workflow can look like:
# .github/workflows/release.yml
name: release
on:
push:
tags:
- '*'
permissions:
contents: write
jobs:
goreleaser:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2
- name: Fetch all tags
run: git fetch --force --tags
- name: Set up Go
uses: actions/setup-go@v2
with:
go-version: 1.18
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v2
with:
distribution: goreleaser
version: ${{ env.GITHUB_REF_NAME }}
args: release --rm-dist
env:
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}
Every time a new tag is pushed to your repository using git tag 1.0.0 && git push --tags
, GoReleaser will release a new version of your CLI using your GitHub credentials stored in the RELEASE_TOKEN
GitHub repository secret.
Conclusion
In this guide, you’ve seen the importance of designing your CLI, the steps necessary to build, release & distribute a Golang CLI and how to automate the release process using GitHub Actions.
Keep in mind that writing comprehensive documentation, creating a user-friendly README file, and establishing channels for user inquiries and bug reports are also essential steps of maintaining a command-line interface.
If you want to deep dive in actual code of widely used & feature-rich CLIs, check out GitHub CLI’s open source code.
Want to ship localized content faster
without loosing developers time?
Recontent helps product teams collaborate on content for web & mobile apps.
No more hardcoded keys, outdated text or misleading translations.