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.

<div style="margin:2.5rem auto;max-width:600px;width:100%;text-align:center;"><svg viewBox="0 0 600 200" xmlns="http://www.w3.org/2000/svg" style="width:100%;height:auto;"><rect width="600" height="200" rx="12" fill="#1a1a2e"/><rect x="0" y="0" width="600" height="28" rx="12" fill="#2d2d44"/><rect x="0" y="12" width="600" height="16" fill="#2d2d44"/><circle cx="18" cy="14" r="5" fill="#ef4444"/><circle cx="34" cy="14" r="5" fill="#f59e0b"/><circle cx="50" cy="14" r="5" fill="#2dd4bf"/><text x="300" y="18" text-anchor="middle" fill="#94a3b8" font-size="10" font-family="system-ui">docker-compose.yml</text><rect x="0" y="28" width="35" height="172" fill="#1e1e32"/><text x="25" y="48" text-anchor="end" fill="#94a3b8" font-size="10" font-family="monospace" opacity="0.5">1</text><text x="25" y="66" text-anchor="end" fill="#94a3b8" font-size="10" font-family="monospace" opacity="0.5">2</text><text x="25" y="84" text-anchor="end" fill="#94a3b8" font-size="10" font-family="monospace" opacity="0.5">3</text><text x="25" y="102" text-anchor="end" fill="#94a3b8" font-size="10" font-family="monospace" opacity="0.5">4</text><text x="25" y="120" text-anchor="end" fill="#94a3b8" font-size="10" font-family="monospace" opacity="0.5">5</text><text x="25" y="138" text-anchor="end" fill="#94a3b8" font-size="10" font-family="monospace" opacity="0.5">6</text><text x="25" y="156" text-anchor="end" fill="#94a3b8" font-size="10" font-family="monospace" opacity="0.5">7</text><text x="25" y="174" text-anchor="end" fill="#94a3b8" font-size="10" font-family="monospace" opacity="0.5">8</text><text x="25" y="192" text-anchor="end" fill="#94a3b8" font-size="10" font-family="monospace" opacity="0.5">9</text><text x="45" y="48" fill="#a855f7" font-size="11" font-family="monospace">version</text><text x="100" y="48" fill="#e2e8f0" font-size="11" font-family="monospace">: &quot;3.8&quot;</text><text x="45" y="66" fill="#a855f7" font-size="11" font-family="monospace">services</text><text x="105" y="66" fill="#e2e8f0" font-size="11" font-family="monospace">:</text><text x="55" y="84" fill="#3b82f6" font-size="11" font-family="monospace"> web</text><text x="80" y="84" fill="#e2e8f0" font-size="11" font-family="monospace">:</text><text x="55" y="102" fill="#2dd4bf" font-size="11" font-family="monospace"> image</text><text x="110" y="102" fill="#e2e8f0" font-size="11" font-family="monospace">: nginx:alpine</text><text x="55" y="120" fill="#2dd4bf" font-size="11" font-family="monospace"> ports</text><text x="102" y="120" fill="#e2e8f0" font-size="11" font-family="monospace">:</text><text x="55" y="138" fill="#e2e8f0" font-size="11" font-family="monospace"> - &quot;80:80&quot;</text><text x="55" y="156" fill="#2dd4bf" font-size="11" font-family="monospace"> volumes</text><text x="118" y="156" fill="#e2e8f0" font-size="11" font-family="monospace">:</text><text x="55" y="174" fill="#e2e8f0" font-size="11" font-family="monospace"> - ./html:/usr/share/nginx</text><rect x="365" y="164" width="2" height="14" fill="#6366f1" opacity="0.8"/></svg><p style="margin-top:0.75rem;font-size:0.85rem;color:#94a3b8;font-style:italic;line-height:1.4;">A well-structured configuration file is the foundation of reproducible infrastructure.</p></div>

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

// 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)
}

<div style="margin:2.5rem auto;max-width:600px;width:100%;text-align:center;"><svg viewBox="0 0 600 190" xmlns="http://www.w3.org/2000/svg" style="width:100%;height:auto;"><rect width="600" height="190" rx="12" fill="#0d1117"/><rect x="0" y="0" width="600" height="28" rx="12" fill="#1c2333"/><rect x="0" y="12" width="600" height="16" fill="#1c2333"/><circle cx="18" cy="14" r="5" fill="#ef4444"/><circle cx="34" cy="14" r="5" fill="#f59e0b"/><circle cx="50" cy="14" r="5" fill="#2dd4bf"/><text x="300" y="18" text-anchor="middle" fill="#94a3b8" font-size="10" font-family="monospace">Terminal</text><text x="20" y="50" fill="#2dd4bf" font-size="11" font-family="monospace">$</text><text x="35" y="50" fill="#e2e8f0" font-size="11" font-family="monospace">docker compose up -d</text><text x="20" y="70" fill="#94a3b8" font-size="11" font-family="monospace">[+] Running 5/5</text><text x="20" y="88" fill="#2dd4bf" font-size="10" font-family="monospace"> &#x2713;</text><text x="38" y="88" fill="#94a3b8" font-size="10" font-family="monospace">Network app_default Created</text><text x="20" y="106" fill="#2dd4bf" font-size="10" font-family="monospace"> &#x2713;</text><text x="38" y="106" fill="#94a3b8" font-size="10" font-family="monospace">Container web Started</text><text x="20" y="124" fill="#2dd4bf" font-size="10" font-family="monospace"> &#x2713;</text><text x="38" y="124" fill="#94a3b8" font-size="10" font-family="monospace">Container api Started</text><text x="20" y="142" fill="#2dd4bf" font-size="10" font-family="monospace"> &#x2713;</text><text x="38" y="142" fill="#94a3b8" font-size="10" font-family="monospace">Container db Started</text><text x="20" y="165" fill="#2dd4bf" font-size="11" font-family="monospace">$</text><rect x="35" y="155" width="8" height="14" fill="#e2e8f0" opacity="0.7"/></svg><p style="margin-top:0.75rem;font-size:0.85rem;color:#94a3b8;font-style:italic;line-height:1.4;">Docker Compose brings up your entire stack with a single command.</p></div>

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

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

