Building CLIs in Go

2 min read

Go’s simplicity, performance, and excellent standard library make it an ideal choice for building command-line interfaces (CLIs). Let’s explore how to create robust, user-friendly CLI applications that your users will love.

Why Go for CLIs?

Go offers several advantages for CLI development:

  • Single binary distribution
  • Cross-platform compilation
  • Fast startup times
  • Excellent concurrency support
  • Rich standard library

Basic Command-Line Parsing

Start with Go’s built-in flag package:

package main

import (
    "flag"
    "fmt"
    "os"
)

func main() {
    var (
        name    = flag.String("name", "World", "name to greet")
        verbose = flag.Bool("verbose", false, "enable verbose output")
        count   = flag.Int("count", 1, "number of greetings")
    )

    flag.Parse()

    for i := 0; i < *count; i++ {
        fmt.Printf("Hello, %s!\n", *name)
        if *verbose {
            fmt.Printf("Greeting #%d completed\n", i+1)
        }
    }
}

Using Cobra for Advanced CLIs

For more complex applications, Cobra provides a powerful framework:

package cmd

import (
    "github.com/spf13/cobra"
)

var rootCmd = &cobra.Command{
    Use:   "mycli",
    Short: "A brief description of your CLI",
    Long: `A longer description that spans multiple lines
and likely contains examples and usage of using your application.`,
    Run: func(cmd *cobra.Command, args []string) {
        // Root command logic here
    },
}

func Execute() error {
    return rootCmd.Execute()
}

Subcommands Structure

Organize functionality with subcommands:

package cmd

import (
    "fmt"
    "github.com/spf13/cobra"
)

var (
    port int
    host string
)

var serveCmd = &cobra.Command{
    Use:   "serve",
    Short: "Start the server",
    RunE: func(cmd *cobra.Command, args []string) error {
        fmt.Printf("Starting server on %s:%d\n", host, port)
        // Server logic here
        return nil
    },
}

func init() {
    rootCmd.AddCommand(serveCmd)
    serveCmd.Flags().IntVarP(&port, "port", "p", 8080, "port to listen on")
    serveCmd.Flags().StringVarP(&host, "host", "H", "localhost", "host to bind to")
}

Configuration Management

Integrate Viper for configuration:

package config

import (
    "fmt"
    "github.com/spf13/viper"
)

type Config struct {
    Database DatabaseConfig `mapstructure:"database"`
    Server   ServerConfig   `mapstructure:"server"`
}

func Load() (*Config, error) {
    viper.SetConfigName("config")
    viper.SetConfigType("yaml")
    viper.AddConfigPath(".")
    viper.AddConfigPath("$HOME/.mycli")

    viper.SetEnvPrefix("MYCLI")
    viper.AutomaticEnv()

    if err := viper.ReadInConfig(); err != nil {
        if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
            return nil, err
        }
    }

    var cfg Config
    if err := viper.Unmarshal(&cfg); err != nil {
        return nil, err
    }

    return &cfg, nil
}

Interactive Prompts

Create interactive experiences with promptui:

package prompt

import (
    "fmt"
    "github.com/manifoldco/promptui"
)

func SelectOption() (string, error) {
    prompt := promptui.Select{
        Label: "Select an option",
        Items: []string{"Development", "Staging", "Production"},
    }

    _, result, err := prompt.Run()
    return result, err
}

func GetPassword() (string, error) {
    prompt := promptui.Prompt{
        Label: "Password",
        Mask:  '*',
    }

    return prompt.Run()
}

Progress Indicators

Show progress for long-running operations:

package progress

import (
    "time"
    "github.com/briandowns/spinner"
)

func WithSpinner(message string, fn func() error) error {
    s := spinner.New(spinner.CharSets[14], 100*time.Millisecond)
    s.Suffix = " " + message
    s.Start()
    defer s.Stop()

    return fn()
}

Colored Output

Make output more readable with colors:

package output

import (
    "fmt"
    "github.com/fatih/color"
)

var (
    Success = color.New(color.FgGreen).SprintFunc()
    Error   = color.New(color.FgRed).SprintFunc()
    Warning = color.New(color.FgYellow).SprintFunc()
    Info    = color.New(color.FgCyan).SprintFunc()
)

func PrintSuccess(format string, args ...interface{}) {
    fmt.Println(Success("✓"), fmt.Sprintf(format, args...))
}

func PrintError(format string, args ...interface{}) {
    fmt.Println(Error("✗"), fmt.Sprintf(format, args...))
}

Error Handling

Implement proper error handling:

package errors

import (
    "fmt"
    "os"
)

type ExitError struct {
    Code    int
    Message string
}

func (e ExitError) Error() string {
    return e.Message
}

func HandleError(err error) {
    if err == nil {
        return
    }

    if exitErr, ok := err.(ExitError); ok {
        fmt.Fprintf(os.Stderr, "Error: %s\n", exitErr.Message)
        os.Exit(exitErr.Code)
    }

    fmt.Fprintf(os.Stderr, "Error: %v\n", err)
    os.Exit(1)
}

Testing CLI Commands

Write tests for your CLI:

package cmd

import (
    "bytes"
    "testing"
    "github.com/stretchr/testify/assert"
)

func TestServeCommand(t *testing.T) {
    cmd := rootCmd
    buf := new(bytes.Buffer)
    cmd.SetOut(buf)
    cmd.SetErr(buf)
    cmd.SetArgs([]string{"serve", "--port", "9090"})

    err := cmd.Execute()
    assert.NoError(t, err)
    assert.Contains(t, buf.String(), "9090")
}

Distribution

Build for multiple platforms:

#!/bin/bash

PLATFORMS=("windows/amd64" "darwin/amd64" "darwin/arm64" "linux/amd64")

for platform in "${PLATFORMS[@]}"
do
    platform_split=(${platform//\// })
    GOOS=${platform_split[0]}
    GOARCH=${platform_split[1]}
    output_name='mycli-'$GOOS'-'$GOARCH

    if [ $GOOS = "windows" ]; then
        output_name+='.exe'
    fi

    env GOOS=$GOOS GOARCH=$GOARCH go build -o dist/$output_name
done

Best Practices

1. Provide Help Text

var rootCmd = &cobra.Command{
    Use:   "mycli [command]",
    Short: "A powerful CLI tool",
    Long: `mycli is a CLI tool that helps you manage your infrastructure.

Complete documentation is available at https://mycli.example.com`,
    Example: `  mycli serve --port 8080
  mycli deploy production
  mycli config set key value`,
}

2. Use Context for Cancellation

func processCommand(cmd *cobra.Command, args []string) error {
    ctx, cancel := context.WithCancel(context.Background())
    defer cancel()

    sigChan := make(chan os.Signal, 1)
    signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)

    go func() {
        <-sigChan
        fmt.Println("\nInterrupted, cleaning up...")
        cancel()
    }()

    return process(ctx, args)
}

3. Version Information

var versionCmd = &cobra.Command{
    Use:   "version",
    Short: "Print version information",
    Run: func(cmd *cobra.Command, args []string) {
        fmt.Printf("mycli version %s\n", version)
        fmt.Printf("Built: %s\n", buildDate)
        fmt.Printf("Commit: %s\n", commit)
    },
}

Conclusion

Building CLIs in Go is a rewarding experience. The language’s simplicity combined with excellent libraries like Cobra, Viper, and others make it possible to create professional-grade command-line tools quickly. Focus on user experience, provide clear documentation, and leverage Go’s strengths to build CLIs that developers will enjoy using.

Remember: a good CLI is predictable, discoverable, and follows platform conventions. Happy coding!