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.
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">: "3.8"</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"> - "80:80"</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@latestProject 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.sumThe 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"> ✓</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"> ✓</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"> ✓</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"> ✓</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.exeOr 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 --cleanUsers 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.
Need help with tutorials?
TechSaaS provides expert consulting and managed services for cloud infrastructure, DevOps, and AI/ML operations.