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/termas 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
- Source: github.com/sgaunet/bullets
- Examples: the
examples/directory has runnable demos for basic, spinner, and updatable usage
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.