This guide covers how the CLI loads, reads, and writes its persistent
configuration (drconfig.yaml), and the rules contributors must follow when
adding new flags or persisted config keys.
- The viperx wrapper
- Where config lives
- How values reach viper
- Writing back to drconfig.yaml
- Rules for new flags
- Rules for new env vars
- Rules for new persisted keys
- Common pitfalls
Outside of internal/config/..., the only viper entry point is
internal/config/viperx. Direct imports of github.com/spf13/viper
are blocked by depguard everywhere else in the tree.
// good
import "github.com/datarobot/cli/internal/config/viperx"
if viperx.GetBool("debug") { /* ... */ }
// blocked by golangci-lint outside internal/config/**
import "github.com/spf13/viper"viperx re-exports only the safe subset of viper's API. The following
symbols are deliberately not re-exported:
viper.WriteConfig,viper.SafeWriteConfig— they serialize the entireviper.AllSettings()map, leaking transient flag state intodrconfig.yaml. Useconfig.UpdateConfigFile(...)instead, which only writes keys in theconfig.PersistableKeysallowlist.viper.BindPFlags(cmd.Flags())— bulk-binds every subcommand flag into viper, with the same leakage problem. Useviperx.BindPFlagfor individual persistent flags; read subcommand flags directly viacmd.Flags().GetX(...).
If you need a viper symbol that is not currently re-exported, add it to
internal/config/viperx/viperx.go consciously and document why. New
additions should be reviewed for whether they expand the leakage surface.
The CLI stores user configuration in a single YAML file:
- Default location:
$XDG_CONFIG_HOME/datarobot/drconfig.yaml(falls back to~/.config/datarobot/drconfig.yaml) - Override with
--config <path>orDATAROBOT_CLI_CONFIG=<path>
Today this file holds connection credentials and a small set of sticky CLI preferences. It is not a dumping ground for transient flag state.
Viper resolves a key from these sources in priority order:
- Explicit
viperx.Set(key, value)call (e.g. after a successful login) - Flag bound via
viperx.BindPFlag(key, flag)(only persistent root flags today — see below) - Environment variable bound via
viperx.BindEnv(key, "DATAROBOT_…")or auto-mapped viaviperx.SetEnvPrefix("DATAROBOT_CLI") - The active named profile's own keys, merged into this layer by
config.ReadConfigFileviaviper.MergeConfigMap(see Named profiles below) — shadows the default profile's values from the same layer - Value loaded from
drconfig.yaml(the default/top-level profile) - Default registered via
viperx.SetDefault
Only the persistent root flags registered in cmd/root_factory.go's
registerFlags are bound explicitly with viperx.BindPFlag, in
bindViperFlags. We do not bulk-bind subcommand flags (and viperx
does not even expose a BindPFlags function), because that would slurp
every subcommand flag (such as --yes, --if-needed) into
viper.AllSettings() and risk leaking transient flag state into
drconfig.yaml.
--output-format is one of these root-bound flags. It is global and supports
DATAROBOT_CLI_OUTPUT_FORMAT, but it is still treated as transient and is not
written to drconfig.yaml (it is not in config.PersistableKeys).
Read subcommand flag values directly from cobra:
yesFlag, _ := cmd.Flags().GetBool("yes")If a subcommand flag also needs an environment variable override, register
the env var only with viperx.BindEnv(...) and merge the two sources
explicitly in your handler:
// cmd/dotenv/cmd.go
_ = viperx.BindEnv("yes", "DATAROBOT_CLI_NON_INTERACTIVE")
// In RunE:
yesFlag, _ := cmd.Flags().GetBool("yes")
yes := yesFlag || viperx.GetBool("yes")This keeps the explicit --yes flag value out of viper.AllSettings()
while preserving env-var support.
Use the allowlisted writer in internal/config:
// Write all allowlisted keys that are currently set in viper:
config.UpdateConfigFile()
// Or write only specific keys (recommended when the call site knows
// exactly what changed):
config.UpdateConfigFile(config.DataRobotURL)
config.UpdateConfigFile(config.DataRobotAPIKey, config.DataRobotURL)UpdateConfigFile reads the existing YAML, overlays only the allowlisted
keys (config.PersistableKeys), and writes the result back. Any other
viper state — including transient flags such as --yes, --verbose,
--debug — is intentionally dropped.
The wrappers in the auth package (auth.WriteConfigFileSilent,
auth.WriteConfigFile) call this writer under the hood.
--profile <name> / DATAROBOT_CLI_PROFILE select a section under
profiles: in drconfig.yaml (see the
user-facing docs). The
mechanism lives in internal/config/profile.go:
config.ReadConfigFilecallsapplyProfile(name), whichviper.MergeConfigMaps the profile's own keys into viper's config layer — the same layerviper.ReadInConfigpopulates from the file. Deliberately notviperx.Set:Setwrites the override layer, which outranks flags and env, so a profile'sendpointwould beat an explicitDATAROBOT_CLI_ENDPOINTinstead of losing to it.- Only
config.ProfileScopedKeys(endpoint,token,ca-cert) may live under a profile; every other persistable key stays global at the top level, shared by all profiles.ssl_verifyis deliberately excluded: nothing in the CLI reads it (TLS is driven by--ca-certand--skip-certificate-check), so it stays a global passthrough value thatUpdateConfigFilepreserves. endpoint/tokenmerge atomically: if a profile defines either one, both are merged (substituting""for the one it omits), so a profile can never end up pairing its own endpoint with the default profile's token.
On the write side, UpdateConfigFile's PersistableKeys allowlist is
unchanged — profileDestPath(key) in internal/config/write.go maps an
allowlisted key to profiles.<name>.<key> instead of <key> when a
profile is active. A bare sweep (UpdateConfigFile() with no explicit
keys) additionally restricts profile-scoped candidates to keys the
profile's own section already defines (candidateKeys) — otherwise it
would copy every value the profile currently inherits from the default
profile (including its endpoint/token) permanently into the profile's
section. Call sites that already pass explicit keys (e.g.
auth.WriteConfigFileSilent passing endpoint, token) are unaffected by
that restriction, which is what lets a brand-new profile's section be
created on its first write.
When adding a new flag, decide which category it falls into:
| Category | Bind to viper? | Persist to drconfig.yaml? |
|---|---|---|
Transient subcommand flag (e.g. --yes, --all) |
No | No |
Global transient root flag (e.g. --output-format) |
Yes (root only) | No |
Sticky preference (e.g. --ca-cert) |
Yes (root only) | Yes — add to PersistableKeys |
Connection credential (e.g. --token) |
Yes | Yes |
For transient subcommand flags:
- Define with
cmd.Flags().Bool(...) - Read with
cmd.Flags().GetBool(...) - Do not call
viperx.BindPFlag(...)
For global transient root flags:
- Register on root with
PersistentFlags() - Bind only on root via
viperx.BindPFlag(...) - Keep the key out of
config.PersistableKeys
viperx.AutomaticEnv() with prefix DATAROBOT_CLI is enabled in
defaultConfigInitializer (cmd/root_factory.go), so any key you
viperx.Get will already check DATAROBOT_CLI_<KEY> (with - replaced by
_).
For env vars that should map to a different name (e.g.
DATAROBOT_CLI_NON_INTERACTIVE → key yes), use viperx.BindEnv and
read the merged value as shown in Subcommand flags.
To make a key writable to drconfig.yaml:
- Add the key to
PersistableKeysininternal/config/write.go - Update its production write call sites to pass the key explicitly:
config.UpdateConfigFile("my-new-key") - Decide whether the key is per-profile or global (see
Named profiles above). Per-profile-with-fallback is
the default for anything that can legitimately differ between DataRobot
installations (e.g. TLS settings); add it to
ProfileScopedKeysininternal/config/profile.goif so. Leave it out (global) otherwise. - Add a regression test under
internal/auth/writeConfig_test.go(or a dedicated test file) verifying the key round-trips correctly and that transient flags still do not leak.
Use viper dotted-path notation if the value is nested, e.g.
"pulumi.config.passphrase". The writer handles nested map creation.
Sensitive keys (credentials, passphrases, tokens) must be redacted in debug output to prevent accidental exposure of secrets in logs. To mark a key as sensitive:
-
Add the key to
sensitiveDebugKeysininternal/config/config.go:var sensitiveDebugKeys = map[string]struct{}{ "token": {}, "pulumi_config_passphrase": {}, }
-
When
--debugis enabled, the key will be redacted as****in console output fromDebugViperConfig().
Redaction is depth-recursive (redactSettings/redactValue in
internal/config/config.go): a key match is redacted no matter how deeply
nested, which is what keeps e.g. profiles.eu-mtsaas.token redacted the
same as top-level token.
- Don't import
github.com/spf13/viperoutsideinternal/config/.depguardwill reject it. Useinternal/config/viperxinstead. - Don't add
viper.WriteConfigorBindPFlagstoviperx. Those omissions are deliberate. If you need to persist new state, extendconfig.PersistableKeysand callconfig.UpdateConfigFile. - Don't read transient flags through viper.
viperx.GetBool("yes")hides whether the value came from a flag, an env var, or a stale drconfig entry. Read flags directly from cobra and merge in env vars explicitly. - Don't write to
drconfig.yamlfrom tests withoutviperx.Reset()and a tempXDG_CONFIG_HOME. Seeinternal/auth/writeConfig_test.gofor the recommended test pattern.