Documents
CLI Flag Wiring Patterns
CLI Flag Wiring Patterns
Type
Topic
Status
Published
Created
Mar 18, 2026
Updated
Apr 19, 2026
Created by
Dosu Bot
Updated by
Dosu Bot

CLI Flag Wiring Patterns#

CLI flag wiring patterns refer to the architectural approach for propagating command-line interface (CLI) flags through an application's execution stack. In the context of the opnDossier project, these patterns define how user-provided command-line arguments flow from flag parsing through intermediate data structures to the components that ultimately use them to control program behavior. The primary goal of these patterns is to prevent silent flag ignores, a class of bugs where the application correctly parses a flag but fails to apply its intended effect, leaving users with incorrect output and no indication that their input was disregarded.

The importance of proper CLI flag wiring became evident through Issue #412, which documented a critical bug in the opnDossier project: the --include-tunables flag was parsed successfully but had no effect on the generated output. Investigation revealed two root causes: first, the display command never wired the sharedIncludeTunables variable into the converter.Options struct; second, the convert command stored the flag value in an untyped CustomFields map that the builder layer never read. Users who specified --include-tunables received identical output to those who omitted the flag, with no error messages or warnings to indicate the problem.

The opnDossier project's response to this incident established a set of architectural principles and implementation patterns designed to prevent similar bugs. These patterns emphasize type safety through explicit struct fields, consistent flag wiring across all commands, comprehensive per-command testing, and avoiding generic data structures for render-affecting configuration. This article documents these patterns as a reference for software engineers working on CLI applications, particularly those built with the Cobra framework in Go.

The Problem: Silent Flag Ignores#

Case Study: Issue #412#

Issue #412 in the opnDossier project documents a representative example of a silent flag ignore bug. The --include-tunables flag, which should control whether system tunables (sysctl settings) appear in full detail or filtered to security-relevant items only, was correctly parsed by both the convert and display commands but produced no change in output. Users running the command with or without the flag received identical reports, creating confusion and eroding trust in the tool's behavior.

Investigation revealed two distinct architectural failures. First, the display command completely failed to wire the sharedIncludeTunables variable into the converter.Options struct that gets passed to the report generation layer. The flag was registered and parsed, but its value never left the command handler. Second, the convert command did capture the flag value, but stored it in the CustomFields map—an untyped map[string]any field on the Options struct. The builder layer that consumed these Options never read from CustomFields, so the flag value was effectively discarded during conversion.

Characteristics of Silent Failures#

Silent flag ignores are particularly insidious because they produce no observable errors. The application:

  • Compiles successfully with no type errors or warnings
  • Runs without panics or exceptions since no invalid operations occur
  • Produces plausible output that looks correct at first glance
  • Provides no feedback that user intent was ignored

This lack of failure signals makes debugging extremely difficult. Developers must trace the flag value through the entire call chain—from the command-line parser through multiple abstraction layers to the final consumer—to discover where the value is lost. Users, meanwhile, have no indication that their flags are being ignored and may unknowingly base decisions on incorrect output.

Architecture Overview#

The Flag Propagation Pipeline#

The opnDossier project implements a four-stage pipeline for flag propagation, designed to maintain clear separation of concerns while ensuring flags reliably reach their consumers:

  1. Flag Definition: Shared flags are defined as package-level variables in cmd/shared_flags.go, such as sharedIncludeTunables bool, sharedComprehensive bool, and sharedRedact bool. These variables use package-level scope because the Cobra framework requires persistent storage for flag binding during initialization.

  2. Options Construction: Command handlers transform flag variables into a typed converter.Options struct using dedicated builder functions like buildConversionOptions() and buildDisplayOptions(). These functions implement precedence logic: CLI flags override configuration file values, which override defaults.

  3. Converter Layer: The Options struct is passed to the converter/generator layer, which uses the flag values to control report generation behavior, such as selecting output formats and determining which sections to include.

  4. Builder Consumption: The builder layer receives device data and uses the configuration from Options to generate the final output, applying filters, formatting rules, and conditional inclusions based on flag values.

Precedence Hierarchy#

Configuration values follow a strict precedence order: CLI flags > config file > defaults. This hierarchy is implemented consistently across all commands through conditional logic in the Options builder functions:

// Sections: CLI flag > config > default
if len(sharedSections) > 0 {
    opt.Sections = sharedSections
} else if cfg != nil && len(cfg.GetSections()) > 0 {
    opt.Sections = cfg.GetSections()
}

This pattern ensures that user-provided flags always take precedence, allowing command-line invocations to override persistent configuration when needed.

Best Practices#

Typed Options Fields#

The converter.Options struct uses explicit typed fields rather than generic map structures for all configuration values that affect output generation. This design choice provides several critical benefits:

  • Compile-time safety: The Go compiler verifies that all fields are correctly typed, preventing assignment errors
  • IDE support: Autocomplete and refactoring tools can discover and suggest available options
  • Documentation: Field types serve as self-documenting API contracts
  • Maintainability: Refactoring operations can safely rename or restructure fields across the codebase

Example of the correct pattern:

type Options struct {
    Format Format // typed enum - not string
    Comprehensive bool // typed bool - not interface{}
    Redact bool // typed bool - not map entry
    IncludeTunables bool // typed bool - prevents silent ignores
}

