Command sources
Command sources
Any type definition can be used as a command source, where public methods are exposed as CLI commands.
class MyCommandSource
{
public static void CommandA( ) { ... }
public static void CommandB( ) { ... }
}
Command discovery
Make methods non-public to prevent discovery:
class MyCommandSource
{
// Reflected as commands
public static void CommandA( ) { ... }
public static void CommandB( ) { ... }
// Excluded from commands
private static void PrivateHelper( ) { ... }
}
The CLINoCommandAttribute can be used to explicitly exclude public methods from discovery:
class MyCommandSource
{
// Included as commands
public static void CommandA( ) { ... }
public static void CommandB( ) { ... }
// Explicitly excluded from commands
[ CLINoCommand ]
public static void PublicHelper( ) { ... }
// Implicitly excluded from commands
private static void PrivateHelper( ) { ... }
}
Public methods found on System.Object are excluded from discovery and can be overridden safely.
Static-only command sourcing
CLI.From( Type source, object? target ) with a null target discovers static commands on any given type:
static class MyCommandSource { ... }
CLI.From( typeof( MyCommandSource ), null )
.Run( );
Instance command sourcing
Providing an invocation instance with any of the following includes public instance methods as commands:
class MyCommandSource { ... }
// explicit overload
var a = CLI.From( typeof( MyCommandSource ), new MyCommandSource( ) );
// implicit overload for public parameterless constructors
var b = CLI.From<MyCommandSource>( );
// explicit instance
var c = CLI.From( new MyCommandSource( arg_a, arg_b ) );
Instance command sourcing also includes public static methods on the same type.
Nested command sources & grouping
Nested command sources are not supported. Only immediate methods of the specified command source are discovered.
To see how commands can be grouped, read Command grouping.
Commands
Methods are commands
C# idiomatic method signatures are implicitly transformed to their CLI command equivalents.
public static string ReverseString ( string arg ) { ... }
// resulting CLI invocations:
// 'reverse string foo'
// 'reverse string --arg foo'
// 'reverse string -a foo'
Translation rules:
- Any method identifier consisting of ASCII letters and underscores is a valid command name.
- PascalCase is transformed to lowercase by inserting whitespace before each subsequent uppercase letter.
- Underscores are ignored and can be used for visual aid.
Command grouping
Commands can be grouped by sharing a common prefix in their names:
public static void Foo ( ) { ... }
public static void FooBar ( ) { ... }
public static void FooBarBaz ( ) { ... }
public static void FooBaz ( ) { ... }
// resulting CLI invocations:
// 'foo'
// 'foo bar'
// 'foo bar baz'
// 'foo baz'
Command overloading
Overloading C# methods used for commands is not supported.
Parameters
Parameters are flags
C# method parameters are transformed to CLI flags implicitly:
public static void Command ( int antArg, int beeArg, int catArg ) { ... }
// Positional invocation:
// 'command 1 2 3'
// Arbitrary order flagged invocation:
// 'command --bee-arg 2 --ant-arg 1 --cat-arg 3'
// Mixed positional & flagged invocation:
// 'command 1 --cat-arg 3 --bee-arg 2'
Translation rules:
- Any parameter identifier consisting of ASCII letters and underscores is a valid flag name.
- camelCase is transformed to lowercase by inserting a dash before each subsequent uppercase letter.
- The identifier's first letter is used as the lowercase short-option.
- Underscores are ignored and can be used for visual aid.
Up to 25 parameters are supported — one per lowercase ASCII letter — with -h being reserved for help generation.
String flags
CLI arguments passed to C# string parameters follow these rules:
- Quotation on string arguments is optional but supported to resolve ambiguity.
- Escape sequences are supported and do not require quotation.
- Arguments are case-sensitive.
Boolean flags
Boolean C# parameters become boolean CLI flags. This means the presence of those flags is treated as true without an explicit argument.
Further, boolean flags can be negated by passing ! as shorthand argument.
public static string Command ( bool ant, bool bee )
{
return $"ant: {ant}\n"
+ $"bee: {bee}";
}
Explicit output
User@CLI> command -a true -b false ant: True bee: False
Implicit output
User@CLI> command -a ! -b ant: False bee: True
Optional flags
Optional C# parameters become optional CLI flags. The given default values, including null, are supplied for omitted flags:
public static string Command ( int? ant = 17, int? bee = null, object cat = null )
{
return $"ant: { ant.HasValue }, {( ant.HasValue ? ant.Value : "null" )}\n"
+ $"bee: { bee.HasValue }, {( bee.HasValue ? bee.Value : "null" )}\n"
+ $"cat: { cat != null }, {( cat != null ? cat : "null" )}";
}
User@CLI> command ant: True, 17 bee: False, null cat: False, null
Array flags
Array C# parameters become array CLI flags. This means they can be supplied with any number of arguments, including none.
public static int Sum ( int[] args )
{
return args.Sum( );
}
User@CLI> sum 0 User@CLI> sum 5 11 17 33
Any array flag acts implicitly as params array when supplied with arguments positionally, as shown in the above example.
When supplied as a flag, the array name is stated explicitly, but it still accepts zero to an arbitrary number of values.
This behavior is not limited to arrays in the last parameter position.
public static int MultiplySum ( int[] args, int multiplier )
{
return args.Sum( ) * multiplier;
}
User@CLI> multiply sum -m 10 0 User@CLI> multiply sum 5 11 17 -m 3 99
Note that this means any flags defined after an array in the parameter list cannot be supplied positionally, as once reached the array will be supplied greedily.
Enum flags
Enum C# parameters become enum CLI flags. This means that they are supplied by their name:
public enum Day { Mon = 1, Tue, Wed, Thu, Fri, Sat, Sun }
public static string PrintDay ( Day day )
{
return $"{day}: {(int)day}";
}
User@CLI> print day mon Mon: 1 User@CLI> print day sun Sun: 7
Enums can be decorated with the MapNumbersAttribute to enable mapping numeric values to named enum members:
[ MapNumbers ]
public enum Day { Mon = 1, Tue, Wed, Thu, Fri, Sat, Sun }
User@CLI> print day 1 Mon: 1 User@CLI> print day 7 Sun: 7 User@CLI> print day 8 ❌ Invalid value for argument 'day' Expected: Day Actual: "8"
While enums with [ Flags ] can be used as described here, combining flags is currently not supported.
Argument parsing
Argument parsing
Non-exhaustive list of built-in support:
- Any C# primitive (
string,int,char,bool, …) - Arrays
- Enums
Nullable<T>for any built-in or custom parseableTDateTime,DateTimeOffset&TimeSpanGuid,Uri,Version
Every BCL type not listed here that provides members discussed in Parse discovery is implicitly supported.
Source argument format
CLI arguments are forwarded to type parsing as raw strings:
- Their quotation is not removed.
- Escape sequences are preserved.
- Case-sensitivity is preserved.
- Multiple tokens separated by whitespace are treated as a single string and have to be split manually.
Arguments passed directly to string flags do not require parsing and thus follow these rules instead.
Parse discovery
Argument parsing for T is automatically discovered for, in this order:
- Public static
T Parse( string source )methods implemented byT - Public constructors of
Twith a single string parameter
Thereby, parsing for custom types can be provided idiomatically by implementing any of these.
// Discoverable string parameter constructor for TypeA
record TypeA ( string arg );
// Discoverable Parse method for TypeB
record TypeB
{
public static TypeB Parse ( string source ) { ... }
}
Explicit parsing
Alternatively, parsing can be supplied explicitly with CLI.AddParseFor<T>:
// Add custom parsing before calling `CLI.From`
CLI.AddParseFor<Note>( inputString => new Note( inputString, NoteType.Text ) );
// All Ajna instances utilize supplied parsing
var a = CLI.From<CommandSourceA>( );
var b = CLI.From<CommandSourceB>( );
Note that this replaces parse discovery, even for built-in types.
Propagating parse failures
Any exception occurring during parsing is wrapped gracefully in a user-facing CLI error. A CLICustomParseException can be used to provide more deliberate parsing errors.
public record Ant
{
public static Ant Parse ( string name )
{
if ( name != String.Empty )
{
...
}
else throw new CLICustomParseException( $"{ nameof( name ) } cannot be empty" );
}
}
public static void NewAnt ( Ant ant ) { ... }
User@CLI> new ant Ant Parse: name cannot be empty
As shown in the above example, type information is already included and providing it would be redundant.
CLICustomParseException is not sealed and can be inherited to integrate custom exception types in consumer code.
Results
Command results
Commands may use any valid C# return type, including void.
Return types not listed in this section will be transformed to CLI output by calling ToString on returned objects.
Thereby, ToString can be overridden on custom types to provide CLI formatting:
public record Ant ( string Name, int Age )
{
public override string ToString ( )
=> $"This ant is called {Name} and is {Age} months old";
}
public static Ant PrintAnt ( ) => new Ant( "Henry", 7 );
User@CLI> print ant This ant is called Henry and is 7 months old
IEnumerable<T>
Any return type implementing IEnumerable<T> will be treated as a sequence of outputs, with each element of type T being transformed by the rules described in this section.
Exception: nested sequences are not supported. Returning IEnumerable<IEnumerable<T>> will call ToString on the nested enumerations.
Streaming output
Command results can be streamed by utilizing enumeration:
public static IEnumerable<CLIResult> Command ( )
{
yield return "Hello";
for ( int i = 0; i < 3; ++i )
{
yield return ".";
}
yield return "\nCLI!";
}
User@CLI> command Hello . . . CLI!
Returning collections
Collections such as arrays, lists and sets are fully enumerated before output.
public static IEnumerable<int> Command ( )
{
return new[] { 1, 2, 3 };
}
Returning LINQ queries
LINQ queries are evaluated lazily and streamed as they are enumerated.
public static IEnumerable<int> Command ( )
{
return Enumerable.Range( 1, 3 ).Select( x => x * 2 );
}
Cancellation
Cancellation
Command sources can declare a CancellationToken to respond to cancellation requests in any of their defined commands. The shared token is reset before each command execution and cancelled once a Control + C key event occurs during execution.
public class CommandSource
{
public static CancellationToken CancelToken;
public void CommandA ( )
{
if ( CancelToken.IsCancellationRequested ) { ... }
}
public void CommandB ( )
{
if ( CancelToken.IsCancellationRequested ) { ... }
}
}
This feature is opt-in; defining a cancellation token is not required.
The token member is located by type, whether it is declared public or non-public, and whether it is an instance or static member. If multiple CancellationToken members exist, the first one declared is used for CLI cancellation and all subsequent tokens are ignored.
public class CommandSource
{
// First token is used for CLI cancellation
public static CancellationToken CancelToken;
// Any subsequent tokens are ignored.
public static CancellationToken ConsumerCodeTokenA;
public static CancellationToken ConsumerCodeTokenB;
}
The token member must be writable — Ajna assigns to it. A readonly field or a get-only property raises InvalidOperationException when the command table is built.
User-facing feedback
Feedback can be provided by throwing a CLICancellationException:
public IEnumerable<string> Countdown ( int seconds )
{
for ( int i = seconds; i > 0; i-- )
{
if ( CancelToken.IsCancellationRequested )
{
throw new CLICancellationException(
"Countdown was interrupted early due to cancellation"
);
}
yield return $"{ i }...";
Thread.Sleep( 1000 );
}
yield return "Time's up!";
}
User@CLI> countdown 10 10... 9... 8... 7... countdown: Countdown was interrupted early due to cancellation
CLICancellationException is not sealed and can be inherited to integrate custom exception types in consumer code.
Errors
Error handling
Any exception occurring during command execution is allowed to propagate and terminate the CLI.
User-facing errors can be produced by throwing a CLICustomCommandException with the respective message instead.
public static void Command ( string[] args )
{
if ( args.Length > 0 )
{
...
}
else throw new CLICustomCommandException( $"{ nameof( args ) } must not be empty." );
}
User@CLI> command command: args must not be empty.
As shown in the above example, the command's name is already included and providing it would be redundant.
CLICustomCommandException is not sealed and can be inherited to integrate custom exception types in consumer code.
Help generation
Help generation
Commands can be called with -h or --help instead of their usual flags to query their respective documentation. Summary comments are used to complement the generated help.
Summaries are read from documentation syntax at compile time, which the C# compiler only produces when documentation parsing is enabled. Consuming projects need <GenerateDocumentationFile>true</GenerateDocumentationFile>; without it every summary is harvested as an empty string and help renders blank.
The generated .xml file itself is never shipped, loaded or read — at build time or at runtime. Documentation is compiled into your assembly as string literals.
Summary
Defines the description of the command itself:
/// <summary>
/// This command's current implementation consists of three hypothetical dots.
/// </summary>
public static void Command ( ) { ... }
User@CLI> command -h This command's current implementation consists of three hypothetical dots. command ( ) -> Void
Param
Defines a description for each given parameter:
/// <summary>
/// This command's current implementation consists of three hypothetical dots.
/// </summary>
/// <param name="antArg">Defines the number of ants</param>
/// <param name="beeArg">Defines the number of bees</param>
/// <param name="catArg">Happy cat?</param>
/// <param name="dogArg">Describes the dog's favorite dish</param>
public static void Command (
int antArg,
int beeArg,
bool catArg = true,
string dogArg = "Pizza" ) { ... }
User@CLI> command -h This command's current implementation consists of three hypothetical dots. command ( [ Required ] --ant-arg : Int32 Defines the number of ants [ Required ] --bee-arg : Int32 Defines the number of bees [ Optional( True ) ] --cat-arg : Boolean Happy cat? [ Optional( Pizza ) ] --dog-arg : String Describes the dog's favorite dish ) -> Void
Returns
Defines the description for the command result. It is appended after the signature:
/// <returns> This command currently returns nothing, which was already obvious by its signature. </returns>
public static void CommandTwo ( int antArg, int beeArg ) { }
User@CLI> command two -h command two ( [ Required ] --ant-arg : Int32 [ Required ] --bee-arg : Int32 ) -> Void This command currently returns nothing, which was already obvious by its signature.
Advanced configuration
Redirecting I/O
Ajna separates command semantics from input/output concerns to allow full control over execution environments.
CLI.With is used to provide implementations for ITerminalSurface and IKeyInputSource to redirect I/O operations:
class CustomTerminalSurface : ITerminalSurface { ... }
class CustomKeyInputSource : IKeyInputSource { ... }
CLI.From<MyCommandSource>( )
.With<ITerminalSurface, CustomTerminalSurface>( new CustomTerminalSurface( ) )
.With<IKeyInputSource, CustomKeyInputSource>( new CustomKeyInputSource( ) )
.Run( );
While providing only one of these is technically supported, do note that both built-in implementations rely on System.Console and should therefore usually be overridden together.
ITerminalSurface
The terminal surface must manage an editable single-line buffer, as well as line based commits to the output stream.
interface ITerminalSurface
{
int Cursor { get; set; }
string Commit ( );
string Peek ( );
void Insert ( char insertion );
void Insert ( ReadOnlySpan<char> insertion );
void Swap ( ReadOnlySpan<char> newLine );
void Delete ( );
void Clear ( );
}
To prevent visual cursor jumps, disable either cursor movement or visibility during these operations.
Cursor
- Directly moves the cursor within the line buffer.
- Gracefully ignores boundary violating operations.
- Internally maps the horizontal offset for line wrapping.
Commit
- Flushes the current line buffer to the output stream.
- Returns the committed string.
Peek
- Returns the current line buffer.
Insert
- Inserts the
insertionargument fromCursorposition into the line buffer. - Extends editable line buffer length where applicable.
Swap
- Clears the current line buffer.
- Inserts
newLineat position 0 into the line buffer.
Delete
- Deletes the character directly after
Cursor. - Gracefully ignores boundary violating operations.
Clear
- Clears the current line buffer.
IKeyInputSource
The key input source must map key inputs to CLIKeyInput and provide sequential, non-queued key events.
interface IKeyInputSource
{
CLIKeyInput ReadKey ( );
event EventHandler CancelKeyPress;
}
ReadKey
- A blocking call that returns the next key event after invocation.
CancelKeyPress
- Occurs when cancellation is requested.
- Must execute handlers in a separate thread.
- Multiple invocations do not need to be prevented.
CLIKey enum values may change. However, they are guaranteed to match System.ConsoleKey by name where applicable. For this reason, relying on named mapping is recommended.
Known limitations
Known limitations
- Console resizing is not supported and may result in undefined behavior.
System.Consolemutates its buffer dimensions on resize and does not expose any way to prevent or observe resize events. - Text highlighting and copying is handled outside of CLI control. While text can be highlighted, it cannot be cut or replaced through the CLI, as
System.Consoleprovides no API to manage or intercept this behavior. - Command overloading is not supported. Commands are keyed by name, so two overloads of the same method collide.
- Nested command sources are not supported. Only immediate methods of the specified command source are discovered.
- Combining
[ Flags ]enum values is not supported, though flag enums themselves can be used. - Identifiers are restricted to ASCII letters and underscores. Anything else raises
CLIInvalidOperationExceptionwhen the command table is built.