Skip to content
Command-line interfaces

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.

Grégoire Mielle
by Grégoire MielleLast updated on 5/22/2023
Table of content

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 return nil , arguments are considered valid and the RunE 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.