From e0338ad051644db47ea1bed88ad3729e19df5b68 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Tue, 4 Aug 2026 20:28:10 +0100 Subject: [PATCH 1/3] feat: add phase-aware CLI arguments --- docs/docs/how-to/custom-commands.md | 26 +++++++ .../Context/CommandLineBuilder.cs | 78 ++++++++++++++++--- .../Options/AdditionalCommandLineArgument.cs | 16 ++++ .../Options/CommandLineToolOptions.cs | 9 ++- .../Context/CommandLineBuilderTests.cs | 45 +++++++++++ 5 files changed, 164 insertions(+), 10 deletions(-) create mode 100644 src/ModularPipelines/Options/AdditionalCommandLineArgument.cs diff --git a/docs/docs/how-to/custom-commands.md b/docs/docs/how-to/custom-commands.md index 7af54e27b3d..9cc6aa06935 100644 --- a/docs/docs/how-to/custom-commands.md +++ b/docs/docs/how-to/custom-commands.md @@ -22,6 +22,32 @@ This is the equivalent to running: `dotnet tool install --global dotnet-coverage` +`Arguments` always appears after generated non-terminal options and operands. It appears +before `RunSettings` (and its `--` marker) and before options in the `Terminal` phase. + +## Adding Unmodeled Options + +Use `AdditionalArguments` when a strongly typed or generated options record does not yet +model a tool option. Each entry accepts a `CommandLinePhase`; entries with +`IsGlobalOption: true` appear before the command or subcommand parts. + +```csharp +var options = new SomeGeneratedOptions +{ + AdditionalArguments = + [ + new("--global-flag", IsGlobalOption: true), + new("--new-option", CommandLinePhase.Normal), + new("value", CommandLinePhase.Normal), + ], +}; +``` + +Within each phase, additional tokens retain their declared order and appear before +generated tokens. The phases render as `EarlyOperand`, `Normal`, `EndOfOptions`, +`Passthrough`, then `Terminal`. Terminal tokens appear after `Arguments` and cannot be +combined with an end-of-options marker or `RunSettings`. + ## Strongly Typed Options Static command identities use one source for each part: diff --git a/src/ModularPipelines/Context/CommandLineBuilder.cs b/src/ModularPipelines/Context/CommandLineBuilder.cs index 7b010c626b9..41fa0cd6fb2 100644 --- a/src/ModularPipelines/Context/CommandLineBuilder.cs +++ b/src/ModularPipelines/Context/CommandLineBuilder.cs @@ -14,8 +14,8 @@ namespace ModularPipelines.Context; /// 1. Resolve tool name from [CliTool] attribute or constructor parameter /// 2. Get subcommand parts from [CliSubCommand] or a preferred [CliCommandAlias] /// 3. Build arguments from [CliOption], [CliFlag], and [CliArgument] attributes -/// 4. Add manual Arguments if present -/// 5. Add RunSettings after "--" if present. +/// 4. Insert phase-aware AdditionalArguments and append manual Arguments +/// 5. Add RunSettings after "--" and terminal options last. /// internal sealed class CommandLineBuilder( IToolResolver toolResolver, @@ -44,6 +44,9 @@ public CommandLine Build(CommandLineToolOptions options) // on a [CliGlobalOptions] base belong before the subcommand; command-specific // properties retain their normal position after it. var commandModel = _commandModelProvider.GetCommandModel(options.GetType()); + var additionalArguments = options.AdditionalArguments?.ToList() ?? []; + ValidateAdditionalArguments(additionalArguments); + var terminalCommandModel = commandModel .Where(part => part.Phase == CommandLinePhase.Terminal) .ToList(); @@ -52,14 +55,15 @@ public CommandLine Build(CommandLineToolOptions options) .ToList(); var globalCommandModel = nonTerminalCommandModel.Where(part => part.IsGlobalOption).ToList(); var commandSpecificModel = nonTerminalCommandModel.Where(part => !part.IsGlobalOption).ToList(); - var globalArgs = _commandArgumentBuilder.BuildArguments(globalCommandModel, options); - var propertyArgs = _commandArgumentBuilder.BuildArguments(commandSpecificModel, options); - var terminalArgs = _commandArgumentBuilder.BuildArguments(terminalCommandModel, options); + var terminalArgs = _commandArgumentBuilder.BuildArguments(terminalCommandModel, options) + .Concat(GetAdditionalArguments(additionalArguments, CommandLinePhase.Terminal)) + .ToList(); - // 4. Combine: global args + command parts (subcommands) + property args - var allArgs = new List(globalArgs); + // 4. Combine: global args + command parts (subcommands) + command-specific args. + var allArgs = new List(); + AddNonTerminalArguments(allArgs, globalCommandModel, additionalArguments, options, isGlobalOption: true); allArgs.AddRange(commandParts); - allArgs.AddRange(propertyArgs); + AddNonTerminalArguments(allArgs, commandSpecificModel, additionalArguments, options, isGlobalOption: false); // 5. Add any manual arguments passed via options.Arguments var manualArgs = options.Arguments?.ToList() ?? []; @@ -71,9 +75,14 @@ public CommandLine Build(CommandLineToolOptions options) .ToList(); var hasPropertyEndOfOptions = _commandArgumentBuilder.BuildArguments(endOfOptionsModel, options).Count > 0; + var hasAdditionalEndOfOptions = additionalArguments + .Any(argument => argument.Phase == CommandLinePhase.EndOfOptions); var hasManualEndOfOptions = manualArgs.Contains("--", StringComparer.Ordinal); - if (hasPropertyEndOfOptions || hasManualEndOfOptions || options.RunSettings is not null) + if (hasPropertyEndOfOptions + || hasAdditionalEndOfOptions + || hasManualEndOfOptions + || options.RunSettings is not null) { throw new InvalidOperationException( "Terminal options cannot be combined with an end-of-options marker."); @@ -94,4 +103,55 @@ public CommandLine Build(CommandLineToolOptions options) return new CommandLine(tool, allArgs); } + + private void AddNonTerminalArguments( + List destination, + IReadOnlyList commandModel, + IReadOnlyList additionalArguments, + CommandLineToolOptions options, + bool isGlobalOption) + { + foreach (var phase in Enum.GetValues() + .Where(phase => phase != CommandLinePhase.Terminal)) + { + var phaseModel = commandModel.Where(part => part.Phase == phase).ToList(); + destination.AddRange(GetAdditionalArguments(additionalArguments, phase, isGlobalOption)); + destination.AddRange(_commandArgumentBuilder.BuildArguments(phaseModel, options)); + } + } + + private static IEnumerable GetAdditionalArguments( + IEnumerable additionalArguments, + CommandLinePhase phase, + bool? isGlobalOption = null) + => additionalArguments + .Where(argument => argument.Phase == phase + && (isGlobalOption is null || argument.IsGlobalOption == isGlobalOption)) + .Select(argument => argument.Value); + + private static void ValidateAdditionalArguments( + IEnumerable additionalArguments) + { + foreach (var argument in additionalArguments) + { + ArgumentNullException.ThrowIfNull(argument); + + if (!Enum.IsDefined(argument.Phase)) + { + throw new ArgumentOutOfRangeException( + nameof(CommandLineToolOptions.AdditionalArguments), + argument.Phase, + "The additional argument phase is not defined."); + } + + ArgumentNullException.ThrowIfNull(argument.Value); + + if (argument is { IsGlobalOption: true, Phase: CommandLinePhase.Terminal }) + { + throw new ArgumentException( + "A terminal additional argument cannot be a global option.", + nameof(CommandLineToolOptions.AdditionalArguments)); + } + } + } } diff --git a/src/ModularPipelines/Options/AdditionalCommandLineArgument.cs b/src/ModularPipelines/Options/AdditionalCommandLineArgument.cs new file mode 100644 index 00000000000..7b6d55b6115 --- /dev/null +++ b/src/ModularPipelines/Options/AdditionalCommandLineArgument.cs @@ -0,0 +1,16 @@ +using ModularPipelines.Attributes; + +namespace ModularPipelines.Options; + +/// +/// A manually supplied command-line token with explicit placement metadata. +/// +/// The token to add to the command line. +/// The semantic rendering phase for the token. +/// +/// Whether the token belongs before the command or subcommand parts. +/// +public sealed record AdditionalCommandLineArgument( + string Value, + CommandLinePhase Phase = CommandLinePhase.Normal, + bool IsGlobalOption = false); diff --git a/src/ModularPipelines/Options/CommandLineToolOptions.cs b/src/ModularPipelines/Options/CommandLineToolOptions.cs index d89cc116c31..105de87e77e 100644 --- a/src/ModularPipelines/Options/CommandLineToolOptions.cs +++ b/src/ModularPipelines/Options/CommandLineToolOptions.cs @@ -20,10 +20,17 @@ public abstract record CommandLineToolOptions public IReadOnlyList? CommandParts { get; init; } /// - /// Gets used for providing switches and arguments to the tool. + /// Gets manual tokens appended after generated non-terminal options and operands, + /// and before and terminal options. /// public IEnumerable? Arguments { get; init; } + /// + /// Gets manual tokens whose placement is controlled by their command-line phase. + /// Use this for unmodeled options on strongly typed or generated option records. + /// + public IEnumerable? AdditionalArguments { get; init; } + /// /// Gets used for command line tools that support -- syntax. /// diff --git a/test/ModularPipelines.UnitTests/Context/CommandLineBuilderTests.cs b/test/ModularPipelines.UnitTests/Context/CommandLineBuilderTests.cs index 477a4c37110..1a343510791 100644 --- a/test/ModularPipelines.UnitTests/Context/CommandLineBuilderTests.cs +++ b/test/ModularPipelines.UnitTests/Context/CommandLineBuilderTests.cs @@ -224,6 +224,51 @@ await Assert.That(result.ToString()).IsEqualTo( "liquibase --search-path=changelogs update --changelog-file=main.xml"); } + [Test] + public async Task Build_Places_Additional_Arguments_By_Phase_And_Scope() + { + var builder = await GetService(); + + var result = builder.Build(new TestMultiLevelCommandOptions + { + Context = "remote", + Reference = "build-reference", + Follow = true, + Arguments = ["manual"], + AdditionalArguments = + [ + new("--global-unmodeled", IsGlobalOption: true), + new("early-unmodeled", CommandLinePhase.EarlyOperand), + new("--normal-unmodeled"), + new("--", CommandLinePhase.EndOfOptions), + new("pass-through", CommandLinePhase.Passthrough), + ], + }); + + await Assert.That(result.ToString()).IsEqualTo( + "docker --global-unmodeled --context remote buildx history logs " + + "early-unmodeled build-reference --normal-unmodeled --follow -- pass-through manual"); + } + + [Test] + public async Task Build_Places_Additional_Terminal_Arguments_Last() + { + var builder = await GetService(); + + var result = builder.Build(new TestAttributeOptions + { + Force = true, + Arguments = ["manual"], + AdditionalArguments = + [ + new("terminal", CommandLinePhase.Terminal), + ], + }); + + await Assert.That(result.ToString()).IsEqualTo( + "mytool sub command --force manual terminal"); + } + [Test] public async Task Build_Keeps_MultiLevel_Command_Chain_Atomic() { From 4823ee7c7570e05befa3a349cffd9479e93947c1 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Tue, 4 Aug 2026 20:41:22 +0100 Subject: [PATCH 2/3] fix: link additional CLI argument type --- .../ModularPipelines.OptionsGenerator.csproj | 1 + 1 file changed, 1 insertion(+) diff --git a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/ModularPipelines.OptionsGenerator.csproj b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/ModularPipelines.OptionsGenerator.csproj index 0269ded5106..4bfdc24b084 100644 --- a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/ModularPipelines.OptionsGenerator.csproj +++ b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/ModularPipelines.OptionsGenerator.csproj @@ -38,6 +38,7 @@ + From 13bdb4c94470c26318dcaaac4fffba4965e79325 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Tue, 4 Aug 2026 21:10:51 +0100 Subject: [PATCH 3/3] fix: guard additional terminators --- .../Attributes/CommandLinePhase.cs | 5 ++++ .../Context/CommandLineBuilder.cs | 19 ++++++++++---- .../Internal/CommandArgumentBuilder.cs | 10 +++---- .../Context/CommandLineBuilderTests.cs | 26 +++++++++++++++++++ 4 files changed, 49 insertions(+), 11 deletions(-) diff --git a/src/ModularPipelines/Attributes/CommandLinePhase.cs b/src/ModularPipelines/Attributes/CommandLinePhase.cs index 0974d0e885c..02b7b17f38d 100644 --- a/src/ModularPipelines/Attributes/CommandLinePhase.cs +++ b/src/ModularPipelines/Attributes/CommandLinePhase.cs @@ -33,3 +33,8 @@ public enum CommandLinePhase /// Passthrough = 3, } + +internal static class CommandLinePhaseCompatibility +{ + internal const CommandLinePhase LegacyEndOfOptions = (CommandLinePhase) 2; +} diff --git a/src/ModularPipelines/Context/CommandLineBuilder.cs b/src/ModularPipelines/Context/CommandLineBuilder.cs index 246a2f4023a..013e7335212 100644 --- a/src/ModularPipelines/Context/CommandLineBuilder.cs +++ b/src/ModularPipelines/Context/CommandLineBuilder.cs @@ -25,8 +25,6 @@ internal sealed class CommandLineBuilder( ICommandModelProvider commandModelProvider, ICommandArgumentBuilder commandArgumentBuilder) : ICommandLineBuilder { - private const CommandLinePhase LegacyEndOfOptionsPhase = (CommandLinePhase) 2; - private static readonly IReadOnlyList RunSettingsCommandModel = [ new ArgumentPart( @@ -193,7 +191,8 @@ private List BuildNonTerminalArguments( phase, isGlobalOption) .ToList(); - if (phase == LegacyEndOfOptionsPhase && phaseAdditionalArguments.Count > 0) + if (phase == CommandLinePhaseCompatibility.LegacyEndOfOptions + && phaseAdditionalArguments.Count > 0) { if (emittedOptionTerminator) { @@ -258,15 +257,25 @@ private static void ValidateAdditionalArguments( nameof(CommandLineToolOptions.AdditionalArguments)); } - if (argument.Phase == LegacyEndOfOptionsPhase && argument.Value != "--") + if (argument.Phase == CommandLinePhaseCompatibility.LegacyEndOfOptions + && argument.Value != "--") { throw new ArgumentException( "The legacy end-of-options phase only accepts the '--' marker.", nameof(CommandLineToolOptions.AdditionalArguments)); } + + if (argument.Value == "--" + && argument.Phase != CommandLinePhaseCompatibility.LegacyEndOfOptions) + { + throw new ArgumentException( + "The '--' marker must use the legacy end-of-options phase.", + nameof(CommandLineToolOptions.AdditionalArguments)); + } } - if (additionalArguments.Count(argument => argument.Phase == LegacyEndOfOptionsPhase) > 1) + if (additionalArguments.Count(argument => + argument.Phase == CommandLinePhaseCompatibility.LegacyEndOfOptions) > 1) { throw new ArgumentException( "Additional arguments can contain at most one end-of-options marker.", diff --git a/src/ModularPipelines/Helpers/Internal/CommandArgumentBuilder.cs b/src/ModularPipelines/Helpers/Internal/CommandArgumentBuilder.cs index 021e0e29975..eb3db57397c 100644 --- a/src/ModularPipelines/Helpers/Internal/CommandArgumentBuilder.cs +++ b/src/ModularPipelines/Helpers/Internal/CommandArgumentBuilder.cs @@ -11,8 +11,6 @@ namespace ModularPipelines.Helpers.Internal; /// internal sealed class CommandArgumentBuilder : ICommandArgumentBuilder { - private const CommandLinePhase LegacyEndOfOptionsPhase = (CommandLinePhase) 2; - /// public IReadOnlyList BuildArguments( IReadOnlyList commandModel, @@ -87,7 +85,7 @@ public IReadOnlyList BuildArguments( { CommandLinePhase.EarlyOperand => 0, CommandLinePhase.Normal => 1, - LegacyEndOfOptionsPhase => 2, + CommandLinePhaseCompatibility.LegacyEndOfOptions => 2, CommandLinePhase.Passthrough => 3, CommandLinePhase.Terminal => 4, _ => throw new ArgumentOutOfRangeException(nameof(phase), phase, null), @@ -123,7 +121,7 @@ private static RenderedPhase RenderPhase( else { AddFlagsAndOptions(rendered, phaseOptions, renderedOptionValues); - if (phase == LegacyEndOfOptionsPhase + if (phase == CommandLinePhaseCompatibility.LegacyEndOfOptions && rendered.IndexOf("--") is var terminatorIndex && terminatorIndex >= 0) { @@ -219,11 +217,11 @@ private static void ValidateOptionTerminatorOrdering( } var legacyOptionTerminatorRendered = renderedOptionValues.Any(static pair => - pair.Key.Phase == LegacyEndOfOptionsPhase + pair.Key.Phase == CommandLinePhaseCompatibility.LegacyEndOfOptions && pair.Value.Contains("--", StringComparer.Ordinal)); if (legacyOptionTerminatorRendered && renderedOptions.Any(static option => - GetRenderOrder(option.Phase) > GetRenderOrder(LegacyEndOfOptionsPhase))) + GetRenderOrder(option.Phase) > GetRenderOrder(CommandLinePhaseCompatibility.LegacyEndOfOptions))) { throw new InvalidOperationException( "CLI flags or options cannot be rendered after a legacy end-of-options marker."); diff --git a/test/ModularPipelines.UnitTests/Context/CommandLineBuilderTests.cs b/test/ModularPipelines.UnitTests/Context/CommandLineBuilderTests.cs index 752343d3de2..bf309e22cdc 100644 --- a/test/ModularPipelines.UnitTests/Context/CommandLineBuilderTests.cs +++ b/test/ModularPipelines.UnitTests/Context/CommandLineBuilderTests.cs @@ -952,6 +952,32 @@ await Assert.That(Build) .And.HasMessageContaining("end-of-options marker"); } + [Test] + public async Task Build_Rejects_Additional_Terminator_Outside_Legacy_Phase() + { + var builder = await GetService(); + CommandLinePhase[] phases = + [ + CommandLinePhase.EarlyOperand, + CommandLinePhase.Normal, + CommandLinePhase.Passthrough, + CommandLinePhase.Terminal, + ]; + + foreach (var phase in phases) + { + CommandLine Build() => builder.Build(new TestTerminalOptions + { + AdditionalArguments = [new("--", phase)], + RunTests = "tests.jq", + }); + + await Assert.That(Build) + .Throws() + .And.HasMessageContaining("legacy end-of-options phase"); + } + } + [Test] public async Task Build_Keeps_MultiLevel_Command_Chain_Atomic() {