Tutorial: Constrói uma aplicação de linha de comandos com System.CommandLine

Sugestão

Este artigo faz parte da secção Fundamentos , escrita para programadores que conhecem pelo menos uma linguagem de programação e estão a aprender C#. Se és novo na programação, começa por começar. Se precisar de cobertura abrangente da biblioteca, consulte a documentação da biblioteca System.CommandLine.

A System.CommandLine biblioteca trata da análise da linha de comandos, geração de texto de ajuda e validação de entradas para que possas concentrar-te na lógica da tua aplicação. Neste tutorial, constróis uma CLI de rastreador de tarefas que demonstra os conceitos centrais: comandos, subcomandos, opções e argumentos.

Neste tutorial, você:

  • Crie uma aplicação baseada em ficheiros usando o System.CommandLine pacote.
  • Defina opções e argumentos com valores tipados.
  • Construa subcomandos e anexe opções e argumentos a eles.
  • Lida com cada subcomando através de uma ação.
  • Testa a aplicação com diferentes entradas de linha de comandos.

Pré-requisitos

Criar a aplicação

Comece por criar um programa C# baseado em ficheiros e adicionar o System.CommandLine pacote.

  1. Crie um arquivo nomeado TaskCli.cs com o seguinte conteúdo:

    #!/usr/bin/env dotnet
    

    A linha shebang #! permite executar o ficheiro diretamente em sistemas Unix. No Windows, execute o ficheiro com dotnet run TaskCli.cs.

  2. Adicione a System.CommandLine diretiva do pacote e as declarações requeridas using :

    #:package System.CommandLine@2.0.0
    
    using System.CommandLine;
    using System.CommandLine.Parsing;
    using System.Text.Json;
    

    Importante

    A versão 2.0.0 é a mais recente no momento em que escrevo. Consulte a página NuGet do pacote para a versão mais recente e certifique-se de que tem as correções de segurança mais recentes.

Compreender a estrutura de comando

Antes de escrever qualquer código de análise, considere como é a CLI do ponto de vista do utilizador. O rastreador de tarefas suporta quatro operações:

dotnet TaskCli.cs -- add "Write documentation" --priority High --due 2026-04-01
dotnet TaskCli.cs -- list --all
dotnet TaskCli.cs -- complete 3
dotnet TaskCli.cs -- remove 3
dotnet TaskCli.cs -- --verbose list

Observação

Nos exemplos anteriores, o -- que ocorre após TaskCli.cs indica ao dotnet run que todos os argumentos restantes são transmitidos para a sua aplicação em vez de serem interpretados pela própria interface de linha de comando dotnet.

Cada linha utiliza vários conceitos de linha de comandos:

  • Subcomandos são verbos que dizem à aplicação o que fazer. O rastreador de tarefas tem quatro: add, list, complete, e remove. Cada subcomando pode definir os seus próprios parâmetros.
  • Os argumentos são valores posicionais que seguem um subcomando. Em add "Write documentation", a cadeia "Write documentation" é um argumento que especifica a descrição da tarefa. Em complete 3, o número 3 é um argumento que especifica o ID da tarefa.
  • As opções são valores nomeados precedidos por --. Em add --priority High --due 2026-04-01, tanto --priority como --due são opções com os seus próprios valores. Em list --all, a --all opção é uma flag booleana que não precisa de valor.
  • As opções globais aplicam-se a todos os subcomandos. A --verbose opção está definida no comando raiz com Recursive = true, por isso funciona com qualquer subcomando. Em --verbose list, a flag verbosa aparece antes do subcomando, mas list --verbose funciona igualmente bem.

Nas secções seguintes, constróis estas peças de baixo para cima. Primeiro, defines as opções individuais (como --priority e --all) e os argumentos (como a descrição da tarefa e o ID). De seguida, crias os quatro subcomandos e anexas as opções e argumentos relevantes a cada um. Depois configuramos uma ação para cada subcomando. A ação é o código que é executado quando o utilizador invoca esse comando. Finalmente, montas o comando raiz, analisas a entrada e invocas a ação correspondente.

