Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions docs/best-practices.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,36 @@ app.Context("project {id:int}", project =>
});
```

## Use global options for cross-cutting configuration

When configuration applies to all commands (tenant, environment, verbosity), register global options and access them via `IGlobalOptionsAccessor`:

```csharp
app.Options(o =>
{
o.Parsing.AddGlobalOption<string>("tenant");
o.Parsing.AddGlobalOption<bool>("verbose");
});
```

For many global options, prefer `UseGlobalOptions<T>()` with a typed class:

```csharp
app.UseGlobalOptions<MyGlobalOptions>();
```

Use `IGlobalOptionsAccessor` in DI factories for services that depend on global option values:

```csharp
services.AddSingleton<ITenantClient>(sp =>
{
var globals = sp.GetRequiredService<IGlobalOptionsAccessor>();
return new TenantClient(globals.GetValue<string>("tenant", "default")!);
});
```

Note: DI singleton factories are resolved lazily, so the values are available after global option parsing completes. However, singleton factories capture values once — in interactive mode, global options can change between commands. If your service needs to see updated values per command, inject `IGlobalOptionsAccessor` directly and read values at call time instead of capturing them in a factory. See [Commands — Accessing global options](commands.md#accessing-global-options-outside-handlers).

## Group related options with `[ReplOptionsGroup]`

When a command has many options, group them into a class instead of listing them all as handler parameters. This keeps handlers clean and makes option sets reusable across commands.
Expand Down
66 changes: 66 additions & 0 deletions docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,72 @@ app.Map(

Root help now includes a dedicated `Global Options:` section with built-ins plus custom options registered through `options.Parsing.AddGlobalOption<T>(...)`.

### Accessing global options outside handlers

Parsed global option values are available via `IGlobalOptionsAccessor`, registered in DI automatically. This enables access from middleware, DI service factories, and handlers:

```csharp
// Register a global option
app.Options(o => o.Parsing.AddGlobalOption<string>("tenant"));

// Access in middleware
app.Use(async (ctx, next) =>
{
var globals = ctx.Services.GetRequiredService<IGlobalOptionsAccessor>();
var tenant = globals.GetValue<string>("tenant");
await next();
});

// Access in a DI factory (lazy — resolved after parsing)
services.AddSingleton<ITenantClient>(sp =>
{
var globals = sp.GetRequiredService<IGlobalOptionsAccessor>();
return new TenantClient(globals.GetValue<string>("tenant", "default")!);
});

// Access in a handler
app.Map("show", (IGlobalOptionsAccessor globals) =>
globals.GetValue<string>("tenant") ?? "none");
```

For applications with many global options, use `UseGlobalOptions<T>()` to register a typed class:

```csharp
public class MyGlobalOptions
{
public string? Tenant { get; set; }
public int Port { get; set; } = 8080;
}

app.UseGlobalOptions<MyGlobalOptions>();

// Access via DI
app.Map("show", (MyGlobalOptions opts) => $"{opts.Tenant}:{opts.Port}");
```

Property names are converted to kebab-case option names (`Port` → `--port`). Use `[ReplOption]` on properties for custom names or aliases.

You can also register global options using a type or constraint name instead of a generic parameter:

```csharp
app.Options(o => o.Parsing.AddGlobalOption("port", "int"));
```

Built-in type names: `string`, `alpha`, `int`, `long`, `bool`, `guid`, `uri`/`url`/`urn`, `date`/`dateonly`/`date-only`, `datetime`/`date-time`, `datetimeoffset`/`date-time-offset`, `time`/`timeonly`/`time-only`, `timespan`/`time-span`. Custom route constraint names are also accepted and resolve to `string`.

### Session-sticky behavior in interactive mode

Global options passed at CLI launch persist as session defaults throughout the interactive session. Per-command overrides are temporary — they apply to that single command, then the session defaults take effect again:

```
$ myapp --env staging # launches interactive with env=staging
> deploy # env=staging (inherited from session)
> deploy --env prod # env=prod (override for this command only)
> status # env=staging (session default restored)
```

This eliminates the need to re-specify global options on every interactive command.

## Parse diagnostics model

Command option parsing returns structured diagnostics through the internal `OptionParsingResult` model:
Expand Down
16 changes: 16 additions & 0 deletions docs/configuration-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,22 @@ Accessed via `ReplOptions.Parsing`.

- `AddRouteConstraint(name, predicate)` — Register a named route constraint.
- `AddGlobalOption<T>(name, aliases, defaultValue)` — Register a global option available to all commands.
- `AddGlobalOption(name, typeName, aliases, defaultValue)` — Register a global option using a type name string (`"int"`, `"bool"`, `"guid"`, etc.).

### IGlobalOptionsAccessor

Registered automatically in DI. Provides typed access to parsed global option values from middleware, DI factories, and handlers.

- `GetValue<T>(name, defaultValue)` — Get typed value, falling back to registration default then caller default.
- `GetRawValues(name)` — Get all raw string values (supports repeated options).
- `HasValue(name)` — Check if the option was explicitly provided.
- `GetOptionNames()` — Enumerate all option names with values.

Values are updated after each global option parsing pass (per-invocation in interactive mode).

### UseGlobalOptions&lt;T&gt;()

Extension method on `ReplApp`. Registers a typed class whose public settable properties become global options. The class is available via DI, populated from parsed values. Property names are converted to kebab-case (`MaxRetries` → `--max-retries`). See [Commands — Accessing global options](commands.md#accessing-global-options-outside-handlers).

## InteractiveOptions

Expand Down
6 changes: 6 additions & 0 deletions docs/execution-pipeline.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,12 @@ any routing takes place. Recognized options:
- Custom global options registered through configuration

These tokens are consumed and removed from the argument list before the next stage.
Parsed custom global option values are stored in `IGlobalOptionsAccessor` (registered in DI),
making them available to middleware, DI service factories, and handlers in subsequent stages.

In interactive mode, CLI-level global options become session defaults — they persist across
all commands unless explicitly overridden per-command. Overrides are temporary and revert
to the session baseline on the next command.

### 2. Prefix Resolution

Expand Down
2 changes: 1 addition & 1 deletion docs/parameter-system.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ This document describes Repl Toolkit's parameter/option model and key design dec
- option value syntaxes: `--name value`, `--name=value`, `--name:value`
- option parsing is case-sensitive by default, configurable via `ParsingOptions.OptionCaseSensitivity`
- response files are supported with `@file.rsp` (non-recursive)
- custom global options can be registered via `ParsingOptions.AddGlobalOption<T>(...)`
- custom global options can be registered via `ParsingOptions.AddGlobalOption<T>(...)` or `AddGlobalOption(name, typeName)`, and accessed outside handlers via `IGlobalOptionsAccessor` (see [Commands — Accessing global options](commands.md#accessing-global-options-outside-handlers))
- signed numeric literals (`-1`, `-0.5`, `-1e3`) are treated as positional values, not options

## Public declaration API
Expand Down
1 change: 1 addition & 0 deletions src/Repl.Core/CoreReplApp.Interactive.cs
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ or AmbientCommandOutcome.Handled

var invocationTokens = scopeTokens.Concat(inputTokens).ToArray();
var globalOptions = GlobalOptionParser.Parse(invocationTokens, _options.Output, _options.Parsing);
_globalOptionsSnapshot.Update(globalOptions.CustomGlobalNamedOptions);
var prefixResolution = ResolveUniquePrefixes(globalOptions.RemainingTokens);
if (prefixResolution.IsAmbiguous)
{
Expand Down
6 changes: 6 additions & 0 deletions src/Repl.Core/CoreReplApp.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,16 @@ public sealed partial class CoreReplApp : ICoreReplApp
private Delegate? _banner;
private readonly AsyncLocal<bool> _bannerRendered = new();
private readonly AsyncLocal<bool> _allBannersSuppressed = new();
private readonly GlobalOptionsSnapshot _globalOptionsSnapshot;

internal ReplOptions OptionsSnapshot => _options;
internal IGlobalOptionsAccessor GlobalOptionsAccessor => _globalOptionsSnapshot;
internal IReplExecutionObserver? ExecutionObserver { get; set; }

private CoreReplApp()
{
_options.Output.SetHostAnsiSupportResolver(() => _options.Capabilities.SupportsAnsi);
_globalOptionsSnapshot = new GlobalOptionsSnapshot(_options.Parsing);
_services = CreateDefaultServiceProvider();
_shellCompletionRuntime = new ShellCompletionRuntime(
_options,
Expand Down Expand Up @@ -416,6 +419,8 @@ private async ValueTask<int> ExecuteCoreAsync(
try
{
var globalOptions = GlobalOptionParser.Parse(args, _options.Output, _options.Parsing);
_globalOptionsSnapshot.Update(globalOptions.CustomGlobalNamedOptions);
_globalOptionsSnapshot.SetSessionBaseline();
Comment thread
carldebilly marked this conversation as resolved.
using var runtimeStateScope = PushRuntimeState(serviceProvider, isInteractiveSession: false);
var prefixResolution = ResolveUniquePrefixes(globalOptions.RemainingTokens);
var resolvedGlobalOptions = globalOptions with { RemainingTokens = prefixResolution.Tokens };
Expand Down Expand Up @@ -1399,6 +1404,7 @@ private DefaultServiceProvider CreateDefaultServiceProvider()
{
[typeof(CoreReplApp)] = this,
[typeof(ICoreReplApp)] = this,
[typeof(IGlobalOptionsAccessor)] = _globalOptionsSnapshot,
[typeof(IReplSessionState)] = new InMemoryReplSessionState(),
[typeof(IReplInteractionChannel)] = new ConsoleInteractionChannel(
_options.Interaction, _options.Output,
Expand Down
3 changes: 2 additions & 1 deletion src/Repl.Core/GlobalOptionDefinition.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@ internal sealed record GlobalOptionDefinition(
string Name,
string CanonicalToken,
IReadOnlyList<string> Aliases,
string? DefaultValue);
string? DefaultValue,
Type ValueType);
6 changes: 5 additions & 1 deletion src/Repl.Core/GlobalOptionParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ public static GlobalInvocationOptions Parse(
ref index,
argument,
customTokenMap,
parsingOptions.GlobalOptions,
customGlobalValues))
{
continue;
Expand Down Expand Up @@ -164,15 +165,18 @@ private static bool TryParseCustomGlobalOption(
ref int index,
string argument,
IReadOnlyDictionary<string, string> tokenMap,
IReadOnlyDictionary<string, GlobalOptionDefinition> definitions,
Dictionary<string, List<string>> customGlobalValues)
{
if (!TryResolveCustomGlobalName(argument, tokenMap, out var optionName, out var inlineValue))
{
return false;
}

var isBool = definitions.TryGetValue(optionName, out var def) && def.ValueType == typeof(bool);

var value = inlineValue;
if (value is null
if (value is null && !isBool
&& index + 1 < args.Count
&& (!args[index + 1].StartsWith('-') || IsSignedNumericLiteral(args[index + 1])))
{
Expand Down
83 changes: 83 additions & 0 deletions src/Repl.Core/GlobalOptionsSnapshot.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
namespace Repl;

internal sealed class GlobalOptionsSnapshot(ParsingOptions parsingOptions) : IGlobalOptionsAccessor
{
private volatile IReadOnlyDictionary<string, IReadOnlyList<string>> _sessionBaseline =
new Dictionary<string, IReadOnlyList<string>>(StringComparer.OrdinalIgnoreCase);

private volatile IReadOnlyDictionary<string, IReadOnlyList<string>> _currentValues =
new Dictionary<string, IReadOnlyList<string>>(StringComparer.OrdinalIgnoreCase);

private volatile HashSet<string> _explicitKeys = new(StringComparer.OrdinalIgnoreCase);

internal void SetSessionBaseline()
{
// Capture only the explicitly parsed values as the new baseline.
// This prevents stale baselines from leaking across separate Run() calls.
var baseline = new Dictionary<string, IReadOnlyList<string>>(StringComparer.OrdinalIgnoreCase);
foreach (var key in _explicitKeys)
{
if (_currentValues.TryGetValue(key, out var values))
{
baseline[key] = values;
}
}
Comment thread
carldebilly marked this conversation as resolved.
Dismissed

_sessionBaseline = baseline;
_currentValues = baseline;
}

internal void Update(IReadOnlyDictionary<string, IReadOnlyList<string>> parsedValues)
{
_explicitKeys = new HashSet<string>(parsedValues.Keys, StringComparer.OrdinalIgnoreCase);
var merged = new Dictionary<string, IReadOnlyList<string>>(_sessionBaseline, StringComparer.OrdinalIgnoreCase);
foreach (var (key, value) in parsedValues)
{
merged[key] = value;
}

_currentValues = merged;
}

public T? GetValue<T>(string name, T? defaultValue = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(name);

if (_currentValues.TryGetValue(name, out var values) && values.Count > 0)
{
return (T?)ParameterValueConverter.ConvertSingle(
values[0],
typeof(T),
parsingOptions.NumericFormatProvider);
}

if (parsingOptions.GlobalOptions.TryGetValue(name, out var definition)
&& definition.DefaultValue is not null)
{
return (T?)ParameterValueConverter.ConvertSingle(
definition.DefaultValue,
typeof(T),
parsingOptions.NumericFormatProvider);
}

return defaultValue;
}

public IReadOnlyList<string> GetRawValues(string name)
{
ArgumentException.ThrowIfNullOrWhiteSpace(name);

return _currentValues.TryGetValue(name, out var values)
? values
: [];
}

public bool HasValue(string name)
{
ArgumentException.ThrowIfNullOrWhiteSpace(name);

return _explicitKeys.Contains(name);
}

public IEnumerable<string> GetOptionNames() => _currentValues.Keys;
}
36 changes: 36 additions & 0 deletions src/Repl.Core/IGlobalOptionsAccessor.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
namespace Repl;

/// <summary>
/// Provides typed access to parsed global option values.
/// Values are updated after each global option parsing pass.
/// </summary>
public interface IGlobalOptionsAccessor
{
/// <summary>
/// Gets the typed value of a global option by its registered name.
/// Returns <paramref name="defaultValue"/> if the option was not provided.
/// </summary>
/// <typeparam name="T">Target value type.</typeparam>
/// <param name="name">The registered option name (without <c>--</c> prefix).</param>
/// <param name="defaultValue">Value to return if the option was not provided.</param>
T? GetValue<T>(string name, T? defaultValue = default);

/// <summary>
/// Gets all raw string values for a global option (supports repeated options).
/// Returns an empty list if the option was not provided.
/// </summary>
/// <param name="name">The registered option name (without <c>--</c> prefix).</param>
IReadOnlyList<string> GetRawValues(string name);

/// <summary>
/// Returns <see langword="true"/> if the named global option was explicitly provided
/// in the current invocation.
/// </summary>
/// <param name="name">The registered option name (without <c>--</c> prefix).</param>
bool HasValue(string name);

/// <summary>
/// Gets all parsed global option names that have values in the current invocation.
/// </summary>
IEnumerable<string> GetOptionNames();
}
Loading
Loading