This approach stands in contrast to storing configuration in untyped maps (map[string]any), which sacrifice type safety for flexibility—a trade-off that enables silent flag ignores.

Dedicated Options Builder Functions#

The opnDossier project centralizes flag-to-Options wiring in dedicated functions like buildConversionOptions() and buildDisplayOptions(). These functions:

  • Centralize the mapping between flag variables and Options fields, making the wiring explicit and reviewable
  • Implement precedence logic consistently across all commands
  • Provide a single location to update when adding new flags

This pattern prevents the scenario where flags are wired in some commands but forgotten in others, as occurred with the --include-tunables flag in Issue #412.

Fluent Builder Methods#

The Options struct provides fluent builder methods like WithFormat(), WithTheme(), and WithWrapWidth() for programmatic option construction. These methods:

  • Enable method chaining for concise configuration: opts.WithFormat(fmt).WithRedact(true)
  • Return modified copies of Options structs, supporting immutable configuration patterns
  • Provide a clear, readable API for test code and internal utilities

Early Validation with PreRunE Hooks#

Flag validation should occur before command execution, using Cobra's PreRunE hooks. This approach:

  • Separates validation logic from business logic, maintaining clean separation of concerns
  • Fails fast with clear error messages when invalid flag combinations are detected
  • Prevents wasted computation on commands that cannot succeed due to invalid input

Early validation ensures users receive immediate feedback about configuration problems rather than encountering cryptic errors deep in the execution path.

Format Validation via Registry#

The --format flag validation delegates to converter.DefaultRegistry, which serves as the single source of truth for supported output formats. This architectural pattern:

  • Centralizes format metadata in the converter package rather than duplicating format lists across cmd and converter layers
  • Automatically updates validation when new formats are registered—adding a format to the registry immediately enables it in CLI validation
  • Provides consistent shell completions by deriving format names from DefaultRegistry.ValidFormats() in cmd/shared_flags.go
  • Eliminates constant duplication—format names, aliases, and file extensions are defined once in handler implementations

Example validation pattern:

// Validate format values via the converter registry
if format != "" {
    validFormats := converter.DefaultRegistry.ValidFormatsWithAliases()
    if !slices.Contains(validFormats, strings.ToLower(format)) {
        return fmt.Errorf("invalid format %q, must be one of: %s", 
            format, strings.Join(validFormats, ", "))
    }
}

This delegation reduces coupling between the CLI and converter layers—the cmd package no longer needs to know which formats exist, only how to query the registry.

Anti-Patterns to Avoid#

CustomFields Generic Map Storage#

The current opnDossier codebase includes an example of this anti-pattern: storing the IncludeTunables flag value in the CustomFields map:

opt.CustomFields["IncludeTunables"] = sharedIncludeTunables // ANTI-PATTERN

This approach creates multiple problems:

  • No compile-time type checking: Typos in field names compile successfully but fail silently at runtime
  • Invisible to consumers: The builder layer has no way to discover what options are available in CustomFields
  • No IDE support: Autocomplete cannot suggest CustomFields keys, and refactoring tools cannot track usage
  • Easy to ignore: Developers implementing builders may not know to check CustomFields for relevant configuration

The solution, as outlined in the Issue #412 fix plan, is to promote such values to first-class typed fields on the Options struct, making them visible to both the compiler and consuming code.

Incomplete Flag Wiring Across Commands#

Issue #412 revealed that the display command never wired the sharedIncludeTunables flag into its Options struct, despite the flag being registered with the command. This created a scenario where the flag appeared in help text and was parsed without errors, but had no effect—a textbook silent failure.

This anti-pattern occurs when:

  • A flag is registered with multiple commands but only wired in some handlers
  • Developers copy-paste command handlers without updating all flag assignments
  • Shared flags are added without updating all affected commands

Prevention strategy: Maintain a checklist when adding shared flags, and verify that each flag is wired in all relevant command handlers. Consider using helper functions that centralize flag wiring to reduce the surface area for this class of error.

Passing Individual Flags as Parameters#

Passing flag values as individual function parameters, rather than as a structured Options object, increases the risk of accidentally dropping flags:

// WRONG - easy to accidentally omit parameters
result := generateReport(config, sharedFormat, sharedRedact)
// IncludeTunables flag forgotten!

// CORRECT - Options object makes all flags visible
opts := converter.Options{
    Format: sharedFormat,
    Redact: sharedRedact,
    IncludeTunables: sharedIncludeTunables,
}
result := generateReport(config, opts)

The Options object pattern ensures all configuration is packaged together, making it impossible to forget individual flags in function signatures.

Builder Directly Reading Flag Variables#

Builders and other lower-level components should receive configuration through Options structs, not by directly accessing package-level flag variables. The opnDossier architecture maintains this separation: the builder works exclusively with device data and receives configuration from the converter layer, which mediates between Options and builder behavior.

This separation of concerns:

  • Makes testing easier by eliminating global state dependencies
  • Allows builders to be reused with different configurations
  • Creates clear boundaries between architectural layers

Testing Strategies#

Per-Command Flag Propagation Tests#

The Issue #412 solution explicitly requires regression tests to verify that flags are correctly wired in each command. These tests should verify multiple aspects of flag propagation:

  1. Flag registration: Verify the flag is registered with the command's flag set
  2. Options population: Verify the flag value correctly flows into the Options struct
  3. Consumption: Verify the builder or converter actually uses the Options field
  4. Output effect: Verify the flag produces the expected change in output

