I love how goreleaser looks when it runs. Tidy bullets, indented sub-steps, the right amount of color, occasional spinners. Every time I write a Go CLI of my own, I want that aesthetic — and I don’t want to pull in a 100-dependency logging framework to get it. So I wrote bullets.

What it gives you

bullets is a small library focused on one thing: making CLI output that humans actually enjoy reading. Inspired by caarlos0/log (the logger goreleaser itself uses), it adds a few things I missed:

  • Configurable bullet symbols (default circles, optional special symbols, custom icons)
  • Animated spinners — Braille dots, rotating circle, bouncing dots, or your own frames
  • Concurrent spinners that coordinate cleanly, each on its own line
  • Updatable bullets — render a line now, mutate it later (status, progress bar, fields)
  • Indented sub-steps for nested operations
  • Structured logging with fields and WithError
  • Thread-safe, with only golang.org/x/term as a real dependency

The README is upfront that we’re pre-v1.0 and the API may shift between minor versions. Vendor or pin if that matters.

Installation

go get github.com/sgaunet/bullets

A quick taste

The simplest case looks the way you’d expect:

package main

import (
    "os"

    "github.com/sgaunet/bullets"
)

func main() {
    logger := bullets.New(os.Stdout)

    logger.Info("building")
    logger.IncreasePadding()
    logger.Info("binary=dist/app_linux_amd64")
    logger.Info("binary=dist/app_darwin_amd64")
    logger.DecreasePadding()

    logger.Success("build succeeded")
}

You get the goreleaser-shaped output:

• building
  • binary=dist/app_linux_amd64
  • binary=dist/app_darwin_amd64
• build succeeded

Spinners (including the concurrent kind)

For long-running steps, swap the bullet for an animated spinner:

spinner := logger.Spinner("downloading files")
time.Sleep(3 * time.Second)
spinner.Success("downloaded 10 files")

The thing I’m most proud of is the concurrent spinner story. Multiple spinners, started from multiple goroutines, each get their own line and a single coordinator drives the animation tick — so you don’t get the usual flicker-and-overlap mess:

db := logger.SpinnerCircle("Connecting to database")
api := logger.SpinnerDots("Fetching API data")
files := logger.SpinnerBounce("Processing files")

go func() { time.Sleep(2 * time.Second); db.Success("Database connected") }()
go func() { time.Sleep(3 * time.Second); api.Success("API data fetched") }()
go func() { time.Sleep(1 * time.Second); files.Error("File processing failed") }()

In a non-TTY environment (CI, redirected to a file), spinners gracefully degrade to plain log lines so your pipelines stay readable.

Updatable bullets and progress bars

Sometimes you want to render a line, then mutate it later — a download bar, a parallel deploy with per-service status, that kind of thing:

logger := bullets.NewUpdatable(os.Stdout)

download := logger.InfoHandle("Downloading file...")
for i := 0; i <= 100; i += 10 {
    download.Progress(i, 100)
    time.Sleep(100 * time.Millisecond)
}
download.Success("Download complete!")

There are also handle groups and chains for batch updates:

h1 := logger.InfoHandle("Service 1")
h2 := logger.InfoHandle("Service 2")
h3 := logger.InfoHandle("Service 3")

bullets.NewHandleGroup(h1, h2, h3).SuccessAll("All services running")

The one gotcha: this needs ANSI escape codes and proper TTY detection. If you’re running through go run or an IDE terminal that lies about being a TTY, set BULLETS_FORCE_TTY=1.

Structured logging, when you want it

Fields work the way you’d hope:

logger.WithField("user", "john").Info("logged in")

logger.WithFields(map[string]interface{}{
    "version": "1.2.3",
    "arch":    "amd64",
}).Info("building package")

logger.WithError(err).Error("upload failed")

Why this and not zap / zerolog / slog?

Different tool for a different job. slog is great when you need structured output for log aggregators. bullets exists for the moment when a human is staring at the terminal waiting for the CLI to finish — and you want that moment to feel polished.

Where to go next

Open source, MIT-licensed, minimal dependencies. If you’re building a CLI in Go and want it to look like it was made on purpose, give it a try.