Para uma análise mais profunda dos conceitos de sintaxe da linha de comandos, veja Visão geral da sintaxe da linha de comandos.

Definir opções e argumentos

As opções representam valores nomeados que os utilizadores especificam com um -- prefixo. Os argumentos representam valores posicionais. Ambos são fortemente tipados. System.CommandLine Analisa a cadeia de entrada no tipo que especificas.

A System.CommandLine biblioteca utiliza tipos genéricos para garantir a segurança dos tipos. Quando se escreve Option<int>, o int dentro dos colchetes angulares é um argumento de tipo. Indica à biblioteca que tipo de valor a opção tem. A própria classe declara um parâmetroT de tipo (como em System.CommandLine.Option<T>), e forneces o tipo concreto quando crias uma instância. A biblioteca analisa a cadeia de entrada do utilizador e converte-a automaticamente para esse tipo. Se o utilizador fornecer --delay abc para um Option<int>, System.CommandLine reporta um erro de análise em vez de passar dados incorretos ao seu código. Verá este padrão com Option<bool>, Option<Priority>, Option<DateOnly?>, e System.CommandLine.Argument<T> nos passos que se seguem. Para obter mais informações sobre genéricos, consulte Genéricos.

  1. Defina as opções. Cada um Option<T> especifica o tipo de valor, o nome e uma descrição. A --priority opção utiliza um enum tipo e System.CommandLine valida automaticamente a entrada contra valores válidos do tipo enumerado:

    var verboseOption = new Option<bool>("--verbose")
    {
        Description = "Show detailed output",
        Recursive = true
    };
    
    var priorityOption = new Option<Priority>("--priority")
    {
        Description = "Task priority level",
        DefaultValueFactory = _ => Priority.Medium
    };
    
    var dueOption = new Option<DateOnly?>("--due")
    {
        Description = "Due date (uses current culture date format)"
    };
    
    var allOption = new Option<bool>("--all")
    {
        Description = "Include completed tasks"
    };
    

    A definição em Recursive = true--verbose torna a opção disponível em todos os subcomandos. O DefaultValueFactory ligado --priority fornece um valor padrão para que os utilizadores possam omitir essa opção.

  2. Defina os argumentos. Cada um Argument<T> especifica o tipo de valor e um nome:

    var descriptionArgument = new Argument<string>("description")
    {
        Description = "Task description"
    };
    
    var taskIdArgument = new Argument<int>("id")
    {
        Description = "Task ID"
    };
    

Comandos e subcomandos de build

A System.CommandLine.Command representa uma ação que o utilizador pode invocar. Adicione as opções e argumentos relevantes a cada comando para System.CommandLine saber quais os parâmetros que pertencem onde.

  1. Crie os quatro subcomandos. Cada comando tem a sua própria combinação de opções e argumentos:

    var addCommand = new Command("add", "Add a new task")
    {
        Arguments = { descriptionArgument },
        Options = { priorityOption, dueOption }
    };
    
    var listCommand = new Command("list", "List all tasks")
    {
        Options = { allOption }
    };
    
    var completeCommand = new Command("complete", "Mark a task as complete")
    {
        Arguments = { taskIdArgument }
    };
    
    var removeCommand = new Command("remove", "Remove a task")
    {
        Arguments = { taskIdArgument }
    };
    
  2. Monta o comando raiz. Este System.CommandLine.RootCommand é o ponto de entrada para a CLI. Adicione a opção global --verbose e todos os subcomandos:

    var rootCommand = new RootCommand("A simple task tracker CLI")
    {
        Options = { verboseOption },
        Subcommands = { addCommand, listCommand, completeCommand, removeCommand }
    };
    

    O texto de ajuda gerado automaticamente mostra a descrição do comando raiz quando o utilizador executa TaskCli --help.

Lidar com comandos por meio de ações

