CLI Runtime - Garume/Manifold GitHub Wiki
The Manifold.Cli package provides the complete command-line interface runtime for Manifold operations. It handles the entire CLI dispatch pipeline: parsing command-line arguments, resolving operations by command path, binding positional arguments and named options to operation parameters, invoking operations through generated invokers (including zero-allocation fast paths), formatting results as text or JSON, and returning structured exit codes. This package depends exclusively on the core Manifold package and is consumed by CLI host applications that wire it together with the source-generated GeneratedCliInvoker and GeneratedOperationRegistry.
The runtime is designed around a dual-path invocation model: a fast path that bypasses parsing overhead by dispatching directly from raw command tokens, and a slow path that provides full argument parsing, validation, and help text generation. This architecture enables Manifold CLI applications to achieve near-zero overhead for simple commands while retaining full-featured CLI semantics. For an overview of how this package fits into the broader system, see the Architecture Overview.
The Manifold.Cli package contains seven source files, each with a focused responsibility:
| File | Type | Purpose |
|---|---|---|
CliApplication.cs |
sealed class |
Entry point orchestrating the full dispatch pipeline |
CliBinding.cs |
static class |
Argument/option parsing, type conversion, service resolution |
CliExitCodes.cs |
static class |
Standardized exit code constants |
CliInvocationResult.cs |
record struct |
Standard-path result container with text and JSON payloads |
FastCliInvocationResult.cs |
struct |
Fast-path result with union-based value overlay |
ICliInvoker.cs |
interface |
Standard invocation contract |
IFastCliInvoker.cs |
interface |
Fast-path invocation contracts (sync and async) |
Sources: src/Manifold.Cli/Manifold.Cli.csproj:1-22, src/Manifold.Cli/CliApplication.cs:1-675
The following diagram illustrates the complete dispatch pipeline from raw command-line arguments to formatted output:
flowchart TD
A["ExecuteAsync(args)"] --> B{"args is string[]?"}
B -- Yes --> C{"Try Fast Path"}
B -- No --> F["Execute Slow Path"]
C -- "IFastSyncCliInvoker" --> D["TryInvokeFastSync"]
C -- "IFastCliInvoker" --> E["TryInvokeFast"]
D -- Success --> G["WriteFastResult"]
E -- Success --> G
D -- Fail --> F
E -- Fail --> F
C -- "No fast invoker" --> F
F --> H["ParseArguments"]
H --> I{"--help only?"}
I -- Yes --> J["Print Usage"]
I -- No --> K["TryResolveOperation"]
K -- Not found --> L["Print Error"]
K -- Found --> M{"--help requested?"}
M -- Yes --> N["Print Operation Usage"]
M -- No --> O["ParseCommandInput"]
O --> P["ICliInvoker.TryInvoke"]
P --> Q["WriteResultAsync"]
G --> R["Exit Code 0"]
Q --> R
J --> R
L --> S["Exit Code 2"]
N --> R
Sources: src/Manifold.Cli/CliApplication.cs:62-195
CliApplication is the central orchestrator of the CLI runtime. It is constructed with the operation registry, a generated invoker, an optional service provider, and optional JSON serialization options. At construction time, it builds an internal lookup structure (FrozenDictionary) for fast command resolution.
public CliApplication(
IReadOnlyList<OperationDescriptor> operations,
ICliInvoker cliInvoker,
IServiceProvider? services = null,
Stream? rawOutput = null,
JsonSerializerOptions? jsonSerializerOptions = null)The constructor inspects the provided cliInvoker via interface casting to detect whether it also implements IFastSyncCliInvoker or IFastCliInvoker. If either fast-path interface is available, the corresponding field is populated for use during dispatch. The JSON serializer defaults to JsonSerializerDefaults.Web with WriteIndented = true.
Sources: src/Manifold.Cli/CliApplication.cs:25-46
A typical CLI host wires CliApplication with the source-generated registry and invoker:
CliApplication application = new(
GeneratedOperationRegistry.Operations,
new GeneratedCliInvoker(),
serviceProvider,
rawOutput: Console.OpenStandardOutput(),
jsonSerializerOptions: new JsonSerializerOptions(JsonSerializerDefaults.Web)
{
WriteIndented = true
});
return await application.ExecuteAsync(args, Console.Out, Console.Error);Sources: samples/Manifold.Samples.CliHost/Program.cs:12-22
CliApplication provides two ExecuteAsync overloads. The string[] overload attempts the fast path first and falls back to the slow path. The IReadOnlyList<string> overload delegates to the array overload when possible, otherwise goes directly to the slow path.
public Task<int> ExecuteAsync(
string[] args,
TextWriter output,
TextWriter error,
CancellationToken cancellationToken = default)
public Task<int> ExecuteAsync(
IReadOnlyList<string> args,
TextWriter output,
TextWriter error,
CancellationToken cancellationToken = default)Sources: src/Manifold.Cli/CliApplication.cs:62-92
The first stage of the slow path extracts the two reserved global flags (--help and --json) from the argument list. These flags are removed from the token stream before command resolution. The result is captured in the internal ParsedArguments record struct:
private readonly record struct ParsedArguments(
IReadOnlyList<string> CommandTokens,
bool Json,
bool RequestHelp);Flag comparison is case-insensitive. When no flags are present, the original argument list is returned directly without allocation.
Sources: src/Manifold.Cli/CliApplication.cs:241-280, src/Manifold.Cli/CliApplication.cs:577
Operation resolution uses a FrozenDictionary<string, CliCommandCandidate[]> keyed by the first token of each command path. Candidates are sorted by path length in descending order, enabling longest-match-first resolution. This supports multi-token command paths such as math add where math is the first token and add is the second.
flowchart TD
A["Input: 'math add 4 5'"] --> B["Lookup first token 'math'"]
B --> C["Get candidates sorted by path length"]
C --> D{"Match 'math add'?"}
D -- Yes --> E["Return operation, consumed=2"]
D -- No --> F{"Match 'math'?"}
F -- Yes --> G["Return operation, consumed=1"]
F -- No --> H["Return false"]
All command matching is case-insensitive via StringComparison.OrdinalIgnoreCase. Command aliases defined via CliCommandAliases on the OperationDescriptor are included as additional candidate paths.
Sources: src/Manifold.Cli/CliApplication.cs:197-239, src/Manifold.Cli/CliApplication.cs:282-322
After the command tokens are consumed, the remaining tokens are parsed into options and positional arguments. Options use the --name value or --name=value format. The parser validates:
-
Known options — unknown options produce an
ArgumentExceptionwith the messageUnknown option '--foo' for command 'bar'. -
Required options — missing required options produce
Missing required --name option. -
Required arguments — missing positional arguments produce
Missing required argument 'name'. -
Option values — options that reference another option token or are empty produce
The --name option requires a non-empty value.
private readonly record struct ParsedCommandInput(
IReadOnlyDictionary<string, string> Options,
IReadOnlyList<string> Arguments);Sources: src/Manifold.Cli/CliApplication.cs:324-391, src/Manifold.Cli/CliApplication.cs:579-581
Manifold defines three invoker interfaces forming a hierarchy from fastest to most flexible. The source generator emits a GeneratedCliInvoker class that implements these interfaces.
The standard invoker receives pre-parsed operation ID, options dictionary, and arguments list:
public interface ICliInvoker
{
public bool TryInvoke(
string operationId,
IReadOnlyDictionary<string, string> options,
IReadOnlyList<string> arguments,
IServiceProvider? services,
bool jsonRequested,
CancellationToken cancellationToken,
out ValueTask<CliInvocationResult> invocation);
}The TryInvoke pattern returns false when no handler exists for the given operationId, allowing the caller to report an appropriate error.
Sources: src/Manifold.Cli/ICliInvoker.cs:1-14
The synchronous fast-path invoker bypasses all parsing and operates directly on raw command tokens:
public interface IFastSyncCliInvoker
{
public bool TryInvokeFastSync(
string[] commandTokens,
IServiceProvider? services,
CancellationToken cancellationToken,
out FastCliInvocationResult invocation);
}This interface enables fully synchronous, zero-allocation dispatch for simple operations that return primitive values.
Sources: src/Manifold.Cli/IFastCliInvoker.cs:3-10
The asynchronous fast-path invoker supports operations that require asynchronous execution while still avoiding the overhead of full argument parsing:
public interface IFastCliInvoker
{
public bool TryInvokeFast(
string[] commandTokens,
IServiceProvider? services,
CancellationToken cancellationToken,
out ValueTask<FastCliInvocationResult> invocation);
}Sources: src/Manifold.Cli/IFastCliInvoker.cs:12-19
sequenceDiagram
participant App as CliApplication
participant Sync as IFastSyncCliInvoker
participant Fast as IFastCliInvoker
participant Std as ICliInvoker
App->>Sync: TryInvokeFastSync(tokens)
alt Sync available and matched
Sync-->>App: FastCliInvocationResult
else Sync unavailable or no match
App->>Fast: TryInvokeFast(tokens)
alt Fast available and matched
Fast-->>App: ValueTask<FastCliInvocationResult>
else Fast unavailable or no match
App->>App: ParseArguments + ResolveOperation
App->>Std: TryInvoke(operationId, options, args)
Std-->>App: ValueTask<CliInvocationResult>
end
end
The fast-path attempt is guarded by [MethodImpl(MethodImplOptions.AggressiveInlining)] and an early exit when commandTokens.Length == 0.
Sources: src/Manifold.Cli/CliApplication.cs:151-195
The fast-path mechanism is the performance-critical dispatch route. When CliApplication receives a string[] of arguments, TryExecuteArrayFastPath is called before any parsing occurs.
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private bool TryExecuteArrayFastPath(
string[] commandTokens,
TextWriter output,
TextWriter error,
CancellationToken cancellationToken,
out Task<int>? execution)The method first attempts IFastSyncCliInvoker.TryInvokeFastSync for fully synchronous execution. If unavailable, it tries IFastCliInvoker.TryInvokeFast. If the ValueTask from the async path completes synchronously (IsCompletedSuccessfully), the result is extracted without awaiting. Exception handling converts ArgumentException to exit code 2 and InvalidOperationException to exit code 3.
Sources: src/Manifold.Cli/CliApplication.cs:151-195
The source generator emits a GeneratedCliInvoker class that implements both ICliInvoker and IFastCliInvoker. The TryInvoke method dispatches by comparing the operationId string using String.Equals with StringComparison.Ordinal:
public sealed class GeneratedCliInvoker : ICliInvoker, IFastCliInvoker
{
public bool TryInvoke(
string operationId,
IReadOnlyDictionary<string, string> options,
IReadOnlyList<string> arguments,
IServiceProvider? services,
bool jsonRequested,
CancellationToken cancellationToken,
out ValueTask<CliInvocationResult> invocation)
{
if (String.Equals(operationId, "math.add", StringComparison.Ordinal))
{
invocation = InvokeMathAddAsync(options, arguments, services, cancellationToken);
return true;
}
// ... additional operations
invocation = default;
return false;
}
}Each per-operation invoker method uses CliBinding helpers to extract and convert parameters, then calls the actual operation method:
private static async ValueTask<CliInvocationResult> InvokeMathAddAsync(
IReadOnlyDictionary<string, string> options,
IReadOnlyList<string> arguments,
IServiceProvider? services,
CancellationToken cancellationToken)
{
int x = (int)CliBinding.ConvertValue(typeof(int),
CliBinding.GetRequiredArgument(arguments, 0, "x"), "x")!;
int y = (int)CliBinding.ConvertValue(typeof(int),
CliBinding.GetRequiredArgument(arguments, 1, "y"), "y")!;
IMathOffsetProvider offsets =
CliBinding.GetRequiredService<IMathOffsetProvider>(services);
int result = await SampleCliOperations.AddAsync(x, y, offsets, cancellationToken)
.ConfigureAwait(false);
string? text = CliBinding.FormatDefaultText(result);
return new CliInvocationResult(result, typeof(int), text);
}For class-based operations implementing IOperation<TRequest, TResult>, the generated code constructs the request object and resolves the operation from the service provider:
private static async ValueTask<CliInvocationResult> InvokeMathScaleAsync(...)
{
int value = (int)CliBinding.ConvertValue(...);
MathScaleOperation.Request request = new() { Value = value };
MathScaleOperation operation =
CliBinding.GetRequiredServiceOrThrow<MathScaleOperation>(services);
int result = await operation.ExecuteAsync(request,
OperationContext.ForCli("math.scale", services, cancellationToken: cancellationToken))
.ConfigureAwait(false);
// ...
}Sources: tests/Manifold.Cli.Tests/.artifacts/generated-cli-tests/.../GeneratedCliInvoker.g.cs:1-110, .artifacts/generated-inspect/.../GeneratedCliInvoker.g.cs:1-66
The CliBinding static class provides the low-level binding infrastructure used by generated invoker code. All methods are marked with [MethodImpl(MethodImplOptions.AggressiveInlining)] for performance.
CliBinding.ConvertValue is the central type conversion method. It accepts a target Type, a raw string value, and a display name for error messages:
flowchart TD
A["ConvertValue(type, text)"] --> B{"string?"}
B -- Yes --> C["Return directly"]
B -- No --> D{"Nullable<T>?"}
D -- Yes --> E["Unwrap, recurse"]
D -- No --> F{"Array?"}
F -- Yes --> G["Split on comma, convert elements"]
F -- No --> H{"Enum?"}
H -- Yes --> I["Case-insensitive Enum.TryParse"]
H -- No --> J{"Primitive type?"}
J -- Yes --> K["Typed parser method"]
J -- No --> L["Throw InvalidOperationException"]
| Target Type | Parser Method | Culture |
|---|---|---|
string |
ParseString |
N/A |
bool |
ParseBoolean |
N/A |
int |
ParseInt32 |
InvariantCulture |
long |
ParseInt64 |
InvariantCulture |
double |
ParseDouble |
InvariantCulture |
decimal |
ParseDecimal |
InvariantCulture |
Guid |
ParseGuid |
N/A |
Uri |
ParseUri |
N/A |
DateTimeOffset |
ParseDateTimeOffset |
InvariantCulture |
Enum |
Enum.TryParse |
Case-insensitive |
T[] |
Split on ,, convert each element |
Per-element |
Nullable<T> |
Recursive, returns null for whitespace |
Per-underlying |
Sources: src/Manifold.Cli/CliBinding.cs:266-329
TryFindOptionValue performs case-insensitive lookup in the options dictionary, checking both the primary name and any aliases:
public static bool TryFindOptionValue(
IReadOnlyDictionary<string, string> options,
string name,
IReadOnlyList<string>? aliases,
out string? value)Sources: src/Manifold.Cli/CliBinding.cs:214-237
IsReservedGlobalFlag uses a hand-optimized character-by-character comparison to detect --json and --help flags. The method checks for exactly 6 characters starting with --, then matches the remaining 4 characters using AsciiEqualsIgnoreCase bit-manipulation:
private static bool AsciiEqualsIgnoreCase(char left, char right)
{
return left == right || (left | (char)0x20) == right;
}Sources: src/Manifold.Cli/CliBinding.cs:8-22, src/Manifold.Cli/CliBinding.cs:382-386
CliBinding provides two service resolution methods for use by generated code:
| Method | Behavior |
|---|---|
GetRequiredService<T>(services) |
Resolves via IServiceProvider.GetService, throws InvalidOperationException if null |
GetRequiredServiceOrThrow<T>(services) |
Same behavior, used for class-based IOperation resolution |
Sources: src/Manifold.Cli/CliBinding.cs:239-264
The standard result type carries the boxed result object, its runtime type, optional pre-formatted text, and optional pre-serialized JSON:
public readonly record struct CliInvocationResult(
object? Result,
Type ResultType,
string? Text = null,
byte[]? RawJsonPayload = null);Sources: src/Manifold.Cli/CliInvocationResult.cs:1-8
The fast-path result uses a discriminated union pattern to avoid boxing. The FastCliInvocationValue internal struct uses [StructLayout(LayoutKind.Explicit, Size = 16)] to overlay all supported value types at offset 0:
[StructLayout(LayoutKind.Explicit, Size = 16)]
internal readonly struct FastCliInvocationValue
{
[FieldOffset(0)] private readonly bool boolean;
[FieldOffset(0)] private readonly int number;
[FieldOffset(0)] private readonly long largeNumber;
[FieldOffset(0)] private readonly double realNumber;
[FieldOffset(0)] private readonly decimal preciseNumber;
[FieldOffset(0)] private readonly Guid identifier;
[FieldOffset(0)] private readonly DateTimeOffset timestamp;
}The FastCliInvocationKind enum discriminates which value is active:
| Kind | Value | .NET Type |
|---|---|---|
None |
0 | (no value) |
Text |
1 | string |
Boolean |
2 | bool |
Number |
3 | int |
LargeNumber |
4 | long |
RealNumber |
5 | double |
PreciseNumber |
6 | decimal |
Identifier |
7 | Guid |
Timestamp |
8 | DateTimeOffset |
Sources: src/Manifold.Cli/FastCliInvocationResult.cs:6-239
Result output depends on the invocation path and the --json flag:
flowchart TD
A{"Fast or Standard Path?"} --> B["Fast Path"]
A --> C["Standard Path"]
B --> D["WriteFastResult"]
D --> E{"Kind?"}
E -- None --> F["Return immediately"]
E -- Text --> G["Write string"]
E -- "Numeric/Bool/etc." --> H["Stack-alloc format via ISpanFormattable"]
C --> I{"--json flag?"}
I -- Yes --> J{"RawJsonPayload?"}
J -- "Yes, has bytes" --> K["Write raw bytes to Stream"]
J -- No --> L["JsonSerializer.Serialize"]
I -- No --> M{"Text non-empty?"}
M -- Yes --> N["Write Text"]
M -- No --> O["Return immediately"]
For the fast path, numeric and other ISpanFormattable types are formatted into a stack-allocated 64-character buffer, avoiding heap allocation entirely:
private static Task<int> WriteFormattableLineAndReturn<T>(TextWriter writer, T value)
where T : ISpanFormattable
{
Span<char> buffer = stackalloc char[64];
if (value.TryFormat(buffer, out int charsWritten, default, CultureInfo.InvariantCulture))
return WriteSpanLineAndReturn(writer, buffer[..charsWritten]);
return WriteLineAndReturnAsync(writer,
value.ToString(null, CultureInfo.InvariantCulture), SuccessExitCodeTask);
}Sources: src/Manifold.Cli/CliApplication.cs:393-511
When --json is passed, the CLI runtime switches to machine-readable JSON output. JSON serialization follows a priority order:
-
Pre-serialized payload — If
CliInvocationResult.RawJsonPayloadcontains bytes, they are written directly. When arawOutputstream is configured (e.g.,Console.OpenStandardOutput()), the bytes are written as raw UTF-8 to the binary stream. Otherwise, they are decoded to a UTF-8 string and written to the text output. -
Dynamic serialization — If no pre-serialized payload exists,
JsonSerializer.Serialize(Result, ResultType, jsonSerializerOptions)is called. The serializer usesJsonSerializerDefaults.WebwithWriteIndented = trueby default.
The --json flag is extracted during global flag parsing and passed through the entire pipeline as the jsonRequested parameter to ICliInvoker.TryInvoke.
Sources: src/Manifold.Cli/CliApplication.cs:393-419, src/Manifold.Cli/CliApplication.cs:480-486
The CliExitCodes static class defines four standardized exit codes:
| Constant | Value | Meaning | Triggered By |
|---|---|---|---|
Success |
0 | Operation completed successfully | Successful invocation and result write |
UnhandledFailure |
1 | Unexpected error | Unhandled exceptions (outside dispatch pipeline) |
UsageError |
2 | Invalid input |
ArgumentException — unknown command, missing arguments, invalid values |
Unavailable |
3 | Missing dependency |
InvalidOperationException — service not registered, no invoker available |
The exit code mapping is implemented via cached Task<int> instances to avoid allocations on repeated exits:
private static readonly Task<int> SuccessExitCodeTask = Task.FromResult(CliExitCodes.Success);
private static readonly Task<int> UsageErrorExitCodeTask = Task.FromResult(CliExitCodes.UsageError);
private static readonly Task<int> UnavailableExitCodeTask = Task.FromResult(CliExitCodes.Unavailable);Both the fast path and slow path catch ArgumentException (exit code 2) and InvalidOperationException (exit code 3) at the dispatch boundary, writing the exception message to the error stream.
Sources: src/Manifold.Cli/CliExitCodes.cs:1-9, src/Manifold.Cli/CliApplication.cs:11-14
GetUsage generates a summary of all visible (non-hidden) CLI operations, sorted alphabetically by display command. It also lists the two global options:
Usage:
app math add <x> <y>
app weather preview --city <value> [--temperature <value>]
Options:
--help Show help for the application or a specific command.
--json Emit machine-readable JSON instead of text.
Sources: src/Manifold.Cli/CliApplication.cs:48-60
GetOperationUsage generates detailed help for a single command, including:
-
Syntax line —
Usage: app command <required-arg> [optional-arg] --required-option <value> [--optional-option <value>] -
Description — from the
OperationDescriptor.Descriptionfield -
Aliases — displayed as
Aliases: cmd, c - Parameter descriptions — each argument and option with its description
Required arguments are shown in <angle brackets>, optional arguments in [square brackets]. Required options omit brackets; optional options are wrapped in [...].
foreach (ParameterDescriptor parameter in operation.ArgumentParameters)
{
builder.Append(' ')
.Append(parameter.Required ? '<' : '[')
.Append(GetCliParameterName(parameter))
.Append(parameter.Required ? '>' : ']');
}Help is triggered by passing --help anywhere in the argument list. If no command tokens are present, the application-level usage is shown. If a command is resolved before --help is detected, the operation-specific usage is shown.
Sources: src/Manifold.Cli/CliApplication.cs:519-575
CliOperationState is a private nested class within CliApplication that pre-computes and caches all CLI-relevant metadata for each operation at construction time:
classDiagram
class CliOperationState {
+OperationDescriptor Operation
+string DisplayCommand
+string[] CommandAliasDisplays
+string[][] CommandPaths
+ParameterDescriptor[] Parameters
+ParameterDescriptor[] ArgumentParameters
+ParameterDescriptor[] OptionParameters
+ParameterDescriptor[] RequiredArgumentParameters
+ParameterDescriptor[] RequiredOptionParameters
+FrozenSet~string~ KnownOptionNames
}
class CliCommandCandidate {
+CliOperationState Operation
+string[] Path
}
CliOperationState --> CliCommandCandidate : grouped into
Key behaviors during construction:
- Parameters are separated by
ParameterSource(Argument vs Option) - Argument parameters are sorted by
Position - Option names and all aliases are collected into a case-insensitive
FrozenSet<string>for validation - Operations with
OperationVisibility.McpOnlyor without aCliCommandPathare excluded - Hidden operations are included in the command lookup but excluded from help text
Sources: src/Manifold.Cli/CliApplication.cs:583-668
The dispatch pipeline uses a structured exception-to-exit-code mapping at two boundaries (fast path and slow path):
| Exception Type | Exit Code | Typical Cause |
|---|---|---|
ArgumentException |
UsageError (2) |
Missing argument, unknown option, invalid value, unknown command |
InvalidOperationException |
Unavailable (3) |
Missing service, unsupported parameter type, no invoker match |
Both exception types are caught at the outermost dispatch boundary. The exception message is written to the error TextWriter, and the corresponding exit code is returned. This design ensures that user-facing errors from CliBinding parsing methods produce clean, actionable messages without stack traces.
Sources: src/Manifold.Cli/CliApplication.cs:120-148, src/Manifold.Cli/CliApplication.cs:165-194
- Architecture Overview — High-level system architecture and the dual-surface dispatch model
-
Core Contracts — Manifold Package —
OperationDescriptor,ParameterDescriptor, and other foundational types consumed by the CLI runtime -
Attributes and Operation Definition —
[CliCommand],[Argument],[Option],[Alias], and other attributes that define CLI-visible operations -
Source Generator — Manifold.Generators — The generator that emits
GeneratedCliInvokerandGeneratedOperationRegistry - Parameter Binding and Type Conversion — Detailed documentation of the binding pipeline shared across CLI and MCP surfaces
-
Result Types and Formatting — Full documentation of
CliInvocationResult,FastCliInvocationResult, and formatting infrastructure - Performance and Benchmarks — Zero-allocation fast-path design, benchmark results, and union-based value overlay details
- Sample — CLI Host — Walkthrough of the CLI host sample application
-
Dependency Injection and Service Resolution — Service provider integration and
[FromServices]attribute usage - MCP Runtime — Manifold.Mcp — The parallel MCP surface runtime for comparison