diff --git a/.vscode/launch.json b/.vscode/launch.json
new file mode 100644
index 000000000..577166d43
--- /dev/null
+++ b/.vscode/launch.json
@@ -0,0 +1,47 @@
+{
+ "version": "0.2.0",
+ "configurations": [
+ {
+ // Prompts you for the command/args each launch, e.g. ":get-configuration" or ":bogus".
+ "name": "fallout (ask for args)",
+ "type": "coreclr",
+ "request": "launch",
+ "preLaunchTask": "build-cli",
+ "program": "${workspaceFolder}/src/Fallout.Cli/bin/Debug/net10.0/Fallout.Cli.dll",
+ "args": "${input:falloutArgs}",
+ "cwd": "${workspaceFolder}",
+ "console": "integratedTerminal",
+ "stopAtEntry": false
+ },
+ {
+ // Dispatch error path — prints the command manifest, no side effects.
+ "name": "fallout :bogus",
+ "type": "coreclr",
+ "request": "launch",
+ "preLaunchTask": "build-cli",
+ "program": "${workspaceFolder}/src/Fallout.Cli/bin/Debug/net10.0/Fallout.Cli.dll",
+ "args": [":bogus"],
+ "cwd": "${workspaceFolder}",
+ "console": "integratedTerminal"
+ },
+ {
+ // Real command via the DelegateCommand path — read-only.
+ "name": "fallout :get-configuration",
+ "type": "coreclr",
+ "request": "launch",
+ "preLaunchTask": "build-cli",
+ "program": "${workspaceFolder}/src/Fallout.Cli/bin/Debug/net10.0/Fallout.Cli.dll",
+ "args": [":get-configuration"],
+ "cwd": "${workspaceFolder}",
+ "console": "integratedTerminal"
+ }
+ ],
+ "inputs": [
+ {
+ "id": "falloutArgs",
+ "type": "promptString",
+ "description": "Arguments to pass to fallout (space-separated), e.g. ':get-configuration'",
+ "default": ":bogus"
+ }
+ ]
+}
diff --git a/.vscode/tasks.json b/.vscode/tasks.json
new file mode 100644
index 000000000..e19bb9547
--- /dev/null
+++ b/.vscode/tasks.json
@@ -0,0 +1,17 @@
+{
+ "version": "2.0.0",
+ "tasks": [
+ {
+ "label": "build-cli",
+ "command": "dotnet",
+ "type": "process",
+ "args": [
+ "build",
+ "${workspaceFolder}/src/Fallout.Cli/Fallout.Cli.csproj",
+ "/property:GenerateFullPaths=true",
+ "/consoleloggerparameters:NoSummary"
+ ],
+ "problemMatcher": "$msCompile"
+ }
+ ]
+}
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 644d92315..5a1a8e0e3 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -13,6 +13,7 @@
+
diff --git a/src/Fallout.Cli/CommandDispatcher.cs b/src/Fallout.Cli/CommandDispatcher.cs
new file mode 100644
index 000000000..86ce2c430
--- /dev/null
+++ b/src/Fallout.Cli/CommandDispatcher.cs
@@ -0,0 +1,69 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Fallout.Cli.Commands;
+using Fallout.Cli.Prompts;
+using Fallout.Common;
+using Fallout.Common.IO;
+using Fallout.Common.Utilities;
+using Fallout.Common.Utilities.Collections;
+
+namespace Fallout.Cli;
+
+///
+/// Routes a command-line invocation to the matching . Commands are
+/// resolved by from the registered set — dash- and
+/// case-insensitively, preserving every spelling the historical reflection dispatch accepted
+/// (e.g. :add-package and :addpackage, :PopDirectory and :popdirectory).
+///
+internal sealed class CommandDispatcher
+{
+ private const char CommandPrefix = ':';
+
+ private readonly IReadOnlyList _commands;
+ private readonly IConsolePrompts _prompts;
+
+ public CommandDispatcher(IEnumerable commands, IConsolePrompts prompts)
+ {
+ _commands = commands.ToList();
+ _prompts = prompts;
+ }
+
+ public int Dispatch(string[] args, AbsolutePath rootDirectory, AbsolutePath buildScript)
+ {
+ var hasCommand = args.FirstOrDefault()?.StartsWithOrdinalIgnoreCase(CommandPrefix.ToString()) ?? false;
+ if (hasCommand)
+ {
+ var token = args.First().Trim(CommandPrefix);
+ if (string.IsNullOrWhiteSpace(token))
+ Assert.Fail($"No command specified. Usage is: fallout {CommandPrefix} [args]");
+
+ var command = Resolve(token);
+ return command.Execute(args.Skip(count: 1).ToArray(), rootDirectory, buildScript);
+ }
+
+ if (rootDirectory == null)
+ {
+ return _prompts.PromptForConfirmation(
+ $"Could not find {Constants.FalloutDirectoryName} directory/file. Do you want to setup a build?")
+ ? GetRequired("setup").Execute(Array.Empty(), rootDirectory: null, buildScript: null)
+ : 0;
+ }
+
+ // TODO: docker
+
+ return GetRequired("run").Execute(args, rootDirectory, BuildProjectResolver.Resolve(rootDirectory));
+ }
+
+ private IFalloutCommand Resolve(string token)
+ {
+ return _commands.SingleOrDefault(x => Normalize(x.Name).EqualsOrdinalIgnoreCase(Normalize(token)))
+ .NotNull(new[] { $"Command '{token}' is not supported, available commands are:" }
+ .Concat(_commands.Select(x => $" - {x.Name}").OrderBy(x => x)).JoinNewLine());
+ }
+
+ private IFalloutCommand GetRequired(string name)
+ => _commands.Single(x => x.Name.EqualsOrdinalIgnoreCase(name));
+
+ private static string Normalize(string value) => value.Replace("-", string.Empty);
+}
diff --git a/src/Fallout.Cli/Commands/DelegateCommand.cs b/src/Fallout.Cli/Commands/DelegateCommand.cs
new file mode 100644
index 000000000..5a60e8559
--- /dev/null
+++ b/src/Fallout.Cli/Commands/DelegateCommand.cs
@@ -0,0 +1,27 @@
+using System;
+using Fallout.Common.IO;
+
+namespace Fallout.Cli.Commands;
+
+///
+/// Transitional adapter that exposes a not-yet-extracted Program.X handler as an
+/// , so the dispatcher can route every command uniformly through the
+/// registry while the per-command conversion (issue #392) lands one PR at a time. Each conversion
+/// replaces one registration of this adapter with a real command type; the adapter is deleted once
+/// the last legacy handler is gone.
+///
+internal sealed class DelegateCommand : IFalloutCommand
+{
+ private readonly Func _handler;
+
+ public DelegateCommand(string name, Func handler)
+ {
+ Name = name;
+ _handler = handler;
+ }
+
+ public string Name { get; }
+
+ public int Execute(string[] args, AbsolutePath rootDirectory, AbsolutePath buildScript)
+ => _handler(args, rootDirectory, buildScript);
+}
diff --git a/src/Fallout.Cli/Commands/IFalloutCommand.cs b/src/Fallout.Cli/Commands/IFalloutCommand.cs
new file mode 100644
index 000000000..04f027d7d
--- /dev/null
+++ b/src/Fallout.Cli/Commands/IFalloutCommand.cs
@@ -0,0 +1,30 @@
+using Fallout.Common.IO;
+
+namespace Fallout.Cli.Commands;
+
+///
+/// A single global-tool command (e.g. fallout :run, fallout :setup).
+/// One command = one type, resolved by from the dependency-injection
+/// container — replacing the historical reflection-over-Program dispatch.
+///
+///
+/// This surface is intentionally minimal. It is not a stable public plugin contract yet;
+/// when a public command SDK lands (milestone #7) the API will be annotated and versioned
+/// explicitly. Until then, treat additions here as internal-by-convention.
+///
+public interface IFalloutCommand
+{
+ ///
+ /// The canonical command name as typed after the : prefix, in dash form
+ /// (e.g. "run", "add-package", "cake-convert"). Matched case-insensitively.
+ ///
+ string Name { get; }
+
+ ///
+ /// Executes the command and returns the process exit code.
+ ///
+ /// The arguments following the command token.
+ /// The resolved repository root, or null when none was found.
+ /// The resolved build script / project file, or null when none applies.
+ int Execute(string[] args, AbsolutePath rootDirectory, AbsolutePath buildScript);
+}
diff --git a/src/Fallout.Cli/Program.Run.cs b/src/Fallout.Cli/Commands/RunCommand.cs
similarity index 87%
rename from src/Fallout.Cli/Program.Run.cs
rename to src/Fallout.Cli/Commands/RunCommand.cs
index a9aefc998..28982c927 100644
--- a/src/Fallout.Cli/Program.Run.cs
+++ b/src/Fallout.Cli/Commands/RunCommand.cs
@@ -8,11 +8,17 @@
using Fallout.Common.Utilities;
using static Fallout.Common.Constants;
-namespace Fallout.Cli;
+namespace Fallout.Cli.Commands;
-partial class Program
+///
+/// fallout :run (and the default, command-less invocation): builds the build project and
+/// runs it, forwarding any remaining arguments to the build.
+///
+public sealed class RunCommand : IFalloutCommand
{
- private static int Run(string[] forwardedArgs, AbsolutePath rootDirectory, AbsolutePath buildProjectFile)
+ public string Name => "run";
+
+ public int Execute(string[] forwardedArgs, AbsolutePath rootDirectory, AbsolutePath buildProjectFile)
{
var dotnet = ResolveDotnet(rootDirectory);
@@ -63,7 +69,7 @@ private static int StartDotnet(string dotnet, IEnumerable arguments)
startInfo.Environment["DOTNET_NOLOGO"] = "1";
startInfo.Environment["DOTNET_ROLL_FORWARD"] = "Major";
startInfo.Environment["FALLOUT_TELEMETRY_OPTOUT"] = "1";
- startInfo.Environment[GlobalToolVersionEnvironmentKey] = typeof(Program).Assembly.GetVersionText();
+ startInfo.Environment[GlobalToolVersionEnvironmentKey] = typeof(RunCommand).Assembly.GetVersionText();
startInfo.Environment[GlobalToolStartTimeEnvironmentKey] = DateTime.Now.ToString("O");
var process = Process.Start(startInfo).NotNull();
diff --git a/src/Fallout.Cli/Fallout.Cli.csproj b/src/Fallout.Cli/Fallout.Cli.csproj
index 996a07f13..60071de75 100644
--- a/src/Fallout.Cli/Fallout.Cli.csproj
+++ b/src/Fallout.Cli/Fallout.Cli.csproj
@@ -17,6 +17,7 @@
+
diff --git a/src/Fallout.Cli/Program.AddPackage.cs b/src/Fallout.Cli/Program.AddPackage.cs
index ea6d28cf5..29efb84be 100644
--- a/src/Fallout.Cli/Program.AddPackage.cs
+++ b/src/Fallout.Cli/Program.AddPackage.cs
@@ -45,7 +45,7 @@ public static int AddPackage(string[] args, AbsolutePath rootDirectory, Absolute
return 0;
}
- private static void AddOrReplacePackage(string packageId, string packageVersion, string packageType, string buildProjectFile)
+ internal static void AddOrReplacePackage(string packageId, string packageVersion, string packageType, string buildProjectFile)
{
var buildProject = ProjectModelTasks.ParseProject(buildProjectFile).NotNull();
diff --git a/src/Fallout.Cli/Program.GetConfiguration.cs b/src/Fallout.Cli/Program.GetConfiguration.cs
index 6600b3273..9a9875d95 100644
--- a/src/Fallout.Cli/Program.GetConfiguration.cs
+++ b/src/Fallout.Cli/Program.GetConfiguration.cs
@@ -11,7 +11,9 @@ namespace Fallout.Cli;
partial class Program
{
- private const string BUILD_PROJECT_FILE = nameof(BUILD_PROJECT_FILE);
+ // internal (not private): shared with AddPackage/Update/Cake; will move into a configuration
+ // service in the final #392 collapse PR.
+ internal const string BUILD_PROJECT_FILE = nameof(BUILD_PROJECT_FILE);
private const string TEMP_DIRECTORY = nameof(TEMP_DIRECTORY);
private const string DOTNET_GLOBAL_FILE = nameof(DOTNET_GLOBAL_FILE);
private const string DOTNET_INSTALL_URL = nameof(DOTNET_INSTALL_URL);
@@ -27,7 +29,7 @@ public static int GetConfiguration(string[] args, AbsolutePath rootDirectory, Ab
return 0;
}
- private static Dictionary GetConfiguration(AbsolutePath buildScript, bool evaluate)
+ internal static Dictionary GetConfiguration(AbsolutePath buildScript, bool evaluate)
{
string ReplaceScriptDirectory(string value)
=> evaluate
diff --git a/src/Fallout.Cli/Program.Setup.cs b/src/Fallout.Cli/Program.Setup.cs
index a094e2e2f..fd0ce6709 100644
--- a/src/Fallout.Cli/Program.Setup.cs
+++ b/src/Fallout.Cli/Program.Setup.cs
@@ -178,13 +178,13 @@ internal static void UpdateSolutionFileContent(
"EndProject");
}
- private static string[] GetTemplate(string templateName)
+ internal static string[] GetTemplate(string templateName)
{
return ResourceUtility.GetResourceAllLines($"templates.{templateName}");
}
- private static void WriteBuildScripts(
+ internal static void WriteBuildScripts(
AbsolutePath scriptDirectory,
AbsolutePath rootDirectory,
AbsolutePath buildDirectory,
@@ -243,7 +243,7 @@ void MakeExecutable(AbsolutePath scriptFile)
}
}
- private static void WriteConfigurationFile(AbsolutePath rootDirectory, AbsolutePath solutionFile)
+ internal static void WriteConfigurationFile(AbsolutePath rootDirectory, AbsolutePath solutionFile)
{
var parametersFile = GetDefaultParametersFile(rootDirectory);
var dictionary = new Dictionary { ["$schema"] = BuildSchemaFileName };
diff --git a/src/Fallout.Cli/Program.cs b/src/Fallout.Cli/Program.cs
index 1702ab81a..0ce3d7e55 100644
--- a/src/Fallout.Cli/Program.cs
+++ b/src/Fallout.Cli/Program.cs
@@ -1,19 +1,19 @@
-using System;
+using System;
using System.IO;
using System.Linq;
using System.Text;
+using Fallout.Cli.Commands;
+using Fallout.Cli.Prompts;
using Fallout.Common;
using Fallout.Common.IO;
using Fallout.Common.Utilities;
-using Spectre.Console;
+using Microsoft.Extensions.DependencyInjection;
namespace Fallout.Cli;
public partial class Program
{
- private const char CommandPrefix = ':';
-
- private static string CurrentBuildScriptName => EnvironmentInfo.IsWin ? "build.ps1" : "build.sh";
+ internal static string CurrentBuildScriptName => EnvironmentInfo.IsWin ? "build.ps1" : "build.sh";
private static int Main(string[] args)
{
@@ -28,7 +28,8 @@ private static int Main(string[] args)
.FirstOrDefault(x => Constants.TryGetRootDirectoryFrom(x.Parent) == rootDirectory)
: null;
- return Handle(args, rootDirectory, buildScript);
+ using var services = BuildServiceProvider();
+ return services.GetRequiredService().Dispatch(args, rootDirectory, buildScript);
}
catch (Exception exception)
{
@@ -37,133 +38,70 @@ private static int Main(string[] args)
}
}
- private static void PrintInfo()
- {
- Host.Information($"NUKE Global Tool 🌐 {typeof(Program).Assembly.GetInformationalText()}");
- }
-
- private static AbsolutePath TryGetRootDirectory()
- {
- // TODO: copied in FalloutBuild.GetRootDirectory
- var parameterValue = EnvironmentInfo.GetNamedArgument(Constants.RootDirectoryParameterName);
- if (parameterValue != null)
- return parameterValue;
-
- if (EnvironmentInfo.GetNamedArgument(Constants.RootDirectoryParameterName))
- return EnvironmentInfo.WorkingDirectory;
-
- return Constants.TryGetRootDirectoryFrom(Directory.GetCurrentDirectory());
- }
-
- private static int Handle(string[] args, AbsolutePath rootDirectory, AbsolutePath buildScript)
- {
- var hasCommand = args.FirstOrDefault()?.StartsWithOrdinalIgnoreCase(CommandPrefix.ToString()) ?? false;
- if (hasCommand)
- {
- var command = args.First().Trim(CommandPrefix).Replace("-", string.Empty);
- if (string.IsNullOrWhiteSpace(command))
- Assert.Fail($"No command specified. Usage is: nuke {CommandPrefix} [args]");
-
- var availableCommands = typeof(Program).GetMethods(ReflectionUtility.Static).Where(x => x.ReturnType == typeof(int)).ToList();
- var commandHandler = availableCommands.SingleOrDefault(x => x.Name.EqualsOrdinalIgnoreCase(command))
- .NotNull(new[] { $"Command '{command}' is not supported, available commands are:" }
- .Concat(availableCommands.Where(x => x.IsPublic).Select(x => $" - {x.Name}").OrderBy(x => x)).JoinNewLine());
- // TODO: add assertions about return type and parameters
-
- var commandArguments = new object[] { args.Skip(count: 1).ToArray(), rootDirectory, buildScript };
- return (int)commandHandler.Invoke(obj: null, commandArguments).NotNull($"Command '{command}' did not return exit code");
- }
-
- if (rootDirectory == null)
- {
- return PromptForConfirmation($"Could not find {Constants.FalloutDirectoryName} directory/file. Do you want to setup a build?")
- ? Setup(new string[0], rootDirectory: null, buildScript: null)
- : 0;
- }
-
- // TODO: docker
-
- return Run(args, rootDirectory, BuildProjectResolver.Resolve(rootDirectory));
- }
-
- private static void ShowInput(string emoji, string title, string value)
+ private static ServiceProvider BuildServiceProvider()
{
- AnsiConsole.MarkupLine($":{emoji}: {$"{title}:",-25} [turquoise2 bold]{value}[/]");
- }
+ var services = new ServiceCollection();
- private static void ShowCompletion(string title)
- {
- AnsiConsole.WriteLine();
- AnsiConsole.MarkupLine($"[bold green]{title} completed![/] :party_popper:");
- }
+ services.AddSingleton();
+ services.AddSingleton();
+ RegisterCommands(services);
- private static void ClearPreviousLine()
- {
- AnsiConsole.Cursor.MoveUp();
- Console.WriteLine(' '.Repeat(Console.WindowWidth));
- AnsiConsole.Cursor.MoveUp();
+ return services.BuildServiceProvider();
}
- private static bool PromptForConfirmation(string question)
+ private static void RegisterCommands(IServiceCollection services)
{
- return AnsiConsole.Confirm(question);
+ // Real command types — issue #392 converts one legacy handler per PR.
+ services.AddSingleton();
+
+ // Legacy handlers still living on Program, adapted until they are extracted into command
+ // types. Each conversion deletes one line here plus its Program.X.cs partial.
+ services.AddSingleton(new DelegateCommand("setup", Setup));
+ services.AddSingleton(new DelegateCommand("update", Update));
+ services.AddSingleton(new DelegateCommand("add-package", AddPackage));
+ services.AddSingleton(new DelegateCommand("cake-convert", CakeConvert));
+ services.AddSingleton(new DelegateCommand("cake-clean", CakeClean));
+ services.AddSingleton(new DelegateCommand("complete", Complete));
+ services.AddSingleton(new DelegateCommand("get-configuration", GetConfiguration));
+ services.AddSingleton(new DelegateCommand("secrets", Secrets));
+ services.AddSingleton(new DelegateCommand("trigger", Trigger));
+ services.AddSingleton(new DelegateCommand("GetNextDirectory", GetNextDirectory));
+ services.AddSingleton(new DelegateCommand("PopDirectory", PopDirectory));
+ services.AddSingleton(new DelegateCommand("PushWithCurrentRootDirectory", PushWithCurrentRootDirectory));
+ services.AddSingleton(new DelegateCommand("PushWithParentRootDirectory", PushWithParentRootDirectory));
+ services.AddSingleton(new DelegateCommand("PushWithChosenRootDirectory", PushWithChosenRootDirectory));
}
- private static string PromptForInput(string question, string defaultValue = null)
+ internal static void PrintInfo()
{
- return AnsiConsole.Prompt(
- new TextPrompt(question)
- .DefaultValue(defaultValue));
+ Host.Information($"NUKE Global Tool 🌐 {typeof(Program).Assembly.GetInformationalText()}");
}
- private static string PromptForSecret(string title, int? minLength = null)
+ private static AbsolutePath TryGetRootDirectory()
{
- Assert.False(title.EndsWith(':'));
+ // TODO: copied in FalloutBuild.GetRootDirectory
+ AbsolutePath parameterValue = EnvironmentInfo.GetNamedArgument(Constants.RootDirectoryParameterName);
+ if (parameterValue != null)
+ return parameterValue;
- return AnsiConsole.Prompt(
- new TextPrompt($"{title}:")
- .Secret()
- .Validate(x => minLength == null || x.Length >= minLength,
- message: $"Secret must be at least {minLength} characters long"));
- }
+ if (EnvironmentInfo.GetNamedArgument(Constants.RootDirectoryParameterName))
+ return EnvironmentInfo.WorkingDirectory;
- private static T PromptForChoice(string question, params (T Value, string Description)[] choices)
- {
- var choice = AnsiConsole.Prompt(
- new SelectionPrompt()
- .Title(question)
- .HighlightStyle(new Style(Color.Turquoise2))
- .UseConverter(x => choices.Single(y => Equals(x, y.Value)).Description)
- .AddChoices(choices.Select(x => x.Value)));
- return choice;
+ return Constants.TryGetRootDirectoryFrom(Directory.GetCurrentDirectory());
}
- private static void ConfirmExecution(string title, Action action)
- {
- Assert.False(title.EndsWith('?'));
-
- var confirmation = PromptForConfirmation($"{title}?");
- ClearPreviousLine();
-
- if (confirmation)
- {
- AnsiConsole.MarkupLine($":hourglass_not_done: {title} ...");
- try
- {
- action.Invoke();
- }
- catch (Exception)
- {
- confirmation = false;
- title = $"{title} (failed)";
- }
- finally
- {
- ClearPreviousLine();
- }
- }
-
- var (emoji, color) = confirmation ? ("check_mark", "green") : ("multiply", "red");
- AnsiConsole.MarkupLine($"[{color}]:{emoji}:[/] {title}");
- }
+ // ── Transitional Spectre prompt delegators ──────────────────────────────────────────────────
+ // The implementations now live in SpectreConsolePrompts; these forwards keep the not-yet-extracted
+ // Program.X.cs command handlers compiling. As each handler becomes a command type taking
+ // IConsolePrompts via the constructor, its use of these disappears; the last conversion deletes them.
+ private static readonly IConsolePrompts s_prompts = new SpectreConsolePrompts();
+
+ private static void ShowInput(string emoji, string title, string value) => s_prompts.ShowInput(emoji, title, value);
+ private static void ShowCompletion(string title) => s_prompts.ShowCompletion(title);
+ private static void ClearPreviousLine() => s_prompts.ClearPreviousLine();
+ private static bool PromptForConfirmation(string question) => s_prompts.PromptForConfirmation(question);
+ private static string PromptForInput(string question, string defaultValue = null) => s_prompts.PromptForInput(question, defaultValue);
+ private static string PromptForSecret(string title, int? minLength = null) => s_prompts.PromptForSecret(title, minLength);
+ private static T PromptForChoice(string question, params (T Value, string Description)[] choices) => s_prompts.PromptForChoice(question, choices);
+ private static void ConfirmExecution(string title, Action action) => s_prompts.ConfirmExecution(title, action);
}
diff --git a/src/Fallout.Cli/Prompts/IConsolePrompts.cs b/src/Fallout.Cli/Prompts/IConsolePrompts.cs
new file mode 100644
index 000000000..465e1fd81
--- /dev/null
+++ b/src/Fallout.Cli/Prompts/IConsolePrompts.cs
@@ -0,0 +1,39 @@
+using System;
+
+namespace Fallout.Cli.Prompts;
+
+///
+/// Interactive console prompts and status rendering used by commands. Injected into commands
+/// so their interaction logic can be unit-tested with a fake, instead of reaching the historical
+/// static Spectre helpers on Program. The default implementation is
+/// .
+///
+public interface IConsolePrompts
+{
+ /// Renders a labelled, read-only input line (used to echo resolved setup choices).
+ void ShowInput(string emoji, string title, string value);
+
+ /// Renders a " completed!" banner.
+ void ShowCompletion(string title);
+
+ /// Clears the previously written console line.
+ void ClearPreviousLine();
+
+ /// Asks a yes/no question and returns the answer.
+ bool PromptForConfirmation(string question);
+
+ /// Asks for free-text input, optionally with a default value.
+ string PromptForInput(string question, string defaultValue = null);
+
+ /// Asks for a masked secret value, optionally enforcing a minimum length.
+ string PromptForSecret(string title, int? minLength = null);
+
+ /// Asks the user to pick one of the supplied .
+ T PromptForChoice(string question, params (T Value, string Description)[] choices);
+
+ ///
+ /// Confirms, then runs with progress/completion rendering,
+ /// reporting success or failure without throwing.
+ ///
+ void ConfirmExecution(string title, Action action);
+}
diff --git a/src/Fallout.Cli/Prompts/SpectreConsolePrompts.cs b/src/Fallout.Cli/Prompts/SpectreConsolePrompts.cs
new file mode 100644
index 000000000..5f688a751
--- /dev/null
+++ b/src/Fallout.Cli/Prompts/SpectreConsolePrompts.cs
@@ -0,0 +1,95 @@
+using System;
+using System.Linq;
+using Fallout.Common;
+using Fallout.Common.Utilities;
+using Spectre.Console;
+
+namespace Fallout.Cli.Prompts;
+
+///
+/// backed by . This is the single home for
+/// the interactive prompt logic that previously lived as static helpers on Program.
+///
+public sealed class SpectreConsolePrompts : IConsolePrompts
+{
+ public void ShowInput(string emoji, string title, string value)
+ {
+ AnsiConsole.MarkupLine($":{emoji}: {$"{title}:",-25} [turquoise2 bold]{value}[/]");
+ }
+
+ public void ShowCompletion(string title)
+ {
+ AnsiConsole.WriteLine();
+ AnsiConsole.MarkupLine($"[bold green]{title} completed![/] :party_popper:");
+ }
+
+ public void ClearPreviousLine()
+ {
+ AnsiConsole.Cursor.MoveUp();
+ System.Console.WriteLine(' '.Repeat(System.Console.WindowWidth));
+ AnsiConsole.Cursor.MoveUp();
+ }
+
+ public bool PromptForConfirmation(string question)
+ {
+ return AnsiConsole.Confirm(question);
+ }
+
+ public string PromptForInput(string question, string defaultValue = null)
+ {
+ return AnsiConsole.Prompt(
+ new TextPrompt(question)
+ .DefaultValue(defaultValue));
+ }
+
+ public string PromptForSecret(string title, int? minLength = null)
+ {
+ Assert.False(title.EndsWith(':'));
+
+ return AnsiConsole.Prompt(
+ new TextPrompt($"{title}:")
+ .Secret()
+ .Validate(x => minLength == null || x.Length >= minLength,
+ message: $"Secret must be at least {minLength} characters long"));
+ }
+
+ public T PromptForChoice(string question, params (T Value, string Description)[] choices)
+ {
+ var choice = AnsiConsole.Prompt(
+ new SelectionPrompt()
+ .Title(question)
+ .HighlightStyle(new Style(Color.Turquoise2))
+ .UseConverter(x => choices.Single(y => Equals(x, y.Value)).Description)
+ .AddChoices(choices.Select(x => x.Value)));
+ return choice;
+ }
+
+ public void ConfirmExecution(string title, Action action)
+ {
+ Assert.False(title.EndsWith('?'));
+
+ var confirmation = PromptForConfirmation($"{title}?");
+ ClearPreviousLine();
+
+ if (confirmation)
+ {
+ AnsiConsole.MarkupLine($":hourglass_not_done: {title} ...");
+ try
+ {
+ action.Invoke();
+ }
+ catch (Exception)
+ {
+ confirmation = false;
+ title = $"{title} (failed)";
+ }
+ finally
+ {
+ ClearPreviousLine();
+ }
+ }
+
+ var (emoji, color) = confirmation ? ("check_mark", "green") : ("multiply", "red");
+ AnsiConsole.MarkupLine($"[{color}]:{emoji}:[/] {title}");
+ }
+}
diff --git a/tests/Fallout.Cli.Tests/CommandDispatcherTests.cs b/tests/Fallout.Cli.Tests/CommandDispatcherTests.cs
new file mode 100644
index 000000000..7fadf6841
--- /dev/null
+++ b/tests/Fallout.Cli.Tests/CommandDispatcherTests.cs
@@ -0,0 +1,217 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using Fallout.Cli.Commands;
+using Fallout.Cli.Prompts;
+using Fallout.Common.IO;
+using FluentAssertions;
+using Xunit;
+
+namespace Fallout.Cli.Tests;
+
+public class CommandDispatcherTests
+{
+ private static readonly AbsolutePath SomeRoot = (AbsolutePath)Path.Combine(Path.GetTempPath(), "fallout-dispatch-root");
+ private static readonly AbsolutePath SomeScript = SomeRoot / "build.sh";
+
+ [Fact]
+ public void Dispatch_ColonCommand_InvokesMatchingCommandWithForwardedArgs()
+ {
+ var run = new RecordingCommand("run");
+ var setup = new RecordingCommand("setup");
+ var dispatcher = new CommandDispatcher(new IFalloutCommand[] { run, setup }, new FakePrompts());
+
+ var exitCode = dispatcher.Dispatch([":setup", "alpha", "beta"], SomeRoot, SomeScript);
+
+ exitCode.Should().Be(0);
+ setup.WasCalled.Should().BeTrue();
+ setup.ReceivedArgs.Should().Equal("alpha", "beta");
+ setup.ReceivedRoot.Should().Be(SomeRoot);
+ setup.ReceivedBuildScript.Should().Be(SomeScript);
+ run.WasCalled.Should().BeFalse();
+ }
+
+ [Theory]
+ [InlineData(":setup")]
+ [InlineData(":SETUP")]
+ [InlineData(":Setup")]
+ public void Dispatch_MatchesNameCaseInsensitively(string token)
+ {
+ var setup = new RecordingCommand("setup");
+ var dispatcher = new CommandDispatcher(new IFalloutCommand[] { setup }, new FakePrompts());
+
+ dispatcher.Dispatch([token], SomeRoot, SomeScript);
+
+ setup.WasCalled.Should().BeTrue();
+ }
+
+ [Theory]
+ [InlineData(":add-package")]
+ [InlineData(":addpackage")]
+ [InlineData(":ADD-PACKAGE")]
+ public void Dispatch_MatchesNameIgnoringDashes(string token)
+ {
+ // Preserves every spelling the historical reflection dispatch accepted.
+ var addPackage = new RecordingCommand("add-package");
+ var dispatcher = new CommandDispatcher(new IFalloutCommand[] { addPackage }, new FakePrompts());
+
+ dispatcher.Dispatch([token], SomeRoot, SomeScript);
+
+ addPackage.WasCalled.Should().BeTrue();
+ }
+
+ [Fact]
+ public void Dispatch_ReturnsCommandExitCode()
+ {
+ var trigger = new RecordingCommand("trigger", exitCode: 42);
+ var dispatcher = new CommandDispatcher(new IFalloutCommand[] { trigger }, new FakePrompts());
+
+ dispatcher.Dispatch([":trigger"], SomeRoot, SomeScript).Should().Be(42);
+ }
+
+ [Fact]
+ public void Dispatch_UnknownCommand_ThrowsWithAvailableCommandListing()
+ {
+ var dispatcher = new CommandDispatcher(
+ new IFalloutCommand[] { new RecordingCommand("run"), new RecordingCommand("setup") },
+ new FakePrompts());
+
+ var action = () => dispatcher.Dispatch([":bogus"], SomeRoot, SomeScript);
+
+ action.Should().Throw()
+ .WithMessage("*'bogus' is not supported*")
+ .And.Message.Should().ContainAll("- run", "- setup");
+ }
+
+ [Fact]
+ public void Dispatch_EmptyCommandToken_Fails()
+ {
+ var dispatcher = new CommandDispatcher(new IFalloutCommand[] { new RecordingCommand("run") }, new FakePrompts());
+
+ var action = () => dispatcher.Dispatch([":"], SomeRoot, SomeScript);
+
+ action.Should().Throw().WithMessage("*No command specified*");
+ }
+
+ [Fact]
+ public void Dispatch_NoCommand_NullRoot_Confirmed_InvokesSetup()
+ {
+ var setup = new RecordingCommand("setup");
+ var dispatcher = new CommandDispatcher(
+ new IFalloutCommand[] { new RecordingCommand("run"), setup },
+ new FakePrompts(confirm: true));
+
+ dispatcher.Dispatch(["whatever"], rootDirectory: null, buildScript: null);
+
+ setup.WasCalled.Should().BeTrue();
+ setup.ReceivedArgs.Should().BeEmpty();
+ setup.ReceivedRoot.Should().BeNull();
+ }
+
+ [Fact]
+ public void Dispatch_NoCommand_NullRoot_Declined_ReturnsZeroWithoutSetup()
+ {
+ var setup = new RecordingCommand("setup");
+ var dispatcher = new CommandDispatcher(
+ new IFalloutCommand[] { new RecordingCommand("run"), setup },
+ new FakePrompts(confirm: false));
+
+ var exitCode = dispatcher.Dispatch(["whatever"], rootDirectory: null, buildScript: null);
+
+ exitCode.Should().Be(0);
+ setup.WasCalled.Should().BeFalse();
+ }
+
+ [Fact]
+ public void Dispatch_NoCommand_WithRoot_InvokesRunWithResolvedBuildProject()
+ {
+ using var root = TempRoot.Create();
+ var buildProject = root.WriteBuildProjectAtConvention();
+ var run = new RecordingCommand("run");
+ var dispatcher = new CommandDispatcher(
+ new IFalloutCommand[] { run, new RecordingCommand("setup") },
+ new FakePrompts());
+
+ dispatcher.Dispatch(["compile", "--verbose"], root.Path, buildScript: null);
+
+ run.WasCalled.Should().BeTrue();
+ run.ReceivedArgs.Should().Equal("compile", "--verbose");
+ run.ReceivedBuildScript.Should().Be(buildProject);
+ }
+
+ private sealed class RecordingCommand : IFalloutCommand
+ {
+ private readonly int _exitCode;
+
+ public RecordingCommand(string name, int exitCode = 0)
+ {
+ Name = name;
+ _exitCode = exitCode;
+ }
+
+ public string Name { get; }
+ public bool WasCalled { get; private set; }
+ public string[] ReceivedArgs { get; private set; }
+ public AbsolutePath ReceivedRoot { get; private set; }
+ public AbsolutePath ReceivedBuildScript { get; private set; }
+
+ public int Execute(string[] args, AbsolutePath rootDirectory, AbsolutePath buildScript)
+ {
+ WasCalled = true;
+ ReceivedArgs = args;
+ ReceivedRoot = rootDirectory;
+ ReceivedBuildScript = buildScript;
+ return _exitCode;
+ }
+ }
+
+ private sealed class FakePrompts : IConsolePrompts
+ {
+ private readonly bool _confirm;
+
+ public FakePrompts(bool confirm = false) => _confirm = confirm;
+
+ public bool PromptForConfirmation(string question) => _confirm;
+
+ public void ShowInput(string emoji, string title, string value) { }
+ public void ShowCompletion(string title) { }
+ public void ClearPreviousLine() { }
+ public string PromptForInput(string question, string defaultValue = null) => defaultValue;
+ public string PromptForSecret(string title, int? minLength = null) => string.Empty;
+ public T PromptForChoice(string question, params (T Value, string Description)[] choices) => default;
+ public void ConfirmExecution(string title, Action action) => action();
+ }
+
+ private sealed class TempRoot : IDisposable
+ {
+ public AbsolutePath Path { get; }
+
+ private TempRoot(AbsolutePath path)
+ {
+ Path = path;
+ (path / ".fallout").CreateDirectory();
+ }
+
+ public static TempRoot Create()
+ {
+ var dir = (AbsolutePath)System.IO.Path.Combine(System.IO.Path.GetTempPath(), "fallout-dispatch-" + Guid.NewGuid().ToString("N"));
+ dir.CreateDirectory();
+ return new TempRoot(dir);
+ }
+
+ public AbsolutePath WriteBuildProjectAtConvention()
+ {
+ var buildDir = Path / "build";
+ buildDir.CreateDirectory();
+ var projectFile = buildDir / "_build.csproj";
+ File.WriteAllText(projectFile, string.Empty);
+ return projectFile;
+ }
+
+ public void Dispose()
+ {
+ if (Directory.Exists(Path))
+ Directory.Delete(Path, recursive: true);
+ }
+ }
+}