Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
376 changes: 376 additions & 0 deletions .editorconfig

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
*WARP.md
*Tasks.md

# Mono auto generated files
mono_crash.*
Expand Down
68 changes: 68 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# AGENTS.md

This file provides guidance to agents when working with code in this repository.

Repository: Sharpify.CommandLineInterface (C#/.NET library + tests)

## Commands

- Build (library only)
- dotnet build src/Sharpify.CommandLineInterface/Sharpify.CommandLineInterface.csproj
- Build (entire solution)
- dotnet build Sharpify.CommandLineInterface.slnx
- Format (respect .editorconfig)
- dotnet format
- Tests (Microsoft Testing Platform runner via dotnet run; pass arguments after `--`)
- Run all tests: dotnet run --project tests/Sharpify.CommandLineInterface.Tests/Sharpify.CommandLineInterface.Tests.csproj
- List tests: dotnet run --project tests/Sharpify.CommandLineInterface.Tests/Sharpify.CommandLineInterface.Tests.csproj --list-tests
- Filter by type/method (examples)
- By class: dotnet run --project tests/Sharpify.CommandLineInterface.Tests/Sharpify.CommandLineInterface.Tests.csproj --filter-class "*ClassName*"
- By class: dotnet run --project tests/Sharpify.CommandLineInterface.Tests/Sharpify.CommandLineInterface.Tests.csproj --filter-method "*MethodName*"

## Agent Workflow Requirements

- Before running any test command, explicitly ask the user for permission to escalate out of the sandbox (if required) and wait for confirmation.

## High-level architecture and structure

- Solution layout
- Sharpify.CommandLineInterface.slnx: solution that groups the library, shared test helpers, manual sample, and test runner projects
- src/Sharpify.CommandLineInterface: core library (targets net9.0)
- tests/Sharpify.CommandLineInterface.Tests.Common: shared fixtures and sample commands referenced by tests
- tests/manual: manual harness for experimenting with the CLI (net9.0 console app)
- tests/Sharpify.CommandLineInterface.Tests: xUnit v3 tests (net9.0), configured to run with Microsoft Testing Platform via dotnet run
- Core concepts (library)
- Commands and execution
- Command and SynchronousCommand are the extensibility points for users to implement CLI commands. They expose compile-time metadata (Name, Description, Usage) and an execution entry point (ExecuteAsync for async ValueTask<int> workflows; Execute for sync).
- CliRunner orchestrates end-to-end invocation: parses input, routes to the correct command, handles help, and writes output. It is created via CliRunner.CreateBuilder().
- CliBuilder provides a fluent builder to add commands, configure output writers, and modify global CLI metadata, producing a configured CliRunner instance.
- HelpCommand and VersionCommand are appended automatically during Build() to service global `--help`/`--version` requests; OnVersionRequestedInvoke allows overriding the version command behavior.
- Arguments and parsing
- Parser converts raw args (string/ReadOnlySpan<char>/IList<string>) to an Arguments instance by tokenizing on spaces/quotes and mapping to a case-insensitive dictionary (default StringComparer.OrdinalIgnoreCase).
- Arguments (implemented across ArgumentsCore/ArgumentsAccess/ArgumentsAccessMultiple) provides high-performance retrieval, validation, and parsing APIs:
- Positional access by index; named access by key (dashes removed); flags as keys with empty values.
- Typed getters/parsers (TryGetValue<T>, GetValue<T>), enum parsing helpers, multi-value retrieval (TryGetValues) with custom separators, and ForwardPositionalArguments to shift positions after command routing.
- Output abstraction
- OutputHelper centralizes returning exit codes while writing to a configurable TextWriter set on the CliRunner (no direct dependency on System.Console). This keeps the library embeddable and testable.
- Metadata and configuration
- CliMetadata captures global app identity (name, description, author, version, license) used for help text and presentation.
- CliRunnerConfiguration and ConfigurationEnums provide structured configuration for runner behavior and modes.
- Shell completions
- Completions folder defines ICompletionProvider with concrete providers for Bash, Zsh, Fish, and PowerShell. These generate shell-specific completion data from the CLI metadata/commands.
- Utilities
- Extensions contains internal helper extensions used across the library.
- Pooling defines a lightweight, concurrency-aware object pool used for frequently allocated types such as StringBuilder and HashSet.
- Tests
- Tests cover parsing (ParserArgumentsTests), argument access APIs, builder/runner behavior (CliBuilderTests), and completion providers for each shell.
- tests/Sharpify.CommandLineInterface.Tests.Common hosts reusable fixture commands (AddCommand, EchoCommand, etc.) shared across unit and manual tests.
- The test project references the library, targets net9.0, and is configured with Microsoft Testing Platform (UseMicrosoftTestingPlatformRunner=true). Execute tests with dotnet run as shown above.
- tests/manual is a net9.0 console app that references the library and common fixtures for exploratory runs; keep it compiling when making API changes.

## CI

- .github/workflows/Tests.yaml runs tests on Ubuntu, Windows, and macOS with .NET 9.0 using a reusable workflow and the Microsoft Testing Platform runner.

## Notes

- The library enables Native AOT scenarios (IsAotCompatible=true, IsTrimmable=true) and intentionally avoids reflection in core paths; when consuming the library, prefer compile-time metadata and explicit parsing over reflection to keep AOT compatibility.
- XML documentation is generated (GenerateDocumentationFile=True). Public APIs should be documented; prefer inheritdoc on members when appropriate.
28 changes: 21 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,20 @@

Most other command line frameworks in c# use `reflection` to provide their "magic" such as generating help text, and providing input validation, `Sharpify.CommandLineInterface` instead uses compile time implemented metadata and user guided validation. each command must implement the `Command` or `SynchronousCommand` abstract class, part of which will be to set the command metadata, the main entry `CliRunner` also has an application level metadata object that can be customized in the `CliBuilder` process, using those, `Sharpify.CommandLineInterface` can resolve and format that metadata to generate an output similar to the other frameworks. Each command's entry point is either `ExecuteAsync` or `Execute` which receive an input of type `Arguments` that can be used to retrieve, validate and parse arguments.

## State as of .NET 10

While `Sharpify.CommandLineInterface` remains one of the best performing libraries for creating CLIs, at the current state [ConsoleAppFramework](https://github.com/Cysharp/ConsoleAppFramework) is a better library and the one I would recommend using. It uses source generators so it is less manual and verbose while providing even better performance and compatibility with other commonly used features such as DI and other abstractions.

For this point onward I plan on using `ConsoleAppFramework` in my own apps, and I am also contributing to it to help make it even better.

Nevertheless, `Sharpify.CommandLineInterface` version 3.0.0 was released to provide performance and stability enhancements to users that already use it, and this repo will stay for the foreseeable future to support with fixes if any issues should arise.

## Installation

```bash
dotnet add package Sharpify.CommandLineInterface
```

## Usage

### Implementing Commands
Expand Down Expand Up @@ -71,16 +85,16 @@ public static class Program {
var runner = CliRunner.CreateBuilder()
.AddCommands(Commands)
.UseConsoleAsOutputWriter()
.ModifyMetadata(metadata => {
metadata.Name = "MyCli";
metadata.Descriptions = "MyCli Description";
metadata.Author = "John Doe";
metadata.Version = "1.0.0";
metadata.License = "MIT"
.WithMetadata(metadata => {
metadata.Name = "MyCli";
metadata.Description = "MyCli Description";
metadata.Author = "John Doe";
metadata.Version = "1.0.0";
metadata.License = "MIT";
})
.Build();

return runner.RunAsync(args).AsTask();
return runner.RunAsync(args).AsTask();
}
}
```
Expand Down
2 changes: 2 additions & 0 deletions Sharpify.CommandLineInterface.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
<Project Path="src/Sharpify.CommandLineInterface/Sharpify.CommandLineInterface.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/manual/manual.csproj" />
<Project Path="tests/Sharpify.CommandLineInterface.Tests.Common/Sharpify.CommandLineInterface.Tests.Common.csproj" />
<Project Path="tests/Sharpify.CommandLineInterface.Tests/Sharpify.CommandLineInterface.Tests.csproj" />
</Folder>
</Solution>
18 changes: 18 additions & 0 deletions VERSIONS.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,23 @@
# CHANGELOG

## Version 3.0.0

- `Arguments` improvements
- `IsFirstOrFlag` will now allow simpler checks to verify if a command was entered as named parameter or vise-versa.
- `HasFlag` now has an overload that accepts aliases.
- All overloads and options will be be `CurrentCulture` aware.
- `CliMetadata` now defaults to keeping every property as empty string.
- This is so that the global help text will only output the overridden properties.
- `CliBuilder.ConfigureEmptyInputBehavior` was removed.
- If there is a single command - it will be executed.
- If there are multiple commands - global help text will be shown.
- You can override this behavior simply by using `if (args.Length == 0) args = ["--help"];` for example.
- Both `Help` and `Version` are no first-class internal commands that the builder injects automatically.
- `Help` was improved and more accurately displays information.
- `Version` will now just display `CliMetadata.Version` so make sure to override it for it to function well.
- `Parser` had many improvements that should result in more consistency, correctness, and better performance.
- General performance improvements.

## Version 2.0.0

**WARNING:** This release may contain breaking changes.
Expand Down
78 changes: 67 additions & 11 deletions src/Sharpify.CommandLineInterface/ArgumentsAccess.cs
Original file line number Diff line number Diff line change
@@ -1,8 +1,38 @@
using System.Globalization;

namespace Sharpify.CommandLineInterface;

public sealed partial class Arguments {
/// <summary>
/// Checks whether <paramref name="value"/> is the value of the first positional argument or a single flag
/// </summary>
/// <param name="value"></param>
/// <remarks>
/// Used mainly for single options, like "--help" or "--version"
/// </remarks>
/// <returns></returns>
public bool IsFirstOrFlag(string value) {
if (TryGetValue(0, out string? first) && first == value) {
return true;
}
return _arguments.Count is 1 && HasFlag(value);
}

/// <summary>
/// Checks whether any of the <paramref name="values"/> is the value of the first positional argument or a single flag
/// </summary>
/// <param name="values"></param>
/// <remarks>
/// Same as <see cref="IsFirstOrFlag(string)"/> but allows aliases.
/// </remarks>
/// <returns></returns>
public bool IsFirstOrFlag(ReadOnlySpan<string> values) {
foreach (var value in values) {
if (TryGetValue(0, out string? first) && first == value) {
return true;
}
}
return _arguments.Count is 1 && HasFlag(values);
}

/// <summary>
/// Checks if the specified key exists in the arguments.
/// </summary>
Expand All @@ -15,7 +45,15 @@ public sealed partial class Arguments {
/// </summary>
/// <param name="position">The positional argument to check.</param>
/// <returns>True if the key exists, false otherwise.</returns>
public bool Contains(int position) => Contains(position.ToString());
public bool Contains(int position) => Contains(position.ToString(CultureInfo.CurrentCulture));

/// <summary>
/// Checks if any of the specified keys exists in the arguments
/// </summary>
/// <param name="keys"></param>
/// <remarks>Can either be used for different keys, or aliases for the same parameter</remarks>
/// <returns></returns>
public bool ContainsAny(ReadOnlySpan<string> keys) => TryGetValue(keys, out _);

/// <summary>
/// Checks if the specified flag is present in the arguments.
Expand All @@ -27,13 +65,23 @@ public sealed partial class Arguments {
/// </remarks>
public bool HasFlag(string flag) => _arguments.TryGetValue(flag, out string? val) && val.Length is 0;

/// <summary>
/// Checks if the any of the specified flag aliases is present in the arguments.
/// </summary>
/// <param name="flagAliases">The flag aliases to check.</param>
/// <returns>True if a flag alias is present and has no value; otherwise, false.</returns>
/// <remarks>
/// This is not the same as <see cref="Contains(string)"/> as this also checks that the value is empty, which is not the case for named arguments that can also be detected by <see cref="Contains(string)"/>
/// </remarks>
public bool HasFlag(ReadOnlySpan<string> flagAliases) => TryGetValue(flagAliases, out string? val) && val.Length is 0;

/// <summary>
/// Tries to retrieve the value of a positional argument.
/// </summary>
/// <param name="position">The key to check.</param>
/// <param name="value">The value of the argument ("" if doesn't exist - NOT NULL).</param>
/// <returns>true if the key exists, false otherwise.</returns>
public bool TryGetValue(int position, out string value) => TryGetValue(position.ToString(), out value);
public bool TryGetValue(int position, out string value) => TryGetValue(position.ToString(CultureInfo.CurrentCulture), out value);

/// <summary>
/// Tries to retrieve the value of a specified key in the arguments.
Expand All @@ -56,8 +104,16 @@ public bool TryGetValue(string key, out string value) {
/// <param name="keys">A collection of aliases for a parameter name</param>
/// <param name="value">The value of the argument ("" if doesn't exist - NOT NULL).</param>
/// <returns>true if the key exists, false otherwise.</returns>
public bool TryGetValue(ReadOnlySpan<string> keys, out string value)
=> _arguments.TryGetValue(keys, out value);
public bool TryGetValue(ReadOnlySpan<string> keys, out string value) {
foreach (var key in keys) {
if (_arguments.TryGetValue(key, out var res)) {
value = res;
return true;
}
}
value = "";
return false;
}

/// <summary>
/// Tries to retrieve the value of the positional argument in the arguments.
Expand All @@ -74,7 +130,7 @@ public bool TryGetValue(ReadOnlySpan<string> keys, out string value)
/// </para>
/// </remarks>
/// <returns>true if the key exists, false otherwise.</returns>
public bool TryGetValue<T>(int position, T defaultValue, out T value) where T : IParsable<T> => TryGetValue(position.ToString(), defaultValue, out value);
public bool TryGetValue<T>(int position, T defaultValue, out T value) where T : IParsable<T> => TryGetValue(position.ToString(CultureInfo.CurrentCulture), defaultValue, out value);

/// <summary>
/// Tries to retrieve the value of a specified key in the arguments.
Expand Down Expand Up @@ -179,7 +235,7 @@ public T GetValue<T>(ReadOnlySpan<string> keys, T defaultValue) where T : IParsa
/// If the key doesn't exist or can't be parsed, the the default(TEnum) will be used in the out parameter, this overloads also implies that the enum will be parsed case-sensitive
/// </remarks>
/// <returns>true if the key exists, false otherwise.</returns>
public bool TryGetEnum<TEnum>(int position, out TEnum value) where TEnum : struct, Enum => TryGetEnum(position.ToString(), default, false, out value);
public bool TryGetEnum<TEnum>(int position, out TEnum value) where TEnum : struct, Enum => TryGetEnum(position.ToString(CultureInfo.CurrentCulture), default, false, out value);

/// <summary>
/// Tries to retrieve the enum value of a specified key in the arguments.
Expand All @@ -191,7 +247,7 @@ public T GetValue<T>(ReadOnlySpan<string> keys, T defaultValue) where T : IParsa
/// If the key doesn't exist or can't be parsed, the default(TEnum) will be used in the out parameter.
/// </remarks>
/// <returns>true if the key exists, false otherwise.</returns>
public bool TryGetEnum<TEnum>(int position, bool ignoreCase, out TEnum value) where TEnum : struct, Enum => TryGetEnum(position.ToString(), default, ignoreCase, out value);
public bool TryGetEnum<TEnum>(int position, bool ignoreCase, out TEnum value) where TEnum : struct, Enum => TryGetEnum(position.ToString(CultureInfo.CurrentCulture), default, ignoreCase, out value);

/// <summary>
/// Tries to retrieve the enum value of a specified key in the arguments.
Expand All @@ -204,7 +260,7 @@ public T GetValue<T>(ReadOnlySpan<string> keys, T defaultValue) where T : IParsa
/// If the key doesn't exist or can't be parsed, the default value will be used in the out parameter.
/// </remarks>
/// <returns>true if the key exists, false otherwise.</returns>
public bool TryGetEnum<TEnum>(int position, TEnum defaultValue, bool ignoreCase, out TEnum value) where TEnum : struct, Enum => TryGetEnum(position.ToString(), defaultValue, ignoreCase, out value);
public bool TryGetEnum<TEnum>(int position, TEnum defaultValue, bool ignoreCase, out TEnum value) where TEnum : struct, Enum => TryGetEnum(position.ToString(CultureInfo.CurrentCulture), defaultValue, ignoreCase, out value);

/// <summary>
/// Tries to retrieve the enum value of a specified key in the arguments.
Expand Down Expand Up @@ -380,4 +436,4 @@ public TEnum GetEnum<TEnum>(ReadOnlySpan<string> keys, TEnum defaultValue, bool
_ = TryGetEnum(keys, defaultValue, ignoreCase, out var value);
return value;
}
}
}
10 changes: 4 additions & 6 deletions src/Sharpify.CommandLineInterface/ArgumentsAccessMultiple.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
using System.Globalization;

namespace Sharpify.CommandLineInterface;

public sealed partial class Arguments {
Expand All @@ -11,7 +9,7 @@ public sealed partial class Arguments {
/// <param name="values">The values of the argument or an empty array if they don't exist.</param>
/// <returns>true if the key exists, false otherwise.</returns>
public bool TryGetValues(int position, string? separator, out string[] values)
=> TryGetValues(position.ToString(), separator, out values);
=> TryGetValues(position.ToString(CultureInfo.CurrentCulture), separator, out values);

/// <summary>
/// Tries to retrieve the value of a specified key in the arguments.
Expand All @@ -37,7 +35,7 @@ public bool TryGetValues(string key, string? separator, out string[] values) {
/// <param name="values">The values of the argument or an empty array if don't exist.</param>
/// <returns>true if the key exists, false otherwise.</returns>
public bool TryGetValues(ReadOnlySpan<string> keys, string? separator, out string[] values) {
if (!_arguments.TryGetValue(keys, out var res)) {
if (!TryGetValue(keys, out var res)) {
values = [];
return false;
}
Expand All @@ -58,7 +56,7 @@ public bool TryGetValues(ReadOnlySpan<string> keys, string? separator, out strin
/// </remarks>
/// <returns>true if the key exists, false otherwise.</returns>
public bool TryGetValues<T>(int position, string? separator, out T[] values) where T : IParsable<T>
=> TryGetValues(position.ToString(), separator, out values);
=> TryGetValues(position.ToString(CultureInfo.CurrentCulture), separator, out values);

/// <summary>
/// Tries to retrieve the values of a specified key in the arguments.
Expand Down Expand Up @@ -127,4 +125,4 @@ public bool TryGetValues<T>(ReadOnlySpan<string> keys, string? separator, out T[
values = result;
return true;
}
}
}
Loading