ADR-0009: Configuration & CLI — Cobra (commands) + Viper (config)
- Status: Accepted (amended 2026-07-11 — see Amendment)
- Date: 2026-06-27
- Relates to: 📝 ADR-0003 (per-source archive roots), 📝 ADR-0010 (secrets via env)
Amendment (2026-07-11): the LLM API key may be stored in the config file
The Settings → LLM tab (issue #191) lets a desktop user edit the LLM API key in the UI and persists it to the config file. This softens the original "secrets never live in the config file" rule to a narrower one:
- The key may live in the config file when the user puts it there — a
desktop user has no convenient shell to export
MSGBROWSE_LLM_API_KEY, and the file is written mode0600under 📝 ADR-0010's loopback single-user trust. The tab shows only whether a key is set (never the value), a blank field keeps the current key, and an explicit Clear control wipes it. - An env-provided key is still env-only. When
MSGBROWSE_LLM_API_KEYis set it overrides the file at boot (unchanged precedence) and is never written back to the config file — saving the tab suppresses the on-disk copy so an env-scoped secret cannot leak onto disk (internal/cli/common.gonewLLMApplier,llm.Settings.APIKeyFromEnv). Deployments that inject the key via env keep exactly the old posture. - Committing the key is still discouraged. The file remains the local app
config, not a committed one;
SECURITY.mdand 📝 ADR-0010 keep the "don't commit a real key" guidance.
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 (or the 0600 config file the user chose).
LLMConfig.APIKeystill defaults to""and env injection (MSGBROWSE_LLM_API_KEY) remains the recommended path for server/Docker deployments. As of the Amendment above, a desktop user may also store the key in the mode-0600config file via the Settings → LLM tab; an env-provided key is never persisted there. The key is never committed by default and the UI never renders it back. - 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 (as the only mechanism). Rejected: env injection
stays the recommended path for server/Docker so a key need never touch disk. The
Amendment adds config-file storage as an opt-in desktop convenience (mode
0600, env still wins and is never persisted), not a replacement — committing a real key remains discouraged 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