byob-go-cli

Logs and chatter share ErrOut; quiet default keeps them separate

byob-logging.4 iostreamslogging

Problem: byob-iostreams.3 allocated ErrOut to human chatter (progress, prompts, warnings). A slog handler wired to the same stream can interleave structured log records into "Loading items…" chatter — now 2>tool.log captures garbage and a scripter piping stderr to jq sees prose mixed with JSON.

Idea: the default log level is Warn (byob-logging.3), so in the common case logs emit nothing and chatter owns ErrOut by itself. When the user passes -v/-vv/--log-level, they've explicitly opted into a mixed stream — they know what they asked for. For scripted capture of structured output, --log-format=json swaps TextHandler to JSONHandler. For full separation, --log-file=<path> writes logs to a file, leaving ErrOut to chatter alone.

Rule of thumb: if the user hasn't asked for logs, don't print any. Chatter (byob-iostreams.3) is still the primary human channel. Logs are machine-parseable opt-in.

No --quiet/-q flag. The template deliberately omits one because the byob-iostreams.3 / byob-logging.4 combination already gives users three orthogonal knobs: 2>/dev/null silences ErrOut entirely, --log-file=/dev/null discards logs while keeping chatter, and the default (no flags) is already quiet for logs. Adding --quiet would overlap confusingly with these. Projects that really want one can add a persistent -q that redirects IO.ErrOut to io.Discard in PersistentPreRunE — that's the one-line answer.

Tradeoffs: "quiet by default" means new developers don't see logs until they know about -v. That's fine — the chatter channel still gives them what they need, and -v is the standard debugging reflex for Unix tools.

The --log-file file handle deliberately lives for the process lifetime; every slog call would need a reference otherwise. A short-lived CLI exits and the OS reclaims it. If a tool later grows a long-running / daemon mode, wire the close into the root command's shutdown hook rather than deferring it next to the open.

Design

// opts here is the root command's options struct (LogFile, LogFormat, etc.).
// The slog handler options get their own name to avoid shadowing.
var w io.Writer = f.IOStreams.ErrOut
if opts.LogFile != "" {
    fh, err := os.OpenFile(opts.LogFile,
        os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
    if err != nil { return err }
    w = fh
}

hopts := &slog.HandlerOptions{Level: lvl}
var h slog.Handler
switch opts.LogFormat {
case "json":
    h = slog.NewJSONHandler(w, hopts)
default:
    h = slog.NewTextHandler(w, hopts)
}
logger := slog.New(h)