LUNAROPS · OPERATIONAL UPLINK 100% UPTIME 1,247d POSTS 893 JEFF.MOON@LUNAROPS.DEV UTC --:--:--

Writing a CLI Tool in Go: From Zero to Distributable Binary

goclicobravipergoreleaserdevopsprogramming

Go produces statically compiled binaries that start in milliseconds, ship without runtime dependencies, and cross-compile for any platform from a single machine. These properties make it the language of choice for CLI tooling — it’s why Docker, kubectl, Terraform, Hugo, and hundreds of other tools are written in it.

This guide builds a realistic CLI tool from scratch: vcheck, a vulnerability checker that queries an API for CVEs affecting packages you specify. Along the way it covers every pattern you’ll need for a production-quality CLI: subcommands, persistent flags, config files, environment variables, structured output, graceful shutdown, testing, cross-compilation, and automated releases with GoReleaser.


Project Setup

1
2
3
4
5
6
7
8
9
mkdir vcheck && cd vcheck
go mod init github.com/yourname/vcheck

# Core dependencies
go get github.com/spf13/cobra@latest
go get github.com/spf13/viper@latest
go get github.com/fatih/color@latest
go get github.com/olekukonko/tablewriter@latest
go get go.uber.org/zap@latest

Directory Structure

vcheck/
├── cmd/
│   ├── root.go          # root command, persistent flags, config init
│   ├── scan.go          # `vcheck scan` subcommand
│   ├── report.go        # `vcheck report` subcommand
│   └── version.go       # `vcheck version` subcommand
├── internal/
│   ├── config/
│   │   └── config.go    # config struct and loader
│   ├── api/
│   │   └── client.go    # HTTP client for CVE API
│   ├── output/
│   │   └── formatter.go # table, JSON, plain text output
│   └── scanner/
│       └── scanner.go   # core scanning logic
├── main.go
├── .goreleaser.yaml
└── Makefile

main.go — The Entry Point

Keep this minimal. All logic lives in cmd/:

1
2
3
4
5
6
7
8
// main.go
package main

import "github.com/yourname/vcheck/cmd"

func main() {
    cmd.Execute()
}

The Root Command

The root command wires together Cobra and Viper, handles persistent flags (flags available on every subcommand), and initializes configuration.

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
// cmd/root.go
package cmd

import (
    "fmt"
    "os"
    "strings"

    "github.com/spf13/cobra"
    "github.com/spf13/viper"
    "github.com/yourname/vcheck/internal/config"
    "go.uber.org/zap"
)

var (
    cfgFile string
    cfg     *config.Config
    log     *zap.SugaredLogger
)

var rootCmd = &cobra.Command{
    Use:   "vcheck",
    Short: "Vulnerability checker for your dependencies",
    Long: `vcheck queries vulnerability databases to find CVEs affecting
the packages and versions you specify. Supports JSON, table, and
plain text output for easy CI integration.`,

    // PersistentPreRunE runs before every subcommand and is the right
    // place to initialize shared state like logging and config.
    PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
        return initRuntime()
    },
}

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

func init() {
    cobra.OnInitialize(initConfig)

    // Persistent flags — available on root and all subcommands
    rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "",
        "config file (default: $HOME/.vcheck.yaml or ./vcheck.yaml)")
    rootCmd.PersistentFlags().String("output", "table",
        "output format: table, json, plain")
    rootCmd.PersistentFlags().Bool("verbose", false,
        "enable verbose logging")
    rootCmd.PersistentFlags().String("api-key", "",
        "API key for vulnerability database (or set VCHECK_API_KEY)")

    // Bind persistent flags to Viper so config file and env vars
    // can override them
    viper.BindPFlag("output", rootCmd.PersistentFlags().Lookup("output"))
    viper.BindPFlag("verbose", rootCmd.PersistentFlags().Lookup("verbose"))
    viper.BindPFlag("api_key", rootCmd.PersistentFlags().Lookup("api-key"))
}

// initConfig is called by cobra.OnInitialize — before any command runs.
// It sets up Viper to read from config file, env vars, and defaults.
func initConfig() {
    if cfgFile != "" {
        viper.SetConfigFile(cfgFile)
    } else {
        // Search order: current dir, then $HOME
        viper.AddConfigPath(".")
        viper.AddConfigPath("$HOME")
        viper.SetConfigName(".vcheck")
        viper.SetConfigType("yaml")
    }

    // Environment variable support: VCHECK_API_KEY → api_key
    viper.SetEnvPrefix("VCHECK")
    viper.SetEnvKeyReplacer(strings.NewReplacer("-", "_", ".", "_"))
    viper.AutomaticEnv()

    // Defaults
    viper.SetDefault("output", "table")
    viper.SetDefault("verbose", false)
    viper.SetDefault("api_url", "https://api.osv.dev/v1")
    viper.SetDefault("timeout_seconds", 30)
    viper.SetDefault("max_concurrent", 5)

    // Read config file — ignore "not found" errors (config is optional)
    if err := viper.ReadInConfig(); err != nil {
        if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
            fmt.Fprintf(os.Stderr, "Error reading config file: %v\n", err)
            os.Exit(1)
        }
    }
}