Example test structure:

func TestConvertFlagWiring(t *testing.T) {
    tests := []struct {
        name string
        flagValue bool
        expectedInOpts bool
    }{
        {"flag true", true, true},
        {"flag false", false, false},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            sharedIncludeTunables = tt.flagValue
            opts := buildConversionOptions("markdown", nil)
            require.Equal(t, tt.expectedInOpts, opts.IncludeTunables,
                "flag value did not flow correctly to Options")
        })
    }
}

These tests should be duplicated for each command that accepts the shared flag, ensuring that the display, convert, and any other relevant commands all wire flags correctly.

Format Validation Testing#

Format flag validation should verify that the registry-based lookup pattern correctly accepts canonical format names and aliases while rejecting invalid values:

func TestFormatValidation(t *testing.T) {
    tests := []struct {
        name string
        format string
        shouldErr bool
    }{
        {"canonical markdown", "markdown", false},
        {"alias md", "md", false},
        {"canonical json", "json", false},
        {"alias yml", "yml", false},
        {"invalid format", "invalid", true},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            validFormats := converter.DefaultRegistry.ValidFormatsWithAliases()
            isValid := slices.Contains(validFormats, strings.ToLower(tt.format))
            if tt.shouldErr {
                require.False(t, isValid, "expected format to be invalid")
            } else {
                require.True(t, isValid, "expected format to be valid")
            }
        })
    }
}

This pattern ensures the CLI validation logic stays synchronized with the registry's supported formats.

Integration Tests#

End-to-end integration tests verify that flags affect output as expected, not just that they propagate through intermediate data structures:

func TestIncludeTunablesIntegration(t *testing.T) {
    config := loadTestConfig(t)

    // Generate report WITHOUT flag
    opts1 := converter.Options{IncludeTunables: false}
    output1 := generateMarkdown(config, opts1)

    // Generate report WITH flag
    opts2 := converter.Options{IncludeTunables: true}
    output2 := generateMarkdown(config, opts2)

    // Verify flag affects output
    require.NotEqual(t, output1, output2,
        "IncludeTunables flag produced no change in output")
    require.Contains(t, output2, "tunables",
        "flag did not include expected content")
}

Integration tests provide the strongest guarantee that flags work end-to-end, catching issues that might be missed by unit tests focused on individual layers.

Negative Tests for Silent Ignores#

Tests should explicitly verify that silent ignore scenarios are prevented:

func TestOptionsFieldsAreUsed(t *testing.T) {
    // Verify all Options fields are actually read somewhere
    // This could use reflection or code analysis tools to ensure
    // no Options fields exist that are never consumed
}

func TestCustomFieldsDocumented(t *testing.T) {
    // If CustomFields is used, verify each entry is documented
    // and there's a migration plan to typed fields
}

These tests create a safety net against the gradual accumulation of ignored configuration values.

Implementation Checklist#

Based on the implementation plan detailed in Issue #412, developers adding new CLI flags to opnDossier should follow this comprehensive checklist:

1. Define Typed Field in Options Struct#

  • Add a new field to internal/converter/options.go
  • Use the most specific type possible (bool, custom enum, etc.)
  • Add JSON struct tags for serialization if the option will be saved to configuration files
  • Document the field's purpose and behavior with a comment

2. Register Flag Variable#

For shared flags (used by multiple commands):

  • Add a package-level variable to cmd/shared_flags.go
  • Bind the variable using BoolVar, StringVar, or the appropriate variant in an init() function
  • Provide clear help text describing the flag's effect
  • Use consistent naming: shared prefix for shared flags

For command-specific flags (used by a single command):

  • Register the flag directly on the specific command in its init() function
  • Use a descriptive name without the shared prefix (e.g., auditMode, validateJSONOutput)
  • Avoids polluting the global flag namespace with flags that only one command uses

3. Wire Flag to All Relevant Commands#

  • For shared flags: Update all affected command option builders
  • For command-specific flags: Wire only in the single relevant command's option builder
  • Implement precedence logic: CLI flag > config file > default

4. Update Builder/Converter to Use Flag#

  • Reference the typed field directly: opts.FieldName
  • Never read from CustomFields for render-affecting configuration
  • Implement the conditional logic that applies the flag's behavior
  • Verify through code review that the builder actually consumes the field

