Building CLI Tools in Go
Go is exceptionally well-suited for building command-line interface (CLI) tools. Its fast compilation, single-binary output, and excellent standard library make it the go-to language for DevOps utilities, platform tools, and system administration commands. Many popular CLI tools like Docker, Kubernetes kubectl, Terraform, and Hugo are written in Go.
Why Go for CLI Tools
Go provides several advantages for CLI development:
- Single binary: Produces self-contained executables with no dependencies
- Cross-compilation: Easy to build for multiple platforms from a single machine
- Fast execution: Compiled binaries start quickly and run efficiently
- Rich standard library: Built-in support for file I/O, networking, JSON, and more
- Strong ecosystem: Excellent third-party libraries for CLI development
graph LR
A[Go Source] --> B[go build]
B --> C[linux/amd64]
B --> D[darwin/amd64]
B --> E[windows/amd64]
Basic CLI Application
Simple CLI with flag Package
package main
import (
"flag"
"fmt"
)
func main() {
// Define flags
name := flag.String("name", "World", "Name to greet")
verbose := flag.Bool("verbose", false, "Enable verbose output")
count := flag.Int("count", 1, "Number of times to greet")
// Parse command line
flag.Parse()
if *verbose {
fmt.Println("Greeting started...")
}
for i := 0; i < *count; i++ {
fmt.Printf("Hello, %s!\n", *name)
}
}
// Usage:
// go run main.go -name Alice -verbose -count 3
Reading from Standard Input
package main
import (
"bufio"
"fmt"
"os"
)
func main() {
scanner := bufio.NewScanner(os.Stdin)
fmt.Print("Enter your name: ")
scanner.Scan()
name := scanner.Text()
fmt.Printf("Hello, %s!\n", name)
}
Using Cobra for Advanced CLI
Cobra is the most popular CLI framework in Go, used by Kubernetes, GitHub CLI, and many others.
Installing Cobra
go get -u github.com/spf13/cobra@latest
Basic Cobra Application
package main
import (
"fmt"
"github.com/spf13/cobra"
"os"
)
var rootCmd = &cobra.Command{
Use: "myapp",
Short: "MyApp is a sample CLI application",
Long: `A longer description of the application.`,
Run: func(cmd *cobra.Command, args []string) {
fmt.Println("Hello from MyApp!")
},
}
func main() {
if err := rootCmd.Execute(); err != nil {
fmt.Println(err)
os.Exit(1)
}
}
Adding Subcommands
package main
import (
"fmt"
"github.com/spf13/cobra"
)
var (
name string
age int
)
var rootCmd = &cobra.Command{
Use: "myapp",
Short: "MyApp CLI",
}
var versionCmd = &cobra.Command{
Use: "version",
Short: "Print version information",
Run: func(cmd *cobra.Command, args []string) {
fmt.Println("MyApp v1.0.0")
},
}
var userCmd = &cobra.Command{
Use: "user",
Short: "Manage users",
}
var createUserCmd = &cobra.Command{
Use: "create",
Short: "Create a new user",
Run: func(cmd *cobra.Command, args []string) {
fmt.Printf("Creating user: %s (age: %d)\n", name, age)
},
}
func init() {
// Add subcommands
rootCmd.AddCommand(versionCmd)
rootCmd.AddCommand(userCmd)
userCmd.AddCommand(createUserCmd)
// Add flags
createUserCmd.Flags().StringVarP(&name, "name", "n", "", "User name (required)")
createUserCmd.Flags().IntVarP(&age, "age", "a", 0, "User age")
createUserCmd.MarkFlagRequired("name")
}
func main() {
rootCmd.Execute()
}
// Usage:
// myapp version
// myapp user create -n Alice -a 30
graph TD
A[myapp] --> B[version]
A --> C[user]
C --> D[create]
C --> E[delete]
C --> F[list]
Using Viper for Configuration
Viper handles configuration from files, environment variables, and command-line flags.
package main
import (
"fmt"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
var rootCmd = &cobra.Command{
Use: "myapp",
Short: "MyApp with configuration",
Run: func(cmd *cobra.Command, args []string) {
fmt.Println("Host:", viper.GetString("host"))
fmt.Println("Port:", viper.GetInt("port"))
fmt.Println("Debug:", viper.GetBool("debug"))
},
}
func init() {
// Set defaults
viper.SetDefault("host", "localhost")
viper.SetDefault("port", 8080)
viper.SetDefault("debug", false)
// Bind flags
rootCmd.Flags().StringP("host", "h", "localhost", "Server host")
rootCmd.Flags().IntP("port", "p", 8080, "Server port")
rootCmd.Flags().BoolP("debug", "d", false, "Enable debug mode")
viper.BindPFlag("host", rootCmd.Flags().Lookup("host"))
viper.BindPFlag("port", rootCmd.Flags().Lookup("port"))
viper.BindPFlag("debug", rootCmd.Flags().Lookup("debug"))
// Read config file
viper.SetConfigName("config")
viper.SetConfigType("yaml")
viper.AddConfigPath(".")
viper.AddConfigPath("$HOME/.myapp")
if err := viper.ReadInConfig(); err == nil {
fmt.Println("Using config file:", viper.ConfigFileUsed())
}
// Read from environment
viper.AutomaticEnv()
viper.SetEnvPrefix("MYAPP")
}
func main() {
rootCmd.Execute()
}
// Config file: config.yaml
// host: api.example.com
// port: 9000
// debug: true
// Environment: MYAPP_HOST=prod.example.com
// Flags: --host localhost --port 3000
File Operations
package main
import (
"fmt"
"io/ioutil"
"os"
"path/filepath"
)
// Read file
func readFile(filename string) (string, error) {
data, err := ioutil.ReadFile(filename)
if err != nil {
return "", err
}
return string(data), nil
}
// Write file
func writeFile(filename, content string) error {
return ioutil.WriteFile(filename, []byte(content), 0644)
}
// Check if file exists
func fileExists(filename string) bool {
_, err := os.Stat(filename)
return !os.IsNotExist(err)
}
// List files in directory
func listFiles(dir string) ([]string, error) {
files := []string{}
err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if !info.IsDir() {
files = append(files, path)
}
return nil
})
return files, err
}
// Create directory
func createDir(path string) error {
return os.MkdirAll(path, 0755)
}
func main() {
// Write and read
err := writeFile("test.txt", "Hello, World!")
if err != nil {
fmt.Println("Error writing:", err)
return
}
content, err := readFile("test.txt")
if err != nil {
fmt.Println("Error reading:", err)
return
}
fmt.Println("Content:", content)
}
Progress Bars and Spinners
package main
import (
"time"
"github.com/schollz/progressbar/v3"
)
func main() {
bar := progressbar.Default(100)
for i := 0; i < 100; i++ {
bar.Add(1)
time.Sleep(40 * time.Millisecond)
}
}
// Spinner example
import (
"time"
"github.com/briandowns/spinner"
)
func longRunningTask() {
s := spinner.New(spinner.CharSets[9], 100*time.Millisecond)
s.Start()
time.Sleep(4 * time.Second) // Simulate work
s.Stop()
}
Colorized Output
package main
import (
"github.com/fatih/color"
)
func main() {
// Basic colors
color.Red("This is red")
color.Green("This is green")
color.Blue("This is blue")
// Combined attributes
color.New(color.FgRed, color.Bold).Println("Bold red")
// Custom color
success := color.New(color.FgGreen, color.Bold)
success.Println("Success!")
// Printf-style
color.Yellow("Warning: %s", "something happened")
}
Table Output
package main
import (
"os"
"github.com/olekukonko/tablewriter"
)
func main() {
data := [][]string{
{"Alice", "30", "Engineering"},
{"Bob", "25", "Marketing"},
{"Carol", "35", "Sales"},
}
table := tablewriter.NewWriter(os.Stdout)
table.SetHeader([]string{"Name", "Age", "Department"})
for _, row := range data {
table.Append(row)
}
table.Render()
}
// Output:
// +-------+-----+-------------+
// | NAME | AGE | DEPARTMENT |
// +-------+-----+-------------+
// | Alice | 30 | Engineering |
// | Bob | 25 | Marketing |
// | Carol | 35 | Sales |
// +-------+-----+-------------+
HTTP Requests
package main
import (
"encoding/json"
"fmt"
"io/ioutil"
"net/http"
)
type User struct {
ID int `json:"id"`
Name string `json:"name"`
}
func getUsers() ([]User, error) {
resp, err := http.Get("https://api.example.com/users")
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("API returned status %d", resp.StatusCode)
}
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var users []User
err = json.Unmarshal(body, &users)
return users, err
}
func main() {
users, err := getUsers()
if err != nil {
fmt.Println("Error:", err)
return
}
for _, user := range users {
fmt.Printf("%d: %s\n", user.ID, user.Name)
}
}
Cross-Platform Build
# Build for current platform
go build -o myapp
# Build for Linux
GOOS=linux GOARCH=amd64 go build -o myapp-linux
# Build for macOS
GOOS=darwin GOARCH=amd64 go build -o myapp-mac
# Build for Windows
GOOS=windows GOARCH=amd64 go build -o myapp.exe
# Build for multiple platforms
for GOOS in linux darwin windows; do
for GOARCH in amd64 arm64; do
output="myapp-$GOOS-$GOARCH"
if [ "$GOOS" = "windows" ]; then
output+=".exe"
fi
echo "Building $output"
GOOS=$GOOS GOARCH=$GOARCH go build -o bin/$output
done
done
graph LR
A[Go Source] --> B[Cross Compiler]
B --> C[Linux Binary]
B --> D[macOS Binary]
B --> E[Windows Binary]
B --> F[ARM Binary]
Practical Example: File Search CLI
package main
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/spf13/cobra"
)
var (
searchPath string
pattern string
caseInsensitive bool
)
var rootCmd = &cobra.Command{
Use: "filesearch",
Short: "Search for files by name pattern",
Run: searchFiles,
}
func init() {
rootCmd.Flags().StringVarP(&searchPath, "path", "p", ".", "Path to search")
rootCmd.Flags().StringVarP(&pattern, "pattern", "t", "", "Search pattern (required)")
rootCmd.Flags().BoolVarP(&caseInsensitive, "ignore-case", "i", false, "Case insensitive search")
rootCmd.MarkFlagRequired("pattern")
}
func searchFiles(cmd *cobra.Command, args []string) {
matchCount := 0
searchPattern := pattern
if caseInsensitive {
searchPattern = strings.ToLower(searchPattern)
}
err := filepath.Walk(searchPath, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() {
return nil
}
filename := filepath.Base(path)
if caseInsensitive {
filename = strings.ToLower(filename)
}
if strings.Contains(filename, searchPattern) {
fmt.Println(path)
matchCount++
}
return nil
})
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
fmt.Printf("\nFound %d files\n", matchCount)
}
func main() {
if err := rootCmd.Execute(); err != nil {
os.Exit(1)
}
}
// Usage:
// filesearch -t ".go" -p /home/user/projects
// filesearch -t "test" -i
Testing CLI Applications
package main
import (
"bytes"
"testing"
"github.com/spf13/cobra"
)
func executeCommand(root *cobra.Command, args ...string) (output string, err error) {
buf := new(bytes.Buffer)
root.SetOut(buf)
root.SetErr(buf)
root.SetArgs(args)
err = root.Execute()
return buf.String(), err
}
func TestVersionCommand(t *testing.Command) {
output, err := executeCommand(rootCmd, "version")
if err != nil {
t.Errorf("Unexpected error: %v", err)
}
expected := "MyApp v1.0.0"
if !strings.Contains(output, expected) {
t.Errorf("Expected output to contain %q, got %q", expected, output)
}
}
Key Takeaways
- Go excels at building CLI tools with single-binary output and fast execution
- The flag package provides basic command-line argument parsing
- Cobra is the standard framework for advanced CLI applications with subcommands
- Viper handles configuration from files, environment variables, and flags
- Cross-compilation allows building for multiple platforms from a single machine
- Rich ecosystem provides libraries for progress bars, colors, tables, and more
- File operations are straightforward with the standard library
- HTTP requests enable CLI tools to interact with APIs
- Proper error handling and user feedback improve CLI usability
- Testing CLI applications ensures reliability
- Single binaries simplify distribution and deployment
- Go CLI tools are fast, efficient, and easy to maintain