Building a CLI Tool in Go: From Zero to Distribution

Build a professional CLI tool in Go with Cobra. Covers project structure, flags, config files, testing, cross-compilation, and distribution via Homebrew.

Y
Yash Pritwani
14 min read

Why Go for CLI Tools?

Go produces single static binaries with no runtime dependencies. No Python version conflicts, no Node.js installs, no JVM. Just one file that works on any machine. This makes Go the dominant language for CLI tools — kubectl, docker, terraform, gh, and hundreds more are written in Go.

docker-compose.yml123456789version: "3.8"services: web: image: nginx:alpine ports: - "80:80" volumes: - ./html:/usr/share/nginx

A well-structured configuration file is the foundation of reproducible infrastructure.

Project Setup

mkdir myctl && cd myctl
go mod init github.com/yourname/myctl

# Install Cobra (the CLI framework used by kubectl, Hugo, etc.)
go get github.com/spf13/cobra@latest
go get github.com/spf13/viper@latest  # For config files

# Install Cobra CLI generator
go install github.com/spf13/cobra-cli@latest

Project structure:

myctl/
├── cmd/
│   ├── root.go        # Root command and global flags
│   ├── deploy.go      # 'myctl deploy' subcommand
│   ├── status.go      # 'myctl status' subcommand
│   └── config.go      # 'myctl config' subcommand
├── internal/
│   ├── client/        # API client
│   ├── config/        # Config management
│   └── output/        # Output formatting
├── main.go
├── go.mod
└── go.sum

The Root Command

Get more insights on Tutorials

Join 2,000+ engineers who get our weekly deep-dives. No spam, unsubscribe anytime.

// cmd/root.go
package cmd

import (
    "fmt"
    "os"

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

var (
    cfgFile string
    verbose bool
    output  string
)

var rootCmd = &cobra.Command{
    Use:   "myctl",
    Short: "Manage your TechSaaS infrastructure",
    Long:  "myctl is a CLI tool for managing TechSaaS deployments, services, and configurations.",
}

func Execute() {
    if err := rootCmd.Execute(); err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
}

func init() {
    cobra.OnInitialize(initConfig)

    rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default $HOME/.myctl.yaml)")
    rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "verbose output")
    rootCmd.PersistentFlags().StringVarP(&output, "output", "o", "text", "output format (text, json, yaml)")
}

func initConfig() {
    if cfgFile != "" {
        viper.SetConfigFile(cfgFile)
    } else {
        home, _ := os.UserHomeDir()
        viper.AddConfigPath(home)
        viper.SetConfigType("yaml")
        viper.SetConfigName(".myctl")
    }
    viper.AutomaticEnv()
    viper.ReadInConfig()
}

Adding Subcommands

// cmd/deploy.go
package cmd

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

var deployCmd = &cobra.Command{
    Use:   "deploy [service]",
    Short: "Deploy a service to your infrastructure",
    Long:  "Deploy a Docker service with automatic Traefik routing and health checks.",
    Args:  cobra.ExactArgs(1),
    RunE: func(cmd *cobra.Command, args []string) error {
        service := args[0]
        env, _ := cmd.Flags().GetString("env")
        dryRun, _ := cmd.Flags().GetBool("dry-run")

        if dryRun {
            fmt.Printf("DRY RUN: Would deploy %s to %s\n", service, env)
            return nil
        }

        fmt.Printf("Deploying %s to %s...\n", service, env)

        // Your deployment logic here
        if err := performDeploy(service, env); err != nil {
            return fmt.Errorf("deployment failed: %w", err)
        }

        fmt.Printf("Successfully deployed %s\n", service)
        return nil
    },
}

func init() {
    rootCmd.AddCommand(deployCmd)
    deployCmd.Flags().StringP("env", "e", "production", "target environment")
    deployCmd.Flags().Bool("dry-run", false, "show what would be deployed")
}
// cmd/status.go
package cmd

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

    "github.com/spf13/cobra"
)

type ServiceStatus struct {
    Name   string `json:"name"`
    Status string `json:"status"`
    CPU    string `json:"cpu"`
    Memory string `json:"memory"`
    Uptime string `json:"uptime"`
}

var statusCmd = &cobra.Command{
    Use:   "status",
    Short: "Show status of all services",
    RunE: func(cmd *cobra.Command, args []string) error {
        services := getServiceStatuses()

        format, _ := cmd.Flags().GetString("output")
        switch format {
        case "json":
            enc := json.NewEncoder(os.Stdout)
            enc.SetIndent("", "  ")
            return enc.Encode(services)
        default:
            return printStatusTable(services)
        }
    },
}

func printStatusTable(services []ServiceStatus) error {
    w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
    fmt.Fprintln(w, "NAME\tSTATUS\tCPU\tMEMORY\tUPTIME")
    for _, s := range services {
        fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n",
            s.Name, s.Status, s.CPU, s.Memory, s.Uptime)
    }
    return w.Flush()
}

func init() {
    rootCmd.AddCommand(statusCmd)
}
Terminal$docker compose up -d[+] Running 5/5Network app_default CreatedContainer web StartedContainer api StartedContainer db Started$