// initRuntime initializes logger and config struct after Viper is ready.
// Called in PersistentPreRunE so it runs after flag parsing.
func initRuntime() error {
    var err error
    cfg, err = config.Load()
    if err != nil {
        return fmt.Errorf("loading config: %w", err)
    }

    // Set up structured logger
    var zapCfg zap.Config
    if cfg.Verbose {
        zapCfg = zap.NewDevelopmentConfig()
    } else {
        zapCfg = zap.NewProductionConfig()
        zapCfg.Level = zap.NewAtomicLevelAt(zap.WarnLevel)
    }

    logger, err := zapCfg.Build()
    if err != nil {
        return fmt.Errorf("building logger: %w", err)
    }
    log = logger.Sugar()
    return nil
}

Configuration

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
// internal/config/config.go
package config

import (
    "fmt"
    "time"

    "github.com/spf13/viper"
)

type Config struct {
    Output         string        `mapstructure:"output"`
    Verbose        bool          `mapstructure:"verbose"`
    APIKey         string        `mapstructure:"api_key"`
    APIURL         string        `mapstructure:"api_url"`
    Timeout        time.Duration
    MaxConcurrent  int           `mapstructure:"max_concurrent"`
    FailOnCritical bool          `mapstructure:"fail_on_critical"`
    IgnoreCVEs     []string      `mapstructure:"ignore_cves"`
}

func Load() (*Config, error) {
    cfg := &Config{}
    if err := viper.Unmarshal(cfg); err != nil {
        return nil, fmt.Errorf("unmarshalling config: %w", err)
    }

    // Viper doesn't directly unmarshal time.Duration from int
    cfg.Timeout = time.Duration(viper.GetInt("timeout_seconds")) * time.Second

    if err := cfg.validate(); err != nil {
        return nil, err
    }
    return cfg, nil
}

func (c *Config) validate() error {
    validOutputs := map[string]bool{"table": true, "json": true, "plain": true}
    if !validOutputs[c.Output] {
        return fmt.Errorf("invalid output format %q: must be table, json, or plain", c.Output)
    }
    if c.MaxConcurrent < 1 || c.MaxConcurrent > 20 {
        return fmt.Errorf("max_concurrent must be between 1 and 20, got %d", c.MaxConcurrent)
    }
    return nil
}

A sample config file users can place at ~/.vcheck.yaml or ./vcheck.yaml:

1
2
3
4
5
6
7
8
# ~/.vcheck.yaml
output: table
api_url: https://api.osv.dev/v1
timeout_seconds: 30
max_concurrent: 5
fail_on_critical: true
ignore_cves:
  - CVE-2021-44228  # log4shell - already mitigated

Precedence order (highest to lowest):

  1. Explicit flag (--output json)
  2. Environment variable (VCHECK_OUTPUT=json)
  3. Config file (output: json)
  4. Default ("table")

The Scan Subcommand

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
// cmd/scan.go
package cmd

import (
    "context"
    "fmt"
    "os"
    "os/signal"
    "syscall"
    "time"

    "github.com/spf13/cobra"
    "github.com/spf13/viper"
    "github.com/yourname/vcheck/internal/output"
    "github.com/yourname/vcheck/internal/scanner"
)

var scanCmd = &cobra.Command{
    Use:   "scan [ecosystem] [package@version...]",
    Short: "Scan packages for known vulnerabilities",
    Long: `Scan one or more packages for known CVEs.

Examples:
  vcheck scan go golang.org/x/net@0.0.0-20220722155237-a158d28d115b
  vcheck scan npm lodash@4.17.20 express@4.18.0
  vcheck scan pypi requests@2.28.0 flask@2.0.0
  vcheck scan --file requirements.txt --ecosystem pypi`,

    Args: func(cmd *cobra.Command, args []string) error {
        fileFlag, _ := cmd.Flags().GetString("file")
        if fileFlag == "" && len(args) < 2 {
            return fmt.Errorf("requires an ecosystem and at least one package, or --file flag")
        }
        return nil
    },

    RunE: func(cmd *cobra.Command, args []string) error {
        return runScan(cmd, args)
    },
}

func init() {
    rootCmd.AddCommand(scanCmd)

    // Local flags — only available on `scan`
    scanCmd.Flags().StringP("file", "f", "",
        "read packages from file (requirements.txt, go.sum, package-lock.json)")
    scanCmd.Flags().StringP("ecosystem", "e", "",
        "package ecosystem when using --file (go, npm, pypi, cargo, maven)")
    scanCmd.Flags().Int("severity", 0,
        "minimum CVSS score to report (0-10, default 0 = all)")
    scanCmd.Flags().Bool("fail-on-critical", false,
        "exit with code 1 if any critical CVEs are found")

    // Bind local flags to viper
    viper.BindPFlag("severity_threshold", scanCmd.Flags().Lookup("severity"))
    viper.BindPFlag("fail_on_critical", scanCmd.Flags().Lookup("fail-on-critical"))
}

