Build CLI tool in Go

Building a CLI Tool from Scratch in Go

Go CLI tool

So I’ve been using command line tools for years – git, docker, kubectl, you name it. But I never actually built one myself until last week when I got tired of manually checking server health across multiple environments. Thought to myself, “how hard could it be?” Turns out, not that hard actually.

I’m gonna walk you through building a real CLI tool in Go. Not some toy example that just prints “hello world” – we’re making something you’d actually use.

What We’re Building

The tool I built is called healthcheck. It pings multiple URLs and tells me which services are up or down. Simple enough to finish in an evening, but complex enough to teach you the important stuff.

Here’s what it’ll do:

  • Accept URLs as arguments
  • Make HTTP requests to check if they’re alive
  • Display results in a nice format
  • Support flags for timeout and verbosity
  • Save results to a file if you want

Why Go for CLI Tools?

Before we dive in, why Go? Three reasons really. First, it compiles to a single binary – no “make sure you have Python 3.9 installed” nonsense. Second, it’s fast. Like, really fast. Third, the standard library has everything you need. No hunting for packages.

Getting Your Project Ready

Make a new directory and set up the module:

mkdir healthcheck
cd healthcheck
go mod init healthcheck

We’ll need Cobra for command parsing. Yeah, you could build this without it, but trust me, Cobra saves you from writing a ton of flag parsing code:

go get -u github.com/spf13/cobra@latest

Basic Structure

Create a file called main.go:

package main

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

func main() {
    if err := rootCmd.Execute(); err != nil {
        fmt.Println(err)
        os.Exit(1)
    }
}

var rootCmd = &cobra.Command{
    Use:   "healthcheck",
    Short: "Check if your services are alive",
    Long:  `A simple CLI tool to ping multiple URLs and report their status.`,
    Run: func(cmd *cobra.Command, args []string) {
        fmt.Println("Use 'healthcheck check <urls>' to check service health")
    },
}

Run it with go run main.go and you should see your help text. Not exciting yet, but it works.

Adding the Check Command

This is where things get interesting. We need a subcommand that actually does something:

var timeout int
var verbose bool
var output string

var checkCmd = &cobra.Command{
    Use:   "check [urls...]",
    Short: "Check health of given URLs",
    Args:  cobra.MinimumNArgs(1),
    Run: func(cmd *cobra.Command, args []string) {
        checkHealth(args)
    },
}

func init() {
    rootCmd.AddCommand(checkCmd)
    checkCmd.Flags().IntVarP(&timeout, "timeout", "t", 5, "Request timeout in seconds")
    checkCmd.Flags().BoolVarP(&verbose, "verbose", "v", false, "Show detailed output")
    checkCmd.Flags().StringVarP(&output, "output", "o", "", "Save results to file")
}

See those flags? That’s Cobra doing the heavy lifting. The IntVarP and BoolVarP functions handle all the parsing. The “P” means it supports both long form (--timeout) and short form (-t).

Actually Checking the Health

Now for the fun part – making HTTP requests:

import (
    "fmt"
    "net/http"
    "time"
    "sync"
)

type Result struct {
    URL        string
    Status     string
    StatusCode int
    Error      string
    Duration   time.Duration
}

func checkHealth(urls []string) {
    var wg sync.WaitGroup
    results := make([]Result, len(urls))
    
    client := &http.Client{
        Timeout: time.Duration(timeout) * time.Second,
    }
    
    for i, url := range urls {
        wg.Add(1)
        go func(index int, address string) {
            defer wg.Done()
            results[index] = checkSingleURL(client, address)
        }(i, url)
    }
    
    wg.Wait()
    displayResults(results)
    
    if output != "" {
        saveResults(results, output)
    }
}

func checkSingleURL(client *http.Client, url string) Result {
    start := time.Now()
    
    resp, err := client.Get(url)
    duration := time.Since(start)
    
    if err != nil {
        return Result{
            URL:      url,
            Status:   "DOWN",
            Error:    err.Error(),
            Duration: duration,
        }
    }
    defer resp.Body.Close()
    
    status := "UP"
    if resp.StatusCode >= 400 {
        status = "DOWN"
    }
    
    return Result{
        URL:        url,
        Status:     status,
        StatusCode: resp.StatusCode,
        Duration:   duration,
    }
}

I’m using goroutines here because waiting for each URL sequentially is painful. If you’re checking 10 services with 5-second timeouts, that’s 50 seconds if you do it one by one. With goroutines, it’s just 5 seconds total.

The WaitGroup makes sure we don’t print results before all checks finish. Learned that the hard way when my first version printed partial results.

