Status:🤷 UNKNOWN
Date:📅 unknown
Decision Makers:unknown
ADR-0009: Configuration & CLI — Cobra (commands) + Viper (config)
- Status: Accepted
- Date: 2026-06-27
- Relates to: 📝 ADR-0003 (per-source archive roots), 📝 ADR-0010 (secrets via env)
Context
msgbrowse is a multi-command binary (import, signal-import,
imessage-import, whatsapp-import, devices, doctor, export, sync,
embed, facts, media, serve, mcp, watch, journal, version) that
must be configurable three ways for three deployment styles: a committed-ish config.yaml for stable
settings, environment variables for Docker/secrets, and flags for one-off
overrides. It needs --help, subcommands, and a single resolved config object
each command can rely on — with a predictable precedence and a hard rule that
secrets never live in the config file.
Decision
Cobra for the command tree, Viper for configuration, wired so precedence is
defaults < config.yaml < MSGBROWSE_* env < flags.
- Cobra command tree.
NewRootCommand(internal/cli/root.go) defines the rootmsgbrowsecommand and attaches every subcommand (each in its own file). The root owns persistent flags shared by all commands (--config,--archive-root,--imessage-archive-root,--whatsapp-archive-root,--data-dir,--log-level) and setsSilenceUsage/SilenceErrorsso failures render through the logger, not as a usage dump. - Viper config, single lifecycle. The root's
PersistentPreRunErunsinitConfigonce (after flag parsing, before any subcommandRunE):config.Loadbuilds a*viper.Viperwith defaults + optional config file + env, thenBindPFlagbinds each persistent flag onto its config key. Each subcommand callsresolveConfig, which unmarshals to a*config.Config, validates, and configures the logger. - Precedence: defaults < file < env < flags.
config.SetDefaultsis the single source of truth for built-in defaults.Loadthen readsconfig.yaml(searched in.,$HOME/.config/msgbrowse,/etc/msgbrowse, or an explicit--config), layersAutomaticEnv, and the CLI binds flags last. Because Viper consultspflag.Changedfor bound flags, only flags the user actually set override file/env values. - Env prefix + replacer.
SetEnvPrefix("MSGBROWSE")plusSetEnvKeyReplacer(strings.NewReplacer(".", "_"))maps nested keys to env vars — e.g.MSGBROWSE_LLM_API_KEY→llm.api_key,MSGBROWSE_ARCHIVE_ROOT→archive_root. - Per-source archive roots. Distinct read-only roots —
archive_root(signal-export),imessage_archive_root(imessage-exporter), andwhatsapp_archive_root(WhatsApp-Chat-Exporter, 📝 ADR-0016) — are separate config keys/flags (📝 ADR-0003), each with its ownerrorHintwhen mis-pointed. - Secrets via env only.
LLMConfig.APIKeyhas a default of""and theconfigpackage docstring directs users toMSGBROWSE_LLM_API_KEYrather than the YAML file, so a key is never encouraged into a committed file (📝 ADR-0010, SECURITY.md). - Validation up front.
config.Validaterejects an invalidvector_backend, an invalidlog_level, and an emptydata_dirbefore any command does work.
Why these choices
- Cobra + Viper over a hand-rolled flag parser: they are the de-facto Go pair for a multi-command, multi-source config tool — subcommands, help, persistent flags, and layered config with flag binding, all without bespoke plumbing.
PersistentPreRunEovercobra.OnInitialize: it receives the invoked command's flag set directly, so flag binding targets the rightpflag.FlagSetand config loads exactly once per invocation.pflag.Changed-aware binding: gives the intuitive precedence (a flag only wins when actually passed), so env/file values aren't clobbered by a flag's zero default.- Env-only secrets: the cleanest way to keep an API key out of git while still
supporting Docker/secret-store injection; the default of
""makes the local-by-default LLM route work with no secret at all.
Consequences
Positive
- One config object, one precedence rule, three input methods — predictable
across
serve, the importers, andmcp. - Docker/secrets work cleanly via
MSGBROWSE_*env without a config file. - Adding a setting is a
SetDefaultsentry plus a struct field; adding a source's root is a new key/flag, no parser changes.
Negative
- Config flows through three layers (
Load→BindPFlag→Unmarshal), with package-levelvandcfgFilevars incli— convenient but global state. - Viper's case-insensitive keys and the
.→_replacer are conventions a contributor must know to name an env var correctly.
Operational
- The config file is optional; a missing file is not an error (defaults + env + flags still apply).
- Secrets must be supplied via env (
MSGBROWSE_LLM_API_KEY); a key placed inconfig.yamlis discouraged and risks being committed.
Alternatives considered
- stdlib
flag+ manual env/file parsing. Rejected: reimplements subcommands, help, and layered precedence that Cobra+Viper provide. - Flags only (no config file). Rejected: a long-lived
serve/journal setup wants a stableconfig.yaml; flags alone are unwieldy for many keys. - Secrets in the config file. Rejected: invites committing an API key; env-only keeps secrets out of git and matches the egress model in SECURITY.md.
References
internal/cli/root.go(command tree,initConfig,resolveConfig, flag binding)internal/config/config.go(SetDefaults,Load, env prefix/replacer,Validate)- 📝 ADR-0003: Dual-source archive
- 📝 ADR-0010: Security & privacy posture