func runScan(cmd *cobra.Command, args []string) error {
    // Set up context with cancellation for graceful shutdown
    ctx, cancel := context.WithCancel(context.Background())
    defer cancel()

    // Handle Ctrl+C and SIGTERM gracefully
    sigCh := make(chan os.Signal, 1)
    signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
    go func() {
        <-sigCh
        fmt.Fprintln(os.Stderr, "\nInterrupted. Finishing in-flight requests...")
        cancel()
    }()

    fileFlag, _ := cmd.Flags().GetString("file")
    ecosystemFlag, _ := cmd.Flags().GetString("ecosystem")
    severityFlag, _ := cmd.Flags().GetInt("severity")

    // Build the list of packages to scan
    var packages []scanner.Package
    var err error

    if fileFlag != "" {
        packages, err = scanner.ParseFile(fileFlag, ecosystemFlag)
        if err != nil {
            return fmt.Errorf("parsing file %s: %w", fileFlag, err)
        }
        if len(packages) == 0 {
            fmt.Println("No packages found in file.")
            return nil
        }
    } else {
        ecosystem := args[0]
        packages, err = scanner.ParseArgs(ecosystem, args[1:])
        if err != nil {
            return err
        }
    }

    log.Infof("Scanning %d packages...", len(packages))

    s := scanner.New(cfg, log)
    results, err := s.Scan(ctx, packages)
    if err != nil {
        return fmt.Errorf("scan failed: %w", err)
    }

    // Filter by severity threshold
    if severityFlag > 0 {
        results = results.FilterBySeverity(float64(severityFlag))
    }

    // Render output
    formatter := output.NewFormatter(viper.GetString("output"), os.Stdout)
    if err := formatter.Render(results); err != nil {
        return err
    }

    // Exit code 1 if critical CVEs found and --fail-on-critical
    if cfg.FailOnCritical && results.HasCritical() {
        return &ExitError{Code: 1, Msg: "critical vulnerabilities found"}
    }

    return nil
}

// ExitError lets us control the exit code without printing "exit status 1"
type ExitError struct {
    Code int
    Msg  string
}

func (e *ExitError) Error() string { return e.Msg }

Handle the custom exit code in main.go:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
// main.go
package main

import (
    "fmt"
    "os"

    "github.com/yourname/vcheck/cmd"
)