Making the Output Pretty

Nobody wants to stare at raw JSON or ugly logs. Let’s make it nice:

import (
    "strings"
)

func displayResults(results []Result) {
    fmt.Println("\n" + strings.Repeat("=", 70))
    fmt.Println("HEALTH CHECK RESULTS")
    fmt.Println(strings.Repeat("=", 70))
    
    for _, r := range results {
        fmt.Printf("\n%-40s [%s]\n", r.URL, r.Status)
        
        if verbose {
            if r.StatusCode > 0 {
                fmt.Printf("  Status Code: %d\n", r.StatusCode)
            }
            fmt.Printf("  Response Time: %v\n", r.Duration)
            if r.Error != "" {
                fmt.Printf("  Error: %s\n", r.Error)
            }
        }
    }
    
    upCount := 0
    for _, r := range results {
        if r.Status == "UP" {
            upCount++
        }
    }
    
    fmt.Printf("\n%s\n", strings.Repeat("-", 70))
    fmt.Printf("Summary: %d/%d services are up\n", upCount, len(results))
    fmt.Println(strings.Repeat("=", 70) + "\n")
}

Nothing fancy here, just some formatting to make it readable. The verbose flag shows extra details if you need to debug something.

Saving Results to a File

Sometimes you want to keep a record. Maybe for monitoring or just because your boss asked for it:

import (
    "encoding/json"
    "os"
)

func saveResults(results []Result, filename string) {
    file, err := os.Create(filename)
    if err != nil {
        fmt.Printf("Error creating file: %v\n", err)
        return
    }
    defer file.Close()
    
    encoder := json.NewEncoder(file)
    encoder.SetIndent("", "  ")
    
    if err := encoder.Encode(results); err != nil {
        fmt.Printf("Error writing to file: %v\n", err)
        return
    }
    
    fmt.Printf("\nResults saved to %s\n", filename)
}

I went with JSON because it’s easy to parse later if you want to feed it into another tool. Could’ve used CSV or plain text too, depending on what you need.

Making It Actually Work

Your complete main.go should look something like this (putting it all together):

package main

import (
    "encoding/json"
    "fmt"
    "net/http"
    "os"
    "strings"
    "sync"
    "time"
    
    "github.com/spf13/cobra"
)

var timeout int
var verbose bool
var output string

type Result struct {
    URL        string        `json:"url"`
    Status     string        `json:"status"`
    StatusCode int           `json:"status_code,omitempty"`
    Error      string        `json:"error,omitempty"`
    Duration   time.Duration `json:"duration"`
}

func main() {
    if err := rootCmd.Execute(); err != nil {
        fmt.Println(err)
        os.Exit(1)
    }
}

var rootCmd = &cobra.Command{
    Use:   "healthcheck",
    Short: "Check if your services are alive",
    Long:  `A CLI tool to ping multiple URLs and report their status quickly.`,
}

var checkCmd = &cobra.Command{
    Use:   "check [urls...]",
    Short: "Check health of given URLs",
    Args:  cobra.MinimumNArgs(1),
    Run: func(cmd *cobra.Command, args []string) {
        checkHealth(args)
    },
}

func init() {
    rootCmd.AddCommand(checkCmd)
    checkCmd.Flags().IntVarP(&timeout, "timeout", "t", 5, "Request timeout in seconds")
    checkCmd.Flags().BoolVarP(&verbose, "verbose", "v", false, "Show detailed output")
    checkCmd.Flags().StringVarP(&output, "output", "o", "", "Save results to file")
}

func checkHealth(urls []string) {
    var wg sync.WaitGroup
    results := make([]Result, len(urls))
    
    client := &http.Client{
        Timeout: time.Duration(timeout) * time.Second,
    }
    
    for i, url := range urls {
        wg.Add(1)
        go func(index int, address string) {
            defer wg.Done()
            results[index] = checkSingleURL(client, address)
        }(i, url)
    }
    
    wg.Wait()
    displayResults(results)
    
    if output != "" {
        saveResults(results, output)
    }
}

func checkSingleURL(client *http.Client, url string) Result {
    start := time.Now()
    
    resp, err := client.Get(url)
    duration := time.Since(start)
    
    if err != nil {
        return Result{
            URL:      url,
            Status:   "DOWN",
            Error:    err.Error(),
            Duration: duration,
        }
    }
    defer resp.Body.Close()
    
    status := "UP"
    if resp.StatusCode >= 400 {
        status = "DOWN"
    }
    
    return Result{
        URL:        url,
        Status:     status,
        StatusCode: resp.StatusCode,
        Duration:   duration,
    }
}

