From 24847f3665e7d1776286b4d68d1928128c379c1c Mon Sep 17 00:00:00 2001 From: Timothy Baldridge Date: Thu, 9 Jan 2025 10:57:26 -0700 Subject: [PATCH] Cli fixes and escape hatches (#2457) * Fix up the CLI on windows, add module support to the CLI * Add support for deleting specific items from specific groups from the CLI * Fix the CLI tests a bit * Handle other OS feedback --- Directory.Packages.props | 1 + .../RendererExtensions.cs | 52 +++++ .../NexusApiVerbs.cs | 24 +-- .../OptionParsers/LoadoutParser.cs | 24 ++- .../OptionParsers/MatcherParser.cs | 25 +++ src/NexusMods.App.Cli/Services.cs | 2 + src/NexusMods.App/ConsoleHelper.cs | 32 +++ src/NexusMods.App/NexusMods.App.csproj | 2 +- src/NexusMods.App/Program.cs | 9 +- .../Verbs/LoadoutManagementVerbs.cs | 185 ++++++++---------- .../NexusMods.DataModel.csproj | 1 + .../VerbDefinitions/ModuleDefinition.cs | 6 + .../VerbDefinitions/OptionAttribute.cs | 8 +- .../VerbDefinitions/OptionDefinition.cs | 2 +- .../VerbDefinitions/ServiceExtensions.cs | 17 +- .../VerbDefinitions/VerbAttribute.cs | 19 +- .../VerbDefinitions/VerbDefinition.cs | 8 +- src/NexusMods.SingleProcess/CommandHandler.cs | 28 ++- .../CommandLineConfigurator.cs | 58 +++++- .../VerbTests/AVerbTest.cs | 3 +- .../VerbTests/ModManagementVerbs.cs | 18 +- 21 files changed, 366 insertions(+), 158 deletions(-) create mode 100644 src/NexusMods.App.Cli/OptionParsers/MatcherParser.cs create mode 100644 src/NexusMods.App/ConsoleHelper.cs create mode 100644 src/NexusMods.ProxyConsole.Abstractions/VerbDefinitions/ModuleDefinition.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index d12283f122..84db004b34 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -8,6 +8,7 @@ + diff --git a/src/Abstractions/NexusMods.Abstractions.Cli/RendererExtensions.cs b/src/Abstractions/NexusMods.Abstractions.Cli/RendererExtensions.cs index 83ddb824e8..4feb50d54b 100644 --- a/src/Abstractions/NexusMods.Abstractions.Cli/RendererExtensions.cs +++ b/src/Abstractions/NexusMods.Abstractions.Cli/RendererExtensions.cs @@ -1,3 +1,4 @@ +using System.Runtime.CompilerServices; using NexusMods.ProxyConsole.Abstractions; using NexusMods.ProxyConsole.Abstractions.Implementations; @@ -23,6 +24,45 @@ await renderer.RenderAsync(new Table }); } + /// + /// A table renderer for when you have a collection of tuples to render + /// + public static ValueTask Table(this IRenderer renderer, IEnumerable rows, params ReadOnlySpan columnNames) + where T : ITuple + { + var namesPrepared = GC.AllocateArray(columnNames.Length); + for (var i = 0; i < columnNames.Length; i++) + { + namesPrepared[i] = Renderable.Text(columnNames[i]); + } + + static IRenderable[] PrepareRow(T row) + { + var rowPrepared = GC.AllocateArray(row.Length); + for (var i = 0; i < row.Length; i++) + { + rowPrepared[i] = Renderable.Text(row[i]!.ToString()!); + } + return rowPrepared; + } + + return renderer.RenderAsync(new Table + { + Columns = namesPrepared, + Rows = rows.Select(PrepareRow).ToArray(), + } + ); + } + + /// + /// Renders the data in the given rows to a table + /// + public static ValueTask RenderTable(this IEnumerable rows, IRenderer renderer, params ReadOnlySpan columnNames) + where T : ITuple + { + return renderer.Table(rows, columnNames); + } + /// /// Renders the given text to the renderer /// @@ -43,6 +83,18 @@ public static async ValueTask Text(this IRenderer renderer, string template, par // Todo: implement custom conversion and formatting for the arguments await renderer.RenderAsync(Renderable.Text(template, args.Select(a => a.ToString()!).ToArray())); } + + /// + /// Renders the text to the renderer with the given arguments and template + /// + /// + /// + public static async ValueTask InputError(this IRenderer renderer, string template, params object[] args) + { + // Todo: implement custom conversion and formatting for the arguments + await renderer.RenderAsync(Renderable.Text(template, args.Select(a => a.ToString()!).ToArray())); + return -1; + } /// /// Runs the fn in a context that will render a progress bar while the user waits diff --git a/src/Networking/NexusMods.Networking.NexusWebApi/NexusApiVerbs.cs b/src/Networking/NexusMods.Networking.NexusWebApi/NexusApiVerbs.cs index 548042436b..40b90e5bbf 100644 --- a/src/Networking/NexusMods.Networking.NexusWebApi/NexusApiVerbs.cs +++ b/src/Networking/NexusMods.Networking.NexusWebApi/NexusApiVerbs.cs @@ -11,31 +11,33 @@ namespace NexusMods.Networking.NexusWebApi; internal static class NexusApiVerbs { internal static IServiceCollection AddNexusApiVerbs(this IServiceCollection collection) => - collection.AddVerb(() => NexusApiVerify) + collection + .AddModule("nexus", "Commands for interacting with the Nexus Mods API") + .AddVerb(() => NexusApiVerify) .AddVerb(() => NexusDownloadLinks); - [Verb("nexus-api-verify", "Verifies the logged in account via the Nexus API")] + [Verb("nexus verify", "Verifies the logged in account via the Nexus API")] private static async Task NexusApiVerify([Injected] IRenderer renderer, [Injected] NexusApiClient nexusApiClient, [Injected] IAuthenticatingMessageFactory messageFactory, [Injected] CancellationToken token) { var userInfo = await messageFactory.Verify(nexusApiClient, token); - await renderer.Table(new[] { "Name", "Premium" }, - new[] - { - new object[] - { - userInfo?.Name ?? "", + + await renderer.Table(["Name", "Premium"], + [ + [ + userInfo?.Name ?? "", userInfo?.IsPremium ?? false, - } - }); + ], + ] + ); return 0; } - [Verb("nexus-download-links", "Generates download links for a given file")] + [Verb("nexus download-links", "Generates download links for a given file")] private static async Task NexusDownloadLinks([Injected] IRenderer renderer, [Option("g", "gameDomain", "Game domain")] string gameDomain, [Option("m", "modId", "Mod ID")] ModId modId, diff --git a/src/NexusMods.App.Cli/OptionParsers/LoadoutParser.cs b/src/NexusMods.App.Cli/OptionParsers/LoadoutParser.cs index ed97e2169e..77770d4184 100644 --- a/src/NexusMods.App.Cli/OptionParsers/LoadoutParser.cs +++ b/src/NexusMods.App.Cli/OptionParsers/LoadoutParser.cs @@ -1,6 +1,8 @@ using System.Globalization; using JetBrains.Annotations; +using NexusMods.Abstractions.Games; using NexusMods.Abstractions.Loadouts; +using NexusMods.Extensions.BCL; using NexusMods.MnemonicDB.Abstractions; using NexusMods.ProxyConsole.Abstractions.VerbDefinitions; @@ -10,7 +12,7 @@ namespace NexusMods.CLI.OptionParsers; /// Parses a string into a loadout marker /// [UsedImplicitly] -internal class LoadoutParser(IConnection conn) : IOptionParser +internal class LoadoutParser(IConnection conn, IOptionParser gameParser) : IOptionParser { public bool TryParse(string input, out Loadout.ReadOnly value, out string error) { @@ -36,6 +38,26 @@ public bool TryParse(string input, out Loadout.ReadOnly value, out string error) return true; } } + + // In the format of "/" + if (input.Contains("/")) + { + var parts = input.Split('/'); + var game = parts[0]; + var shortName = parts[1]; + + if (gameParser.TryParse(game, out var gameValue, out _)) + { + if (Loadout + .FindByShortName(db, shortName) + .TryGetFirst(l => l.Installation.GameId == gameValue.GameId, out var foundLoadout)) + { + value = foundLoadout; + return true; + } + + } + } var found = Loadout.FindByName(db, input).ToArray(); diff --git a/src/NexusMods.App.Cli/OptionParsers/MatcherParser.cs b/src/NexusMods.App.Cli/OptionParsers/MatcherParser.cs new file mode 100644 index 0000000000..deaaceaa20 --- /dev/null +++ b/src/NexusMods.App.Cli/OptionParsers/MatcherParser.cs @@ -0,0 +1,25 @@ +using Microsoft.Extensions.FileSystemGlobbing; +using NexusMods.ProxyConsole.Abstractions.VerbDefinitions; + +namespace NexusMods.CLI.OptionParsers; + +internal class MatcherParser : IOptionParser +{ + /// + public bool TryParse(string toParse, out Matcher value, out string error) + { + try + { + value = new Matcher(); + value.AddInclude(toParse); + error = string.Empty; + return true; + } + catch (Exception exception) + { + value = null!; + error = exception.Message; + return false; + } + } +} diff --git a/src/NexusMods.App.Cli/Services.cs b/src/NexusMods.App.Cli/Services.cs index 7f4fe09c49..b4ae5a3db6 100644 --- a/src/NexusMods.App.Cli/Services.cs +++ b/src/NexusMods.App.Cli/Services.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.FileSystemGlobbing; using NexusMods.Abstractions.Games; using NexusMods.Abstractions.Loadouts; using NexusMods.CLI.OptionParsers; @@ -28,6 +29,7 @@ public static IServiceCollection AddCLI(this IServiceCollection services) .AddOptionParser(u => (new Uri(u), null)) .AddOptionParser(v => (Version.Parse(v), null)) .AddOptionParser(s => (s, null)) + .AddOptionParser() .AddOptionParser(); // Protocol Handlers diff --git a/src/NexusMods.App/ConsoleHelper.cs b/src/NexusMods.App/ConsoleHelper.cs new file mode 100644 index 0000000000..0f4888d91d --- /dev/null +++ b/src/NexusMods.App/ConsoleHelper.cs @@ -0,0 +1,32 @@ +using System.Runtime.InteropServices; +using System.Runtime.Versioning; + +namespace NexusMods.App; + +/// +/// Helpers for consoles on Windows +/// +[SupportedOSPlatform("windows")] +public static class ConsoleHelper +{ + // ReSharper disable once InconsistentNaming + private const int ATTACH_PARENT_PROCESS = -1; + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool AttachConsole(int dwProcessId); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool AllocConsole(); + + /// + /// Attempt to attach to the console, if it fails, create a new console window if desired + /// + /// If there is no parent console, should one be created? + public static void EnsureConsole(bool forceNewConsoleIfNoParent = false) + { + if (!AttachConsole(ATTACH_PARENT_PROCESS) && !forceNewConsoleIfNoParent) + { + AllocConsole(); + } + } +} diff --git a/src/NexusMods.App/NexusMods.App.csproj b/src/NexusMods.App/NexusMods.App.csproj index 73d4c58028..085c99ad3e 100644 --- a/src/NexusMods.App/NexusMods.App.csproj +++ b/src/NexusMods.App/NexusMods.App.csproj @@ -1,6 +1,6 @@ - WinExe + Exe icon.ico app.manifest diff --git a/src/NexusMods.App/Program.cs b/src/NexusMods.App/Program.cs index b484f4f155..a6b93a9ae3 100644 --- a/src/NexusMods.App/Program.cs +++ b/src/NexusMods.App/Program.cs @@ -24,6 +24,7 @@ using NLog.Targets; using ReactiveUI; using Spectre.Console; +using Spectre.Console.Advanced; namespace NexusMods.App; @@ -90,11 +91,12 @@ public static int Main(string[] args) try { + if (OperatingSystem.IsWindows()) + ConsoleHelper.EnsureConsole(); + if (startupMode.RunAsMain) { - LogMessages.StartingProcess(_logger, Environment.ProcessPath, Environment.ProcessId, - args - ); + LogMessages.StartingProcess(_logger, Environment.ProcessPath, Environment.ProcessId, args); if (startupMode.ShowUI) { @@ -180,6 +182,7 @@ private static Task RunCliTaskAsMain(IServiceProvider provider, StartupMode if (!startupMode.ExecuteCli) return Task.FromResult(0); var configurator = provider.GetRequiredService(); + _logger.LogInformation("Starting with Spectre.Cli"); return configurator.RunAsync(startupMode.Args, new SpectreRenderer(AnsiConsole.Console), CancellationToken.None); } diff --git a/src/NexusMods.DataModel/CommandLine/Verbs/LoadoutManagementVerbs.cs b/src/NexusMods.DataModel/CommandLine/Verbs/LoadoutManagementVerbs.cs index 2195cb89df..1e81595cee 100644 --- a/src/NexusMods.DataModel/CommandLine/Verbs/LoadoutManagementVerbs.cs +++ b/src/NexusMods.DataModel/CommandLine/Verbs/LoadoutManagementVerbs.cs @@ -1,14 +1,12 @@ using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.FileSystemGlobbing; using NexusMods.Abstractions.Cli; -using NexusMods.Abstractions.FileStore; using NexusMods.Abstractions.GameLocators; using NexusMods.Abstractions.Games; using NexusMods.Abstractions.Library; -using NexusMods.Abstractions.Library.Models; using NexusMods.Abstractions.Loadouts; -using NexusMods.Abstractions.Loadouts.Files; -using NexusMods.Abstractions.Loadouts.Synchronizers; using NexusMods.MnemonicDB.Abstractions; +using NexusMods.MnemonicDB.Abstractions.TxFunctions; using NexusMods.Paths; using NexusMods.ProxyConsole.Abstractions; using NexusMods.ProxyConsole.Abstractions.VerbDefinitions; @@ -27,18 +25,20 @@ public static class LoadoutManagementVerbs /// public static IServiceCollection AddLoadoutManagementVerbs(this IServiceCollection services) => services + .AddModule("loadouts", "Commands for managing loadouts as a whole") + .AddModule("loadout", "Commands for managing a specific loadout") + .AddModule("loadout groups", "Commands for managing the file groups in a loadout") + .AddModule("loadout group", "Commands for managing a specific group of files in a loadout") + .AddModule("loadout group items", "Commands for managing the items in a group of files in a loadout") .AddVerb(() => Synchronize) - .AddVerb(() => ChangeTracking) - .AddVerb(() => Ingest) .AddVerb(() => InstallMod) .AddVerb(() => ListLoadouts) - .AddVerb(() => ListModContents) - .AddVerb(() => ListMods) - .AddVerb(() => CreateLoadout) - .AddVerb(() => RenameLoadout) - .AddVerb(() => RemoveLoadout); + .AddVerb(() => ListGroupContents) + .AddVerb(() => ListGroups) + .AddVerb(() => DeleteGroupItems) + .AddVerb(() => CreateLoadout); - [Verb("synchronize", "Synchronize the loadout with the game folders, adding any changes in the game folder to the loadout and applying any new changes in the loadout to the game folder")] + [Verb("loadout synchronize", "Synchronize the loadout with the game folders, adding any changes in the game folder to the loadout and applying any new changes in the loadout to the game folder")] private static async Task Synchronize([Injected] IRenderer renderer, [Option("l", "loadout", "Loadout to apply")] Loadout.ReadOnly loadout, [Injected] ISynchronizerService syncService) @@ -47,41 +47,8 @@ private static async Task Synchronize([Injected] IRenderer renderer, return 0; } - [Verb("ingest", "Ingest changes from the game folders into the given loadout")] - private static async Task Ingest([Injected] IRenderer renderer, - [Option("l", "loadout", "Loadout ingest changes into")] LoadoutId loadout) - { - throw new NotImplementedException(); - /* - var state = await loadout.Value.Ingest(); - loadout.Alter("Ingest changes from the game folder", _ => state); - - await renderer.Text($"Ingested game folder changes into {loadout.Value.Name}"); - - return 0; - */ - } - - [Verb("change-tracking", "Show changes for the given loadout")] - private static async Task ChangeTracking([Injected] IRenderer renderer, - [Option("l", "loadout", "Loadout to track changes for")] LoadoutId loadout, - [Injected] CancellationToken token) - { - throw new NotImplementedException(); - /* - using var d = registry.Revisions(loadout.Id) - .Subscribe(async id => - { - await renderer.Text("Revision {Id} for {LoadoutId}", id, loadout.Id); - }); - - while (!token.IsCancellationRequested) - await Task.Delay(1000, token); - return 0; - */ - } - [Verb("install-mod", "Installs a mod into a loadout")] + [Verb("loadout install", "Installs a mod into a loadout")] private static async Task InstallMod([Injected] IRenderer renderer, [Option("l", "loadout", "loadout to add the mod to")] Loadout.ReadOnly loadout, [Option("f", "file", "Mod file to install")] AbsolutePath file, @@ -98,74 +65,108 @@ private static async Task InstallMod([Injected] IRenderer renderer, } - [Verb("list-loadouts", "Lists all the loadouts")] + [Verb("loadouts list", "Lists all the loadouts")] private static async Task ListLoadouts([Injected] IRenderer renderer, [Injected] IConnection conn, [Injected] CancellationToken token) { var db = conn.Db; - var rows = Loadout.All(db) + await Loadout.All(db) .Where(x => x.IsVisible()) - .Select(list => new object[] { list.Name, list.Installation, list.LoadoutId, list.Items.Count }) - .ToList(); - - await renderer.Table(["Name", "Game", "Id", "Mod Count"], rows); + .Select(list => (list.Name, list.Installation.Name, list.LoadoutId, list.Items.Count)) + .RenderTable(renderer, "Name", "Game", "Id", "Items"); return 0; } - [Verb("list-mod-contents", "Lists the contents of a mod")] - private static async Task ListModContents([Injected] IRenderer renderer, + [Verb("loadout group list", "Lists the contents of a loadout group")] + private static async Task ListGroupContents([Injected] IRenderer renderer, [Option("l", "loadout", "Loadout to load")] Loadout.ReadOnly loadout, - [Option("m", "mod", "Mod to print the contents of")] string modName, + [Option("g", "group", "Name of the group to list")] string groupName, + [Option("f", "filterFiles", "Filter files by the given glob", true)] Matcher? filterFiles, [Injected] CancellationToken token) { - var rows = new List(); var mod = loadout.Items .OfTypeLoadoutItemGroup() - .First(m => m.AsLoadoutItem().Name == modName); - foreach (var file in mod.Children) - { - if (!file.TryGetAsLoadoutItemWithTargetPath(out var withPath)) - continue; - - if (withPath.TryGetAsLoadoutFile(out var stored)) - rows.Add([withPath.TargetPath, stored.Hash]); - else - rows.Add([file.GetType().ToString(), ""]); - } - - await renderer.Table(["Name", "Source"], rows); + .First(m => m.AsLoadoutItem().Name == groupName); + + if (!mod.IsValid()) + return await renderer.InputError("Group {0} not found", groupName); + + Func filter = _ => true; + + if (filterFiles != null) + filter = s => filterFiles.Match(s).HasMatches; + + await mod.Children + .Select(c => + { + var hasPath = c.TryGetAsLoadoutItemWithTargetPath(out var withPath); + var hasFile = withPath.TryGetAsLoadoutFile(out var withFile); + + if (hasPath && hasFile) + return (withPath.TargetPath.Item2, withPath.TargetPath.Item3, withFile.Hash.ToString()); + + if (hasPath) + return (withPath.TargetPath.Item2, withPath.TargetPath.Item3, ""); + + return default((LocationId, RelativePath, string)); + + }) + .Where(v => v != default((LocationId, RelativePath, string))) + .Where(f => filter(f.Item2.ToString())) + .OrderBy(v => v.Item1) + .ThenBy(v => v.Item2) + .RenderTable(renderer, "Folder", "File", "Hash"); return 0; } - [Verb("list-mods", "Lists the mods in a loadout")] - private static async Task ListMods([Injected] IRenderer renderer, + [Verb("loadout group items delete", "Deletes items from a group that match a given pattern")] + private static async Task DeleteGroupItems( + [Injected] IRenderer renderer, [Option("l", "loadout", "Loadout to load")] Loadout.ReadOnly loadout, + [Option("g", "group", "Name of the group to list")] string groupName, + [Option("f", "filterFiles", "Filter files by the given glob")] Matcher filterFiles, [Injected] CancellationToken token) { - var rows = loadout.Items + var mod = loadout.Items .OfTypeLoadoutItemGroup() - .Where(group => !group.Contains(LoadoutItem.Parent)) - .Select(mod => new object[] { mod.AsLoadoutItem().Name }) - .ToList(); - - await renderer.Table(["Name"], rows); + .First(m => m.AsLoadoutItem().Name == groupName); + + if (!mod.IsValid()) + return await renderer.InputError("Group {0} not found", groupName); + + var ids = mod.Children + .OfTypeLoadoutItemWithTargetPath() + .Where(t => filterFiles.Match(t.TargetPath.Item3.ToString()).HasMatches) + .Select(f => f.Id) + .ToArray(); + + await renderer.Text("Deleting {0} items", ids.Length); + + using var tx = loadout.Db.Connection.BeginTransaction(); + foreach (var id in ids) + tx.Delete(id, false); + await tx.Commit(); + + await renderer.Text("Complete", ids.Length); + return 0; } - [Verb("rename", "Rename a loadout id to a specific registry name")] - private static async Task RenameLoadout([Option("l", "loadout", "Loadout to assign a name")] LoadoutId loadout, - [Option("n", "name", "Name to assign the loadout")] string name, - [Injected] LoadoutId registry) + [Verb("loadout groups list", "Lists the groups in a loadout")] + private static async Task ListGroups([Injected] IRenderer renderer, + [Option("l", "loadout", "Loadout to load")] Loadout.ReadOnly loadout, + [Injected] CancellationToken token) { - throw new NotImplementedException(); - /* - registry.Alter(loadout.LoadoutId, $"Renamed {loadout.DataStoreId} to {name}", _ => loadout); + await loadout.Items + .OfTypeLoadoutItemGroup() + .Select(mod => (mod.AsLoadoutItem().Name, mod.Children.Count)) + .RenderTable(renderer, "Name", "Items"); + return 0; - */ } - [Verb("create-loadout", "Create a Loadout for a given game")] + [Verb("loadout create", "Create a Loadout for a given game")] private static async Task CreateLoadout([Injected] IRenderer renderer, [Option("g", "game", "Game to create a loadout for")] IGame game, [Option("v", "version", "Version of the game to manage")] Version version, @@ -184,16 +185,4 @@ private static async Task CreateLoadout([Injected] IRenderer renderer, return 0; }); } - - [Verb("remove-loadout", "Remove a loadout by its ID")] - private static async Task RemoveLoadout( - [Injected] IRenderer renderer, - [Injected] IConnection conn, - [Option("l", "loadout", "Loadout to delete")] LoadoutId loadoutId, - [Injected] CancellationToken token) - { - - // TODO: make this call into the new removal logic that uses disk states - throw new Exception("Not implemented"); - } } diff --git a/src/NexusMods.DataModel/NexusMods.DataModel.csproj b/src/NexusMods.DataModel/NexusMods.DataModel.csproj index 10a6f97c0e..0a2f57202b 100644 --- a/src/NexusMods.DataModel/NexusMods.DataModel.csproj +++ b/src/NexusMods.DataModel/NexusMods.DataModel.csproj @@ -30,6 +30,7 @@ + diff --git a/src/NexusMods.ProxyConsole.Abstractions/VerbDefinitions/ModuleDefinition.cs b/src/NexusMods.ProxyConsole.Abstractions/VerbDefinitions/ModuleDefinition.cs new file mode 100644 index 0000000000..1878aaa059 --- /dev/null +++ b/src/NexusMods.ProxyConsole.Abstractions/VerbDefinitions/ModuleDefinition.cs @@ -0,0 +1,6 @@ +namespace NexusMods.ProxyConsole.Abstractions.VerbDefinitions; + +/// +/// Documentation for a collection of verbs +/// +public record ModuleDefinition(string Name, string Description); diff --git a/src/NexusMods.ProxyConsole.Abstractions/VerbDefinitions/OptionAttribute.cs b/src/NexusMods.ProxyConsole.Abstractions/VerbDefinitions/OptionAttribute.cs index cf0b7dfbbc..01a360c7e9 100644 --- a/src/NexusMods.ProxyConsole.Abstractions/VerbDefinitions/OptionAttribute.cs +++ b/src/NexusMods.ProxyConsole.Abstractions/VerbDefinitions/OptionAttribute.cs @@ -9,7 +9,7 @@ namespace NexusMods.ProxyConsole.Abstractions.VerbDefinitions; /// /// [AttributeUsage(AttributeTargets.Parameter)] -public class OptionAttribute(string shortName, string longName, string helpText) : Attribute +public class OptionAttribute(string shortName, string longName, string helpText, bool isOptional = false) : Attribute { /// /// The short name of the option. For example `h` @@ -25,5 +25,9 @@ public class OptionAttribute(string shortName, string longName, string helpText) /// The help text for the option. /// public string HelpText { get; } = helpText; - + + /// + /// True if the option is optional, false otherwise. + /// + public bool IsOptional { get; } = isOptional; } diff --git a/src/NexusMods.ProxyConsole.Abstractions/VerbDefinitions/OptionDefinition.cs b/src/NexusMods.ProxyConsole.Abstractions/VerbDefinitions/OptionDefinition.cs index 30751d73e4..085bbc9745 100644 --- a/src/NexusMods.ProxyConsole.Abstractions/VerbDefinitions/OptionDefinition.cs +++ b/src/NexusMods.ProxyConsole.Abstractions/VerbDefinitions/OptionDefinition.cs @@ -10,4 +10,4 @@ namespace NexusMods.ProxyConsole.Abstractions.VerbDefinitions; /// /// /// -public record OptionDefinition(Type Type, string ShortName, string LongName, string HelpText, bool IsInjected); +public record OptionDefinition(Type Type, string ShortName, string LongName, string HelpText, bool IsInjected, bool IsOptional); diff --git a/src/NexusMods.ProxyConsole.Abstractions/VerbDefinitions/ServiceExtensions.cs b/src/NexusMods.ProxyConsole.Abstractions/VerbDefinitions/ServiceExtensions.cs index 98d8946e8c..1f28502825 100644 --- a/src/NexusMods.ProxyConsole.Abstractions/VerbDefinitions/ServiceExtensions.cs +++ b/src/NexusMods.ProxyConsole.Abstractions/VerbDefinitions/ServiceExtensions.cs @@ -1,9 +1,5 @@ -using System; -using System.Collections.Generic; -using System.Linq; using System.Linq.Expressions; using System.Reflection; -using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; namespace NexusMods.ProxyConsole.Abstractions.VerbDefinitions; @@ -25,6 +21,15 @@ public static IServiceCollection AddVerb(this IServiceCollection coll, Expressio ?.Value!; return coll.AddVerb(methodInfo); } + + /// + /// Add a new module to the CLI, any verbs that are in a given module path must have a corresponding module definition. + /// + public static IServiceCollection AddModule(this IServiceCollection coll, string name, string description) + { + return coll + .AddSingleton(new ModuleDefinition(name, description)); + } /// @@ -70,11 +75,11 @@ public static IServiceCollection AddVerb(this IServiceCollection services, Metho if (option is not null) { - options.Add(new OptionDefinition(param.ParameterType, option.ShortName, option.LongName, option.HelpText, false)); + options.Add(new OptionDefinition(param.ParameterType, option.ShortName, option.LongName, option.HelpText, false, option.IsOptional)); } else if (injected is not null) { - options.Add(new OptionDefinition(param.ParameterType, string.Empty, string.Empty, string.Empty, true)); + options.Add(new OptionDefinition(param.ParameterType, string.Empty, string.Empty, string.Empty, true, false)); } } diff --git a/src/NexusMods.ProxyConsole.Abstractions/VerbDefinitions/VerbAttribute.cs b/src/NexusMods.ProxyConsole.Abstractions/VerbDefinitions/VerbAttribute.cs index 5cc77808e4..bda7ffe1e0 100644 --- a/src/NexusMods.ProxyConsole.Abstractions/VerbDefinitions/VerbAttribute.cs +++ b/src/NexusMods.ProxyConsole.Abstractions/VerbDefinitions/VerbAttribute.cs @@ -5,18 +5,27 @@ namespace NexusMods.ProxyConsole.Abstractions.VerbDefinitions; /// /// Defines a method that should be exposed as a verb. /// -/// -/// [AttributeUsage(AttributeTargets.Method)] -public class VerbAttribute(string name, string description) : Attribute +public class VerbAttribute : Attribute { + /// + /// Defines a method that should be exposed as a verb. + /// + /// + /// + public VerbAttribute(string name, string description) + { + Name = name; + Description = description; + } + /// /// The name of the verb. /// - public string Name { get; } = name; + public string Name { get; } /// /// Help text for the verb. /// - public string Description { get; } = description; + public string Description { get; } } diff --git a/src/NexusMods.ProxyConsole.Abstractions/VerbDefinitions/VerbDefinition.cs b/src/NexusMods.ProxyConsole.Abstractions/VerbDefinitions/VerbDefinition.cs index d923598fb8..7a4abc51c0 100644 --- a/src/NexusMods.ProxyConsole.Abstractions/VerbDefinitions/VerbDefinition.cs +++ b/src/NexusMods.ProxyConsole.Abstractions/VerbDefinitions/VerbDefinition.cs @@ -5,8 +5,8 @@ namespace NexusMods.ProxyConsole.Abstractions.VerbDefinitions; /// /// A definition of a verb, used as an abstraction layer between the CLI handler code and the verb code. /// -/// -/// -/// -/// +/// The name of the verb, prefixed by the module path it is in +/// A description of the verb +/// The method to invoke to run the verb +/// Option definitions for the method's parameters public record VerbDefinition(string Name, string Description, MethodInfo Info, OptionDefinition[] Options); diff --git a/src/NexusMods.SingleProcess/CommandHandler.cs b/src/NexusMods.SingleProcess/CommandHandler.cs index 8ea1b3bebc..5f2a8bdcf9 100644 --- a/src/NexusMods.SingleProcess/CommandHandler.cs +++ b/src/NexusMods.SingleProcess/CommandHandler.cs @@ -3,6 +3,9 @@ using System.CommandLine.Invocation; using System.Reflection; using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using NexusMods.Abstractions.Cli; +using NexusMods.ProxyConsole.Abstractions; namespace NexusMods.SingleProcess; @@ -11,7 +14,7 @@ namespace NexusMods.SingleProcess; /// /// /// -internal class CommandHandler(List> getters, MethodInfo methodInfo) +internal class CommandHandler(IServiceProvider serviceProvider, List> getters, MethodInfo methodInfo) : ICommandHandler { public int Invoke(InvocationContext context) @@ -19,15 +22,24 @@ public int Invoke(InvocationContext context) throw new NotSupportedException("Only async is supported"); } - public Task InvokeAsync(InvocationContext context) + public async Task InvokeAsync(InvocationContext context) { - // Resolve all the parameters - var args = GC.AllocateUninitializedArray(getters.Count); - for (var i = 0; i < getters.Count; i++) + try { - args[i] = getters[i](context); + // Resolve all the parameters + var args = GC.AllocateUninitializedArray(getters.Count); + for (var i = 0; i < getters.Count; i++) + { + args[i] = getters[i](context); + } + + // Invoke the method + return await (Task)methodInfo.Invoke(null, args)!; + } + catch (Exception ex) + { + await serviceProvider.GetRequiredService().Text("An error occurred while executing the command {0}", ex.ToString()); + return -1; } - // Invoke the method - return (Task)methodInfo.Invoke(null, args)!; } } diff --git a/src/NexusMods.SingleProcess/CommandLineConfigurator.cs b/src/NexusMods.SingleProcess/CommandLineConfigurator.cs index c27ef277ae..457cd844e2 100644 --- a/src/NexusMods.SingleProcess/CommandLineConfigurator.cs +++ b/src/NexusMods.SingleProcess/CommandLineConfigurator.cs @@ -1,3 +1,4 @@ +using System.Collections.Immutable; using System.CommandLine; using System.CommandLine.Builder; using System.CommandLine.Invocation; @@ -30,12 +31,12 @@ public class CommandLineConfigurator /// /// /// - public CommandLineConfigurator(IServiceProvider provider, IEnumerable verbDefinitions) + public CommandLineConfigurator(IServiceProvider provider, IEnumerable verbDefinitions, IEnumerable moduleDefinitions) { _logger = provider.GetRequiredService>(); _makeOptionMethod = GetType().GetMethod(nameof(MakeOption), BindingFlags.Instance | BindingFlags.NonPublic)!; - (_rootCommand, _injectedTypes) = MakeRootCommand(verbDefinitions); + (_rootCommand, _injectedTypes) = MakeRootCommand(verbDefinitions, moduleDefinitions); _provider = provider; } @@ -44,14 +45,52 @@ public CommandLineConfigurator(IServiceProvider provider, IEnumerable /// /// - private (RootCommand, Type[]) MakeRootCommand(IEnumerable verbDefinitions) + private (RootCommand, Type[]) MakeRootCommand(IEnumerable verbDefinitions, IEnumerable moduleDefinitions) { var rootCommand = new RootCommand(); var injectedTypes = new HashSet(); - foreach (var verbDefinition in verbDefinitions) + var modules = new Dictionary(); + + // Create the modules first so we can tie verbs to them + foreach (var module in moduleDefinitions.OrderBy(v => v.Name.Length).ThenBy(v => v.Name.Last())) + { + if (modules.ContainsKey(module.Name)) + throw new InvalidOperationException($"Module {module.Name} already exists can't define it again"); + + var nameParts = module.Name.Split(" ", StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); + var localName = nameParts[^1]; + var command = new Command(localName, module.Description); + modules.Add(module.Name, command); + + if (nameParts.Length > 1) + { + var parentName = string.Join(" ", nameParts[..^1]); + if (!modules.TryGetValue(parentName, out var parent)) + throw new InvalidOperationException($"Parent module {module.Name} does not exist"); + + parent.AddCommand(command); + } + else + { + rootCommand.AddCommand(command); + } + + } + + foreach (var verbDefinition in verbDefinitions.OrderBy(v => v.Name.Last())) { - var command = new Command(verbDefinition.Name, verbDefinition.Description); + Command parentCommand = rootCommand; + var nameParts = verbDefinition.Name.Split(" ", StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); + if (nameParts.Length > 1) + { + var moduleName = string.Join(" ", nameParts[..^1]); + if (!modules.TryGetValue(moduleName, out var moduleCommand)) + throw new InvalidOperationException($"Module {moduleName} does not exist"); + parentCommand = moduleCommand; + } + + var command = new Command(nameParts[^1], verbDefinition.Description); var getters = new List>(); foreach (var optionDefinition in verbDefinition.Options) @@ -65,14 +104,17 @@ public CommandLineConfigurator(IServiceProvider provider, IEnumerable ctx.ParseResult.GetValueForOption(option)); } } - command.Handler = new CommandHandler(getters, verbDefinition.Info); + command.Handler = new CommandHandler(_provider, getters, verbDefinition.Info); - rootCommand.AddCommand(command); + parentCommand.AddCommand(command); } return (rootCommand, injectedTypes.ToArray()); } diff --git a/tests/NexusMods.CLI.Tests/VerbTests/AVerbTest.cs b/tests/NexusMods.CLI.Tests/VerbTests/AVerbTest.cs index ccdb8046ab..b9048e8d6e 100644 --- a/tests/NexusMods.CLI.Tests/VerbTests/AVerbTest.cs +++ b/tests/NexusMods.CLI.Tests/VerbTests/AVerbTest.cs @@ -34,7 +34,8 @@ public async Task Run(params string[] args) { var renderer = new LoggingRenderer(); var configurator = provider.GetRequiredService(); - var result = await configurator.RunAsync(args, renderer, CancellationToken.None); + var withSplitName = args[0].Split(' ').Concat(args[1..]).ToArray(); + var result = await configurator.RunAsync(withSplitName, renderer, CancellationToken.None); if (result != 0) { var errorLog = renderer.Logs.OfType().Select(t => t.Template).Aggregate((acc, itm) => acc + itm); diff --git a/tests/NexusMods.CLI.Tests/VerbTests/ModManagementVerbs.cs b/tests/NexusMods.CLI.Tests/VerbTests/ModManagementVerbs.cs index bc070d57f2..b75086ddd2 100644 --- a/tests/NexusMods.CLI.Tests/VerbTests/ModManagementVerbs.cs +++ b/tests/NexusMods.CLI.Tests/VerbTests/ModManagementVerbs.cs @@ -14,25 +14,25 @@ public async Task CanCreateAndManageLists() var install = await CreateInstall(); - var log = await Run("create-loadout", "-g", $"{uint.MaxValue}", "-v", + var log = await Run("loadout create", "-g", $"{uint.MaxValue}", "-v", install.Version.ToString(), "-n", listName); - log = await Run("list-loadouts"); + log = await Run("loadouts list"); - log.LastTableColumns.Should().BeEquivalentTo("Name", "Game", "Id", "Mod Count"); + log.LastTableColumns.Should().BeEquivalentTo("Name", "Game", "Id", "Items"); log.TableCellsWith(0, listName).Should().NotBeEmpty(); - log = await Run("list-mods", "-l", listName); + log = await Run("loadout groups list", "-l", listName); log.LastTable.Rows.Length.Should().Be(2); - log = await Run("install-mod", "-l", listName, "-f", Data7ZipLZMA2.ToString(), "-n", Data7ZipLZMA2.GetFileNameWithoutExtension()); + log = await Run("loadout install", "-l", listName, "-f", Data7ZipLZMA2.ToString(), "-n", Data7ZipLZMA2.GetFileNameWithoutExtension()); - log = await Run("list-mods", "-l", listName); - log.LastTable.Rows.Length.Should().Be(2); + log = await Run("loadout groups list", "-l", listName); + log.LastTable.Rows.Length.Should().Be(3); - log = await Run("list-mod-contents", "-l", listName, "-m", Data7ZipLZMA2.FileName); + log = await Run("loadout group list", "-l", listName, "-g", Data7ZipLZMA2.FileName); log.LastTable.Rows.Length.Should().Be(3); - log = await Run("synchronize", "-l", listName); + log = await Run("loadout synchronize", "-l", listName); } }