<div style="margin:2.5rem auto;max-width:600px;width:100%;text-align:center;"><svg viewBox="0 0 600 200" xmlns="http://www.w3.org/2000/svg" style="width:100%;height:auto;"><rect width="600" height="200" rx="12" fill="#1a1a2e"/><rect x="30" y="30" width="100" height="130" rx="6" fill="none" stroke="#3b82f6" stroke-width="1.5"/><text x="80" y="55" text-anchor="middle" fill="#3b82f6" font-size="10" font-family="monospace">docker-</text><text x="80" y="70" text-anchor="middle" fill="#3b82f6" font-size="10" font-family="monospace">compose</text><text x="80" y="85" text-anchor="middle" fill="#3b82f6" font-size="10" font-family="monospace">.yml</text><line x1="45" y1="95" x2="115" y2="95" stroke="#3b82f6" stroke-width="0.5" opacity="0.5"/><rect x="50" y="105" width="50" height="8" rx="2" fill="#94a3b8" opacity="0.3"/><rect x="50" y="118" width="60" height="8" rx="2" fill="#94a3b8" opacity="0.3"/><rect x="50" y="131" width="40" height="8" rx="2" fill="#94a3b8" opacity="0.3"/><path d="M135,95 L175,95" stroke="#e2e8f0" stroke-width="2" marker-end="url(#arrow2)"/><defs><marker id="arrow2" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto"><path d="M0,0 L8,3 L0,6" fill="#e2e8f0"/></marker></defs><rect x="180" y="20" width="130" height="35" rx="6" fill="#6366f1" opacity="0.85"/><text x="245" y="42" text-anchor="middle" fill="#ffffff" font-size="11" font-family="system-ui">Web App</text><rect x="180" y="62" width="130" height="35" rx="6" fill="#a855f7" opacity="0.85"/><text x="245" y="84" text-anchor="middle" fill="#ffffff" font-size="11" font-family="system-ui">API Server</text><rect x="180" y="104" width="130" height="35" rx="6" fill="#2dd4bf" opacity="0.85"/><text x="245" y="126" text-anchor="middle" fill="#1a1a2e" font-size="11" font-family="system-ui">Database</text><rect x="180" y="146" width="130" height="35" rx="6" fill="#f59e0b" opacity="0.85"/><text x="245" y="168" text-anchor="middle" fill="#1a1a2e" font-size="11" font-family="system-ui">Cache</text><rect x="370" y="40" width="200" height="130" rx="8" fill="none" stroke="#e2e8f0" stroke-width="1" stroke-dasharray="5,4"/><text x="470" y="62" text-anchor="middle" fill="#e2e8f0" font-size="10" font-family="system-ui">Docker Network</text><line x1="310" y1="37" x2="390" y2="80" stroke="#94a3b8" stroke-width="1" opacity="0.5"/><line x1="310" y1="79" x2="390" y2="100" stroke="#94a3b8" stroke-width="1" opacity="0.5"/><line x1="310" y1="121" x2="390" y2="120" stroke="#94a3b8" stroke-width="1" opacity="0.5"/><line x1="310" y1="163" x2="390" y2="140" stroke="#94a3b8" stroke-width="1" opacity="0.5"/><circle cx="400" cy="80" r="5" fill="#6366f1"/><circle cx="400" cy="100" r="5" fill="#a855f7"/><circle cx="400" cy="120" r="5" fill="#2dd4bf"/><circle cx="400" cy="140" r="5" fill="#f59e0b"/><text x="470" y="85" text-anchor="middle" fill="#94a3b8" font-size="10" font-family="system-ui">:3000</text><text x="470" y="105" text-anchor="middle" fill="#94a3b8" font-size="10" font-family="system-ui">:8080</text><text x="470" y="125" text-anchor="middle" fill="#94a3b8" font-size="10" font-family="system-ui">:5432</text><text x="470" y="145" text-anchor="middle" fill="#94a3b8" font-size="10" font-family="system-ui">:6379</text></svg><p style="margin-top:0.75rem;font-size:0.85rem;color:#94a3b8;font-style:italic;line-height:1.4;">Docker Compose defines your entire application stack in a single YAML file.</p></div>

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

Need help with tutorials?

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