func main() {
    if err := cmd.Execute(); err != nil {
        var exitErr *cmd.ExitError
        if errors.As(err, &exitErr) {
            os.Exit(exitErr.Code)
        }
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
}

The Scanner Core

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
// internal/scanner/scanner.go
package scanner

import (
    "context"
    "fmt"
    "strings"
    "sync"

    "github.com/yourname/vcheck/internal/api"
    "github.com/yourname/vcheck/internal/config"
    "go.uber.org/zap"
)

type Package struct {
    Ecosystem string
    Name      string
    Version   string
}

type Vulnerability struct {
    ID       string
    Summary  string
    Severity float64
    CVSSv3   string
    Affected []string
    Fixed    string
    URL      string
}

type PackageResult struct {
    Package         Package
    Vulnerabilities []Vulnerability
    Error           error
}

type Results []PackageResult

func (r Results) HasCritical() bool {
    for _, pr := range r {
        for _, v := range pr.Vulnerabilities {
            if v.Severity >= 9.0 {
                return true
            }
        }
    }
    return false
}

func (r Results) FilterBySeverity(min float64) Results {
    out := make(Results, 0, len(r))
    for _, pr := range r {
        filtered := make([]Vulnerability, 0)
        for _, v := range pr.Vulnerabilities {
            if v.Severity >= min {
                filtered = append(filtered, v)
            }
        }
        if len(filtered) > 0 || pr.Error != nil {
            pr.Vulnerabilities = filtered
            out = append(out, pr)
        }
    }
    return out
}

type Scanner struct {
    cfg    *config.Config
    client *api.Client
    log    *zap.SugaredLogger
}

func New(cfg *config.Config, log *zap.SugaredLogger) *Scanner {
    return &Scanner{
        cfg:    cfg,
        client: api.NewClient(cfg),
        log:    log,
    }
}

func (s *Scanner) Scan(ctx context.Context, packages []Package) (Results, error) {
    results := make(Results, len(packages))

    // Semaphore to limit concurrency
    sem := make(chan struct{}, s.cfg.MaxConcurrent)
    var wg sync.WaitGroup

    for i, pkg := range packages {
        wg.Add(1)
        go func(idx int, p Package) {
            defer wg.Done()

            select {
            case sem <- struct{}{}:
                defer func() { <-sem }()
            case <-ctx.Done():
                results[idx] = PackageResult{Package: p, Error: ctx.Err()}
                return
            }

            s.log.Debugf("Scanning %s/%s@%s", p.Ecosystem, p.Name, p.Version)
            vulns, err := s.client.Query(ctx, p)
            results[idx] = PackageResult{
                Package:         p,
                Vulnerabilities: vulns,
                Error:           err,
            }
        }(i, pkg)
    }

    wg.Wait()
    return results, nil
}

// ParseArgs parses "ecosystem pkg@version pkg@version..." from CLI args
func ParseArgs(ecosystem string, args []string) ([]Package, error) {
    packages := make([]Package, 0, len(args))
    for _, arg := range args {
        parts := strings.SplitN(arg, "@", 2)
        if len(parts) != 2 || parts[1] == "" {
            return nil, fmt.Errorf("invalid package format %q: expected name@version", arg)
        }
        packages = append(packages, Package{
            Ecosystem: ecosystem,
            Name:      parts[0],
            Version:   parts[1],
        })
    }
    return packages, nil
}

The API Client

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
// internal/api/client.go
package api

import (
    "bytes"
    "context"
    "encoding/json"
    "fmt"
    "net/http"
    "time"

    "github.com/yourname/vcheck/internal/config"
    "github.com/yourname/vcheck/internal/scanner"
)

type Client struct {
    httpClient *http.Client
    baseURL    string
    apiKey     string
}

func NewClient(cfg *config.Config) *Client {
    return &Client{
        httpClient: &http.Client{
            Timeout: cfg.Timeout,
        },
        baseURL: cfg.APIURL,
        apiKey:  cfg.APIKey,
    }
}

// osvQueryRequest matches the OSV API (https://osv.dev/docs)
type osvQueryRequest struct {
    Package osvPackage `json:"package"`
    Version string     `json:"version"`
}

type osvPackage struct {
    Name      string `json:"name"`
    Ecosystem string `json:"ecosystem"`
}

func (c *Client) Query(ctx context.Context, pkg scanner.Package) ([]scanner.Vulnerability, error) {
    body, err := json.Marshal(osvQueryRequest{
        Package: osvPackage{Name: pkg.Name, Ecosystem: pkg.Ecosystem},
        Version: pkg.Version,
    })
    if err != nil {
        return nil, err
    }

    req, err := http.NewRequestWithContext(ctx, http.MethodPost,
        c.baseURL+"/query", bytes.NewReader(body))
    if err != nil {
        return nil, err
    }

    req.Header.Set("Content-Type", "application/json")
    if c.apiKey != "" {
        req.Header.Set("Authorization", "Bearer "+c.apiKey)
    }

    resp, err := c.httpClient.Do(req)
    if err != nil {
        return nil, fmt.Errorf("API request failed: %w", err)
    }
    defer resp.Body.Close()

    if resp.StatusCode == http.StatusTooManyRequests {
        // Respect Retry-After header
        if ra := resp.Header.Get("Retry-After"); ra != "" {
            return nil, fmt.Errorf("rate limited (retry after %s)", ra)
        }
        return nil, fmt.Errorf("rate limited by API")
    }

    if resp.StatusCode != http.StatusOK {
        return nil, fmt.Errorf("API returned status %d", resp.StatusCode)
    }

    var osvResp struct {
        Vulns []struct {
            ID       string `json:"id"`
            Summary  string `json:"summary"`
            Severity []struct {
                Type  string `json:"type"`
                Score string `json:"score"`
            } `json:"severity"`
            References []struct {
                URL string `json:"url"`
            } `json:"references"`
            Affected []struct {
                Ranges []struct {
                    Events []struct {
                        Fixed string `json:"fixed,omitempty"`
                    } `json:"events"`
                } `json:"ranges"`
            } `json:"affected"`
        } `json:"vulns"`
    }

    if err := json.NewDecoder(resp.Body).Decode(&osvResp); err != nil {
        return nil, fmt.Errorf("decoding response: %w", err)
    }

    vulns := make([]scanner.Vulnerability, 0, len(osvResp.Vulns))
    for _, v := range osvResp.Vulns {
        vuln := scanner.Vulnerability{
            ID:      v.ID,
            Summary: v.Summary,
        }

        // Extract CVSS score
        for _, s := range v.Severity {
            if s.Type == "CVSS_V3" {
                vuln.CVSSv3 = s.Score
                vuln.Severity = parseCVSSScore(s.Score)
            }
        }

        // Get reference URL
        if len(v.References) > 0 {
            vuln.URL = v.References[0].URL
        }

        // Get fixed version
        for _, affected := range v.Affected {
            for _, r := range affected.Ranges {
                for _, e := range r.Events {
                    if e.Fixed != "" {
                        vuln.Fixed = e.Fixed
                    }
                }
            }
        }

        vulns = append(vulns, vuln)
    }
    return vulns, nil
}

func parseCVSSScore(vector string) float64 {
    // Parse base score from CVSS v3 vector string
    // CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H — score is computed, not embedded
    // In practice you'd use a CVSS library. Returning 0 as a stub.
    return 0
}

Output Formatting

Good CLI output matters. Support multiple formats so the tool works for humans and machines alike.

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
// internal/output/formatter.go
package output

import (
    "encoding/json"
    "fmt"
    "io"
    "os"
    "strings"

    "github.com/fatih/color"
    "github.com/olekukonko/tablewriter"
    "github.com/yourname/vcheck/internal/scanner"
)

type Formatter struct {
    format string
    writer io.Writer
}

func NewFormatter(format string, w io.Writer) *Formatter {
    return &Formatter{format: format, writer: w}
}

func (f *Formatter) Render(results scanner.Results) error {
    switch f.format {
    case "json":
        return f.renderJSON(results)
    case "plain":
        return f.renderPlain(results)
    default:
        return f.renderTable(results)
    }
}

func (f *Formatter) renderTable(results scanner.Results) error {
    red := color.New(color.FgRed, color.Bold).SprintFunc()
    yellow := color.New(color.FgYellow).SprintFunc()
    green := color.New(color.FgGreen).SprintFunc()
    cyan := color.New(color.FgCyan).SprintFunc()

    totalVulns := 0
    for _, pr := range results {
        totalVulns += len(pr.Vulnerabilities)
    }

    if totalVulns == 0 {
        fmt.Fprintln(f.writer, green("✓ No vulnerabilities found"))
        return nil
    }

    table := tablewriter.NewWriter(f.writer)
    table.SetHeader([]string{"Package", "Version", "CVE ID", "Severity", "Summary", "Fix"})
    table.SetBorder(false)
    table.SetColumnSeparator("  ")
    table.SetHeaderAlignment(tablewriter.ALIGN_LEFT)
    table.SetAlignment(tablewriter.ALIGN_LEFT)
    table.SetAutoWrapText(true)
    table.SetColWidth(40)

    for _, pr := range results {
        if pr.Error != nil {
            table.Append([]string{
                pr.Package.Name,
                pr.Package.Version,
                red("ERROR"),
                "",
                pr.Error.Error(),
                "",
            })
            continue
        }

        for _, v := range pr.Vulnerabilities {
            severityStr := formatSeverity(v.Severity, red, yellow, cyan)
            summary := v.Summary
            if len(summary) > 60 {
                summary = summary[:57] + "..."
            }
            fix := v.Fixed
            if fix == "" {
                fix = yellow("no fix")
            }

            table.Append([]string{
                pr.Package.Name,
                pr.Package.Version,
                cyan(v.ID),
                severityStr,
                summary,
                fix,
            })
        }
    }

    table.Render()

    // Summary line
    criticals := 0
    highs := 0
    for _, pr := range results {
        for _, v := range pr.Vulnerabilities {
            if v.Severity >= 9.0 {
                criticals++
            } else if v.Severity >= 7.0 {
                highs++
            }
        }
    }

    fmt.Fprintln(f.writer)
    summary := fmt.Sprintf("Found %d vulnerabilities", totalVulns)
    if criticals > 0 {
        summary += fmt.Sprintf(" (%s critical, %d high)", red(fmt.Sprintf("%d", criticals)), highs)
    } else if highs > 0 {
        summary += fmt.Sprintf(" (%s high)", yellow(fmt.Sprintf("%d", highs)))
    }
    fmt.Fprintln(f.writer, summary)

    return nil
}

func (f *Formatter) renderJSON(results scanner.Results) error {
    enc := json.NewEncoder(f.writer)
    enc.SetIndent("", "  ")

    type jsonOutput struct {
        Package         string                  `json:"package"`
        Version         string                  `json:"version"`
        Ecosystem       string                  `json:"ecosystem"`
        Vulnerabilities []scanner.Vulnerability `json:"vulnerabilities"`
        Error           string                  `json:"error,omitempty"`
    }

    out := make([]jsonOutput, 0, len(results))
    for _, pr := range results {
        row := jsonOutput{
            Package:         pr.Package.Name,
            Version:         pr.Package.Version,
            Ecosystem:       pr.Package.Ecosystem,
            Vulnerabilities: pr.Vulnerabilities,
        }
        if pr.Error != nil {
            row.Error = pr.Error.Error()
        }
        out = append(out, row)
    }
    return enc.Encode(out)
}

func (f *Formatter) renderPlain(results scanner.Results) error {
    for _, pr := range results {
        if pr.Error != nil {
            fmt.Fprintf(f.writer, "ERROR %s@%s: %v\n", pr.Package.Name, pr.Package.Version, pr.Error)
            continue
        }
        for _, v := range pr.Vulnerabilities {
            fmt.Fprintf(f.writer, "%s %s@%s %.1f %s\n",
                v.ID, pr.Package.Name, pr.Package.Version, v.Severity, v.Summary)
        }
    }
    return nil
}

func formatSeverity(score float64, red, yellow, cyan func(...interface{}) string) string {
    switch {
    case score >= 9.0:
        return red(fmt.Sprintf("CRITICAL (%.1f)", score))
    case score >= 7.0:
        return yellow(fmt.Sprintf("HIGH (%.1f)", score))
    case score >= 4.0:
        return cyan(fmt.Sprintf("MEDIUM (%.1f)", score))
    default:
        return fmt.Sprintf("LOW (%.1f)", score)
    }
}

The Version Subcommand

A version command is table stakes for any distributable CLI tool. The trick is injecting build metadata at compile time:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// cmd/version.go
package cmd

import (
    "fmt"
    "runtime"

    "github.com/spf13/cobra"
)

// These are set by -ldflags at build time
var (
    Version   = "dev"
    Commit    = "none"
    BuildDate = "unknown"
)

var versionCmd = &cobra.Command{
    Use:   "version",
    Short: "Print version information",
    Run: func(cmd *cobra.Command, args []string) {
        fmt.Printf("vcheck %s\n", Version)
        fmt.Printf("  commit:     %s\n", Commit)
        fmt.Printf("  built:      %s\n", BuildDate)
        fmt.Printf("  go version: %s\n", runtime.Version())
        fmt.Printf("  os/arch:    %s/%s\n", runtime.GOOS, runtime.GOARCH)
    },
}

func init() {
    rootCmd.AddCommand(versionCmd)
}

Build with injected version:

1
2
3
4
go build -ldflags="-X github.com/yourname/vcheck/cmd.Version=1.2.3 \
  -X github.com/yourname/vcheck/cmd.Commit=$(git rev-parse --short HEAD) \
  -X 'github.com/yourname/vcheck/cmd.BuildDate=$(date -u +%Y-%m-%dT%H:%M:%SZ)'" \
  -o vcheck .

Shell Completion

Cobra generates completion scripts for free:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// cmd/root.go — add in init()
rootCmd.AddCommand(&cobra.Command{
    Use:   "completion [bash|zsh|fish|powershell]",
    Short: "Generate shell completion scripts",
    Long: `Generate shell completion scripts for vcheck.

# Bash
source <(vcheck completion bash)
# Add to ~/.bashrc:
echo 'source <(vcheck completion bash)' >> ~/.bashrc

# Zsh
source <(vcheck completion zsh)
# Add to ~/.zshrc:
echo 'source <(vcheck completion zsh)' >> ~/.zshrc

# Fish
vcheck completion fish | source
`,
    ValidArgs: []string{"bash", "zsh", "fish", "powershell"},
    Args:      cobra.ExactValidArgs(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.GenPowerShellCompletionWithDesc(os.Stdout)
        }
        return nil
    },
})

