Skip to content

Conversation

@Mpdreamz
Copy link
Contributor

This a rather large PR sorry in advance!

I often run in to the need to be able to bind to records/classes as command arguments.

E.g a worst case is this rather large command: https://github.com/elastic/docs-builder/blob/main/src/tooling/docs-builder/Commands/IndexCommand.cs

This adds support for binding to records/classes (constructor and property binding) without reflection, source generated. It supports inheritance including inheriting from global options for which a new ConfigureGlobalOptions<T>() is introduced.

I used Claude Code to help me code this and help generate extensive tests.


Add [Bind] attribute for class-based parameter binding and typed ConfigureGlobalOptions()

Summary

This PR introduces two major features that simplify CLI application development:

  1. [Bind] attribute - Bind command parameters to class/record properties instead of inline parameters
  2. Typed ConfigureGlobalOptions<T>() - Define global options as a type without manual builder wiring
  3. [Bind] + Global Options inheritance - Automatically flow global options into command-specific types

All features are fully AOT-compatible with source-generated parsing code.

[Bind] Attribute - Class-Based Parameter Binding

Why?

Commands with many parameters become unwieldy when defined inline. [Bind] enables organizing parameters into reusable classes/records, supporting primary constructors, the required modifier, and XML doc comments for aliases.

Basic Usage

public record ServerConfig(
    string Host = "localhost",
    int Port = 8080,
    bool Verbose = false
);

ConsoleApp.Run(args, ([Bind] ServerConfig config) =>
{
    Console.WriteLine($"Starting on {config.Host}:{config.Port}");
});
// Usage: app --host 0.0.0.0 --port 3000 --verbose

Required Parameters

public record DeployConfig(
    string Environment,                          // Required (no default)
    int Replicas = 1
)
{
    public required string ImageTag { get; init; }  // Required via modifier
}

Positional Arguments

public record CopyConfig(
    [property: Argument] string Source,      // Positional [0]
    [property: Argument] string Destination  // Positional [1]
);
// Usage: app ./src.txt ./dest.txt

Aliases via XML Doc

public record Options
{
    /// <summary>-v|--verbose, Enable verbose output.</summary>
    public bool Verbose { get; init; }

    /// <summary>-o|--output, Output file path.</summary>
    public string Output { get; init; } = "out.txt";
}

Multiple [Bind] with Prefixes

public record DbConfig(string Host = "localhost", int Port = 5432);

ConsoleApp.Run(args, (
    [Bind("source")] DbConfig source,
    [Bind("target")] DbConfig target
) => { });
// Usage: app --source-host db1 --source-port 5432 --target-host db2

Typed ConfigureGlobalOptions()

Why?

The existing builder-based ConfigureGlobalOptions((ref builder) => ...) requires manual wiring. The typed approach lets you define global options as a simple class/record - no special attributes required.

Usage

public record GlobalOptions
{
    /// <summary>-v|--verbose, Enable verbose output.</summary>
    public bool Verbose { get; init; }
    public string LogLevel { get; init; } = "info";
}

var app = ConsoleApp.Create();
app.ConfigureGlobalOptions<GlobalOptions>();

app.Add("build", (ConsoleAppContext ctx) =>
{
    var globals = (GlobalOptions)ctx.GlobalOptions!;
    if (globals.Verbose) Console.WriteLine("Verbose mode");
});

app.Run(args);
// Usage: app --verbose --log-level debug build

Global options are parsed before command routing and can appear anywhere on the command line.

[Bind] + Global Options Inheritance

Why?

When commands need both global options and command-specific options, inheritance eliminates repetition. The [Bind] type automatically receives global option values when it inherits from the registered global options type.

Usage

public record GlobalOptions
{
    public bool Verbose { get; init; }
    public bool DryRun { get; init; }
}

public record DeployConfig : GlobalOptions  // Inherits Verbose, DryRun
{
    public string Environment { get; init; } = "staging";
    public int Replicas { get; init; } = 1;
}

var app = ConsoleApp.Create();
app.ConfigureGlobalOptions<GlobalOptions>();

app.Add("deploy", ([Bind] DeployConfig config) =>
{
    // config.Verbose and config.DryRun come from global options
    // config.Environment and config.Replicas are command-specific
    if (config.Verbose) Console.WriteLine($"Deploying to {config.Environment}");
});

app.Run(args);
// Usage: app --verbose --dry-run deploy --environment production --replicas 3

Help Text

Global options appear in root help under "Global Options:" section. Command help shows only command-specific options (inherited global options are excluded to avoid duplication).

Usage: [command] [-h|--help] [--version]

Global Options:
  -v, --verbose    Enable verbose output
  --dry-run        Simulate without making changes

Commands:
  deploy
  build

Test Coverage

  • tests/ConsoleAppFramework.GeneratorTests/Bind/ - Comprehensive [Bind] tests
  • tests/ConsoleAppFramework.GeneratorTests/GlobalOptions/ - Typed global options tests
  • Native AOT validation in NativeAotTests

[Bind] Attribute - Class-Based Parameter Binding:
- Bind command parameters to class/record properties and constructor parameters
- Properties become CLI options (--property-name) with automatic kebab-case conversion
- Support for required modifier, [Argument] for positional args, and XML doc aliases
- Multiple [Bind] parameters with prefix support to namespace options
- Mixed constructor and property binding for flexible initialization

Typed Global Options:
- ConfigureGlobalOptions<T>() for type-based global options without manual builder wiring
- Any class/record with parameterless constructor works (no special attribute required)
- XML doc comments for aliases and descriptions
- Global options parsed before command routing, can appear anywhere on command line
- Properly populates ConsoleAppContext.GlobalOptions for filters

[Bind] + Global Options Inheritance:
- When [Bind] type inherits from the global options type, inherited properties
  are automatically populated from parsed global options
- Enables shared configuration across commands without repetition
- Global options flow through to command-specific [Bind] types

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant