14. Integrating Typed CLI Consumers
The preceding chapters explain the individual mechanisms. This chapter puts the implemented contracts together: a generated command and typed environment describe an invocation, a binding identifies its executable, a runner owns execution, and a fresh parser turns the resulting lines into observations.
Vagrant provides a complete example. The same composition boundary exists in the other executable consumers, with their own command types, event records and reviewed output formats.
The application composition boundary
The application host selects a binding, a process transport and a concrete consumer runner. Business components receive the runner interface from the consumer's .Contracts assembly.
This separates the code that handles typed observations from the code that chooses an executable path and starts a process. A generated client or builder can help construct commands, but neither performs execution merely by producing a command object.
CommandExecutor accepts an IBinaryResolver and optional IProcessRunner. Supplying a transport explicitly makes the execution boundary visible. Omitting it selects the default real process runner, which starts an executable when the operation is consumed.
The concrete consumer runner supplies its parser factory and binary identifier. Business code can remain written against IVagrantRunner or the corresponding tool interface. Chapter 10 details that assembly boundary.
A complete offline composition
This example connects the implemented Vagrant contracts and runner to a fake process transport. It starts no executable. The synthetic stdout row follows the reviewed Vagrant 2.4.9 machine-readable shape; stderr is retained as a separate source.
using FrenchExDev.Net.BinaryWrapper;
using FrenchExDev.Net.BinaryWrapper.Lib;
using FrenchExDev.Net.BinaryWrapper.Testing;
using FrenchExDev.Net.Vagrant;
var version = SemanticVersion.Parse("2.4.9");
var binding = new BinaryBinding
{
Identifier = new BinaryIdentifier("vagrant"),
ExecutablePath = "unused",
DetectedVersion = version,
EnvironmentVariables = new Dictionary<string, string?>
{
["VAGRANT_HOME"] = "default-config",
},
};
var command = new VagrantUpCommand(version)
{
MachineReadable = true,
Environment = new VagrantEnvironment
{
Home = "example/.vagrant.d",
},
};
var transport = new FakeProcessRunner([
new OutputLine(
"1700000000,web,ui,output,Machine booted and ready!",
OutputSource.StdOut),
new OutputLine("provider diagnostic", OutputSource.StdErr),
]);
var executor = new CommandExecutor(
new DictionaryBinaryResolver(binding), transport);
IVagrantRunner runner = new VagrantRunner(executor, binding.Identifier);
await foreach (var observation in runner.StreamAsync(command))
{
if (observation.OutputLine is { } line)
Console.WriteLine(
$"{observation.OutputVersion}/{observation.OutputProfile} " +
$"{line.Source}: {line.Text}");
if (observation is VagrantMachineBooted ready)
Console.WriteLine($"Communicator ready: {ready.MachineName}");
if (observation is VagrantCommandExited exited)
Console.WriteLine($"Actual process exit: {exited.ExitCode}");
}
Console.WriteLine(
transport.LastSpec?.EnvironmentVariables?["VAGRANT_HOME"]);using FrenchExDev.Net.BinaryWrapper;
using FrenchExDev.Net.BinaryWrapper.Lib;
using FrenchExDev.Net.BinaryWrapper.Testing;
using FrenchExDev.Net.Vagrant;
var version = SemanticVersion.Parse("2.4.9");
var binding = new BinaryBinding
{
Identifier = new BinaryIdentifier("vagrant"),
ExecutablePath = "unused",
DetectedVersion = version,
EnvironmentVariables = new Dictionary<string, string?>
{
["VAGRANT_HOME"] = "default-config",
},
};
var command = new VagrantUpCommand(version)
{
MachineReadable = true,
Environment = new VagrantEnvironment
{
Home = "example/.vagrant.d",
},
};
var transport = new FakeProcessRunner([
new OutputLine(
"1700000000,web,ui,output,Machine booted and ready!",
OutputSource.StdOut),
new OutputLine("provider diagnostic", OutputSource.StdErr),
]);
var executor = new CommandExecutor(
new DictionaryBinaryResolver(binding), transport);
IVagrantRunner runner = new VagrantRunner(executor, binding.Identifier);
await foreach (var observation in runner.StreamAsync(command))
{
if (observation.OutputLine is { } line)
Console.WriteLine(
$"{observation.OutputVersion}/{observation.OutputProfile} " +
$"{line.Source}: {line.Text}");
if (observation is VagrantMachineBooted ready)
Console.WriteLine($"Communicator ready: {ready.MachineName}");
if (observation is VagrantCommandExited exited)
Console.WriteLine($"Actual process exit: {exited.ExitCode}");
}
Console.WriteLine(
transport.LastSpec?.EnvironmentVariables?["VAGRANT_HOME"]);The environment printed at the end is example/.vagrant.d: the command overrides the binding's default-config value. The executor passes both serialized arguments and the merged environment to the transport.
The selected parser keeps the logical source line, stdout/stderr provenance, OutputVersion and OutputProfile on line-derived events. Several observations can share the same line. The terminal event reports the actual process exit and has no invented source line.
The binding's DetectedVersion is supplied metadata. It does not install Vagrant or select a different executable. Replacing the fake transport with a real runner requires an intended executable and operation.
Request a format before interpreting it
Command properties and environment variables request output formats; parser factories select decoders. These decisions must agree.
| Consumer | Invocation setting | Parsing consequence |
|---|---|---|
| Vagrant | MachineReadable = true |
The default factory selects the machine-readable path for a reviewed version. |
| Git | Porcelain or another command-specific output option | The factory distinguishes status, references, diff and Trace2 contexts. |
| Docker | An appropriate JSON/format option for a supported command | The selected parser interprets engine events, formatted objects or inspection output. |
| Docker Compose | A supported command and typed progress configuration | Format selection accounts for CLI version, progress encoding and attached service output. |
| Packer | MachineReadable = true |
Exact serialization enables machine-readable recognition rather than human output parsing. |
An explicit parsing factory does not add an output switch. Conversely, requesting JSON does not establish that the current parser recognizes every server or plugin schema contained in it.
The implementation matrix reports reviewed output contracts independently from help collection. Each study shows the relevant command, version and format together.
Keep observations and invocation results separate
Typed observations and process completion describe different facts. A readiness message can precede failure, and a successful process can finish without producing the observation an application expected.
VagrantUpCollector aggregates distinct ready-machine names and accounts for terminal status. PackerBuildCollector also tracks artifact-report structure and completeness. Their result types retain tool-specific meaning rather than presenting one generic success flag for every observation.
Typed streaming exposes terminal parser events even when the process exits nonzero. Typed collected execution delivers events to the collector but returns CommandError on a nonzero exit without calling its final Complete(). Applications that need partial observations can consume the stream or retain their collector explicitly.
A container start record is a lifecycle observation. A server pipeline status is a resource observation. Neither replaces the exit status of the CLI process that printed it. Chapters 7 and 8 explain those distinctions.
Combining consumers without merging their protocols
An application can depend on several runner interfaces while retaining each tool's event vocabulary. GitEvent, PackerEvent and VagrantEvent carry different observations and evidence; shared BinaryWrapper contracts describe their transport and invocation boundaries.
The host can reuse a resolver containing several bindings or construct separate executors with transports appropriate to each tool. Either way, the consumer factory creates a fresh parser for each execution, so JSON buffers and protocol state are not shared between runs.
The application chooses which commands to invoke and how to use their results. The wrappers do not automatically connect every consumer into one workflow. GitLab CI YAML is also a separate configuration library: serialization creates pipeline text; a Git or glab invocation is another operation with its own result.
Keep effective inputs inspectable
Binary selection, command targets, output profiles and environment compatibility remain separate inputs.
| Input | What to inspect |
|---|---|
| Binding | Executable path, binary identifier, detected version and environment defaults. |
| Command | Path segments, serialized argument array, explicit assignments and target version. |
| Environment | Variable conversions, compatibility bounds and command-over-binding precedence. |
| Parser factory | Selected format, reviewed interval, prerelease handling and raw fallback. |
| Observations | Original source lines, profile metadata, projected fields and terminal events. |
| Collected result | Tool-specific completeness rules and the executor's failure result. |
Parser selection uses command.TargetVersion ?? binding.DetectedVersion. An explicit target therefore takes precedence for parsing, while environment serialization still uses the command's own version-aware contract. Supplying one version value does not erase the other compatibility questions.
A raw fallback preserves evidence and terminal status for an unknown format or version. Code requiring a particular semantic record must handle the possibility that only raw observations are available.
Choose the appropriate verification boundary
Runner doubles, fake process transports and real processes exercise different integration boundaries.
Use a runner double to verify business reactions to known event sequences. Use a fake transport with the real consumer runner to verify argument serialization, environment propagation, factory selection, evidence identity and actual exit handling. Real-process checks additionally exercise executable resolution, process lifetime and external dependencies.
The complete example above verifies the second boundary. Its output confirms the command environment, channel provenance and terminal status; it does not claim that a Vagrant operation was performed.
The extension and validation chapter provides the maintenance sequence. The implementation studies document each consumer's available formats, tests and remaining limits.
Source snapshot
Source inspection: 2 October 2026. These implementation paths are relative to Net/FrenchExDev in the local ecosystem checkout. The v2 changes are largely uncommitted; these paths identify inspected code, without claiming that public branch links contain it.
BinaryWrapper/src/FrenchExDev.Net.BinaryWrapper/Code.csBinaryWrapper/src/FrenchExDev.Net.BinaryWrapper.Contracts/CommandRunnerContracts.csVagrant/src/FrenchExDev.Net.Vagrant.Contracts/IVagrantRunner.csVagrant/src/FrenchExDev.Net.Vagrant/VagrantRunner.csVagrant/src/FrenchExDev.Net.Vagrant/VagrantUpCollector.csPacker/src/FrenchExDev.Net.Packer/PackerBuildCollector.cs