Add completion to the scan command’s ValidArgs so tab-completing ecosystem names works:

1
2
3
4
5
6
scanCmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
    if len(args) == 0 {
        return []string{"go", "npm", "pypi", "cargo", "maven", "nuget"}, cobra.ShellCompDirectiveNoFileComp
    }
    return nil, cobra.ShellCompDirectiveDefault
}

Testing

Good CLI tests cover three levels: unit tests for internal logic, integration tests for command execution, and golden file tests for output formatting.

Unit Tests

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
// internal/scanner/scanner_test.go
package scanner_test

import (
    "testing"

    "github.com/yourname/vcheck/internal/scanner"
)

func TestParseArgs(t *testing.T) {
    tests := []struct {
        name      string
        ecosystem string
        args      []string
        want      []scanner.Package
        wantErr   bool
    }{
        {
            name:      "valid packages",
            ecosystem: "go",
            args:      []string{"golang.org/x/net@0.5.0", "github.com/gin-gonic/gin@1.8.0"},
            want: []scanner.Package{
                {Ecosystem: "go", Name: "golang.org/x/net", Version: "0.5.0"},
                {Ecosystem: "go", Name: "github.com/gin-gonic/gin", Version: "1.8.0"},
            },
        },
        {
            name:      "missing version",
            ecosystem: "npm",
            args:      []string{"lodash"},
            wantErr:   true,
        },
        {
            name:      "empty version",
            ecosystem: "pypi",
            args:      []string{"requests@"},
            wantErr:   true,
        },
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            got, err := scanner.ParseArgs(tt.ecosystem, tt.args)
            if (err != nil) != tt.wantErr {
                t.Errorf("ParseArgs() error = %v, wantErr %v", err, tt.wantErr)
                return
            }
            if !tt.wantErr && len(got) != len(tt.want) {
                t.Errorf("ParseArgs() got %d packages, want %d", len(got), len(tt.want))
            }
        })
    }
}