5. Add Comprehensive Tests#

  • Unit test: Verify Options struct correctly receives flag value
  • Per-command test: Verify each command that uses the flag wires it correctly
    • For shared flags: test each affected command (convert, display, etc.)
    • For command-specific flags: test the single command that uses it
  • Integration test: Verify flag affects final output as expected
  • Negative test: Verify omitting the flag produces different behavior
  • Flag presence test: Verify the flag appears (or doesn't appear) on the correct commands

6. Update Documentation#

  • Add the flag to CLI reference documentation
  • Document the flag's effect on different output formats (markdown, JSON, HTML, etc.)
  • Update configuration file documentation if the flag can be set persistently
  • Add migration notes if the flag changes existing behavior

This systematic approach prevents the dual failure modes observed in Issue #412: forgetting to wire a flag in some commands, and using untyped storage that consumers ignore.

When to Use Command-Specific vs. Shared Flags#

The choice between registering a flag as shared (persistent or added to multiple commands) versus command-specific affects maintainability and user experience:

Use command-specific flags when:

  • Only one command uses the flag (e.g., --json-output on validate only)
  • The flag's behavior is meaningless or irrelevant to other commands
  • Registering the flag globally would create confusion in help text

Use shared flags when:

  • Multiple commands genuinely need the same configuration (e.g., --format, --wrap-width)
  • The flag represents a cross-cutting concern (e.g., output styling, verbosity)
  • Consistent behavior across commands improves user experience

Example: --json-output flag evolution (Issue #479)

The --json-output flag was initially registered as a persistent root flag, making it available to all commands. However, only the validate command actually used it to format validation errors as JSON. This created two problems:

  1. Misleading help text: The flag appeared in help output for display, convert, audit, and other commands where it had no effect
  2. Silent ignore: Users who specified --json-output with non-validate commands received no indication that the flag was being ignored

PR #516 fixed this by moving --json-output from cmd/root.go (persistent) to cmd/validate.go (local), scoping it to the only command that uses it. The fix included:

// cmd/root.go - Remove persistent flag registration
// OLD:
// rootCmd.PersistentFlags().
// Bool("json-output", false, "Output errors in JSON format (for machine consumption)")

// NEW:
// Note: --json-output is registered on validateCmd only (not here as persistent).
// It has no effect on other commands. See issue #479, GOTCHAS.md §5.1.

// cmd/validate.go - Add as local flag
func init() {
    rootCmd.AddCommand(validateCmd)

    // --json-output is validate-specific: outputs validation errors as JSON for machine consumption.
    // Scoped here (not on rootCmd) so it only appears on commands that act on it (issue #479).
    validateCmd.Flags().Bool("json-output", false, "Output errors in JSON format (for machine consumption)")
    setFlagAnnotation(validateCmd.Flags(), "json-output", []string{"output"})
}

This demonstrates that proper flag scoping prevents silent ignores at the CLI level, complementing the Options struct patterns that prevent silent ignores at the wiring level. When a flag is genuinely single-use, registering it only on the relevant command provides clearer semantics than registering it globally and rejecting it in other commands' PreRunE hooks.

Usage and Examples#

Example: Correct Flag Wiring Pattern#

The buildConversionOptions() function in cmd/convert.go demonstrates the correct pattern for wiring CLI flags into the Options struct:

func buildConversionOptions(format string, cfg *config.Config) converter.Options {
    opt := converter.DefaultOptions()

    // Direct assignment to typed fields
    opt.Comprehensive = sharedComprehensive
    opt.Redact = sharedRedact

    // Precedence logic: CLI > config > default
    if len(sharedSections) > 0 {
        opt.Sections = sharedSections
    } else if cfg != nil && len(cfg.GetSections()) > 0 {
        opt.Sections = cfg.GetSections()
    }

    // Wrap width with three-level precedence
    switch {
    case sharedWrapWidth >= 0:
        opt.WrapWidth = sharedWrapWidth
    case cfg != nil && cfg.GetWrapWidth() >= 0:
        opt.WrapWidth = cfg.GetWrapWidth()
    default:
        opt.WrapWidth = -1
    }

    return opt
}

This pattern centralizes all flag wiring logic in a single function, making it easy to audit and update when new flags are added.

Example: Before and After Issue #412#

The following comparison illustrates the transformation from anti-pattern to correct implementation:

Before (Incorrect):

// cmd/convert.go - Using untyped CustomFields
opt.CustomFields["IncludeTunables"] = sharedIncludeTunables

// cmd/display.go - Flag not wired at all
// (IncludeTunables assignment missing entirely)

// internal/converter/builder/builder.go
// Builder has no way to access the flag value
// CustomFields is never read

After (Correct):

// internal/converter/options.go - Add typed field
type Options struct {
    Format Format
    Comprehensive bool
    Redact bool
    IncludeTunables bool // Explicit typed field
    // ...
}

// cmd/convert.go - Wire to typed field
opt.IncludeTunables = sharedIncludeTunables

// cmd/display.go - Wire in all commands
opt.IncludeTunables = sharedIncludeTunables

// Builder can now reliably access opts.IncludeTunables

This transformation follows the fix plan outlined in Issue #412, promoting the flag from untyped storage to a first-class field.

Example: Test for Flag Propagation#

Comprehensive testing should verify flag propagation through each command:

func TestConvertFlagWiring(t *testing.T) {
    tests := []struct {
        name string
        flagValue bool
        expectedInOpts bool
    }{
        {"flag enabled", true, true},
        {"flag disabled", false, false},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            // Set the shared flag variable
            sharedIncludeTunables = tt.flagValue

            // Build Options using the command's builder function
            opts := buildConversionOptions("markdown", nil)

            // Verify flag value propagated correctly
            require.Equal(t, tt.expectedInOpts, opts.IncludeTunables,
                "IncludeTunables flag did not propagate to Options")
        })
    }
}

This test pattern should be replicated for every command that uses shared flags, creating a comprehensive regression test suite that catches wiring failures.

Example: Audit Command - Separate and Shared Flags#

