Skip to main content
Version: Next

Generate a private CLI integration

ModularPipelines.OptionsGenerator is a .NET tool that generates the same strongly typed options and services used by first-party ModularPipelines CLI packages. It accepts a versioned JSON definition, so private and experimental tools do not need changes in the ModularPipelines repository.

Install and pin the generator

Use a local tool manifest so every developer and CI runner uses the same generator version:

dotnet new tool-manifest
dotnet tool install ModularPipelines.OptionsGenerator --version <version>

Commit .config/dotnet-tools.json. Update the pinned package version deliberately, review the generated diff, and regenerate before merging the update.

The NuGet package follows the ModularPipelines semantic version. The input format is versioned independently by schemaVersion. This release supports schema version 1 and rejects unknown versions rather than interpreting them differently.

Define the private tool

Create tools/private-widget.json:

{
"schemaVersion": 1,
"tool": {
"ownershipId": "private-widget-integration",
"toolName": "private-widget",
"namespacePrefix": "PrivateWidget",
"targetNamespace": "Example.Build.PrivateWidget",
"outputDirectory": "src/Example.Build.PrivateWidget",
"documentationOutputDirectory": null,
"executablePrerequisiteMetadataExemption": "Installation is controlled by the private repository.",
"commands": [
{
"fullCommand": "private-widget deploy",
"commandParts": ["deploy"],
"className": "PrivateWidgetDeployOptions",
"parentClassName": "PrivateWidgetOptions",
"toolNamespacePrefix": "PrivateWidget",
"description": "Deploys a private widget.",
"options": [
{
"switchName": "--environment",
"propertyName": "Environment",
"cSharpType": "string?",
"description": "Deployment environment."
},
{
"switchName": "--dry-run",
"propertyName": "DryRun",
"cSharpType": "bool?",
"isFlag": true,
"valueArity": "none"
}
]
}
]
}
}

ownershipId is an immutable identifier for the integration. Keep it unchanged when renaming the tool, namespace prefix, target namespace, or output directories so the generator can reconcile files it previously owned. Use a different value for every independent definition.

outputDirectory and documentationOutputDirectory are relative to --output-dir. Absolute paths, paths that escape that root, and paths traversing symbolic links or filesystem reparse points are rejected. Generated namespaces, types, properties, methods, and enum names must be valid C# syntax. Set documentationOutputDirectory to null when the integration repository does not want a generated Markdown reference.

The integration project needs a package reference to ModularPipelines:

<ItemGroup>
<PackageReference Include="ModularPipelines" Version="..." />
</ItemGroup>

Generate

Run the tool from any directory:

dotnet tool restore
dotnet tool run modular-pipelines-options -- \
--input tools/private-widget.json \
--output-dir .

The command is deterministic: identical metadata and generator versions produce identical files. Generated C# files, command-coverage manifests, and optional documentation are owned by the JSON definition and generator version. The generator records this set in .modular-pipelines-options/<namespacePrefix>.files, allowing later runs to remove stale generated documentation when its directory or tool name changes. Commit that manifest and the generated files, but do not edit either manually. Change the JSON and rerun instead.

For CI, restore the manifest and run the same command, then fail when git diff --exit-code finds uncommitted output:

- uses: actions/setup-dotnet@v6.0.0
with:
dotnet-version: 10.0.x
- run: dotnet tool restore
- run: >-
dotnet tool run modular-pipelines-options --
--input tools/private-widget.json
--output-dir .
- run: git diff --exit-code

Use --change-manifest <path> when automation needs the exact generated and deleted paths. The existing --tools mode remains reserved for first-party scrapers; it cannot be combined with --input.

Migrate custom integration registration for v4

Version 4 replaces the process-wide ModularPipelinesContextRegistry and integration module initializers with generated assembly metadata. Output from the current options generator already uses the new mechanism.

For a hand-written integration, mark its service-registration method:

using Microsoft.Extensions.DependencyInjection;
using ModularPipelines.Attributes;

public static class PrivateWidgetExtensions
{
[ModularPipelinesIntegration]
public static IServiceCollection RegisterPrivateWidget(
this IServiceCollection services)
{
services.AddScoped<IPrivateWidget, PrivateWidget>();
return services;
}
}

The method must be accessible from its assembly, static, non-generic, declared on a non-generic accessible type, and accept one IServiceCollection parameter. It can return either void or IServiceCollection.

To expose the integration through the discoverable context.Tools.PrivateWidget API, keep its public IPipelineContext extension accessor in the same static class as the attributed registration method:

public static IPrivateWidget PrivateWidget(this IPipelineContext context) =>
context.Services.Get<IPrivateWidget>();

The generator uses that shared declaring type to associate the accessor with its registration. Accessor names must be unique across all referenced integration packages. They also cannot use names already exposed by IToolsContext or object, such as Get or GetType, because instance-member lookup would hide the generated property. context.Tools.* is the preferred discoverable API; the original extension method remains available for compatibility. Generated tool properties use C# 14 extension members. On older language versions, registration metadata still generates and MPG0008 explains why the optional Tools property was skipped and names the context.X() compatibility accessor.

Referencing the ModularPipelines package includes the source generator as an analyzer. The generator emits immutable assembly registration metadata, which each pipeline consumes independently. Remove the old parameterless module initializer and any call to ModularPipelinesContextRegistry.RegisterContext.

If the integration DLL is copied beside the application without a compile-time reference, opt in to filename-based assembly discovery before building the pipeline:

builder.ConfigurePipelineOptions(options => options with
{
LoadModularPipelineAssemblies = true,
});

The option is disabled by default to avoid scanning and loading every matching assembly at pipeline startup. Integrations already loaded through normal application use and explicitly registered services do not need it.