How to create & distribute a CLI in Golang?

Cloud computing plays a vital role in the creation of software products and services. It's also one of the most highly sought-after skills in the tech industry.

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

go get -u 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" "" )

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"



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 {

} 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("" + 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"



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

	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:


before: hooks: - go mod tidy - go generate ./...


  • <<: &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:
  • <<: *build_defaults id: windows goos: [windows] goarch: [386, amd64, arm64]


  • 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:
  • id: windows builds: [windows] <<: *archive_defaults wrap_in_directory: false format: zip files:

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

Second, add a brews section in your .goreleaser.yaml configuration file:



  • name: name description: How engineers learn about CLIs homepage: tap: owner: username name: homebrew-cli commit_author: name: github_handle email: 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:


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.