diff --git a/.github/workflows/pr-preview-packages.yml b/.github/workflows/pr-preview-packages.yml
new file mode 100644
index 0000000..dbac094
--- /dev/null
+++ b/.github/workflows/pr-preview-packages.yml
@@ -0,0 +1,47 @@
+name: Publish Preview NuGet Packages
+
+on:
+ pull_request:
+ branches:
+ - master
+
+env:
+ DOTNET_VERSION: '10.0.x'
+ PACKAGE_SOURCE: https://nuget.pkg.github.com/WiSave/index.json
+
+jobs:
+ publish-preview:
+ runs-on: ubuntu-latest
+ permissions:
+ packages: write
+ contents: read
+
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: ${{ env.DOTNET_VERSION }}
+
+ - name: Restore
+ run: dotnet restore WiSave.Console.slnx
+
+ - name: Build
+ run: dotnet build WiSave.Console.slnx -c Release --no-restore
+
+ - name: Test
+ run: dotnet test WiSave.Console.slnx -c Release --no-build
+
+ - name: Pack preview packages
+ run: >
+ dotnet pack WiSave.Console.slnx
+ -c Release
+ --no-build
+ -o ./nupkg
+ -p:MinVerDefaultPreReleaseIdentifiers=preview.${{ github.event.pull_request.number }}.${{ github.run_number }}
+
+ - name: Push preview packages
+ run: dotnet nuget push ./nupkg/*.nupkg --source "${{ env.PACKAGE_SOURCE }}" --api-key ${{ secrets.GITHUB_TOKEN }} --skip-duplicate
diff --git a/.github/workflows/publish-packages.yml b/.github/workflows/publish-packages.yml
new file mode 100644
index 0000000..9329cce
--- /dev/null
+++ b/.github/workflows/publish-packages.yml
@@ -0,0 +1,70 @@
+name: Publish Stable NuGet Packages
+
+on:
+ push:
+ branches:
+ - master
+ workflow_dispatch:
+
+env:
+ DOTNET_VERSION: '10.0.x'
+ PACKAGE_SOURCE: https://nuget.pkg.github.com/WiSave/index.json
+
+jobs:
+ publish:
+ runs-on: ubuntu-latest
+ permissions:
+ packages: write
+ contents: write
+
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: Ensure release tag for this commit
+ id: release_tag
+ run: |
+ git fetch --tags
+ existing_tag="$(git tag --points-at HEAD 'v*' | sort -V | tail -n 1)"
+ if [ -n "$existing_tag" ]; then
+ echo "tag=$existing_tag" >> "$GITHUB_OUTPUT"
+ exit 0
+ fi
+
+ latest_tag="$(git tag -l 'v*' | sort -V | tail -n 1)"
+ latest_version="${latest_tag#v}"
+ if [ -z "$latest_tag" ] || [ "${latest_version%%.*}" -lt 1 ]; then
+ tag="v1.0.0"
+ else
+ major="$(echo "$latest_version" | cut -d. -f1)"
+ minor="$(echo "$latest_version" | cut -d. -f2)"
+ patch="$(echo "$latest_version" | cut -d. -f3)"
+ tag="v${major}.${minor}.$((patch + 1))"
+ fi
+
+ git config user.name "github-actions[bot]"
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
+ git tag "$tag"
+ git push origin "$tag"
+ echo "tag=$tag" >> "$GITHUB_OUTPUT"
+
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: ${{ env.DOTNET_VERSION }}
+
+ - name: Restore
+ run: dotnet restore WiSave.Console.slnx
+
+ - name: Build
+ run: dotnet build WiSave.Console.slnx -c Release --no-restore
+
+ - name: Test
+ run: dotnet test WiSave.Console.slnx -c Release --no-build
+
+ - name: Pack stable packages
+ run: dotnet pack WiSave.Console.slnx -c Release --no-build -o ./nupkg
+
+ - name: Push stable packages
+ run: dotnet nuget push ./nupkg/*.nupkg --source "${{ env.PACKAGE_SOURCE }}" --api-key ${{ secrets.GITHUB_TOKEN }} --skip-duplicate
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..11eaaa6
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,10 @@
+bin/
+obj/
+TestResults/
+.vs/
+.idea/
+*.user
+*.suo
+*.nupkg
+*.snupkg
+nupkg/
diff --git a/Directory.Build.props b/Directory.Build.props
new file mode 100644
index 0000000..8a97a6b
--- /dev/null
+++ b/Directory.Build.props
@@ -0,0 +1,11 @@
+
+
+ net10.0
+ enable
+ enable
+ true
+ true
+ v
+ 1.0
+
+
diff --git a/NuGet.config b/NuGet.config
new file mode 100644
index 0000000..36e9a72
--- /dev/null
+++ b/NuGet.config
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..eea7514
--- /dev/null
+++ b/README.md
@@ -0,0 +1,22 @@
+# WiSave.Console
+
+Reusable console command infrastructure for WiSave services.
+
+The package provides:
+
+- command contracts and parameter metadata
+- command-line parsing
+- command catalog and runner
+- interactive shell hosting
+- console output abstraction for tests
+- dependency injection registration helpers
+
+## Usage
+
+```csharp
+services.AddWiSaveConsole(
+ options => options.Title = "WiSave Expenses Console",
+ typeof(Program).Assembly);
+```
+
+Commands implement `IConsoleCommand` and are discovered from the assemblies passed to `AddWiSaveConsole`.
diff --git a/WiSave.Console.slnx b/WiSave.Console.slnx
new file mode 100644
index 0000000..ad274b1
--- /dev/null
+++ b/WiSave.Console.slnx
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/src/WiSave.Console/Execution/CommandCatalog.cs b/src/WiSave.Console/Execution/CommandCatalog.cs
new file mode 100644
index 0000000..4fe848e
--- /dev/null
+++ b/src/WiSave.Console/Execution/CommandCatalog.cs
@@ -0,0 +1,25 @@
+using Microsoft.Extensions.DependencyInjection;
+
+namespace WiSave.Console.Execution;
+
+public interface ICommandCatalog
+{
+ IReadOnlyList List();
+ CommandDescriptor? Find(string name);
+}
+
+public sealed class CommandCatalog(IServiceScopeFactory scopeFactory) : ICommandCatalog
+{
+ public IReadOnlyList List()
+ {
+ using var scope = scopeFactory.CreateScope();
+
+ return scope.ServiceProvider.GetServices()
+ .Select(command => new CommandDescriptor(command.Name, command.Description, command.ParameterDefinitions))
+ .OrderBy(command => command.Name, StringComparer.OrdinalIgnoreCase)
+ .ToArray();
+ }
+
+ public CommandDescriptor? Find(string name)
+ => List().FirstOrDefault(command => command.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
+}
diff --git a/src/WiSave.Console/Execution/CommandDescriptor.cs b/src/WiSave.Console/Execution/CommandDescriptor.cs
new file mode 100644
index 0000000..fa3097f
--- /dev/null
+++ b/src/WiSave.Console/Execution/CommandDescriptor.cs
@@ -0,0 +1,6 @@
+namespace WiSave.Console.Execution;
+
+public sealed record CommandDescriptor(
+ string Name,
+ string Description,
+ IReadOnlyList Parameters);
diff --git a/src/WiSave.Console/Execution/CommandExecutionContext.cs b/src/WiSave.Console/Execution/CommandExecutionContext.cs
new file mode 100644
index 0000000..7529149
--- /dev/null
+++ b/src/WiSave.Console/Execution/CommandExecutionContext.cs
@@ -0,0 +1,11 @@
+namespace WiSave.Console.Execution;
+
+public sealed class CommandExecutionContext(IReadOnlyDictionary arguments, bool allowPrompting = false)
+{
+ public IReadOnlyDictionary Arguments { get; } = new Dictionary(arguments, StringComparer.OrdinalIgnoreCase);
+
+ public bool AllowPrompting { get; } = allowPrompting;
+
+ public string? GetArgument(string name)
+ => Arguments.GetValueOrDefault(name);
+}
diff --git a/src/WiSave.Console/Execution/CommandInvocation.cs b/src/WiSave.Console/Execution/CommandInvocation.cs
new file mode 100644
index 0000000..f693939
--- /dev/null
+++ b/src/WiSave.Console/Execution/CommandInvocation.cs
@@ -0,0 +1,14 @@
+namespace WiSave.Console.Execution;
+
+public sealed class CommandInvocation
+{
+ public CommandInvocation(string commandName, IReadOnlyDictionary arguments)
+ {
+ CommandName = commandName;
+ Arguments = new Dictionary(arguments, StringComparer.OrdinalIgnoreCase);
+ }
+
+ public string CommandName { get; }
+
+ public IReadOnlyDictionary Arguments { get; }
+}
diff --git a/src/WiSave.Console/Execution/CommandLineParseResult.cs b/src/WiSave.Console/Execution/CommandLineParseResult.cs
new file mode 100644
index 0000000..bb06018
--- /dev/null
+++ b/src/WiSave.Console/Execution/CommandLineParseResult.cs
@@ -0,0 +1,23 @@
+namespace WiSave.Console.Execution;
+
+public sealed class CommandLineParseResult
+{
+ private CommandLineParseResult(bool isInteractive, CommandInvocation? invocation, string? errorMessage)
+ {
+ IsInteractive = isInteractive;
+ Invocation = invocation;
+ ErrorMessage = errorMessage;
+ }
+
+ public bool IsInteractive { get; }
+
+ public CommandInvocation? Invocation { get; }
+
+ public string? ErrorMessage { get; }
+
+ public static CommandLineParseResult Interactive() => new(true, null, null);
+
+ public static CommandLineParseResult Success(CommandInvocation invocation) => new(false, invocation, null);
+
+ public static CommandLineParseResult Failure(string errorMessage) => new(false, null, errorMessage);
+}
diff --git a/src/WiSave.Console/Execution/CommandLineParser.cs b/src/WiSave.Console/Execution/CommandLineParser.cs
new file mode 100644
index 0000000..fdb71af
--- /dev/null
+++ b/src/WiSave.Console/Execution/CommandLineParser.cs
@@ -0,0 +1,50 @@
+namespace WiSave.Console.Execution;
+
+public interface ICommandLineParser
+{
+ CommandLineParseResult Parse(string[] args);
+}
+
+public sealed class CommandLineParser : ICommandLineParser
+{
+ public CommandLineParseResult Parse(string[] args)
+ {
+ if (args.Length == 0)
+ {
+ return CommandLineParseResult.Interactive();
+ }
+
+ var commandName = args[0].Trim();
+ if (string.IsNullOrWhiteSpace(commandName))
+ {
+ return CommandLineParseResult.Failure("Command name cannot be empty.");
+ }
+
+ var arguments = new Dictionary(StringComparer.OrdinalIgnoreCase);
+
+ for (var index = 1; index < args.Length; index++)
+ {
+ var token = args[index];
+ if (!token.StartsWith("--", StringComparison.Ordinal))
+ {
+ return CommandLineParseResult.Failure($"Unexpected argument '{token}'. Expected '--name value'.");
+ }
+
+ var parameterName = token[2..].Trim();
+ if (string.IsNullOrWhiteSpace(parameterName))
+ {
+ return CommandLineParseResult.Failure("Parameter name after '--' cannot be empty.");
+ }
+
+ string? parameterValue = "true";
+ if (index + 1 < args.Length && !args[index + 1].StartsWith("--", StringComparison.Ordinal))
+ {
+ parameterValue = args[++index];
+ }
+
+ arguments[parameterName] = parameterValue;
+ }
+
+ return CommandLineParseResult.Success(new CommandInvocation(commandName, arguments));
+ }
+}
diff --git a/src/WiSave.Console/Execution/CommandParameter.cs b/src/WiSave.Console/Execution/CommandParameter.cs
new file mode 100644
index 0000000..10c8c17
--- /dev/null
+++ b/src/WiSave.Console/Execution/CommandParameter.cs
@@ -0,0 +1,7 @@
+namespace WiSave.Console.Execution;
+
+public sealed record CommandParameter(
+ string Name,
+ string Description,
+ bool Required,
+ string? DefaultValue = null);
diff --git a/src/WiSave.Console/Execution/CommandPrompter.cs b/src/WiSave.Console/Execution/CommandPrompter.cs
new file mode 100644
index 0000000..8fd75a2
--- /dev/null
+++ b/src/WiSave.Console/Execution/CommandPrompter.cs
@@ -0,0 +1,76 @@
+using WiSave.Console.Shell;
+
+namespace WiSave.Console.Execution;
+
+public interface ICommandPrompter
+{
+ Task> PromptForMissingArgumentsAsync(
+ CommandDescriptor descriptor,
+ IReadOnlyDictionary existingArguments,
+ CancellationToken ct);
+}
+
+public sealed class CommandPrompter(IConsoleOutput consoleOutput) : ICommandPrompter
+{
+ public Task> PromptForMissingArgumentsAsync(
+ CommandDescriptor descriptor,
+ IReadOnlyDictionary existingArguments,
+ CancellationToken ct)
+ {
+ var arguments = new Dictionary(existingArguments, StringComparer.OrdinalIgnoreCase);
+
+ foreach (var parameter in descriptor.Parameters)
+ {
+ if (arguments.TryGetValue(parameter.Name, out var currentValue) && !string.IsNullOrWhiteSpace(currentValue))
+ {
+ continue;
+ }
+
+ while (true)
+ {
+ ct.ThrowIfCancellationRequested();
+
+ var label = $"Enter {parameter.Name}";
+ if (!string.IsNullOrWhiteSpace(parameter.Description))
+ {
+ label += $" ({parameter.Description})";
+ }
+
+ if (!string.IsNullOrWhiteSpace(parameter.DefaultValue))
+ {
+ label += $" [{parameter.DefaultValue}]";
+ }
+ else if (!parameter.Required)
+ {
+ label += " [optional]";
+ }
+
+ label += ": ";
+ consoleOutput.Write(label);
+
+ var input = consoleOutput.ReadLine()?.Trim();
+ if (string.IsNullOrWhiteSpace(input))
+ {
+ if (!string.IsNullOrWhiteSpace(parameter.DefaultValue))
+ {
+ arguments[parameter.Name] = parameter.DefaultValue;
+ break;
+ }
+
+ if (!parameter.Required)
+ {
+ break;
+ }
+
+ consoleOutput.WriteLine($"Parameter '{parameter.Name}' is required.");
+ continue;
+ }
+
+ arguments[parameter.Name] = input;
+ break;
+ }
+ }
+
+ return Task.FromResult(arguments);
+ }
+}
diff --git a/src/WiSave.Console/Execution/CommandResult.cs b/src/WiSave.Console/Execution/CommandResult.cs
new file mode 100644
index 0000000..c80d716
--- /dev/null
+++ b/src/WiSave.Console/Execution/CommandResult.cs
@@ -0,0 +1,23 @@
+namespace WiSave.Console.Execution;
+
+public sealed class CommandResult
+{
+ private CommandResult(bool success, string message, IReadOnlyList? details = null)
+ {
+ Success = success;
+ Message = message;
+ Details = details ?? [];
+ }
+
+ public bool Success { get; }
+
+ public string Message { get; }
+
+ public IReadOnlyList Details { get; }
+
+ public static CommandResult SuccessResult(string message, IReadOnlyList? details = null)
+ => new(true, message, details);
+
+ public static CommandResult FailureResult(string message, IReadOnlyList? details = null)
+ => new(false, message, details);
+}
diff --git a/src/WiSave.Console/Execution/CommandRunner.cs b/src/WiSave.Console/Execution/CommandRunner.cs
new file mode 100644
index 0000000..465adae
--- /dev/null
+++ b/src/WiSave.Console/Execution/CommandRunner.cs
@@ -0,0 +1,108 @@
+using Microsoft.Extensions.DependencyInjection;
+using WiSave.Console.Shell;
+
+namespace WiSave.Console.Execution;
+
+public interface ICommandRunner
+{
+ Task RunAsync(CommandInvocation invocation, bool allowPrompting, CancellationToken ct);
+}
+
+public sealed class CommandRunner(
+ IServiceScopeFactory scopeFactory,
+ ICommandCatalog commandCatalog,
+ ICommandPrompter commandPrompter,
+ IConsoleOutput consoleOutput) : ICommandRunner
+{
+ public async Task RunAsync(CommandInvocation invocation, bool allowPrompting, CancellationToken ct)
+ {
+ var descriptor = commandCatalog.Find(invocation.CommandName);
+ if (descriptor is null)
+ {
+ consoleOutput.WriteLine($"Unknown command '{invocation.CommandName}'.");
+ PrintAvailableCommands();
+ return 1;
+ }
+
+ var arguments = new Dictionary(invocation.Arguments, StringComparer.OrdinalIgnoreCase);
+ if (arguments.ContainsKey("help"))
+ {
+ PrintUsage(descriptor);
+ return 0;
+ }
+
+ if (allowPrompting)
+ {
+ arguments = await commandPrompter.PromptForMissingArgumentsAsync(descriptor, arguments, ct);
+ }
+
+ var missingRequiredParameters = descriptor.Parameters
+ .Where(parameter =>
+ parameter.Required &&
+ (!arguments.TryGetValue(parameter.Name, out var value) || string.IsNullOrWhiteSpace(value)))
+ .Select(parameter => parameter.Name)
+ .ToArray();
+
+ if (missingRequiredParameters.Length > 0)
+ {
+ consoleOutput.WriteLine("Missing required parameters:");
+ foreach (var parameterName in missingRequiredParameters)
+ {
+ consoleOutput.WriteLine($" --{parameterName}");
+ }
+
+ consoleOutput.WriteLine(string.Empty);
+ PrintUsage(descriptor);
+ return 1;
+ }
+
+ using var scope = scopeFactory.CreateScope();
+ var command = scope.ServiceProvider.GetServices()
+ .FirstOrDefault(candidate => candidate.Name.Equals(descriptor.Name, StringComparison.OrdinalIgnoreCase));
+
+ if (command is null)
+ {
+ consoleOutput.WriteLine($"Command '{descriptor.Name}' is registered in the catalog but could not be resolved.");
+ return 1;
+ }
+
+ try
+ {
+ var result = await command.ExecuteAsync(new CommandExecutionContext(arguments, allowPrompting), ct);
+ PrintResult(result);
+ return result.Success ? 0 : 1;
+ }
+ catch (Exception ex)
+ {
+ consoleOutput.WriteLine($"Command '{descriptor.Name}' failed: {ex.Message}");
+ return 1;
+ }
+ }
+
+ private void PrintAvailableCommands()
+ {
+ foreach (var command in commandCatalog.List().OrderBy(command => command.Name, StringComparer.OrdinalIgnoreCase))
+ {
+ consoleOutput.WriteLine($" {command.Name} - {command.Description}");
+ }
+ }
+
+ private void PrintUsage(CommandDescriptor descriptor)
+ {
+ var usage = descriptor.Parameters.Count == 0
+ ? descriptor.Name
+ : $"{descriptor.Name} {string.Join(" ", descriptor.Parameters.Select(parameter => $"[--{parameter.Name} ]"))}";
+
+ consoleOutput.WriteLine($"Usage: {usage}");
+ }
+
+ private void PrintResult(CommandResult result)
+ {
+ consoleOutput.WriteLine(result.Success ? $"OK: {result.Message}" : $"ERROR: {result.Message}");
+
+ foreach (var detail in result.Details)
+ {
+ consoleOutput.WriteLine($" {detail}");
+ }
+ }
+}
diff --git a/src/WiSave.Console/Execution/IConsoleCommand.cs b/src/WiSave.Console/Execution/IConsoleCommand.cs
new file mode 100644
index 0000000..012ef88
--- /dev/null
+++ b/src/WiSave.Console/Execution/IConsoleCommand.cs
@@ -0,0 +1,12 @@
+namespace WiSave.Console.Execution;
+
+public interface IConsoleCommand
+{
+ string Name { get; }
+
+ string Description { get; }
+
+ IReadOnlyList ParameterDefinitions { get; }
+
+ Task ExecuteAsync(CommandExecutionContext context, CancellationToken ct);
+}
diff --git a/src/WiSave.Console/Infrastructure/ConsoleShellOptions.cs b/src/WiSave.Console/Infrastructure/ConsoleShellOptions.cs
new file mode 100644
index 0000000..752b4c1
--- /dev/null
+++ b/src/WiSave.Console/Infrastructure/ConsoleShellOptions.cs
@@ -0,0 +1,6 @@
+namespace WiSave.Console.Infrastructure;
+
+public sealed class ConsoleShellOptions
+{
+ public string Title { get; set; } = "WiSave Console";
+}
diff --git a/src/WiSave.Console/Infrastructure/ServiceCollectionExtensions.cs b/src/WiSave.Console/Infrastructure/ServiceCollectionExtensions.cs
new file mode 100644
index 0000000..da6b131
--- /dev/null
+++ b/src/WiSave.Console/Infrastructure/ServiceCollectionExtensions.cs
@@ -0,0 +1,54 @@
+using System.Reflection;
+using Microsoft.Extensions.DependencyInjection;
+using WiSave.Console.Execution;
+using WiSave.Console.Shell;
+
+namespace WiSave.Console.Infrastructure;
+
+public static class ServiceCollectionExtensions
+{
+ public static IServiceCollection AddWiSaveConsole(
+ this IServiceCollection services,
+ Action? configure = null,
+ params Assembly[] commandAssemblies)
+ {
+ if (configure is null)
+ {
+ services.Configure(_ => { });
+ }
+ else
+ {
+ services.Configure(configure);
+ }
+
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+
+ foreach (var assembly in commandAssemblies)
+ {
+ RegisterCommands(services, assembly);
+ }
+
+ return services;
+ }
+
+ private static void RegisterCommands(IServiceCollection services, Assembly assembly)
+ {
+ var commandTypes = assembly.GetTypes()
+ .Where(type =>
+ !type.IsAbstract &&
+ !type.IsInterface &&
+ typeof(IConsoleCommand).IsAssignableFrom(type))
+ .ToArray();
+
+ foreach (var commandType in commandTypes)
+ {
+ services.AddTransient(typeof(IConsoleCommand), commandType);
+ }
+ }
+}
diff --git a/src/WiSave.Console/Shell/ConsoleApplication.cs b/src/WiSave.Console/Shell/ConsoleApplication.cs
new file mode 100644
index 0000000..c0c2c27
--- /dev/null
+++ b/src/WiSave.Console/Shell/ConsoleApplication.cs
@@ -0,0 +1,48 @@
+using WiSave.Console.Execution;
+
+namespace WiSave.Console.Shell;
+
+public interface IConsoleApplication
+{
+ Task RunAsync(string[] args, CancellationToken ct);
+}
+
+public sealed class ConsoleApplication(
+ ICommandLineParser commandLineParser,
+ ICommandRunner commandRunner,
+ ICommandCatalog commandCatalog,
+ IConsoleShell consoleShell,
+ IConsoleOutput consoleOutput) : IConsoleApplication
+{
+ public async Task RunAsync(string[] args, CancellationToken ct)
+ {
+ if (args.Length == 1 &&
+ (args[0].Equals("help", StringComparison.OrdinalIgnoreCase) ||
+ args[0].Equals("--help", StringComparison.OrdinalIgnoreCase)))
+ {
+ consoleOutput.WriteLine("Available commands:");
+ foreach (var command in commandCatalog.List())
+ {
+ consoleOutput.WriteLine($" {command.Name} - {command.Description}");
+ }
+
+ consoleOutput.WriteLine(string.Empty);
+ consoleOutput.WriteLine("Run without arguments to start the interactive shell.");
+ return 0;
+ }
+
+ var parseResult = commandLineParser.Parse(args);
+ if (parseResult.IsInteractive)
+ {
+ return await consoleShell.RunAsync(ct);
+ }
+
+ if (parseResult.Invocation is null)
+ {
+ consoleOutput.WriteLine(parseResult.ErrorMessage ?? "Failed to parse command line arguments.");
+ return 1;
+ }
+
+ return await commandRunner.RunAsync(parseResult.Invocation, allowPrompting: false, ct);
+ }
+}
diff --git a/src/WiSave.Console/Shell/ConsoleShell.cs b/src/WiSave.Console/Shell/ConsoleShell.cs
new file mode 100644
index 0000000..7139f6e
--- /dev/null
+++ b/src/WiSave.Console/Shell/ConsoleShell.cs
@@ -0,0 +1,93 @@
+using Microsoft.Extensions.Options;
+using WiSave.Console.Execution;
+using WiSave.Console.Infrastructure;
+
+namespace WiSave.Console.Shell;
+
+public interface IConsoleShell
+{
+ Task RunAsync(CancellationToken ct);
+}
+
+public sealed class ConsoleShell(
+ ICommandCatalog commandCatalog,
+ ICommandRunner commandRunner,
+ IConsoleOutput consoleOutput,
+ IOptions options) : IConsoleShell
+{
+ public async Task RunAsync(CancellationToken ct)
+ {
+ while (true)
+ {
+ ct.ThrowIfCancellationRequested();
+
+ var commands = commandCatalog.List();
+ RenderMenu(commands);
+
+ consoleOutput.Write("Choose a command by number or name: ");
+ var input = consoleOutput.ReadLine()?.Trim();
+
+ if (string.IsNullOrWhiteSpace(input))
+ {
+ consoleOutput.WriteLine("Enter a command, or type 'exit' to close the shell.");
+ consoleOutput.WriteLine(string.Empty);
+ continue;
+ }
+
+ if (input.Equals("exit", StringComparison.OrdinalIgnoreCase) ||
+ input.Equals("quit", StringComparison.OrdinalIgnoreCase))
+ {
+ return 0;
+ }
+
+ if (input.Equals("clear", StringComparison.OrdinalIgnoreCase))
+ {
+ consoleOutput.Clear();
+ continue;
+ }
+
+ if (input.Equals("help", StringComparison.OrdinalIgnoreCase))
+ {
+ continue;
+ }
+
+ var descriptor = ResolveCommand(commands, input);
+ if (descriptor is null)
+ {
+ consoleOutput.WriteLine($"Unknown selection '{input}'.");
+ consoleOutput.WriteLine(string.Empty);
+ continue;
+ }
+
+ consoleOutput.WriteLine(string.Empty);
+ await commandRunner.RunAsync(new CommandInvocation(descriptor.Name, new Dictionary()), allowPrompting: true, ct);
+ consoleOutput.WriteLine(string.Empty);
+ }
+ }
+
+ private void RenderMenu(IReadOnlyList commands)
+ {
+ consoleOutput.WriteLine(options.Value.Title);
+ consoleOutput.WriteLine(new string('=', options.Value.Title.Length));
+
+ for (var index = 0; index < commands.Count; index++)
+ {
+ var command = commands[index];
+ consoleOutput.WriteLine($"{index + 1}. {command.Name} - {command.Description}");
+ }
+
+ consoleOutput.WriteLine(string.Empty);
+ consoleOutput.WriteLine("Built-ins: help, clear, exit");
+ consoleOutput.WriteLine(string.Empty);
+ }
+
+ private static CommandDescriptor? ResolveCommand(IReadOnlyList commands, string input)
+ {
+ if (int.TryParse(input, out var index) && index >= 1 && index <= commands.Count)
+ {
+ return commands[index - 1];
+ }
+
+ return commands.FirstOrDefault(command => command.Name.Equals(input, StringComparison.OrdinalIgnoreCase));
+ }
+}
diff --git a/src/WiSave.Console/Shell/IConsoleOutput.cs b/src/WiSave.Console/Shell/IConsoleOutput.cs
new file mode 100644
index 0000000..6e12ee6
--- /dev/null
+++ b/src/WiSave.Console/Shell/IConsoleOutput.cs
@@ -0,0 +1,9 @@
+namespace WiSave.Console.Shell;
+
+public interface IConsoleOutput
+{
+ void Write(string value);
+ void WriteLine(string? value);
+ string? ReadLine();
+ void Clear();
+}
diff --git a/src/WiSave.Console/Shell/SystemConsoleOutput.cs b/src/WiSave.Console/Shell/SystemConsoleOutput.cs
new file mode 100644
index 0000000..a0d9a06
--- /dev/null
+++ b/src/WiSave.Console/Shell/SystemConsoleOutput.cs
@@ -0,0 +1,12 @@
+namespace WiSave.Console.Shell;
+
+public sealed class SystemConsoleOutput : IConsoleOutput
+{
+ public void Write(string value) => global::System.Console.Write(value);
+
+ public void WriteLine(string? value) => global::System.Console.WriteLine(value);
+
+ public string? ReadLine() => global::System.Console.ReadLine();
+
+ public void Clear() => global::System.Console.Clear();
+}
diff --git a/src/WiSave.Console/WiSave.Console.csproj b/src/WiSave.Console/WiSave.Console.csproj
new file mode 100644
index 0000000..50a088b
--- /dev/null
+++ b/src/WiSave.Console/WiSave.Console.csproj
@@ -0,0 +1,26 @@
+
+
+
+ WiSave.Console
+ Reusable console command infrastructure for WiSave services.
+ JacobChwastek
+ MIT
+ README.md
+ https://github.com/WiSave/wisave-console
+ true
+
+
+
+
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
diff --git a/tests/WiSave.Console.Tests/CommandLineParserTests.cs b/tests/WiSave.Console.Tests/CommandLineParserTests.cs
new file mode 100644
index 0000000..6b41ec5
--- /dev/null
+++ b/tests/WiSave.Console.Tests/CommandLineParserTests.cs
@@ -0,0 +1,55 @@
+using WiSave.Console.Execution;
+
+namespace WiSave.Console.Tests;
+
+public sealed class CommandLineParserTests
+{
+ [Fact]
+ public void Parse_returns_interactive_result_when_no_arguments_are_provided()
+ {
+ var sut = new CommandLineParser();
+
+ var result = sut.Parse([]);
+
+ Assert.True(result.IsInteractive);
+ Assert.Null(result.Invocation);
+ Assert.Null(result.ErrorMessage);
+ }
+
+ [Fact]
+ public void Parse_returns_invocation_with_named_arguments()
+ {
+ var sut = new CommandLineParser();
+
+ var result = sut.Parse(["db-migrate", "--connection-string", "Host=localhost"]);
+
+ Assert.False(result.IsInteractive);
+ Assert.NotNull(result.Invocation);
+ Assert.Equal("db-migrate", result.Invocation.CommandName);
+ Assert.Equal("Host=localhost", result.Invocation.Arguments["connection-string"]);
+ }
+
+ [Fact]
+ public void Parse_treats_argument_without_value_as_boolean_true()
+ {
+ var sut = new CommandLineParser();
+
+ var result = sut.Parse(["db-migrate", "--dry-run"]);
+
+ Assert.False(result.IsInteractive);
+ Assert.NotNull(result.Invocation);
+ Assert.Equal("true", result.Invocation.Arguments["dry-run"]);
+ }
+
+ [Fact]
+ public void Parse_rejects_unexpected_positional_argument_after_command_name()
+ {
+ var sut = new CommandLineParser();
+
+ var result = sut.Parse(["db-migrate", "unexpected"]);
+
+ Assert.False(result.IsInteractive);
+ Assert.Null(result.Invocation);
+ Assert.Equal("Unexpected argument 'unexpected'. Expected '--name value'.", result.ErrorMessage);
+ }
+}
diff --git a/tests/WiSave.Console.Tests/CommandRunnerTests.cs b/tests/WiSave.Console.Tests/CommandRunnerTests.cs
new file mode 100644
index 0000000..2dabe73
--- /dev/null
+++ b/tests/WiSave.Console.Tests/CommandRunnerTests.cs
@@ -0,0 +1,76 @@
+using Microsoft.Extensions.DependencyInjection;
+using WiSave.Console.Execution;
+using WiSave.Console.Shell;
+
+namespace WiSave.Console.Tests;
+
+public sealed class CommandRunnerTests
+{
+ [Fact]
+ public async Task RunAsync_reports_missing_required_parameters()
+ {
+ var output = new TestConsoleOutput();
+ var services = CreateServices(output, new RequiredParameterCommand());
+ var sut = services.GetRequiredService();
+
+ var exitCode = await sut.RunAsync(new CommandInvocation("required", new Dictionary()), false, CancellationToken.None);
+
+ Assert.Equal(1, exitCode);
+ Assert.Contains("Missing required parameters:", output.Lines);
+ Assert.Contains(" --name", output.Lines);
+ }
+
+ [Fact]
+ public async Task RunAsync_resolves_command_names_case_insensitively()
+ {
+ var output = new TestConsoleOutput();
+ var command = new RequiredParameterCommand();
+ var services = CreateServices(output, command);
+ var sut = services.GetRequiredService();
+
+ var exitCode = await sut.RunAsync(
+ new CommandInvocation("REQUIRED", new Dictionary { ["name"] = "value" }),
+ false,
+ CancellationToken.None);
+
+ Assert.Equal(0, exitCode);
+ Assert.True(command.WasExecuted);
+ Assert.Contains("OK: ran", output.Lines);
+ }
+
+ private static ServiceProvider CreateServices(IConsoleOutput output, params IConsoleCommand[] commands)
+ {
+ var services = new ServiceCollection();
+ services.AddSingleton(output);
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+
+ foreach (var command in commands)
+ {
+ services.AddSingleton(command);
+ }
+
+ return services.BuildServiceProvider();
+ }
+
+ private sealed class RequiredParameterCommand : IConsoleCommand
+ {
+ public bool WasExecuted { get; private set; }
+
+ public string Name => "required";
+
+ public string Description => "Requires a name.";
+
+ public IReadOnlyList ParameterDefinitions { get; } =
+ [
+ new("name", "Name to use.", true)
+ ];
+
+ public Task ExecuteAsync(CommandExecutionContext context, CancellationToken ct)
+ {
+ WasExecuted = true;
+ return Task.FromResult(CommandResult.SuccessResult("ran"));
+ }
+ }
+}
diff --git a/tests/WiSave.Console.Tests/ConsoleApplicationTests.cs b/tests/WiSave.Console.Tests/ConsoleApplicationTests.cs
new file mode 100644
index 0000000..53656eb
--- /dev/null
+++ b/tests/WiSave.Console.Tests/ConsoleApplicationTests.cs
@@ -0,0 +1,47 @@
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
+using WiSave.Console.Execution;
+using WiSave.Console.Infrastructure;
+using WiSave.Console.Shell;
+
+namespace WiSave.Console.Tests;
+
+public sealed class ConsoleApplicationTests
+{
+ [Fact]
+ public async Task RunAsync_prints_available_commands_for_help()
+ {
+ var output = new TestConsoleOutput();
+ var services = new ServiceCollection();
+ services.AddSingleton(output);
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton(Options.Create(new ConsoleShellOptions()));
+ await using var provider = services.BuildServiceProvider();
+ var sut = provider.GetRequiredService();
+
+ var exitCode = await sut.RunAsync(["help"], CancellationToken.None);
+
+ Assert.Equal(0, exitCode);
+ Assert.Contains("Available commands:", output.Lines);
+ Assert.Contains(" sample - Sample command.", output.Lines);
+ Assert.Contains("Run without arguments to start the interactive shell.", output.Lines);
+ }
+
+ private sealed class SampleCommand : IConsoleCommand
+ {
+ public string Name => "sample";
+
+ public string Description => "Sample command.";
+
+ public IReadOnlyList ParameterDefinitions => [];
+
+ public Task ExecuteAsync(CommandExecutionContext context, CancellationToken ct)
+ => Task.FromResult(CommandResult.SuccessResult("sampled"));
+ }
+}
diff --git a/tests/WiSave.Console.Tests/ConsoleShellTests.cs b/tests/WiSave.Console.Tests/ConsoleShellTests.cs
new file mode 100644
index 0000000..3f3f200
--- /dev/null
+++ b/tests/WiSave.Console.Tests/ConsoleShellTests.cs
@@ -0,0 +1,69 @@
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
+using WiSave.Console.Execution;
+using WiSave.Console.Infrastructure;
+using WiSave.Console.Shell;
+
+namespace WiSave.Console.Tests;
+
+public sealed class ConsoleShellTests
+{
+ [Fact]
+ public async Task RunAsync_executes_command_selected_by_number_and_then_exits()
+ {
+ var output = new TestConsoleOutput("1", "exit");
+ var command = new SampleCommand();
+ var services = CreateServices(output, command);
+ var sut = services.GetRequiredService();
+
+ var exitCode = await sut.RunAsync(CancellationToken.None);
+
+ Assert.Equal(0, exitCode);
+ Assert.True(command.WasExecuted);
+ Assert.Contains("Test Console", output.Lines);
+ }
+
+ [Fact]
+ public async Task RunAsync_executes_command_selected_by_name_and_then_exits()
+ {
+ var output = new TestConsoleOutput("sample", "exit");
+ var command = new SampleCommand();
+ var services = CreateServices(output, command);
+ var sut = services.GetRequiredService();
+
+ var exitCode = await sut.RunAsync(CancellationToken.None);
+
+ Assert.Equal(0, exitCode);
+ Assert.True(command.WasExecuted);
+ }
+
+ private static ServiceProvider CreateServices(IConsoleOutput output, IConsoleCommand command)
+ {
+ var services = new ServiceCollection();
+ services.AddSingleton(output);
+ services.AddSingleton(command);
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton(Options.Create(new ConsoleShellOptions { Title = "Test Console" }));
+ return services.BuildServiceProvider();
+ }
+
+ private sealed class SampleCommand : IConsoleCommand
+ {
+ public bool WasExecuted { get; private set; }
+
+ public string Name => "sample";
+
+ public string Description => "Sample command.";
+
+ public IReadOnlyList ParameterDefinitions => [];
+
+ public Task ExecuteAsync(CommandExecutionContext context, CancellationToken ct)
+ {
+ WasExecuted = true;
+ return Task.FromResult(CommandResult.SuccessResult("sampled"));
+ }
+ }
+}
diff --git a/tests/WiSave.Console.Tests/TestConsoleOutput.cs b/tests/WiSave.Console.Tests/TestConsoleOutput.cs
new file mode 100644
index 0000000..c5240cb
--- /dev/null
+++ b/tests/WiSave.Console.Tests/TestConsoleOutput.cs
@@ -0,0 +1,30 @@
+using WiSave.Console.Shell;
+
+namespace WiSave.Console.Tests;
+
+internal sealed class TestConsoleOutput(params string[] inputs) : IConsoleOutput
+{
+ private readonly Queue inputQueue = new(inputs);
+ private readonly List lines = [];
+
+ public IReadOnlyList Lines => lines;
+
+ public void Write(string value)
+ {
+ if (value.Length > 0)
+ {
+ lines.Add(value);
+ }
+ }
+
+ public void WriteLine(string? value)
+ => lines.Add(value ?? string.Empty);
+
+ public string? ReadLine()
+ => inputQueue.Count > 0 ? inputQueue.Dequeue() : null;
+
+ public void Clear()
+ {
+ lines.Clear();
+ }
+}
diff --git a/tests/WiSave.Console.Tests/WiSave.Console.Tests.csproj b/tests/WiSave.Console.Tests/WiSave.Console.Tests.csproj
new file mode 100644
index 0000000..599a13b
--- /dev/null
+++ b/tests/WiSave.Console.Tests/WiSave.Console.Tests.csproj
@@ -0,0 +1,28 @@
+
+
+
+ false
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+
+
+
+
+