CLI Runtime - Garume/Manifold GitHub Wiki

CLI Runtime — Manifold.Cli

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.

Package Structure

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

CLI Dispatch Pipeline

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
Loading

Sources: src/Manifold.Cli/CliApplication.cs:62-195

CliApplication Entry Point

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.

Constructor

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

Wiring in a Host Application

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

ExecuteAsync Overloads

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

Command Parsing and Resolution

Global Flag Extraction

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

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"]
Loading

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

Option and Argument Parsing

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:

  1. Known options — unknown options produce an ArgumentException with the message Unknown option '--foo' for command 'bar'.
  2. Required options — missing required options produce Missing required --name option.
  3. Required arguments — missing positional arguments produce Missing required argument 'name'.
  4. 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

Invoker Interfaces

Manifold defines three invoker interfaces forming a hierarchy from fastest to most flexible. The source generator emits a GeneratedCliInvoker class that implements these interfaces.

ICliInvoker (Standard Path)

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

IFastSyncCliInvoker (Synchronous Fast Path)

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

IFastCliInvoker (Asynchronous Fast Path)

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

Invoker Selection Sequence

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
Loading

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

Fast-Path Invocation

The fast-path mechanism is the performance-critical dispatch route. When CliApplication receives a string[] of arguments, TryExecuteArrayFastPath is called before any parsing occurs.

TryExecuteArrayFastPath

[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

Generated CLI Invoker

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

Argument and Option Binding — CliBinding

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.

Type Conversion

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"]
Loading
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

Option Lookup

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

Reserved Global Flags

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

Service Resolution

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

Result Types and Formatting

CliInvocationResult (Standard Path)

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

FastCliInvocationResult (Fast Path)

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 Writing

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"]
Loading

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

JSON Output Mode

When --json is passed, the CLI runtime switches to machine-readable JSON output. JSON serialization follows a priority order:

  1. Pre-serialized payload — If CliInvocationResult.RawJsonPayload contains bytes, they are written directly. When a rawOutput stream 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.

  2. Dynamic serialization — If no pre-serialized payload exists, JsonSerializer.Serialize(Result, ResultType, jsonSerializerOptions) is called. The serializer uses JsonSerializerDefaults.Web with WriteIndented = true by 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

Exit Codes

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

Help Text Generation

Application-Level Usage

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

Operation-Level Usage

GetOperationUsage generates detailed help for a single command, including:

  • Syntax lineUsage: app command <required-arg> [optional-arg] --required-option <value> [--optional-option <value>]
  • Description — from the OperationDescriptor.Description field
  • 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

Internal State Model — CliOperationState

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
Loading

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.McpOnly or without a CliCommandPath are excluded
  • Hidden operations are included in the command lookup but excluded from help text

Sources: src/Manifold.Cli/CliApplication.cs:583-668

Error Handling

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

Related Pages

⚠️ **GitHub.com Fallback** ⚠️