CLI приложения #4. Подкоманды

До сих пор наше приложение умело ровно одну операцию. Настоящие инструменты устроены сложнее: git commit, docker run, dotnet build. Первое слово после имени программы — это не опция, а подкоманда, и у каждой свой набор параметров.
Сделаем работу с настройками, как в git:

myapp config set theme dark 
myapp config get theme 
myapp --verbose config set theme dark
Дерево myapp/config/set/get и общая опция --verbose

Дерево команд

Код:

using System.CommandLine;

internal class Program
{
    private static int Main(string[] args)
    {
        // Recursive = true — опция видна и в корне, и во всех подкомандах
        var verboseOption = new Option<bool>("--verbose", "-v")
        {
            Description = "Подробный вывод",
            Recursive = true
        };

        var setKeyArgument = new Argument<string>("key")
        {
            Description = "Имя настройки"
        };
        var setValueArgument = new Argument<string>("value")
        {
            Description = "Значение настройки"
        };

        var setCommand = new Command("set", "Записать настройку");
        setCommand.Arguments.Add(setKeyArgument);
        setCommand.Arguments.Add(setValueArgument);
        setCommand.SetAction(parseResult =>
        {
            var key = parseResult.GetValue(setKeyArgument);
            var value = parseResult.GetValue(setValueArgument);

            if (parseResult.GetValue(verboseOption))
                Console.WriteLine("Открываем файл настроек...");

            Console.WriteLine($"Записано: {key} = {value}");
            return 0;
        });

        var getKeyArgument = new Argument<string>("key")
        {
            Description = "Имя настройки"
        };

        var getCommand = new Command("get", "Прочитать настройку");
        // Добавляем псевдоним (аналог, можно использовать "show" вместо команды "get" )
        getCommand.Aliases.Add("show");
        getCommand.Arguments.Add(getKeyArgument);
        getCommand.SetAction(parseResult =>
        {
            Console.WriteLine($"Значение: {parseResult.GetValue(getKeyArgument)}");
            return 0;
        });

        var configCommand = new Command("config", "Работа с настройками");
        configCommand.Subcommands.Add(setCommand);
        configCommand.Subcommands.Add(getCommand);

        var rootCommand = new RootCommand("Учебное CLI приложение");
        rootCommand.Options.Add(verboseOption);
        rootCommand.Subcommands.Add(configCommand);

        return rootCommand.Parse(args).Invoke();
    }
}

Логика построения достаточно простая: соединяем подкоманды через SetAction , дерево вложенности может быть до трёх уровней.

Теперь посмотрим, что получается.

  • Справка стала иерархической. dotnet run -- --help покажет список команд, а dotnet run -- config --help — только то, что относится к config. Пользователю не нужно продираться через полотно из пятидесяти опций, чтобы найти нужные три.
  • У команды config нет своего SetAction — и это сделано намеренно. Вызовите dotnet run -- config без подкоманды, и приложение само покажет справку по этой ветке. Ровно так же ведёт себя git remote или dotnet tool.
  • Опция --verbose работает в любом месте строки: и до подкоманды, и после. За это отвечает Recursive = true. Внутри SetAction она читается как обычно, независимо от того, где пользователь её написал.
  • Алиас show для команды get — одна строка. Пользователь наберёт привычное ему слово, и оба варианта отработают одинаково.

Ещё важный момент, не столько про код, сколько про архитектуру и планирование: хорошей практикой будет перед написанием кода набросать план и, возможно, схему, чтобы определить, какая команда как и где будет вызываться. Дерево команд - это пользовательский интерфейс и менять его после публикации не очень дружелюбно по отношению к пользователям.

В следующих постах поговорим про коды возврата и про взаимодействие приложения с внешним миром в принципе.