The audit command (PR #454) demonstrates a pattern for combining command-specific flags with shared output flags. This separation allows audit-specific configuration (mode, plugins, plugin directory) to be isolated while reusing well-established output and formatting flags.

Audit-Specific Flags (cmd/audit.go):

// Package-level flag variables for the audit command
var (
    auditMode string // audit reporting mode
    auditBlackhat bool // blackhat commentary toggle
    auditPlugins []string // selected compliance plugins
    auditPluginDir string // dynamic plugin directory
)

func init() {
    rootCmd.AddCommand(auditCmd)

    // Audit-specific flags (shorter names since this is the dedicated audit command)
    auditCmd.Flags().StringVar(&auditMode, "mode", "blue", 
        "Audit mode (blue|red)")
    auditCmd.Flags().BoolVar(&auditBlackhat, "blackhat", false, 
        "Enable blackhat commentary for red team reports")
    auditCmd.Flags().StringSliceVar(&auditPlugins, "plugins", []string{}, 
        "Compliance plugins to run (stig,sans,firewall)")
    auditCmd.Flags().StringVar(&auditPluginDir, "plugin-dir", "", 
        "Directory containing dynamic .so compliance plugins")

    // Output and format flags (reuse existing package-level variables)
    auditCmd.Flags().StringVarP(&format, "format", "f", "markdown", 
        "Output format for audit report (markdown, json, yaml, text, html)")
    auditCmd.Flags().StringVarP(&outputFile, "output", "o", "", 
        "Output file path for saving audit report (default: print to console)")

    // Add shared styling and content flags
    addSharedTemplateFlags(auditCmd)
    addSharedRedactFlag(auditCmd)
}

Options Construction (cmd/audit.go):

// Build conversion options with precedence: CLI flags > env vars > config > defaults
eff := buildEffectiveFormat(format, cmdConfig)
opt := buildConversionOptions(eff, cmdConfig)

// Build audit options from audit-specific flag variables (not shared globals)
auditOpts := audit.Options{
    AuditMode: auditMode,
    BlackhatMode: auditBlackhat,
    SelectedPlugins: auditPlugins,
}

if auditPluginDir != "" {
    auditOpts.PluginDir = auditPluginDir
    auditOpts.ExplicitPluginDir = true
}

This pattern demonstrates several key benefits:

  1. Clear separation: Audit-specific configuration uses dedicated package-level variables (auditMode, auditPlugins) while output configuration reuses shared variables (format, outputFile)
  2. Namespace clarity: The audit prefix on flag variables makes ownership obvious in the codebase
  3. Reuse of shared logic: The buildConversionOptions() function handles precedence for output flags consistently across commands
  4. Independent testing: Audit-specific flags can be tested in isolation from shared output flags

Example: Shared Validation with validateOutputFlags()#

The validateOutputFlags() function (PR #454) centralizes validation logic for output-related flags (format, wrap, section) that are shared across multiple commands. This pattern reduces duplication and ensures consistent validation behavior.

Validation Function (cmd/shared_flags.go):

// validateOutputFlags validates format, wrap, and section flag combinations that are
// shared across multiple commands (convert, audit). It checks mutual exclusivity of
// wrap flags, validates the output format against the converter registry, warns when
// section filtering is used with JSON or YAML, and validates wrap width range.
func validateOutputFlags(flags *pflag.FlagSet, cmdLogger *logging.Logger) error {
    // Validate mutual exclusivity for wrap flags
    if flags != nil {
        noWrapFlag := flags.Lookup("no-wrap")
        wrapFlag := flags.Lookup("wrap")
        if noWrapFlag != nil && wrapFlag != nil && noWrapFlag.Changed && wrapFlag.Changed {
            return errors.New("--no-wrap and --wrap flags are mutually exclusive")
        }
    }

    // Validate format values via the converter registry
    if format != "" {
        validFormats := converter.DefaultRegistry.ValidFormatsWithAliases()
        if !slices.Contains(validFormats, strings.ToLower(format)) {
            return fmt.Errorf("invalid format %q, must be one of: %s", 
                format, strings.Join(validFormats, ", "))
        }
    }

    // Warn when section filtering is used with JSON or YAML
    if strings.EqualFold(format, "json") && len(sharedSections) > 0 {
        if cmdLogger != nil {
            cmdLogger.Warn("section filtering not supported with JSON format, sections will be ignored")
        } else {
            fmt.Fprintln(os.Stderr, "Warning: section filtering not supported with JSON format, sections will be ignored")
        }
    }

    // Validate wrap width range
    if sharedWrapWidth < -1 {
        return fmt.Errorf("invalid wrap width %d: must be -1 (auto-detect), 0 (no wrapping), or positive",
            sharedWrapWidth)
    }

    return nil
}

Usage in Convert Command (cmd/convert.go):

func validateConvertFlags(flags *pflag.FlagSet, cmdLogger *logging.Logger) error {
    // Validate format, wrap, and section flags (shared across convert and audit)
    if err := validateOutputFlags(flags, cmdLogger); err != nil {
        return err
    }

    // Validate audit mode if provided (convert-specific: uses shared globals)
    if sharedAuditMode != "" {
        validModes := []string{"blue", "red"}
        if !slices.Contains(validModes, strings.ToLower(sharedAuditMode)) {
            return fmt.Errorf("invalid audit mode %q, must be one of: %s", 
                sharedAuditMode, strings.Join(validModes, ", "))
        }
    }

    // Additional convert-specific validation...
    return nil
}

Usage in Audit Command (cmd/audit.go):

PreRunE: func(cmd *cobra.Command, args []string) error {
    // Validate audit mode
    validModes := []string{"blue", "red"}
    if !slices.Contains(validModes, strings.ToLower(auditMode)) {
        return fmt.Errorf("invalid audit mode %q, must be one of: %s",
            auditMode, strings.Join(validModes, ", "))
    }

    // Reject --plugins when the selected mode does not execute compliance checks
    if len(auditPlugins) > 0 && !strings.EqualFold(auditMode, "blue") {
        return fmt.Errorf("--plugins is only supported with --mode blue; %q mode does not run compliance checks",
            auditMode)
    }

    // Reject --output with multiple input files to prevent output clobbering
    if outputFile != "" && len(args) > 1 {
        return errors.New(
            "--output cannot be used with multiple input files; omit --output to auto-name each report as <input>-audit.<ext>",
        )
    }

    // Validate format/wrap flag combinations (shared output flags only)
    if err := validateOutputFlags(cmd.Flags(), cmdLogger); err != nil {
        return fmt.Errorf("audit command validation failed: %w", err)
    }

    return nil
}

This pattern demonstrates several key benefits:

  1. Centralized validation: Format, wrap, and section validation logic is defined once in validateOutputFlags() and reused by both convert and audit commands
  2. Consistent error messages: All commands report identical validation errors for shared flags, improving user experience
  3. Command-specific extensions: Each command's PreRunE can perform additional validation specific to that command (e.g., audit validates --plugins only with --mode blue)
  4. Clear separation: The function accepts a logger parameter for structured warnings, maintaining proper separation between validation logic and logging concerns

Example: Conditional Validation with --failures-only Flag (PR #495)#

PR #495 demonstrates the CLI flag wiring pattern for the --failures-only flag, which filters blue mode compliance tables to show only failing controls. This implementation exemplifies conditional validation—a flag that is only valid with specific mode and format combinations.

Flag Definition (cmd/audit.go):

var auditFailuresOnly bool

func init() {
    auditCmd.Flags().
        BoolVar(&auditFailuresOnly, "failures-only", false, 
            "Show only failing controls in blue mode plugin results tables")
    setFlagAnnotation(auditCmd.Flags(), "failures-only", []string{"audit"})
}

Options Struct (internal/audit/options.go):

type Options struct {
    // FailuresOnly filters the control results table to show only non-compliant
    // controls. Only meaningful in blue mode where compliance checks are executed.
    FailuresOnly bool
}

PreRunE Validation (cmd/audit.go):

PreRunE: func(cmd *cobra.Command, args []string) error {
    // Reject --failures-only when the selected mode does not execute compliance checks
    if auditFailuresOnly && !strings.EqualFold(auditMode, "blue") {
        return fmt.Errorf(
            "--failures-only is only supported with --mode blue; %q mode does not run compliance checks",
            auditMode,
        )
    }

    // Reject --failures-only with non-markdown formats — the flag only affects
    // the markdown plugin controls table. JSON/YAML consumers should filter
    // client-side to avoid information loss.
    if auditFailuresOnly && !strings.EqualFold(format, "markdown") {
        return fmt.Errorf(
            "--failures-only is only supported with --format markdown; %q format always includes all controls",
            format,
        )
    }

    return nil
}

Options Wiring (cmd/audit.go):

// Build audit options from audit-specific flag variables
auditOpts := audit.Options{
    AuditMode: auditMode,
    SelectedPlugins: auditPlugins,
    FailuresOnly: auditFailuresOnly,
}

Implementation (cmd/audit_handler.go):

func handleAuditMode(ctx context.Context, device *config.DeviceConfig, 
                     auditOpts audit.Options, opt converter.Options, logger *slog.Logger) (string, error) {
    // Thread audit-specific rendering options into converter options
    opt.FailuresOnly = auditOpts.FailuresOnly

    // Delegate to the shared generator pipeline
    return generateWithProgrammaticGenerator(ctx, device, opt, logger)
}

The builder layer consumes this option through the SetFailuresOnly method (internal/converter/builder/builder.go):

// SetFailuresOnly configures whether only non-compliant controls are shown in audit reports.
func (b *MarkdownBuilder) SetFailuresOnly(v bool) {
    b.failuresOnly = v
}

func (b *MarkdownBuilder) writePluginControlsTable(...) {
    for _, ctrl := range sortedControls {
        if b.failuresOnly && ctrl.Status == common.ControlStatusPass {
            continue // Filter out passing controls
        }
        // ... render row
    }
}

Test Coverage (cmd/audit_test.go):

func TestAuditCmdPreRunEFailuresOnlyRequiresBlueMode(t *testing.T) {
    tests := []struct {
        name string
        mode string
        format string
        failuresOnly bool
        wantErr bool
        errSubstr string
    }{
        {"failures-only with red rejected", "red", "markdown", true, true,
            "--failures-only is only supported with --mode blue"},
        {"failures-only with blue markdown accepted", "blue", "markdown", true, false, ""},
        {"failures-only with json rejected", "blue", "json", true, true,
            "--failures-only is only supported with --format markdown"},
        {"failures-only with yaml rejected", "blue", "yaml", true, true,
            "--failures-only is only supported with --format markdown"},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            // Set flags and invoke PreRunE
            err := auditCmd.PreRunE(tempCmd, []string{"dummy.xml"})
            if tt.wantErr {
                require.Error(t, err)
                assert.Contains(t, err.Error(), tt.errSubstr)
            } else {
                assert.NoError(t, err)
            }
        })
    }
}

This implementation demonstrates several key patterns:

  1. Conditional validation: The flag is only valid with --mode=blue and --format=markdown, preventing misuse with incompatible configurations
  2. Early rejection: PreRunE validation fails fast with clear error messages before any work is performed
  3. Type safety: Uses a typed FailuresOnly bool field rather than string-based mode detection
  4. Comprehensive testing: Verifies rejection of all invalid mode/format combinations
  5. Clear intent: The validation error messages explain why the flag is rejected, guiding users toward valid usage

Example: Correct Implementation of --plugin-dir Flag (Historical — PR #445)#

Note: The examples in this section reflect historical patterns from PR #445. Phase 7 changes (PR #589) have since removed sharedPluginDir as a dead global, removed buildAuditOptions(), and consolidated registry handling in audit.NewPluginManager(logger, reg). The patterns shown here remain instructive for understanding flag wiring architecture, but production code should reference the current implementation.

PR #445 demonstrates the complete CLI flag wiring pattern for the --plugin-dir flag, which specifies a directory containing dynamic .so compliance plugins. This implementation exemplifies all best practices:

Flag Definition (cmd/shared_flags.go):

var sharedPluginDir string

func addSharedAuditFlags(cmd *cobra.Command) {
    cmd.Flags().StringVar(&sharedPluginDir, "plugin-dir", "", 
        "Directory containing dynamic .so compliance plugins")
    setFlagAnnotation(cmd.Flags(), "plugin-dir", []string{"audit"})
}

Options Struct (internal/audit/options.go):

type Options struct {
    // PluginDir is the directory from which dynamic .so plugins are loaded.
    PluginDir string

    // ExplicitPluginDir indicates that PluginDir was explicitly configured
    // by the user (via CLI flag). When true and the directory does not exist,
    // LoadDynamicPlugins returns an error instead of silently continuing.
    ExplicitPluginDir bool
}

Options Builder (cmd/convert.go):

func buildAuditOptions() audit.Options {
    opts := audit.Options{
        AuditMode: sharedAuditMode,
        BlackhatMode: sharedBlackhatMode,
        SelectedPlugins: sharedSelectedPlugins,
    }

    // Plugin directory: CLI flag is the source. When set, mark as explicit
    // so that a missing directory produces a Warn-level log.
    if sharedPluginDir != "" {
        opts.PluginDir = sharedPluginDir
        opts.ExplicitPluginDir = true
    }

    return opts
}

Consumption (historical from PR #445):

func handleAuditMode(ctx context.Context, device *config.DeviceConfig, 
                     auditOpts audit.Options, logger *slog.Logger) (string, error) {
    // Phase 7 update: NewPluginManager signature changed to (logger, *PluginRegistry)
    pm := audit.NewPluginManager(logger, nil)

    // Configure dynamic plugin directory before initialization so that
    // LoadDynamicPlugins actually executes when the user provides a path.
    if auditOpts.PluginDir != "" {
        pm.SetPluginDir(auditOpts.PluginDir, auditOpts.ExplicitPluginDir)
    }

    if err := pm.InitializePlugins(ctx); err != nil {
        return "", fmt.Errorf("initialize plugins: %w", err)
    }

    // Surface any dynamic plugin load failures to the CLI user.
    if loadResult := pm.GetLoadResult(); loadResult.Failed() > 0 {
        failedNames := make([]string, len(loadResult.Failures))
        for i, f := range loadResult.Failures {
            failedNames[i] = f.Name
        }

        logger.Warn("Some dynamic plugins failed to load",
            "failed", loadResult.Failed(),
            "loaded", loadResult.Loaded,
            "files", strings.Join(failedNames, ", "),
        )
    }

    // ... continue with audit report generation
}

Test Coverage (cmd/audit_handler_test.go):

func TestBuildAuditOptions(t *testing.T) {
    tests := []struct {
        name string
        pluginDir string
        wantPluginDir string
        wantExplicitDir bool
    }{
        {
            name: "empty defaults",
            pluginDir: "",
            wantPluginDir: "",
            wantExplicitDir: false,
        },
        {
            name: "with plugin dir",
            pluginDir: "/path/to/plugins",
            wantPluginDir: "/path/to/plugins",
            wantExplicitDir: true,
        },
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            sharedPluginDir = tt.pluginDir
            result := buildAuditOptions()
            assert.Equal(t, tt.wantPluginDir, result.PluginDir)
            assert.Equal(t, tt.wantExplicitDir, result.ExplicitPluginDir)
        })
    }
}

Test Isolation (historical from PR #445, sharedPluginDir removed in Phase 7):

type sharedFlagSnapshot struct {
    // ... other flags
    // Phase 7: sharedPluginDir deleted from cmd/shared_flags.go as dead global
}

func captureSharedFlags() sharedFlagSnapshot {
    return sharedFlagSnapshot{
        // ... other fields
        // Phase 7: pluginDir field removed from snapshot
    }
}

func (s sharedFlagSnapshot) restore() {
    // ... restore other fields
    // Phase 7: sharedPluginDir restoration removed
}

This implementation demonstrates several key patterns:

  1. Dual-field approach: Uses both PluginDir (the value) and ExplicitPluginDir (whether it was user-provided) to distinguish user intent from defaults
  2. Explicit precedence: The conditional if sharedPluginDir != "" ensures the flag only sets ExplicitPluginDir when actually provided
  3. Configuration before use: SetPluginDir() is called before InitializePlugins(), ensuring the plugin manager has the directory configured when it needs it
  4. Comprehensive testing: Verifies both the flag value and the explicit flag in unit tests, ensuring the precedence logic is testable
  5. Test isolation: Adds the new flag to sharedFlagSnapshot for proper cleanup between test runs

Relevant Code Files#

The following files in the opnDossier repository implement the CLI flag wiring patterns described in this article:

FilePurposeKey Elements
cmd/shared_flags.goShared flag definitionsPackage-level flag variables (sharedIncludeTunables, sharedComprehensive, etc.); registration functions (addSharedContentFlags); validateOutputFlags() for centralized format/wrap/section validation; shell completion functions that query converter.DefaultRegistry. Note: sharedPluginDir, sharedAuditMode, and sharedSelectedPlugins were removed in Phase 7 as dead globals.
cmd/convert.goConvert commandbuildConversionOptions() function implementing flag-to-Options wiring for converter; normalizeFormat() delegating to registry for alias resolution; validateConvertFlags() delegating to validateOutputFlags(). Note: buildAuditOptions() was removed in Phase 7.
cmd/display.goDisplay commandbuildDisplayOptions() function mirroring convert command's wiring pattern
cmd/validate.goValidate commandCommand-specific --json-output flag (local to validate only, not inherited; see PR #516); validation logic that consumes the flag
cmd/audit.goAudit commandCommand-specific flag variables (auditMode, auditBlackhat, auditPlugins, auditPluginDir); reuses shared output flags (format, outputFile); generateAuditOutput() function building both converter.Options and audit.Options; PreRunE validation using validateOutputFlags() plus audit-specific constraints
internal/converter/options.goOptions struct definitionTyped fields for all configuration values; fluent builder methods (WithFormat, WithTheme, etc.); DefaultOptions() constructor; Format.Validate() delegating to DefaultRegistry.Get()
internal/converter/registry.goFormat registryFormatRegistry managing format handlers; DefaultRegistry singleton; FormatHandler interface defining format-specific generation and metadata; handler implementations for markdown, JSON, YAML, text, and HTML
internal/converter/builder/builder.goReport builder implementationReportBuilder interface; MarkdownBuilder struct; report generation methods that consume device data
internal/audit/options.goAudit options structTyped fields for audit configuration (AuditMode, BlackhatMode, PluginDir, ExplicitPluginDir); demonstrates dual-field pattern for distinguishing user-provided values from defaults
cmd/audit_handler.goAudit command handlerConsumes audit.Options; demonstrates proper ordering (configure plugin directory before InitializePlugins()); surfaces load failures via logger warnings

These files collectively implement the four-stage flag propagation pipeline, from flag definition through Options construction to builder consumption.

  • CLI Command Architecture: The opnDossier project uses the Cobra framework for CLI command structure, providing a foundation for flag parsing and command routing. Understanding Cobra's flag binding mechanisms is essential context for these patterns.

  • Configuration System: opnDossier implements a three-tier configuration hierarchy (CLI flags, config file, defaults) that requires careful coordination between flag wiring and persistent configuration. The patterns described here integrate with the broader configuration system.

  • Options Pattern: The Options struct follows the Go options pattern, providing a structured approach to configuring components with multiple optional parameters. This pattern contrasts with variadic parameters or functional options.

  • Builder Pattern: The report generation system uses the builder pattern to programmatically construct complex markdown, JSON, and HTML outputs. The separation between Options (configuration) and Builder (construction) demonstrates clear architectural layering.

  • Registry Pattern: The FormatRegistry centralizes format dispatch, replacing scattered switch statements with a single source of truth for format names, aliases, file extensions, and generation handlers. This architectural pattern reduces coupling between the CLI and converter layers while ensuring validation, shell completion, and generation logic stay synchronized.

  • Type Safety in Go: The emphasis on typed fields rather than generic maps reflects broader considerations about compile-time versus runtime safety in Go applications. These trade-offs affect maintainability and error detection.

  • Testing Strategies: Per-command flag propagation testing, integration testing, and negative testing for silent failures represent comprehensive quality assurance approaches applicable to any CLI application.

Summary#

The CLI flag wiring patterns documented in this article emerged from Issue #412, which exposed critical weaknesses in how the opnDossier project propagated command-line flags through its execution stack. The root causes—incomplete flag wiring across commands and reliance on untyped CustomFields storage—enabled silent flag ignores that undermined user trust in the tool's behavior.

The solution establishes clear architectural principles: use typed Options fields exclusively for render-affecting configuration, centralize flag wiring in dedicated builder functions, maintain strict precedence hierarchies (CLI > config > defaults), and implement comprehensive per-command testing. These patterns leverage Go's type system to transform silent runtime failures into compile-time errors, making flag wiring problems visible during development rather than in production.

While these patterns are specific to the opnDossier project's architecture, the underlying principles apply broadly to CLI applications: prefer explicit over implicit, typed over untyped, and loud failures over silent ones. By treating configuration propagation as a first-class architectural concern—with dedicated data structures, clear boundaries, and comprehensive testing—developers can build CLI tools that reliably respect user intent.

CLI Flag Wiring Patterns | Dosu