func TestResultsHasCritical(t *testing.T) {
    results := scanner.Results{
        {Vulnerabilities: []scanner.Vulnerability{{Severity: 9.8, ID: "CVE-2021-44228"}}},
        {Vulnerabilities: []scanner.Vulnerability{{Severity: 5.0, ID: "CVE-2022-12345"}}},
    }
    if !results.HasCritical() {
        t.Error("HasCritical() = false, want true")
    }

    safeResults := scanner.Results{
        {Vulnerabilities: []scanner.Vulnerability{{Severity: 6.5}}},
    }
    if safeResults.HasCritical() {
        t.Error("HasCritical() = true, want false")
    }
}

Command Integration Tests with cobra/command

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
// cmd/scan_test.go
package cmd_test

import (
    "bytes"
    "testing"

    "github.com/yourname/vcheck/cmd"
)

func TestScanCommandHelp(t *testing.T) {
    buf := new(bytes.Buffer)
    rootCmd := cmd.NewRootCmd()
    rootCmd.SetOut(buf)
    rootCmd.SetErr(buf)
    rootCmd.SetArgs([]string{"scan", "--help"})

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

    output := buf.String()
    if !bytes.Contains(buf.Bytes(), []byte("Scan one or more packages")) {
        t.Errorf("help output missing expected content, got: %s", output)
    }
}

func TestScanCommandInvalidArgs(t *testing.T) {
    rootCmd := cmd.NewRootCmd()
    rootCmd.SetArgs([]string{"scan"})  // missing ecosystem and packages

    err := rootCmd.Execute()
    if err == nil {
        t.Error("Execute() expected error for missing args, got nil")
    }
}

// For commands that call external APIs, use an httptest.Server mock:
func TestScanCommandWithMockAPI(t *testing.T) {
    // Set up mock HTTP server
    server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Content-Type", "application/json")
        w.WriteHeader(http.StatusOK)
        // Return a mock OSV response
        fmt.Fprintln(w, `{"vulns": []}`)
    }))
    defer server.Close()

    buf := new(bytes.Buffer)
    rootCmd := cmd.NewRootCmd()
    rootCmd.SetOut(buf)
    rootCmd.SetArgs([]string{
        "--api-url", server.URL,
        "scan", "go", "golang.org/x/net@0.5.0",
    })

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

    if !bytes.Contains(buf.Bytes(), []byte("No vulnerabilities found")) {
        t.Errorf("unexpected output: %s", buf.String())
    }
}

Golden File Tests for Output

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
// internal/output/formatter_test.go
package output_test

import (
    "bytes"
    "flag"
    "os"
    "path/filepath"
    "testing"

    "github.com/yourname/vcheck/internal/output"
    "github.com/yourname/vcheck/internal/scanner"
)

var update = flag.Bool("update", false, "update golden files")

func TestFormatterJSON(t *testing.T) {
    results := scanner.Results{
        {
            Package: scanner.Package{Ecosystem: "go", Name: "golang.org/x/net", Version: "0.0.1"},
            Vulnerabilities: []scanner.Vulnerability{
                {ID: "CVE-2022-00001", Severity: 9.8, Summary: "Remote code execution"},
            },
        },
    }

    var buf bytes.Buffer
    f := output.NewFormatter("json", &buf)
    if err := f.Render(results); err != nil {
        t.Fatalf("Render() error: %v", err)
    }

    golden := filepath.Join("testdata", "json_output.golden")
    if *update {
        os.WriteFile(golden, buf.Bytes(), 0644)
    }

    want, err := os.ReadFile(golden)
    if err != nil {
        t.Fatalf("reading golden file: %v", err)
    }

    if !bytes.Equal(buf.Bytes(), want) {
        t.Errorf("output mismatch\ngot:\n%s\nwant:\n%s", buf.String(), string(want))
    }
}

Run with go test ./... -update to regenerate golden files after intentional output changes.