Cada subcomando precisa de uma ação. Uma ação é um delegado que se executa quando o utilizador invoca esse comando. Um delegado é um tipo que representa uma referência a um método. Aqui, passa uma expressão lambda (uma função anónima em linha definida com =>) como uma função de delegação. Chamar SetAction para atribuir cada ação. O delegado recebe um ParseResult que fornece acesso a valores analisados através de GetValue.

  1. Define a ação para o add comando. Esta ação introduz a interpolação de cadeias ($"..." cadeias que incorporam expressões em chaves), o operador condicional (?:), e um padrão de teste de tipo (due is DateOnly dueDate) que verifica se um valor anulável tem um valor e o atribui a uma nova variável num passo:

    addCommand.SetAction(parseResult =>
    {
        var description = parseResult.GetValue(descriptionArgument)!;
        var priority = parseResult.GetValue(priorityOption);
        var due = parseResult.GetValue(dueOption);
        var verbose = parseResult.GetValue(verboseOption);
    
        var tasks = LoadTasks();
        var id = tasks.Count > 0 ? tasks.Max(t => t.Id) + 1 : 1;
        var task = new TaskItem(id, description, priority, due, false);
        tasks.Add(task);
        SaveTasks(tasks);
    
        Console.WriteLine($"Added task {id}: {description}");
        if (verbose)
        {
            Console.WriteLine($"  Priority: {priority}");
            if (due is DateOnly dueDate)
            {
                Console.WriteLine($"  Due: {dueDate}");
            }
        }
    });
    
  2. Define a ação para o list comando. O LINQ (Language Integrated Query) fornece operadores de consulta padrão para coleções em memória. Nesta ação, Enumerable.Where filtra as tarefas apenas para os itens que correspondem a uma condição e Enumerable.ToList materializa a sequência filtrada numa lista. A ação então usa um foreach ciclo para iterar sobre os resultados, e o operador condicional para selecionar um símbolo de status:

    listCommand.SetAction(parseResult =>
    {
        var showAll = parseResult.GetValue(allOption);
        var verbose = parseResult.GetValue(verboseOption);
    
        var tasks = LoadTasks();
        var filtered = showAll ? tasks : tasks.Where(t => !t.IsComplete).ToList();
    
        if (filtered.Count == 0)
        {
            Console.WriteLine("No tasks found.");
            return;
        }
    
        foreach (var task in filtered)
        {
            var status = task.IsComplete ? "✓" : " ";
            Console.WriteLine($"  [{status}] {task.Id}: {task.Description}");
            if (verbose)
            {
                Console.WriteLine($"       Priority: {task.Priority}");
                if (task.Due is DateOnly dueDate)
                {
                    Console.WriteLine($"       Due: {dueDate}");
                }
            }
        }
    });
    

    Para mais detalhes, consulte LINQ.

  3. Define a ação para o complete comando. Esta ação utiliza LINQs Enumerable.FirstOrDefault para encontrar:

    • Uma tarefa de correspondência.
    • Um is null padrão para verificar se a tarefa existe.
    • Uma with expressão para criar uma nova instância de registo copiando primeiro os valores existentes e depois aplicando as propriedades que definiste no with inicializador (aqui, IsComplete = true). Os registos são imutáveis por defeito, portanto, este padrão de copiar e atualizar é o método para produzir um valor modificado.

    Como a ação pode falhar (por exemplo, o ID da tarefa não existe), a ação devolve um código de erro inteiro que se torna o código de saída da aplicação:

    completeCommand.SetAction(parseResult =>
    {
        var id = parseResult.GetValue(taskIdArgument);
        var verbose = parseResult.GetValue(verboseOption);
    
        var tasks = LoadTasks();
        var task = tasks.FirstOrDefault(t => t.Id == id);
    
        if (task is null)
        {
            Console.Error.WriteLine($"Task {id} not found.");
            return -1;
        }
    
        tasks[tasks.IndexOf(task)] = task with { IsComplete = true };
        SaveTasks(tasks);
    
        Console.WriteLine($"Completed task {id}: {task.Description}");
        if (verbose)
        {
            Console.WriteLine($"  Priority: {task.Priority}");
        }
        return 0;
    });
    
  4. Define a ação para o remove comando. Esta ação segue o mesmo padrão de procura e validação que complete. Use FirstOrDefault para encontrar a tarefa e is null tratar do caso pendente:

    removeCommand.SetAction(parseResult =>
    {
        var id = parseResult.GetValue(taskIdArgument);
        var verbose = parseResult.GetValue(verboseOption);
    
        var tasks = LoadTasks();
        var task = tasks.FirstOrDefault(t => t.Id == id);
    
        if (task is null)
        {
            Console.Error.WriteLine($"Task {id} not found.");
            return -1;
        }
    
        tasks.Remove(task);
        SaveTasks(tasks);
    
        Console.WriteLine($"Removed task {id}: {task.Description}");
        if (verbose)
        {
            Console.WriteLine($"  Priority: {task.Priority}");
        }
        return 0;
    });
    
  5. Analise a linha de comandos e invoque a ação correspondente:

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

    rootCommand.Parse(args) analisa a entrada num ParseResult, e .Invoke() executa a ação para o comando emparelhado. O valor de retorno é um código de saída (0 para sucesso).

Adicionar tipos de suporte e auxiliares de dados

A aplicação precisa de alguns elementos de apoio: funções locais, um enum, a record, e um contexto de serialização. As secções seguintes apresentam cada conceito, explicam porque o escolheriam e mostram o código. A aplicação utiliza o System.Text.Json para armazenar tarefas como JSON (JavaScript Object Notation). As aplicações baseadas em ficheiros exigem que as declarações de tipo apareçam após todas as instruções de topo e funções locais.

  1. Adiciona as funções locais que carregam e guardam tarefas. Uma função local é um método declarado dentro de outra função, incluindo dentro de outras funções locais. As funções locais mantêm a lógica auxiliar próxima do código que a chama, o que melhora a legibilidade porque um leitor não precisa de saltar para uma classe ou ficheiro separado para entender o fluxo. Aqui, LoadTasks e SaveTasks encapsulam a entrada/saída de ficheiro que múltiplas ações de comando partilham, para que a aplicação escreva a lógica de carregar/guardar uma vez e a reutilize.

    static string GetTaskFilePath() => Path.Combine(
        Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
        "taskcli-sample",
        "tasks.json");
    
    static List<TaskItem> LoadTasks()
    {
        var path = GetTaskFilePath();
        if (!File.Exists(path))
        {
            return [];
        }
    
        var json = File.ReadAllText(path);
        return JsonSerializer.Deserialize(json, TaskJsonContext.Default.ListTaskItem) ?? [];
    }
    
    static void SaveTasks(List<TaskItem> tasks)
    {
        var path = GetTaskFilePath();
        Directory.CreateDirectory(Path.GetDirectoryName(path)!);
        var json = JsonSerializer.Serialize(tasks, TaskJsonContext.Default.ListTaskItem);
        File.WriteAllText(path, json);
    }
    
  2. Adicione o Priority enum e o TaskItem registo no final do ficheiro.

    public enum Priority { Low, Medium, High }
    

    An enum é um tipo de valor que define um conjunto fixo de constantes nomeadas suportadas por um tipo inteiro. Poderia representar os níveis de prioridade com inteiros simples (0, 1, 2), mas um enum é uma escolha melhor por várias razões: o compilador restringe as atribuições aos nomes definidos, de modo que um erro tipográfico como Hihg resulta num erro de compilação em vez de um bug silencioso; os nomes Low, Medium, e High tornam o código mais legível; e System.CommandLine valida automaticamente a entrada do utilizador em relação aos membros do enum, proporcionando assim verificação de entrada gratuita.

    public record TaskItem(int Id, string Description, Priority Priority, DateOnly? Due, bool IsComplete);
    

    A record é um tipo que o compilador equipa com igualdade baseada em valores e mutação não destrutiva. A record é o ajuste correto para TaskItem porque os dados da tarefa são estados simples sem comportamento complexo — compara-se as tarefas por seus valores, não pela identidade de referência. O compilador gera Equals, GetHashCode, ToString, e with suporte de expressões a partir dos parâmetros do construtor, isto assegura verificações corretas de igualdade, fácil geração de saída para depuração e atualizações imutáveis sem escrever código padrão. Como os registos são imutáveis por defeito, usas uma with expressão para produzir uma cópia modificada (como a complete ação faz) em vez de mutar o original, o que previne efeitos secundários acidentais quando diferentes ações leem e escrevem a mesma lista.

  3. Adicione o contexto de serialização JSON para serialização compatível com AOT:

    [System.Text.Json.Serialization.JsonSourceGenerationOptions(WriteIndented = true)]
    [System.Text.Json.Serialization.JsonSerializable(typeof(List<TaskItem>))]
    internal partial class TaskJsonContext : System.Text.Json.Serialization.JsonSerializerContext;
    

    Esta classe utiliza dois atributos (anotações de metadados colocadas entre parênteses acima de uma declaração). O [JsonSourceGenerationOptions(WriteIndented = true)] atributo indica ao gerador de origem para emitir JSON indentado para maior legibilidade. O [JsonSerializable(typeof(List<TaskItem>))] atributo indica ao gerador para que tipo deve criar código de serialização. Em conjunto, estes atributos permitem a serialização gerada pela fonte, que evita reflexões em tempo de execução e suporta a compilação antecipada (AOT).

