Skip to content
ExponaBLOG
GO PROGRAMMING & PRODUCTION APIS · DAY 060 / 100

Building a CLI in Go: a practical guide for developers

Developer tools people love.

Expona Admin8 min read

Building a CLI in Go: a practical guide for developers

Most teams eventually need internal tooling: deploy scripts, database migrations, data processing pipelines. Writing these as CLIs in Go gives you fast, statically-typed binaries that run anywhere without runtime dependencies. Building a CLI in Go: a practical guide for developers walks through the real patterns we use in production to make command-line tools that your team will actually want to use.

Why Go for CLI tooling?

Go compiles to a single binary with no external dependencies. Ship it to staging, production, or hand it to a teammate—it just works. Compare that to Node scripts that need npm, Python scripts that need virtual environments, or shell scripts that break across operating systems. For our teams shipping CLIs across e-commerce platforms, trading systems, and SaaS applications, Go eliminates friction.

A well-built CLI also feels professional. Proper command structure, help text, flag validation, and consistent output earn trust. Users learn your CLI's patterns once and apply them everywhere.

Cobra: Commands, flags and help text

Cobra is the standard choice for building structured CLI applications in Go. It handles command hierarchies, flags (both persistent and per-command), and auto-generated help documentation. Rather than parsing raw os.Args, you describe what your CLI does, and Cobra handles the rest.

Start with a root command and add subcommands as needed. Here's a practical example for a deployment tool:

go
package main

import (
	"fmt"
	"log"

	"github.com/spf13/cobra"
)

var rootCmd = &cobra.Command{
	Use:   "deploy",
	Short: "Deployment and infrastructure management",
	Long:  "A CLI for managing deployments across environments",
}

var deployCmd = &cobra.Command{
	Use:   "service [service-name]",
	Short: "Deploy a service",
	Long:  "Deploy a service to staging or production",
	Args:  cobra.ExactArgs(1),
	RunE: func(cmd *cobra.Command, args []string) error {
		env, _ := cmd.Flags().GetString("env")
		version, _ := cmd.Flags().GetString("version")
		fmt.Printf("Deploying %s to %s (version: %s)\n", args[0], env, version)
		return nil
	},
}

func init() {
	rootCmd.AddCommand(deployCmd)

	// Per-command flags
	deployCmd.Flags().StringP("env", "e", "staging", "Target environment")
	deployCmd.Flags().StringP("version", "v", "", "Service version (required)")
	deployCmd.MarkFlagRequired("version")

	// Persistent flags (available to all commands)
	rootCmd.PersistentFlags().StringP("config", "c", "", "Config file path")
}

func main() {
	if err := rootCmd.Execute(); err != nil {
		log.Fatal(err)
	}
}

Notice how RunE returns an error. Cobra catches it and exits with code 1, keeping error handling consistent. Short flags (-e) and long flags (--env) both work automatically.

Flags have sensible defaults. Your users run deploy service api --version 1.2.3 or deploy service api -e prod -v 1.2.3—both work. Cobra generates deploy --help and deploy service --help automatically from your command descriptions.

For complex CLIs, organize your code into separate packages. Put each command's logic in its own file under a cmd/ directory. As your tool grows, this structure scales.

Config: Viper or env vars for settings

CLIs need settings: API endpoints, credentials, defaults. Hard-coding them is inflexible; reading from environment every time is repetitive. Viper handles both cleanly.

Viper reads config from files (YAML, TOML, JSON), environment variables, or flags, with a clear precedence order. It's invaluable when you need different settings across development, staging, and production.

go
package main

import (
	"fmt"
	"os"

	"github.com/spf13/cobra"
	"github.com/spf13/viper"
)

var deployCmd = &cobra.Command{
	Use:   "service [service-name]",
	Short: "Deploy a service",
	RunE: func(cmd *cobra.Command, args []string) error {
		// Load config from flag, env, or config file
		configPath, _ := cmd.Flags().GetString("config")
		if configPath != "" {
			viper.SetConfigFile(configPath)
			if err := viper.ReadInConfig(); err != nil {
				return fmt.Errorf("failed to read config: %w", err)
			}
		}

		// Environment variables override config file
		viper.SetEnvPrefix("DEPLOY")
		viper.AutomaticEnv()

		// Retrieve settings (env vars take precedence)
		apiEndpoint := viper.GetString("api_endpoint")
		if apiEndpoint == "" {
			apiEndpoint = "https://api.example.com"
		}

		fmt.Printf("Using API endpoint: %s\n", apiEndpoint)
		return nil
	},
}

func init() {
	rootCmd.AddCommand(deployCmd)
	deployCmd.Flags().StringP("config", "c", "", "Config file path")
}

With this setup, your users can:

  • Use defaults: deploy service api --version 1.2.3
  • Override with env: DEPLOY_API_ENDPOINT=https://staging-api.example.com deploy service api --version 1.2.3
  • Use a config file: deploy --config config.yaml service api --version 1.2.3