The Makefile

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
# Makefile
BINARY      := vcheck
VERSION     ?= $(shell git describe --tags --always --dirty)
COMMIT      := $(shell git rev-parse --short HEAD)
BUILD_DATE  := $(shell date -u +%Y-%m-%dT%H:%M:%SZ)
LDFLAGS     := -ldflags "-X github.com/yourname/vcheck/cmd.Version=$(VERSION) \
                          -X github.com/yourname/vcheck/cmd.Commit=$(COMMIT) \
                          -X 'github.com/yourname/vcheck/cmd.BuildDate=$(BUILD_DATE)' \
                          -s -w"

.PHONY: build test lint clean install release snapshot

build:
	go build $(LDFLAGS) -o $(BINARY) .

# Build for all platforms
build-all:
	GOOS=linux   GOARCH=amd64  go build $(LDFLAGS) -o dist/$(BINARY)-linux-amd64 .
	GOOS=linux   GOARCH=arm64  go build $(LDFLAGS) -o dist/$(BINARY)-linux-arm64 .
	GOOS=darwin  GOARCH=amd64  go build $(LDFLAGS) -o dist/$(BINARY)-darwin-amd64 .
	GOOS=darwin  GOARCH=arm64  go build $(LDFLAGS) -o dist/$(BINARY)-darwin-arm64 .
	GOOS=windows GOARCH=amd64  go build $(LDFLAGS) -o dist/$(BINARY)-windows-amd64.exe .

test:
	go test ./... -v -race -count=1 -timeout=60s

test-cover:
	go test ./... -coverprofile=coverage.out
	go tool cover -html=coverage.out -o coverage.html

lint:
	golangci-lint run ./...

clean:
	rm -f $(BINARY)
	rm -rf dist/

install: build
	install -m 0755 $(BINARY) /usr/local/bin/$(BINARY)

# GoReleaser snapshot (local dry run without publishing)
snapshot:
	goreleaser release --snapshot --clean

# Full release (requires GITHUB_TOKEN)
release:
	goreleaser release --clean

Cross-Compilation

Go’s cross-compilation is one of its superpowers — no toolchain required for the target platform:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
# Build for Linux from macOS (or any platform)
GOOS=linux GOARCH=amd64 go build -o vcheck-linux-amd64 .

# All common targets
for GOOS in linux darwin windows; do
    for GOARCH in amd64 arm64; do
        ext=""
        [ "$GOOS" = "windows" ] && ext=".exe"
        GOOS=$GOOS GOARCH=$GOARCH go build \
            -ldflags="-s -w" \
            -o "dist/vcheck-${GOOS}-${GOARCH}${ext}" .
    done
done

# CGO must be disabled for true static binaries (default when cross-compiling)
# If your code uses CGO (e.g., SQLite via mattn/go-sqlite3):
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o vcheck-linux-amd64 .

# Verify it's statically linked
file vcheck-linux-amd64
# vcheck-linux-amd64: ELF 64-bit LSB executable, x86-64, statically linked

The -s -w linker flags strip the symbol table and debug info, reducing binary size by 30-50%:

  • -s: strip symbol table
  • -w: strip DWARF debug info

For further size reduction, use upx:

1
2
upx --best vcheck-linux-amd64
# Reduces 8MB binary to ~3MB with no runtime penalty

GoReleaser — Automated Releases

GoReleaser builds, packages, signs, and publishes releases across platforms from a single config file.

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
# .goreleaser.yaml
version: 2

project_name: vcheck

before:
  hooks:
    - go mod tidy
    - go generate ./...

builds:
  - id: vcheck
    main: .
    binary: vcheck
    env:
      - CGO_ENABLED=0
    goos:
      - linux
      - darwin
      - windows
    goarch:
      - amd64
      - arm64
    ignore:
      - goos: windows
        goarch: arm64
    ldflags:
      - -s -w
      - -X github.com/yourname/vcheck/cmd.Version={{.Version}}
      - -X github.com/yourname/vcheck/cmd.Commit={{.Commit}}
      - -X github.com/yourname/vcheck/cmd.BuildDate={{.Date}}

archives:
  - id: vcheck
    name_template: "{{ .ProjectName }}_{{ .Os }}_{{ .Arch }}"
    formats:
      - tar.gz
    format_overrides:
      - goos: windows
        format: zip
    files:
      - README.md
      - LICENSE

checksum:
  name_template: "checksums.txt"
  algorithm: sha256

signs:
  - artifacts: checksum
    args:
      - "--batch"
      - "--local-user"
      - "{{ .Env.GPG_FINGERPRINT }}"
      - "--output"
      - "${signature}"
      - "--detach-sign"
      - "${artifact}"

changelog:
  sort: asc
  filters:
    exclude:
      - "^docs:"
      - "^test:"
      - "^chore:"
      - Merge pull request
      - Merge branch

release:
  github:
    owner: yourname
    name: vcheck
  draft: false
  prerelease: auto  # tag v1.0.0-rc.1 becomes a prerelease automatically
  name_template: "v{{.Version}}"

# Homebrew tap (optional)
brews:
  - name: vcheck
    repository:
      owner: yourname
      name: homebrew-tap
    homepage: https://github.com/yourname/vcheck
    description: "Vulnerability checker for your dependencies"
    license: MIT
    install: |
      bin.install "vcheck"
      bash_completion.install "completions/vcheck.bash" => "vcheck"
      zsh_completion.install "completions/vcheck.zsh" => "_vcheck"

