Tyrrrz / CliFx

Class-first framework for building command-line interfaces
MIT License
1.5k stars 61 forks source link

Running a CliFx application interactively #72

Closed jim-wilson-kt closed 4 years ago

jim-wilson-kt commented 4 years ago

Forgive me if this is not the correct place to post this question. This framework is really impressive. I am able to run the demo app. My question is this: Is there a way to use CliFx interactively? What I mean by that, is that I launch the application and it waits for commands triggered by the enter key (i.e., Console.ReadLine()) and exits on an exit command. In a nutshell, I don't want to have to run dotnet myapp.dll and then some command. Thanks and keep up the good work! (I'll buy you at least a coffee if I can get this to work out!)

adambajguz commented 4 years ago

I have just thought about the same thing. This would be awesome to have!

adambajguz commented 4 years ago

That's my quickly written, "hacky" solution.

internal static class Program
{
    public static async Task<int> Main(string[] args)
    {
        CliApplicationBuilder cliBuilder = new CliApplicationBuilder();
        //cliBuilder.UseTypeActivator(serviceProvider.GetService);
        cliBuilder.AddCommandsFromThisAssembly();
        CliApplication cli = cliBuilder.Build();

        string executableName = TryGetDefaultExecutableName() ?? string.Empty;
        if (args.Length >= 1 && args[0].ToLower() == "[interactive]")
        {
            ImmutableList<string>? arguments = args.Length > 1 ? args.Skip(1).ToImmutableList() : null;
            while (true)
            {
                arguments ??= GetInput(executableName);

                int exitCode = await cli.RunAsync(arguments);
                arguments = null;

                ConsoleColor currentForeground = Console.ForegroundColor;
                Console.ForegroundColor = ConsoleColor.White;
                Console.WriteLine($"{executableName}: Command finished with exit code ({exitCode})");
                Console.ForegroundColor = currentForeground;
            }
        }

        return await cli.RunAsync();
    }

    private static ImmutableList<string> GetInput(string executableName)
    {
        ImmutableList<string>? arguments;
        string line;
        do
        {
            ConsoleColor currentForeground = Console.ForegroundColor;
            Console.ForegroundColor = ConsoleColor.Cyan;
            Console.Write(executableName);
            Console.Write("> ");

            line = Console.ReadLine();
            Console.ForegroundColor = currentForeground;

            if (line.ToLower() == "[default]")
            {
                return ImmutableList<string>.Empty;
            }

            arguments = line.Split(' ', StringSplitOptions.RemoveEmptyEntries).ToImmutableList();

        } while (string.IsNullOrWhiteSpace(line));

        return arguments;
    }

    private static string? TryGetDefaultExecutableName()
    {
        string? entryAssemblyLocation = Assembly.GetEntryAssembly()?.Location;

        // The assembly can be an executable or a dll, depending on how it was packaged
        bool isDll = string.Equals(Path.GetExtension(entryAssemblyLocation), ".dll", StringComparison.OrdinalIgnoreCase);

        return isDll
            ? "dotnet " + Path.GetFileName(entryAssemblyLocation)
            : Path.GetFileNameWithoutExtension(entryAssemblyLocation);
    }
}
jim-wilson-kt commented 4 years ago

@adambajguz thanks for your responses. I look forward to investigating this further after my work day. I'm not a developer--just trying to write a utility.

adambajguz commented 4 years ago

This is the same but a bit more clean, i.e., a wrapper class:

namespace Application
{
    using System;
    using System.Collections.Immutable;
    using System.IO;
    using System.Linq;
    using System.Reflection;
    using System.Threading.Tasks;
    using CliFx;

    public static class Directives
    {
        public const string Debug = "[debug]";
        public const string Preview = "[preview]";
        public const string Interactive = "[interactive]";
        public const string Default = "[default]";
    }

    public static class CliApplicationBuilderExtensions
    {
        public static InteractiveCli BuildInteractive(this CliApplicationBuilder builder)
        {
            CliApplication cliApplication = builder.Build();
            return new InteractiveCli(cliApplication);
        }
    }

    public sealed class InteractiveCli
    {
        private readonly CliApplication _cliApplication;

        public InteractiveCli(CliApplication cliApplication)
        {
            _cliApplication = cliApplication;
        }

        public async Task<int> RunAsync(bool alwaysStartInInteractive = false)
        {
            string executableName = TryGetDefaultExecutableName() ?? string.Empty;

            string[] args = Environment.GetCommandLineArgs()
                                       .Skip(1)
                                       .ToArray();

            List<string> args = Environment.GetCommandLineArgs().Skip(1).ToList();
            if (alwaysStartInInteractive && (args.Count == 0 || args[0].ToLower() != Directives.Interactive))
            {
                args.Insert(0, Directives.Interactive);
            }

            if (args.Count >= 1 && args[0].ToLower() == Directives.Interactive)
            {
                ImmutableList<string>? arguments = args.Length > 1 ? args.Skip(1).ToImmutableList() : null;
                while (true)
                {
                    arguments ??= GetInput(executableName);

                    int exitCode = await _cliApplication.RunAsync(arguments);
                    arguments = null;

                    ConsoleColor currentForeground = Console.ForegroundColor;
                    Console.ForegroundColor = ConsoleColor.White;
                    Console.WriteLine($"{executableName}: Command finished with exit code ({exitCode})");
                    Console.ForegroundColor = currentForeground;
                }
            }

            return await _cliApplication.RunAsync();
        }

        private static ImmutableList<string> GetInput(string executableName)
        {
            ImmutableList<string>? arguments;
            string line;
            do
            {
                 ConsoleColor currentForeground = Console.ForegroundColor == ConsoleColor.Cyan ?
                      Console.ForegroundColor : ConsoleColor.Gray;
                Console.ForegroundColor = ConsoleColor.Cyan;
                Console.Write(executableName);
                Console.Write("> ");

                line = Console.ReadLine();
                Console.ForegroundColor = currentForeground;

                if (line.ToLower() == Directives.Default)
                {
                    return ImmutableList<string>.Empty;
                }

                arguments = line.Split(' ', StringSplitOptions.RemoveEmptyEntries).ToImmutableList();

            } while (string.IsNullOrWhiteSpace(line));

            return arguments;
        }

        private static string? TryGetDefaultExecutableName()
        {
            string? entryAssemblyLocation = Assembly.GetEntryAssembly()?.Location;

            // The assembly can be an executable or a dll, depending on how it was packaged
            bool isDll = string.Equals(Path.GetExtension(entryAssemblyLocation), ".dll", StringComparison.OrdinalIgnoreCase);

            return isDll
                ? "dotnet " + Path.GetFileName(entryAssemblyLocation)
                : Path.GetFileNameWithoutExtension(entryAssemblyLocation);
        }
    }
}

To use this simply replace .Build() with .BuildInteractive().


EDIT: The limitation of my approach is that [interactive] directive must be used before [debug] or [preview]. Also, since other arguments will be processed, you can also do sth like dotnet ... [interactive] [default] or dotnet ... [interactive] --help.


EDIT: I have added an option to force starting in interactive mode despite [interactive] directive being passed or not.

Tyrrrz commented 4 years ago

Hi. I'm a bit confused, why don't you use console.Input.ReadLine() from inside your commands? If you want, you can make a default command (i.e. no name) and make it have no options/parameters, so that it works purely interactively.

Note that it wouldn't be a "CLI" under the classic definition, it would just be a console application. A typical CLI should be pure in the sense that all its inputs must come from the command line arguments.

adambajguz commented 4 years ago

The question was: "Is there a way to use CliFx interactively? What I mean by that, is that I launch the application and it waits for commands triggered by the enter key (i.e., Console.ReadLine()) and exits on an exit command (...)"

So the InteractiveCli class does exactly this but without changing the library. console.Input.ReadLine() only gets a line of text from the input - it won't parse it and execute CliFx command. @jim-wilson-kt and also my intention was to build an app that acts like mssql-cli, bash, cmd, or PowerShell.


EDIT:

"Note that it wouldn't be a "CLI" under the classic definition, it would just be a console application. A typical CLI should be pure in the sense that all its inputs must come from the command line arguments." - so this issue presents a hidden feature/usage of your lib (it allows to build an app that is a brand new, simple CLI).


EDIT: To be honest, I started using your lib today, and I have found this interesting issue. Maybe the name of the class is bad, but keep in mind this was all written in less than an hour (also I have not much more than a few hours of experience with your lib).

jim-wilson-kt commented 4 years ago

Good question, @Tyrrrz. I fully expected that a response to my original post may have been something like, "No, it does not, nor should it." That said, I couldn't help myself and I tried out @adambajguz's code and it worked well. Specifically with respect to your question about "...why don't you use..." perhaps I would have thought of that after learning "No, it does not, nor should it." I guess my answer to your question would depend upon the answer to another question: Would using the ReadLine() approach tap into all the "magic" you've enabled (e.g., multi-level command hierarchies) or would all parsing and command invocation be up to the implementer? I'm just trying to wrap my head around how your framework works and perhaps more importantly what your design scope is. Thanks again for a fantastic framework.

Tyrrrz commented 4 years ago

So do I understand it correctly, you want to keep the same way of defining commands, but instead of having the parameters/input come from arguments, you want them to be automatically prompted from the user?

adambajguz commented 4 years ago

My answer is yes, and I actually find this very useful - I have just test added this to ASP.NET Core MVC app. After hooking DI to the CliFx and writing some extra services, I can start the webhost and event execute some code in the webhost context, e.g. publishing MediatR query and receiving its result works just fine - that's so cool :blue_heart: (the webhost is running and I can have "text/terminal superadmin panel").

But let's wait for @jim-wilson-kt answer.

jim-wilson-kt commented 4 years ago

@Tyrrrz: I want to keep the same way of defining commands and I want automatically generated help content, but I want the application to do these things after I launch it. So, I launch it and it just sits there waiting for a command to be entered. Then I enter a command, the command is processed, results returned, and it waits for another command. So, in demo app terms, rather than run dotnet CliFx.Demo.dll book add... followed by dotnet CliFx.Demo.dll book list, I can run dotnet CliFx.Demo.dll then book add... then book list and on and on. Again, this is what I want to do. I'm not suggesting that a CLIs should be able to operate this way, and for this reason perhaps attempting to use CliFx in that way would ultimately be frustrating. However @adambajguz's hack (as he described it) looks promising. By the way, assuming I understand what @adambajguz is doing with his MVC app, I more or less did the same thing in a very simple way with a server-side Blazor app (not using CliFx). I was curious about how I might be able to apply CliFx in that scenario (in this case interactivity doesn't apply in the way we've discussed in this thread). Sort of a command-line-in-a-browser solution.

adambajguz commented 4 years ago

@jim-wilson-kt "Sort of a command-line-in-a-browser solution." My implementation is more like command-line-in-system's-terminal-window, but ...in-a-browser 😮, damn, I think this library might be used also for that but this would require some hacky (or more likely very hacky) class with IConsole interface implementation. CliFx should have separate library e.g. CliFx.Core that would only have a parser, a command runner, and maybe some other stuff not associated with "real" command prompt.

adambajguz commented 4 years ago

Oh, and I almost forgotten, you can achieve "exit" command for my InteractiveCli class can be achieved simply by adding a command:

    [Command("exit")]
    public class QuitCommand : ICommand
    {
        public ValueTask ExecuteAsync(IConsole console)
        {
            Environment.Exit(0);

            return default;
        }
    }

PS. Implementation of InteractiveCli.RunAsync() is not the best, esspecially the lines with variables args, arguments, but it does its job. This can be optimized.

Tyrrrz commented 4 years ago

I see. Well like you noted, this is beyond the scope of the library, but if @adambajguz's solution satisfies your needs then great!

jim-wilson-kt commented 4 years ago

@Tyrrrz and @adambajguz, I appreciate the discussion. My question has been answered and more. I don't know the GitHub etiquette here. Does @Tyrrrz close the issue? Do I? Does it need to be closed? (This is the first time I have ever created an issue in GitHub.) Again, nice framework. Buying your coffee now!

Tyrrrz commented 4 years ago

There is no etiquette. I'll close it for you ;)