Docker Compose brings up your entire stack with a single command.

Interactive Input and Progress

// internal/ui/prompt.go
package ui

import (
    "bufio"
    "fmt"
    "os"
    "strings"
)

func Confirm(prompt string) bool {
    reader := bufio.NewReader(os.Stdin)
    fmt.Printf("%s [y/N]: ", prompt)
    input, _ := reader.ReadString('\n')
    return strings.TrimSpace(strings.ToLower(input)) == "y"
}

func Spinner(done chan bool, message string) {
    chars := []rune{'⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'}
    i := 0
    for {
        select {
        case <-done:
            fmt.Printf("\r✓ %s\n", message)
            return
        default:
            fmt.Printf("\r%c %s", chars[i%len(chars)], message)
            i++
            time.Sleep(80 * time.Millisecond)
        }
    }
}

Testing CLI Commands

// cmd/deploy_test.go
package cmd

import (
    "bytes"
    "testing"
)

func TestDeployDryRun(t *testing.T) {
    buf := new(bytes.Buffer)
    rootCmd.SetOut(buf)
    rootCmd.SetArgs([]string{"deploy", "nginx", "--dry-run"})

    err := rootCmd.Execute()
    if err != nil {
        t.Fatalf("unexpected error: %v", err)
    }

    output := buf.String()
    if !strings.Contains(output, "DRY RUN") {
        t.Errorf("expected dry run output, got: %s", output)
    }
}

func TestDeployRequiresServiceName(t *testing.T) {
    rootCmd.SetArgs([]string{"deploy"})
    err := rootCmd.Execute()
    if err == nil {
        t.Fatal("expected error for missing service name")
    }
}

Cross-Compilation and Release

Go makes cross-compilation trivial:

# Build for all platforms
GOOS=linux   GOARCH=amd64 go build -o dist/myctl-linux-amd64
GOOS=linux   GOARCH=arm64 go build -o dist/myctl-linux-arm64
GOOS=darwin  GOARCH=amd64 go build -o dist/myctl-darwin-amd64
GOOS=darwin  GOARCH=arm64 go build -o dist/myctl-darwin-arm64
GOOS=windows GOARCH=amd64 go build -o dist/myctl-windows-amd64.exe

Or use GoReleaser for automated releases:

# .goreleaser.yml
project_name: myctl
builds:
  - env:
      - CGO_ENABLED=0
    goos:
      - linux
      - darwin
      - windows
    goarch:
      - amd64
      - arm64

archives:
  - format: tar.gz
    name_template: "{{ .ProjectName }}_{{ .Os }}_{{ .Arch }}"
    format_overrides:
      - goos: windows
        format: zip

brews:
  - repository:
      owner: yourname
      name: homebrew-tap
    homepage: https://github.com/yourname/myctl
    description: Manage your TechSaaS infrastructure
    install: |
      bin.install "myctl"
# Tag and release
git tag v1.0.0
git push --tags
goreleaser release --clean

Free Resource

Free Cloud Architecture Checklist

A 47-point checklist covering security, scalability, cost optimization, and disaster recovery for production cloud environments.

Download the Checklist

Users can then install via:

# Homebrew (macOS/Linux)
brew install yourname/tap/myctl

# Go install
go install github.com/yourname/myctl@latest

# Direct download
curl -L https://github.com/yourname/myctl/releases/latest/download/myctl-$(uname -s)-$(uname -m) -o myctl
chmod +x myctl
docker-compose.ymlWeb AppAPI ServerDatabaseCacheDocker Network:3000:8080:5432:6379

Docker Compose defines your entire application stack in a single YAML file.

Shell Completions

Cobra generates completions for bash, zsh, fish, and PowerShell:

// cmd/completion.go
var completionCmd = &cobra.Command{
    Use:   "completion [bash|zsh|fish|powershell]",
    Short: "Generate shell completions",
    Args:  cobra.ExactArgs(1),
    RunE: func(cmd *cobra.Command, args []string) error {
        switch args[0] {
        case "bash":
            return rootCmd.GenBashCompletion(os.Stdout)
        case "zsh":
            return rootCmd.GenZshCompletion(os.Stdout)
        case "fish":
            return rootCmd.GenFishCompletion(os.Stdout, true)
        case "powershell":
            return rootCmd.GenPowerShellCompletion(os.Stdout)
        }
        return fmt.Errorf("unsupported shell: %s", args[0])
    },
}

Go's combination of static binaries, fast compilation, excellent standard library, and the Cobra framework make it the ideal choice for CLI tools. At TechSaaS, our internal tooling is all built in Go for exactly these reasons.

#go#golang#cli#cobra#tutorial#open-source

Related Service

Cloud Solutions

Let our experts help you build the right technology strategy for your business.

Need help with tutorials?

TechSaaS provides expert consulting and managed services for cloud infrastructure, DevOps, and AI/ML operations.

We Will Build You a Demo Site — For Free

Like it? Pay us. Do not like it? Walk away, zero complaints. You will spend way less than hiring developers or any agency.

47+ companies trusted us
99.99% uptime
< 48hr response

No spam. No contracts. Just a free demo.