# Docker image (optional)
dockers:
  - image_templates:
      - "ghcr.io/yourname/vcheck:{{ .Tag }}"
      - "ghcr.io/yourname/vcheck:latest"
    dockerfile: Dockerfile
    build_flag_templates:
      - "--platform=linux/amd64"
      - "--label=org.opencontainers.image.version={{.Version}}"

GitHub Actions Release Workflow

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
# .github/workflows/release.yml
name: Release

on:
  push:
    tags:
      - "v*"

permissions:
  contents: write
  packages: write

jobs:
  release:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0  # GoReleaser needs full history for changelog

      - uses: actions/setup-go@v5
        with:
          go-version-file: go.mod
          cache: true

      - name: Import GPG key
        uses: crazy-max/ghaction-import-gpg@v6
        with:
          gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }}
          passphrase: ${{ secrets.GPG_PASSPHRASE }}
        id: import_gpg

      - name: Run GoReleaser
        uses: goreleaser/goreleaser-action@v6
        with:
          version: latest
          args: release --clean
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          GPG_FINGERPRINT: ${{ steps.import_gpg.outputs.fingerprint }}

CI Workflow for PRs

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
# .github/workflows/ci.yml
name: CI

on:
  push:
    branches: [main]
  pull_request:

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
        with:
          go-version-file: go.mod
          cache: true

      - name: Test
        run: go test ./... -race -coverprofile=coverage.out

      - name: Upload coverage
        uses: codecov/codecov-action@v4

  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
        with:
          go-version-file: go.mod
      - uses: golangci/golangci-lint-action@v6
        with:
          version: latest

  build-snapshot:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - uses: actions/setup-go@v5
        with:
          go-version-file: go.mod
          cache: true
      - uses: goreleaser/goreleaser-action@v6
        with:
          args: release --snapshot --clean

UX Patterns Worth Stealing

Progress Indicators for Long Operations

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
import "github.com/schollz/progressbar/v3"

bar := progressbar.NewOptions(len(packages),
    progressbar.OptionSetDescription("Scanning packages..."),
    progressbar.OptionShowCount(),
    progressbar.OptionSetTheme(progressbar.Theme{
        Saucer:        "=",
        SaucerHead:    ">",
        SaucerPadding: " ",
        BarStart:      "[",
        BarEnd:        "]",
    }),
)

for _, pkg := range packages {
    result := scan(pkg)
    bar.Add(1)
    // ...
}
bar.Finish()

Confirm Dangerous Actions

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
import "github.com/AlecAivazis/survey/v2"

func confirmAction(msg string) bool {
    if !isatty.IsTerminal(os.Stdin.Fd()) {
        // Non-interactive (CI) — require explicit --yes flag
        return viper.GetBool("yes")
    }
    var confirm bool
    survey.AskOne(&survey.Confirm{Message: msg}, &confirm)
    return confirm
}

// Usage
if !confirmAction(fmt.Sprintf("Delete %d records?", count)) {
    fmt.Println("Aborted.")
    return nil
}

Detect Non-Interactive Mode

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
import "github.com/mattn/go-isatty"

func isInteractive() bool {
    return isatty.IsTerminal(os.Stdout.Fd())
}

// Disable colors in pipes and CI
if !isInteractive() {
    color.NoColor = true
}

Pager Support for Long Output

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
import "github.com/charmbracelet/glamour"

func renderWithPager(content string) error {
    if !isInteractive() {
        fmt.Print(content)
        return nil
    }
    // Use $PAGER or fall back to less
    pager := os.Getenv("PAGER")
    if pager == "" {
        pager = "less -R"
    }
    parts := strings.Fields(pager)
    cmd := exec.Command(parts[0], parts[1:]...)
    cmd.Stdin = strings.NewReader(content)
    cmd.Stdout = os.Stdout
    cmd.Stderr = os.Stderr
    return cmd.Run()
}

Publishing

Install via go install (zero-friction for Go users)

1
go install github.com/yourname/vcheck@latest

This works once you’ve pushed a tagged release and your module is in the Go module proxy.

Homebrew (macOS and Linux)

GoReleaser generates the formula automatically if you configure the brews section. Users install with:

1
2
brew tap yourname/tap
brew install vcheck

Docker

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# Dockerfile — minimal scratch image
FROM golang:1.22-alpine AS builder
WORKDIR /build
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build \
    -ldflags="-s -w" \
    -o vcheck .

FROM scratch
COPY --from=builder /build/vcheck /vcheck
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
ENTRYPOINT ["/vcheck"]
1
docker run --rm ghcr.io/yourname/vcheck:latest scan go golang.org/x/net@0.5.0

What Makes a Great CLI

Before closing, the principles that separate forgettable tools from ones people actually reach for:

Be predictable. Follow Unix conventions. Exit 0 on success, non-zero on failure. Write errors to stderr, data to stdout. Support --help on every command.

Be pipeable. vcheck scan go ./... --output plain | grep CRITICAL | wc -l should work naturally. Don’t pollute stdout with progress bars when output is being piped (isatty check).

Be quiet by default. Show what the user asked for. Put diagnostics behind --verbose. Nobody wants a wall of logs when they just want the result.

Fail clearly. A bad error message (“error: failed”) wastes the user’s time. A good one (“error: package lodash@4.17.20 not found in npm — check spelling or try lodash@4.17.21”) solves the problem.

Version everything. --version must exist and must be specific enough to reproduce the exact binary. Include commit hash.

Document with examples. The Long description on every Cobra command should have runnable examples. Examples teach faster than prose.


Related: Go for DevOps Engineers, CI/CD Pipelines, GitHub Actions Advanced Patterns

Comments