func displayResults(results []Result) {
    fmt.Println("\n" + strings.Repeat("=", 70))
    fmt.Println("HEALTH CHECK RESULTS")
    fmt.Println(strings.Repeat("=", 70))
    
    for _, r := range results {
        fmt.Printf("\n%-40s [%s]\n", r.URL, r.Status)
        
        if verbose {
            if r.StatusCode > 0 {
                fmt.Printf("  Status Code: %d\n", r.StatusCode)
            }
            fmt.Printf("  Response Time: %v\n", r.Duration)
            if r.Error != "" {
                fmt.Printf("  Error: %s\n", r.Error)
            }
        }
    }
    
    upCount := 0
    for _, r := range results {
        if r.Status == "UP" {
            upCount++
        }
    }
    
    fmt.Printf("\n%s\n", strings.Repeat("-", 70))
    fmt.Printf("Summary: %d/%d services are up\n", upCount, len(results))
    fmt.Println(strings.Repeat("=", 70) + "\n")
}

func saveResults(results []Result, filename string) {
    file, err := os.Create(filename)
    if err != nil {
        fmt.Printf("Error creating file: %v\n", err)
        return
    }
    defer file.Close()
    
    encoder := json.NewEncoder(file)
    encoder.SetIndent("", "  ")
    
    if err := encoder.Encode(results); err != nil {
        fmt.Printf("Error writing to file: %v\n", err)
        return
    }
    
    fmt.Printf("\nResults saved to %s\n", filename)
}

Testing Your Tool

Build it first:

go build -o healthcheck

Now try it out:

# Basic check
./healthcheck check https://google.com https://github.com

# With timeout
./healthcheck check -t 10 https://google.com

# Verbose output
./healthcheck check -v https://google.com

# Save to file
./healthcheck check -o results.json https://google.com https://github.com

If you get errors about URLs, make sure you’re including the https:// part. That tripped me up initially.

Installing It System-Wide

Want to use it from anywhere? Move it to your PATH:

# On Mac/Linux
sudo mv healthcheck /usr/local/bin/

# On Windows, move to a directory in your PATH

Now you can just type healthcheck from any directory.

What I’d Add Next

This tool works great for basic health checks, but there’s room to grow:

Config files – Instead of typing URLs every time, read them from a YAML or JSON config. I usually use Viper for this.

Better error messages – Right now if a URL is malformed, the error isn’t super helpful. Add validation before making requests.

Colored output – Green for UP, red for DOWN. The fatih/color package makes this trivial.

Scheduled checks – Run checks every X minutes and alert if something goes down. Could use a goroutine with time.Ticker.

Different HTTP methods – Sometimes you need to POST or PUT, not just GET.

Authentication – Add support for API keys or basic auth when checking protected endpoints.

What I Learned

Building this taught me a few things. First, Cobra really does make CLI development way easier than rolling your own flag parsing. Second, goroutines are perfect for I/O-bound tasks like HTTP requests. Third, users appreciate good output formatting more than you’d think.

The whole thing took maybe 2-3 hours from start to finish, including testing. Most of that time was spent making the output look good, honestly.

Some Gotchas I Hit

When I first wrote this, I forgot to add the timeout to the HTTP client. The default timeout is basically infinite, so a dead server would just hang forever. Always set timeouts on HTTP clients.

Also, I initially tried to append results to a slice from multiple goroutines without any synchronization. That caused a race condition. Using a pre-allocated slice with indexed assignment fixed it.

Oh, and make sure your goroutines are actually finishing. I had a bug where I was calling wg.Done() conditionally, which deadlocked the program when certain conditions weren’t met.

Why This Pattern Works

The structure we used here scales really well. Need to add more commands? Just create new cobra.Command structs. Need different output formats? Add a switch statement in displayResults. Need to support more protocols beyond HTTP? Refactor checkSingleURL into an interface.

I’ve used variations of this pattern for database migration tools, deployment scripts, and monitoring utilities. It’s solid.

CLI tools are incredibly useful, and Go makes building them straightforward. You get a fast, standalone binary that works on any platform without dependencies. Can’t really ask for more than that.

Start with something simple like this health checker, then expand based on what you actually need. Don’t over-engineer it from the start – build the basics, use it for a while, then add features when you know what’s actually useful.

If you build something cool with this, let me know in the comments. Always curious to see what people come up with.

Leave a Comment

Comments

No comments yet. Why don’t you start the discussion?

Leave a Reply

Your email address will not be published. Required fields are marked *