For sensitive data (API keys, passwords), always use environment variables, never config files. Many teams source these from secret management systems before invoking the CLI.

Output: Readable tables and JSON output modes

Good CLIs adapt their output to the user's needs. Interactive output (tables, colored text) is great for direct invocation. JSON output is essential when another tool or script needs to parse the result.

Here's a practical pattern for supporting both:

go
package main

import (
	"encoding/json"
	"fmt"
	"os"

	"github.com/olekukonko/tablewriter"
	"github.com/spf13/cobra"
)

type Deployment struct {
	Service   string
	Version   string
	Env       string
	Status    string
	Timestamp string
}

var listCmd = &cobra.Command{
	Use:   "list",
	Short: "List recent deployments",
	RunE: func(cmd *cobra.Command, args []string) error {
		format, _ := cmd.Flags().GetString("format")

		// Fetch deployments from API
		deployments := []Deployment{
			{"api", "1.2.3", "prod", "success", "2024-01-15 10:30"},
			{"web", "2.1.0", "prod", "success", "2024-01-15 10:15"},
			{"worker", "0.5.1", "staging", "pending", "2024-01-15 10:05"},
		}

		if format == "json" {
			return outputJSON(deployments)
		}
		return outputTable(deployments)
	},
}

func outputTable(deployments []Deployment) error {
	table := tablewriter.NewWriter(os.Stdout)
	table.SetHeader([]string{"Service", "Version", "Environment", "Status", "Timestamp"})
	table.SetBorders(tablewriter.Border{Left: true, Top: true, Right: true, Bottom: true})

	for _, d := range deployments {
		table.Append([]string{d.Service, d.Version, d.Env, d.Status, d.Timestamp})
	}

	table.Render()
	return nil
}

func outputJSON(deployments []Deployment) error {
	data, err := json.MarshalIndent(deployments, "", "  ")
	if err != nil {
		return err
	}
	fmt.Println(string(data))
	return nil
}

func init() {
	rootCmd.AddCommand(listCmd)
	listCmd.Flags().StringP("format", "f", "table", "Output format: table or json")
}

Your team runs deploy list and sees a clean table. A CI/CD pipeline runs deploy list --format json and pipes it to jq for filtering. One tool, two output modes.

Many production CLIs also support CSV output or custom templates via Go's text/template package. Choose based on what your users actually need.

Distribute: Single binaries with GoReleaser and Homebrew taps

You've built a great CLI. Now distribute it so your team can actually use it. GoReleaser automates building binaries for every platform (Linux, macOS, Windows), creating checksums, and publishing to GitHub Releases. Then set up a Homebrew tap so macOS users install it with brew install your-org/tools/deploy.

Create a .goreleaser.yaml file in your repository:

go
version: 2

builds:
  - id: deploy
    main: ./main.go
    binary: deploy
    goos:
      - linux
      - darwin
      - windows
    goarch:
      - amd64
      - arm64

archives:
  - format: tar.gz
    format_overrides:
      - goos: windows
        format: zip

brews:
  - tap:
      owner: your-org
      name: homebrew-tools
      token: "{{ .Env.GITHUB_TOKEN }}"
    commit_author:
      name: ci-user
      email: ci@example.com
    install: |
      bin.install "deploy"

In your CI/CD (GitHub Actions, GitLab CI), tag a release and run goreleaser release. It builds for all platforms, uploads artifacts to GitHub Releases, and updates your Homebrew tap automatically.

Your team then installs with:

go
brew tap your-org/tools
brew install deploy

Updates are trivial: brew upgrade deploy. This is how professional tools work. Your internal CLI feels like a real product.

For non-macOS users, distribute via GitHub Releases directly, or publish to artifact repositories your organization already uses (Artifactory, Cloud Storage, etc.).

Common mistakes

  • Ignoring error handling: Use RunE instead of Run so errors exit cleanly with non-zero codes. CI pipelines depend on this.
  • Unclear flag names: Avoid abbreviations that only make sense to you. --wait-timeout beats --wt. Your future self will thank you.
  • Hard-coded credentials: Never. Always read from environment or secure vaults. A leaked binary is a leaked credential.
  • No version information: Embed a version at build time using -ldflags "-X main.Version=1.0.0". Users need to know what they're running.
  • Inconsistent exit codes: Use 0 for success, 1 for general errors, 2 for usage errors. Scripts rely on this.
  • Verbose output by default: Keep output clean. Add a --verbose flag for detailed logging, not the reverse.

Key takeaways

  • Use Cobra for command structure, flags, and help generation—it scales as your CLI grows.
  • Leverage Viper for configuration: it elegantly handles files, env vars, and defaults with sensible precedence.
  • Support both human-readable and machine-parseable output; add --format json or --format csv for scripting.
  • Automate distribution with GoReleaser and Homebrew taps; a CLI people can't easily install won't be used.
  • Treat your CLI like a product: clear commands, helpful error messages, and consistent behavior earn your team's trust.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles