Tyrrrz / CliFx

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

Global options & always parsing options as 'multiple' #58

Closed stefanjarina closed 4 years ago

stefanjarina commented 4 years ago

Hello,

first of all, I quite like this library, it is really easy to grasp, which is rare in .net world.

There are 2 features though which are a blocker for me to consider using it, so just want to ask if I missed something or not.

Problem 1

One feature which I think is missing is global or persistent options, or did I missed it? Specified on parent command, accessible to child command (maybe dependency injection can be used here?)

some useful examples

./cmd --log-level=debug subcommand --local-option  # set loggin level for all subcommands
./ginit --repo gitlab init --project-type rust ./my_project # set that all subcommands will be run against gitlab
./cmd -q subcommand # no output for all subcommands, only return codes
./cmd --json fetch 34 --include-images # return a JSON string for all subcommands

One way to have global options might be to enable them at the end (before arguments). like e.g. 'npm' handles it

npm -g ls --depth 1   # is an equivalent to
npm ls --depth 1 -g

Problem 2

This library is always parsing options as multiple values, which I think is really unusual (and essentially blocks global options). I can't remember used it like that in case cli supported commands and subcommands.

How I think this feature is mostly implemented:

./cmd -i test.txt -i test.txt  # used e.g. in docker cli
# or
./cmd -i test.txt,test.txt     # used in a lot of cli apps + it is native to how powershell handles it
# or
./cmd -i=test.txt,tests.txt   # used e.g. in git cli

Just wondering what was the reasoning for this implementation, because going through some modern big CLIs like (kubectl, docker, dotnet, hugo, git, github, az, npm, yarn, cargo, pip, choco, scoop, etc.) it was hard to find similar behaviour. At least for me it is simply not intuitive and more or less unexpected.

Thank you for answers, Stefan

domn1995 commented 4 years ago

I would solve Problem 1 by using inheritance. Essentially, put all the "global" options in an abstract base class and extend that base class for all your commands.

stefanjarina commented 4 years ago

Hello, thank you a lot for answer, abstract class works and it solves Problem 1 that I can use it at the end of command without implementing it in every command.

Works:

-> dotnet run -- config get myKey -r Gitlab

Passed Global Option Repo: 'Gitlab'

Problem 2 however blocks in using it in the middle Doesn't Work (and it is strange)

-> dotnet run -- config -r Github get myKey

Option --repo|-r expects a single value, but provided with multiple:
"Github" "get" "myKey"
Usage
  dotnet ginit.dll config [command] [options]

Options
  -r|--repo         Specify repository Valid values: "Github", "Gitlab", "Azure", "Bitbucket". Default: "Github".
  -h|--help         Shows help text.

Commands
  get               Get value of a configuration key

You can run `dotnet ginit.dll config [command] --help` to show help on a specific command.

I think it can be solved 2 ways:

1. Allow single

[CommandOption("name", "n", IsSingle = true, ....)] or [CommandOptionSingle("name", "n", ....)]

2. Allow to specify the # of arguments belonging to the option

[CommandOption("name", "n", ArgSize = 2)] or NArgs, NumberOfArguments NumberOfArgs, etc...

example: python click library have it implemented in this way:

Sometimes, you have options that take more than one argument. For options, only a fixed number of arguments is supported. This can be configured by the nargs parameter. The values are then stored as a tuple.

@click.command()
@click.option('--pos', nargs=2, type=float)
def findme(pos):
    click.echo('%s / %s' % pos)

And on the command line:

$ findme --pos 2.0 3.0
2.0 / 3.0

I think, here might be problematic to pass value to subcommands, so for me it is enough that I can use globals at the end of the commands, however it is strange and people would expect that globals work anywhere in the command.

Once again, thanks.

Tyrrrz commented 4 years ago

Regarding "Problem 1" I would also suggest default interface members feature introduced in C#8 which essentially allows multiple inheritance. You can use that to compose options/parameters in your commands.

Regarding "Problem 2" it's explained a little bit in readme and, to summarize, the answer is readability. With dotnet run -- config -r Github get myKey it's very unclear whether get and myKey are also part of -r or not, and you can only be sure if you know how the tool is implemented. With dotnet run -- config get myKey -r Gitlab it's clear and there's no ambiguity.

Most CLIs that I know are not picky about order and accept options mixed with parameters, so both approaches work. With CliFx the order is enforced for consistency.

Also, dotnet is an example of a CLI where order matters as well:

image

stefanjarina commented 4 years ago

Hi, thanks a lot, Problem 1 pretty much solved. As for the Problem 2 I guess, that I will just make it absolutely clear in help that it have to be specified at the end of the command and that's it. Thanks for your help.

Tyrrrz commented 4 years ago

You're welcome!

alirezanet commented 2 years ago

Regarding "Problem 1" I would also suggest default interface members feature introduced in C#8 which essentially allows multiple inheritance. You can use that to compose options/parameters in your commands.

this is a little bit confusing because clifx doesn't recognize default interface members in the commands! what did I miss!? can you explain a little bit?

Tyrrrz commented 2 years ago

Regarding "Problem 1" I would also suggest default interface members feature introduced in C#8 which essentially allows multiple inheritance. You can use that to compose options/parameters in your commands.

this is a little bit confusing because clifx doesn't recognize default interface members in the commands! what did I miss!? can you explain a little bit?

I haven't tried that in a while, what happens exactly? Do default members function differently from inherited members in terms of reflection?

alirezanet commented 2 years ago

Yes, if you mean something like this

public interface IBaseOptions : ICommand
{
   [CommandOption("no-color", 'c', Description = "Disable color output")]
   public bool NoColor
   {
      get => !Logger.Colors;
      set => Logger.Colors = !value;
   }

   [CommandOption("quite", 'q', Description = "Disable console output")]
   public bool Quite
   {
      get => Logger.Quiet;
      set => Logger.Quiet = value;
   }

   [CommandOption("verbose", 'v', Description = "Enable verbose output")]
   public bool Verbose
   {
      get => Logger.Verbose;
      set => Logger.Verbose = value;
   }
}

public class SomeCommand: IBaseOptions {}

it doesn't work by default. you need explicit type casting first, these props belong to the IBaseOptions type, not SomeCommand (this could be a great feature though :) )