Testar a aplicação

Executa a aplicação com diferentes comandos para exercitar cada subcomando.

  1. Veja a ajuda gerada automaticamente:

    dotnet run TaskCli.cs -- --help
    
    Description:
      A simple task tracker CLI
    
    Usage:
      TaskCli [command] [options]
    
    Options:
      --verbose       Show detailed output
      -?, -h, --help  Show help and usage information
      --version       Show version information
    
    Commands:
      add <description>  Add a new task
      list               List all tasks
      complete <id>      Mark a task as complete
      remove <id>        Remove a task
    
  2. Adicione tarefas com diferentes opções:

    dotnet run TaskCli.cs -- add "Write documentation" --priority High --due 2026-04-01
    dotnet run TaskCli.cs -- add "Review pull request"
    dotnet run TaskCli.cs -- add "Fix build errors"
    
    Added task 1: Write documentation
    Added task 2: Review pull request
    Added task 3: Fix build errors
    
  3. Liste tarefas e use --verbose para mostrar detalhes extra:

    dotnet run TaskCli.cs -- --verbose list
    
      [ ] 1: Write documentation
           Priority: High
           Due: 4/1/2026
      [ ] 2: Review pull request
           Priority: Medium
      [ ] 3: Fix build errors
           Priority: Medium
    
  4. Completar uma tarefa e, em seguida, verificar se a lista filtra automaticamente as tarefas concluídas:

    dotnet run TaskCli.cs -- complete 2
    dotnet run TaskCli.cs -- list
    
    Completed task 2: Review pull request
      [ ] 1: Write documentation
      [ ] 3: Fix build errors
    
  5. Use --all para incluir tarefas concluídas:

    dotnet run TaskCli.cs -- list --all
    
      [ ] 1: Write documentation
      [✓] 2: Review pull request
      [ ] 3: Fix build errors
    
  6. Remover uma tarefa:

    dotnet run TaskCli.cs -- remove 3
    
    Removed task 3: Fix build errors
    

Limpeza de recursos

O rastreador de tarefas armazena os dados num ficheiro JSON na pasta local de dados da aplicação. Apague a taskcli-sample pasta para remover os dados de exemplo:

  • Windows: Apagar %LOCALAPPDATA%\taskcli-sample.
  • macOS: Apagar ~/Library/Application Support/taskcli-sample.
  • Linux: Apagar ~/.local/share/taskcli-sample.