This guide covers best practices for defining and managing command-line flags in the DataRobot CLI.
Note: This guide is about command-line flags (e.g.,
--verbose,--output file.txt), not feature gates. For feature gates, see Feature gates.
- Define flags clearly
- Count flags (parse-time validation)
- Global output-format pattern
- Universal flags (forwarded to plugins)
- Mark flag groups
- Flag naming conventions
- Examples from the codebase
Define flags at the beginning of your command function, using clear variable names:
// cmd/mycommand/cmd.go
var (
myFlag bool
count int
)
func Cmd() *cobra.Command {
cmd := &cobra.Command{
Use: "mycommand",
Short: "Description",
RunE: func(cmd *cobra.Command, args []string) error {
// Implementation
return nil
},
}
// Define flags (use singular flag names)
cmd.Flags().BoolVar(&myFlag, "my-flag", false, "Description")
cmd.Flags().IntVar(&count, "count", 0, "Description")
return cmd
}Flags that carry a count — --limit, --offset, --tail, --concurrency —
register through internal/countflags so out-of-range values are rejected
while the flags are parsed, before the command runs:
import "github.com/datarobot/cli/internal/countflags"
cmd.Flags().Var(countflags.PositiveInt(&limit, 100), "limit", "Maximum number of workloads to return")
cmd.Flags().Var(countflags.NonNegativeInt(&offset, 0), "offset", "Number of workloads to skip before returning results")PositiveIntrejects zero and negative values. Use it for page sizes: a limit of 0 would fetch nothing and read as an empty result instead of an error.NonNegativeIntallows zero (meaningful as "from the start" or "no limit") and rejects negatives.- Both bind a caller-owned
int; read the resolved value from your variable as withIntVar, andFlags().GetInt(...)(used by telemetry closures) keeps working. - Do not use them where 0 means "unset, use a default" (for example
workload config --port), or for selectors like--node-id.
Invalid input fails at parse time with cobra's standard wrapping:
Error: invalid argument "0" for "--limit" flag: must be a positive integer
so RunE stays free of flag-shape checks. Keep library-level argument
validation in internal/... as defense in depth for non-CLI callers. The
pattern follows cmd/internal/pollflags, which does the same for durations.
--output-format is implemented as a shared flag strategy:
cmd/root.goregisters it once as a persistent root flag viaoutputformat.AddPersistentFlag(...)- The root flag is inherited by all subcommands, so no per-command registration is needed
- Commands that render text/JSON read the effective value via
outputformat.GetFormat(cmd)to resolve format from CLI flags or environment variables
Use this pattern for output-aware commands:
import "github.com/datarobot/cli/internal/outputformat"
func Cmd() *cobra.Command {
cmd := &cobra.Command{
Use: "list",
RunE: func(cmd *cobra.Command, _ []string) error {
// --output-format is a global persistent flag; no local AddFlag needed.
format := outputformat.GetFormat(cmd)
return render(format)
},
}
return cmd
}Why GetFormat(cmd) instead of reading the variable directly:
- It correctly handles local flag usage (
dr <cmd> --output-format json) - It correctly handles inherited root usage (
dr --output-format json <cmd>) - It respects environment variable overrides (
DATAROBOT_CLI_OUTPUT_FORMAT)
When outputting JSON, use outputformat.PrintJSONEnvelope to wrap data in a consistent envelope:
if format == outputformat.OutputFormatJSON {
outputs := make([]MyOutput, len(items))
for i, item := range items {
outputs[i] = MyOutput{/* ... */}
}
return outputformat.PrintJSONEnvelope(os.Stdout, "items", outputs)
}The envelope format is {"<key>": <data>}, which ensures output is always a JSON object (never a bare array). This makes the output forward-compatible for adding metadata, pagination, or warnings without breaking callers.
When you need top-level metadata siblings alongside the primary data key (for example, a source field naming where data came from), use outputformat.PrintJSONEnvelopeWithMeta:
return outputformat.PrintJSONEnvelopeWithMeta(cmd.OutOrStdout(), "items", outputs,
map[string]any{"source": "drconfig.yaml"},
)This yields {"items": <data>, "source": "drconfig.yaml"}. It is otherwise identical to PrintJSONEnvelope; a metadata key that collides with the primary data key is not overwritten.
When the effective format is JSON, stdout must contain only valid JSON. Anything that would break dr <cmd> --output-format json | jq . (or ... 2>&1 | jq .) is a bug.
All non-JSON diagnostics belong on stderr, never stdout:
- Cobra deprecation warnings (e.g. from
MarkDeprecated/ the legacy-o/--formatshorthand) - Usage/help printed on error
- Log lines, warnings, progress messages, update hints
Concretely:
- Write JSON via
fmt.Fprintln(cmd.OutOrStdout(), payload)oroutputformat.PrintJSONEnvelope(os.Stdout, ...)— never mix in non-JSONfmt.Fprintln(cmd.OutOrStdout(), ...)calls on the JSON path. - Return errors from
RunEso cobra routes them to stderr; do not print errors to stdout. - If you need to emit a deprecation/notice alongside JSON output, write it to
cmd.ErrOrStderr().
Some root flags must be forwarded to plugin subprocesses as DATAROBOT_CLI_* environment variables so plugins can honour them (e.g. --debug → DATAROBOT_CLI_DEBUG=1). These are called universal flags.
Separation of concerns is strict:
| Layer | Responsibility |
|---|---|
cmd/root.go |
Declares which flags are universal and what env var suffix they map to |
internal/plugin |
Reads the annotations and injects env vars when launching a subprocess |
The two sides share only one thing: the annotation key constant plugin.UniversalAnnotationKey = "plugin-universal".
Call bindUniversal in the universal flags block in cmd/root.go — that is the only change required:
// cmd/root.go — universal flags block
bindUniversal("debug")
bindUniversal("disable-telemetry")
bindUniversal("my-new-flag") // ← one line, donebindUniversal does three things at once:
- Looks up the already-registered
*pflag.Flag - Binds it to viper (same as a plain
viperx.BindPFlagcall) - Sets
flag.Annotations["plugin-universal"] = []string{"MY_NEW_FLAG"}— the suffix is derived automatically from the flag name (uppercased, hyphens → underscores)
internal/plugin discovers annotated flags automatically when it builds the subprocess environment — no edits needed there.
cmd/plugin.RegisterPluginCommands passes the root command's persistent flagset to internal/plugin.SetRootFlags. From that point universalFlagEnv() (called inside buildPluginEnv) walks the flagset, finds any flag carrying a plugin-universal annotation, and emits DATAROBOT_CLI_<SUFFIX>=<value> into the subprocess environment.
- Do not add a new flag name to
universalFlagEnvinexec.go— the annotation on the flag drives discovery automatically. - Do not call
internalPlugin.SetRootFlagsfromcmd/root.go— that call lives insideRegisterPluginCommands.
Use Cobra's flag group markers to enforce constraints on flag combinations. This provides better UX and clearer error messages.
Prevent users from using incompatible flags together:
// Only one of these flags can be used
cmd.MarkFlagsMutuallyExclusive("list", "versions", "version")When multiple flags are used together, users get a clear error:
Error: if any flags in the group [list versions version] are set none of the others can be; list version were all set
Use cases:
- Different operation modes (e.g.,
--listall vs--versionsfor specific) - Conflicting output levels (e.g.,
--silentvs--verbose) - Incompatible actions (e.g.,
--parallelvs--watch)
If any flag in a group is used, all must be used:
// If --name is used, --version, --url, --sha256, and --release-date must also be used
cmd.MarkFlagsRequiredTogether("name", "version", "url", "sha256", "release-date")Use cases:
- Flags that form a complete set of parameters (e.g., all fields required for a record)
- Flags that depend on each other for validity
At least one flag from a group must be provided:
// User must provide either --output or --stdout
cmd.MarkFlagsOneRequired("output", "stdout")Use cases:
- Required operation modes where user must choose one
- Output destination selection
You can combine multiple constraints on the same flags:
// User must pick ONE of these approaches
cmd.MarkFlagsRequiredTogether("name", "version", "url", "sha256", "release-date")
cmd.MarkFlagsMutuallyExclusive("from-file", "name")
cmd.MarkFlagsMutuallyExclusive("from-file", "version")
cmd.MarkFlagsMutuallyExclusive("from-file", "url")
cmd.MarkFlagsMutuallyExclusive("from-file", "sha256")
cmd.MarkFlagsMutuallyExclusive("from-file", "release-date")This pattern means:
- Either use
--from-filealone - Or use all five manual flags together
- But never mix them
See the Cobra Command documentation for complete API reference.
-
Use singular names —
template,dependency,plugin(nottemplates,dependencies,plugins)- Plural aliases are acceptable for backward compatibility
-
Use lowercase with hyphens —
--my-flag(not--myFlagor--my_flag) -
Provide both short and long forms when appropriate:
cmd.Flags().BoolVarP(&force, "force", "f", false, "Force operation")
-
Be descriptive — Flag descriptions should explain the purpose and any side effects
-
Document defaults — If a flag has a non-obvious default value, mention it in the description
// Incompatible operations
cmd.MarkFlagsMutuallyExclusive("parallel", "watch")Rationale: Cannot run multiple tasks in parallel while watching files for changes.
// Different operation modes
cmd.MarkFlagsMutuallyExclusive("list", "versions", "version")Rationale:
--listshows all available plugins--versionsshows available versions for one plugin--versionspecifies an exact version to install
These are fundamentally different operations that can't coexist.
// Manual flags must be used together
cmd.MarkFlagsRequiredTogether("name", "version", "url", "sha256", "release-date")
// But mutually exclusive with file-based approach
cmd.MarkFlagsMutuallyExclusive("from-file", "name")
cmd.MarkFlagsMutuallyExclusive("from-file", "version")
cmd.MarkFlagsMutuallyExclusive("from-file", "url")
cmd.MarkFlagsMutuallyExclusive("from-file", "sha256")
cmd.MarkFlagsMutuallyExclusive("from-file", "release-date")Rationale: Users can either:
- Load all plugin metadata from a JSON file (
--from-file) - Specify all fields manually (requires all five flags together)
-
Add constraints at flag definition time — Mark flag groups before returning the command:
func Cmd() *cobra.Command { cmd := &cobra.Command{ /* ... */ } // Define flags cmd.Flags().BoolVar(...) cmd.Flags().StringVar(...) // Mark constraints AFTER all flags are defined cmd.MarkFlagsMutuallyExclusive("flag1", "flag2") return cmd }
-
Consider shell completion — Cobra automatically hides mutually exclusive flags from completion once one is selected, improving UX.
-
Write clear descriptions — Help users understand why flags are incompatible:
cmd.Flags().BoolVar(¶llel, "parallel", false, "Run tasks in parallel (cannot be used with --watch)") cmd.Flags().BoolVar(&watch, "watch", false, "Watch files and re-run (cannot be used with --parallel)")
-
Test flag combinations — Verify that your constraints work as expected:
# Should error: incompatible flags dr mycommand --flag1 --flag2 # Should work: one flag only dr mycommand --flag1
The CLI deliberately limits which flags are bound to viper. Subcommand
flags (such as --yes, --all, --if-needed) must not be bound via
viperx.BindPFlag, and cmd/root.go does not bulk-bind subcommand flags
either. Doing so would slurp those flag values into viper.AllSettings()
and risk persisting them to drconfig.yaml on the next config write.
Outside internal/config/..., all viper interaction goes through the
internal/config/viperx wrapper, which omits WriteConfig,
SafeWriteConfig, and BindPFlags by design. Direct
github.com/spf13/viper imports are blocked by depguard.
Quick rules for new flags:
-
Transient flags (per-invocation): read directly via
cmd.Flags().GetBool(...)(for examplecli.YesFlagName). Do not bind to viper. -
Env-var override needed? Register only the env var with
viperx.BindEnv(key, "DATAROBOT_CLI_…")and merge the sources with the shared helper:_ = viperx.BindEnv(cli.YesFlagName, "DATAROBOT_CLI_NON_INTERACTIVE") nonInteractive := cli.IsNonInteractive(cmd)
-
Sticky CLI preferences (rare): bind via
viperx.BindPFlagand add the key toconfig.PersistableKeysininternal/config/write.go.
For full details and test patterns, see the Configuration guide.
- Cobra documentation
- Building guide — General development setup and standards
- Configuration guide — viper, drconfig.yaml, viperx, persisted keys