Sviluppare applicazioni CLI in C#
Per molti anni sviluppare strumenti da riga di comando in C# ha significato scrivere applicazioni console legate a Windows e al .NET Framework. Oggi lo scenario è completamente diverso: .NET è multipiattaforma e open source, la stessa CLI dotnet è scritta in C#, e con la compilazione Native AOT un'applicazione console diventa un eseguibile nativo che si avvia in pochi millisecondi senza richiedere il runtime installato. In questo articolo costruiremo tasks, un gestore di attività con persistenza su file JSON, usando System.CommandLine, la libreria ufficiale di Microsoft per il parsing della riga di comando, arrivata alla versione stabile 2.0 dopo un lunghissimo periodo di anteprima.
Le convenzioni da rispettare
Anche in .NET valgono le regole che rendono uno strumento prevedibile:
- i risultati vanno su standard output, errori e diagnostica su standard error;
- il codice di uscita vale
0in caso di successo,1per gli errori di esecuzione e2per gli errori di utilizzo; - nessuno stack trace per gli errori previsti;
- colori solo su terminale e mai se
NO_COLORè impostata; - un formato JSON per l'uso da script.
tasks add "Rinnovare il certificato TLS" --priority high
tasks list --all --format json
tasks done 3
tasks rm 3
Struttura del progetto
Creiamo una soluzione con il progetto principale e un progetto di test basato su xUnit, usando .NET 10, la versione con supporto a lungo termine attualmente in vigore.
dotnet new sln -n Tasks
dotnet new console -n Tasks -o src/Tasks
dotnet new xunit -n Tasks.Tests -o tests/Tasks.Tests
dotnet sln add src/Tasks tests/Tasks.Tests
dotnet add src/Tasks package System.CommandLine --version 2.0.0
dotnet add tests/Tasks.Tests reference src/Tasks
Tasks/
├── Tasks.sln
├── src/Tasks/
│ ├── Tasks.csproj
│ ├── Program.cs
│ ├── TaskItem.cs
│ ├── TaskStore.cs
│ └── TasksApp.cs
└── tests/Tasks.Tests/
└── TasksAppTests.cs
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<AssemblyName>tasks</AssemblyName>
<Version>1.0.0</Version>
<InvariantGlobalization>true</InvariantGlobalization>
<!-- Distribuzione come strumento globale di .NET -->
<PackAsTool>true</PackAsTool>
<ToolCommandName>tasks</ToolCommandName>
<PackageId>Example.Tasks</PackageId>
<!-- Abilita gli analizzatori di compatibilità con la compilazione nativa -->
<IsAotCompatible>true</IsAotCompatible>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="System.CommandLine" Version="2.0.0" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="Tasks.Tests" />
</ItemGroup>
</Project>
L'impostazione IsAotCompatible attiva gli analizzatori che segnalano, già in fase di compilazione, il codice incompatibile con la compilazione nativa, come l'uso non annotato della riflessione. È il motivo per cui useremo la generazione del codice di System.Text.Json invece della serializzazione basata sulla riflessione.
Il modello dei dati e la serializzazione
Un record posizionale rappresenta un'attività. Lo chiamiamo TaskItem per evitare il conflitto con System.Threading.Tasks.Task, importato automaticamente dagli using impliciti.
using System.Text.Json.Serialization;
namespace Tasks;
public sealed record TaskItem(
int Id,
string Title,
string Priority,
bool Done,
DateTimeOffset CreatedAt)
{
public static readonly string[] Priorities = ["low", "normal", "high"];
}
// Il generatore di codice produce in fase di compilazione il serializzatore:
// nessuna riflessione a runtime, quindi compatibile con Native AOT.
[JsonSourceGenerationOptions(
PropertyNamingPolicy = JsonKnownNamingPolicy.SnakeCaseLower,
WriteIndented = true)]
[JsonSerializable(typeof(List<TaskItem>))]
internal sealed partial class TasksJsonContext : JsonSerializerContext;
Con SnakeCaseLower le proprietà vengono serializzate come id, title, created_at: lo stesso formato usato dalle versioni di tasks negli altri linguaggi della serie, così che i file dati siano intercambiabili.
Il livello di persistenza
La classe TaskStore legge e scrive il file JSON. Il percorso predefinito viene calcolato a partire da un ambiente passato come funzione, così che i test possano fornire valori controllati senza modificare le variabili del processo.
using System.Text.Json;
namespace Tasks;
/// <summary>Errore di esecuzione legato ai dati (codice di uscita 1).</summary>
public sealed class StoreException(string message, Exception? inner = null) : Exception(message, inner);
public sealed class TaskStore(string path)
{
public string Path { get; } = path;
/// <summary>Restituisce il percorso del file dati rispettando le convenzioni del sistema.</summary>
public static string DefaultPath(Func<string, string?> env)
{
var overridePath = env("TASKS_FILE");
if (!string.IsNullOrWhiteSpace(overridePath))
{
return overridePath;
}
string baseDir;
if (OperatingSystem.IsWindows() || OperatingSystem.IsMacOS())
{
// ApplicationData punta ad AppData\Roaming su Windows e a ~/Library/Application Support su macOS.
baseDir = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
}
else
{
var xdg = env("XDG_DATA_HOME");
baseDir = string.IsNullOrWhiteSpace(xdg)
? System.IO.Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".local", "share")
: xdg;
}
return System.IO.Path.Combine(baseDir, "tasks", "tasks.json");
}
public List<TaskItem> Load()
{
try
{
using var stream = File.OpenRead(Path);
return JsonSerializer.Deserialize(stream, TasksJsonContext.Default.ListTaskItem) ?? [];
}
catch (FileNotFoundException)
{
return [];
}
catch (DirectoryNotFoundException)
{
return [];
}
catch (JsonException e)
{
throw new StoreException($"il file {Path} non è valido: {e.Message}", e);
}
catch (IOException e)
{
throw new StoreException($"impossibile leggere {Path}: {e.Message}", e);
}
}
La scrittura segue lo schema atomico: file temporaneo nella stessa directory, scaricamento forzato sul disco con Flush(flushToDisk: true) e spostamento sopra l'originale con File.Move e il parametro overwrite. Su Linux e macOS l'operazione si traduce in una chiamata rename, atomica all'interno dello stesso filesystem.
public void Save(List<TaskItem> tasks)
{
var dir = System.IO.Path.GetDirectoryName(System.IO.Path.GetFullPath(Path))!;
var tmp = System.IO.Path.Combine(dir, $".tasks-{Guid.NewGuid():N}.json");
try
{
Directory.CreateDirectory(dir);
using (var stream = new FileStream(tmp, FileMode.CreateNew, FileAccess.Write))
{
JsonSerializer.Serialize(stream, tasks, TasksJsonContext.Default.ListTaskItem);
stream.WriteByte((byte)'\n');
// Garantisce che i dati siano sul disco prima dello spostamento.
stream.Flush(flushToDisk: true);
}
File.Move(tmp, Path, overwrite: true);
}
catch (Exception e) when (e is IOException or UnauthorizedAccessException)
{
throw new StoreException($"impossibile scrivere {Path}: {e.Message}", e);
}
finally
{
if (File.Exists(tmp))
{
File.Delete(tmp);
}
}
}
public static int NextId(List<TaskItem> tasks) => tasks.Count == 0 ? 1 : tasks.Max(t => t.Id) + 1;
public static int IndexOf(List<TaskItem> tasks, int id)
{
var index = tasks.FindIndex(t => t.Id == id);
return index >= 0 ? index : throw new StoreException($"attività non trovata: {id}");
}
}
I comandi con System.CommandLine
La versione 2.0 di System.CommandLine ha un'API molto diversa dalle anteprime che circolano ancora in molti tutorial: non esistono più SetHandler, InvocationContext né CommandLineBuilder. Il modello attuale è lineare: si creano un RootCommand, dei Command per i sottocomandi, oggetti Option<T> e Argument<T>, si collega a ogni comando un'azione con SetAction e si leggono i valori dal ParseResult con GetValue.
Raccogliamo la costruzione della CLI in una classe TasksApp che riceve lo store e i due flussi di output. Le azioni scrivono sempre su questi TextWriter e mai direttamente su Console: è ciò che rende l'applicazione testabile.
using System.CommandLine;
using System.CommandLine.Parsing;
using System.Text.Json;
namespace Tasks;
public sealed class TasksApp(TaskStore store, TextWriter output, TextWriter error, bool color)
{
public const int ExitOk = 0;
public const int ExitFailure = 1;
public const int ExitUsage = 2;
private RootCommand BuildRoot()
{
var root = new RootCommand("Un semplice gestore di attività da riga di comando.");
root.Subcommands.Add(BuildAdd());
root.Subcommands.Add(BuildList());
root.Subcommands.Add(BuildDone());
root.Subcommands.Add(BuildRemove());
return root;
}
add
Il titolo è un argomento di tipo string[] con arità OneOrMore, così che tasks add Comprare il latte funzioni senza virgolette. Per la priorità, AcceptOnlyFromAmong limita i valori ammessi: un valore diverso produce un errore di parsing con l'elenco delle alternative.
private Command BuildAdd()
{
var title = new Argument<string[]>("title")
{
Description = "Titolo dell'attività",
Arity = ArgumentArity.OneOrMore,
};
var priority = new Option<string>("--priority", "-p")
{
Description = "Priorità dell'attività",
DefaultValueFactory = _ => "normal",
};
priority.AcceptOnlyFromAmong(TaskItem.Priorities);
var command = new Command("add", "Aggiunge un'attività") { title, priority };
command.SetAction(result => Guard(() =>
{
var text = string.Join(' ', result.GetValue(title)!).Trim();
var tasks = store.Load();
var task = new TaskItem(TaskStore.NextId(tasks), text, result.GetValue(priority)!, false, DateTimeOffset.UtcNow);
tasks.Add(task);
store.Save(tasks);
output.WriteLine($"Aggiunta l'attività {task.Id}: {task.Title}");
}));
return command;
}
Nella versione 2.0 il primo parametro del costruttore di Option è il nome, mentre i successivi sono alias: "--priority", "-p" dichiara la forma lunga e quella breve.
list
private Command BuildList()
{
var all = new Option<bool>("--all", "-a") { Description = "Include le attività completate" };
var format = new Option<string>("--format", "-f")
{
Description = "Formato di output",
DefaultValueFactory = _ => "table",
};
format.AcceptOnlyFromAmong("table", "json");
var command = new Command("list", "Elenca le attività") { all, format };
command.Aliases.Add("ls");
command.SetAction(result => Guard(() =>
{
var showAll = result.GetValue(all);
var tasks = store.Load().Where(t => showAll || !t.Done).ToList();
if (result.GetValue(format) == "json")
{
output.WriteLine(JsonSerializer.Serialize(tasks, TasksJsonContext.Default.ListTaskItem));
return;
}
PrintTable(tasks);
}));
return command;
}
private void PrintTable(List<TaskItem> tasks)
{
if (tasks.Count == 0)
{
output.WriteLine("Nessuna attività.");
return;
}
var idWidth = Math.Max(2, tasks.Max(t => t.Id.ToString().Length));
output.WriteLine($"{"ID".PadRight(idWidth)} {"STATO",-6} {"PRIORITÀ",-8} TITOLO");
foreach (var t in tasks)
{
// Il colore avvolge la cella già riempita, così l'allineamento resta corretto.
var cell = Paint(t.Priority, t.Priority.PadRight(8));
output.WriteLine($"{t.Id.ToString().PadRight(idWidth)} {(t.Done ? "fatta" : "aperta"),-6} {cell} {t.Title}");
}
}
done e rm
L'ID è un Argument<int>: la conversione da stringa è automatica e un valore come abc produce un errore di parsing. Aggiungiamo un validatore per rifiutare i valori non positivi; gli errori aggiunti con AddError vengono trattati esattamente come quelli di parsing.
private static Argument<int> IdArgument()
{
var id = new Argument<int>("id") { Description = "ID dell'attività" };
id.Validators.Add(result =>
{
if (result.GetValueOrDefault<int>() <= 0)
{
result.AddError("l'ID deve essere un intero positivo");
}
});
return id;
}
private Command BuildDone()
{
var id = IdArgument();
var command = new Command("done", "Segna un'attività come completata") { id };
command.SetAction(result => Guard(() =>
{
var value = result.GetValue(id);
var tasks = store.Load();
var index = TaskStore.IndexOf(tasks, value);
tasks[index] = tasks[index] with { Done = true };
store.Save(tasks);
output.WriteLine($"Attività {value} completata.");
}));
return command;
}
private Command BuildRemove()
{
var id = IdArgument();
var command = new Command("rm", "Elimina un'attività") { id };
command.Aliases.Add("remove");
command.SetAction(result => Guard(() =>
{
var value = result.GetValue(id);
var tasks = store.Load();
tasks.RemoveAt(TaskStore.IndexOf(tasks, value));
store.Save(tasks);
output.WriteLine($"Attività {value} eliminata.");
}));
return command;
}
Il record rende elegante la modifica: l'espressione with { Done = true } crea una copia dell'attività con la sola proprietà Done cambiata.
Errori e codici di uscita
System.CommandLine gestisce da sé gli errori di parsing, ma per impostazione predefinita termina con codice 1, lo stesso degli errori di esecuzione. Le eccezioni sollevate dalle azioni vengono invece intercettate da un gestore predefinito che stampa lo stack trace completo. Nessuno dei due comportamenti è quello che vogliamo, quindi prendiamo il controllo in due punti.
Il primo è il metodo Guard, che avvolge ogni azione: trasforma una StoreException in un messaggio compatto su standard error con codice 1, senza stack trace.
private int Guard(Action action)
{
try
{
action();
return ExitOk;
}
catch (StoreException e)
{
error.WriteLine($"tasks: {e.Message}");
return ExitFailure;
}
}
Il secondo è il metodo Run: analizziamo gli argomenti con Parse e, se il risultato contiene errori, li stampiamo noi su standard error e restituiamo 2, senza invocare alcuna azione. In assenza di errori, Invoke esegue l'azione selezionata, che può essere uno dei nostri comandi oppure l'azione integrata per --help e --version. Una InvocationConfiguration indirizza l'output di queste azioni integrate verso i nostri flussi.
public int Run(string[] args)
{
var parseResult = BuildRoot().Parse(args);
if (parseResult.Errors.Count > 0)
{
foreach (var parseError in parseResult.Errors)
{
error.WriteLine($"tasks: {parseError.Message}");
}
error.WriteLine("Esegui \"tasks --help\" per l'elenco dei comandi.");
return ExitUsage;
}
return parseResult.Invoke(new InvocationConfiguration
{
Output = output,
Error = error,
});
}
private static readonly Dictionary<string, string> Colors = new()
{
["high"] = "\u001b[31m",
["normal"] = "\u001b[33m",
["low"] = "\u001b[90m",
};
private string Paint(string priority, string text) =>
color && Colors.TryGetValue(priority, out var code) ? $"{code}{text}\u001b[0m" : text;
}
Il comando radice senza sottocomandi e senza azione propria produce automaticamente un errore di parsing che segnala la mancanza del comando: tasks invocato da solo esce quindi con codice 2, come atteso.
Il punto di ingresso, i colori e i segnali
Il file Program.cs usa le istruzioni di primo livello e si limita a comporre gli oggetti con i flussi reali della console. Il colore viene abilitato solo se lo standard output non è rediretto e NO_COLOR non è impostata: Console.IsOutputRedirected svolge esattamente il ruolo che in altri linguaggi ha la funzione isatty.
using Tasks;
var store = new TaskStore(TaskStore.DefaultPath(Environment.GetEnvironmentVariable));
var color = !Console.IsOutputRedirected
&& string.IsNullOrEmpty(Environment.GetEnvironmentVariable("NO_COLOR"));
return new TasksApp(store, Console.Out, Console.Error, color).Run(args);
Il valore restituito dalle istruzioni di primo livello diventa il codice di uscita del processo. A differenza di Environment.Exit, il ritorno da Main lascia che il runtime svuoti i buffer di output prima di terminare.
Per le operazioni lunghe, System.CommandLine offre le azioni asincrone, che ricevono un CancellationToken. La libreria intercetta SIGINT e SIGTERM e annulla il token, dando all'azione la possibilità di terminare in modo ordinato:
command.SetAction(async (ParseResult result, CancellationToken cancellationToken) =>
{
try
{
await importer.RunAsync(result.GetValue(source)!, cancellationToken);
return TasksApp.ExitOk;
}
catch (OperationCanceledException)
{
await Console.Error.WriteLineAsync("tasks: importazione interrotta");
// 130 = 128 + SIGINT, la convenzione delle shell per i processi interrotti
return 130;
}
});
Test
Con flussi e store iniettati, i test di xUnit eseguono l'applicazione completa in memoria. Ogni test crea la propria directory temporanea, eliminata da Dispose.
using System.Text.Json;
using Tasks;
namespace Tasks.Tests;
public sealed class TasksAppTests : IDisposable
{
private readonly string _dir = Directory.CreateTempSubdirectory("tasks-test-").FullName;
private readonly TaskStore _store;
public TasksAppTests() => _store = new TaskStore(Path.Combine(_dir, "tasks.json"));
public void Dispose() => Directory.Delete(_dir, recursive: true);
private (int Code, string Out, string Err) Run(params string[] args)
{
var output = new StringWriter();
var error = new StringWriter();
var code = new TasksApp(_store, output, error, color: false).Run(args);
return (code, output.ToString(), error.ToString());
}
[Fact]
public void AddsAndListsTasksAsJson()
{
Assert.Equal(0, Run("add", "Scrivere", "i", "test", "--priority", "high").Code);
var (code, output, _) = Run("list", "--format", "json");
Assert.Equal(0, code);
using var json = JsonDocument.Parse(output);
var task = Assert.Single(json.RootElement.EnumerateArray());
Assert.Equal("Scrivere i test", task.GetProperty("title").GetString());
Assert.Equal("high", task.GetProperty("priority").GetString());
}
[Fact]
public void HidesCompletedTasksByDefault()
{
Run("add", "Prima");
Run("add", "Seconda");
Run("done", "1");
using var open = JsonDocument.Parse(Run("list", "-f", "json").Out);
Assert.Equal(1, open.RootElement.GetArrayLength());
using var all = JsonDocument.Parse(Run("list", "-a", "-f", "json").Out);
Assert.Equal(2, all.RootElement.GetArrayLength());
}
[Theory]
[InlineData("", 2)]
[InlineData("fly", 2)]
[InlineData("add", 2)]
[InlineData("add x --priority urgent", 2)]
[InlineData("list --colour", 2)]
[InlineData("done abc", 2)]
[InlineData("done 0", 2)]
[InlineData("done 42", 1)]
[InlineData("list --help", 0)]
public void ReturnsExpectedExitCodes(string commandLine, int expected)
{
var args = commandLine.Split(' ', StringSplitOptions.RemoveEmptyEntries);
Assert.Equal(expected, Run(args).Code);
}
[Fact]
public void WritesErrorsToStderrWithoutStackTrace()
{
var (code, output, error) = Run("rm", "7");
Assert.Equal(1, code);
Assert.Equal("", output);
Assert.StartsWith("tasks: attività non trovata", error);
Assert.DoesNotContain(" at ", error);
}
[Fact]
public void ReportsCorruptedFile()
{
File.WriteAllText(_store.Path, "{non json");
var (code, _, error) = Run("list");
Assert.Equal(1, code);
Assert.Contains("non è valido", error);
}
}
dotnet test
Distribuzione
.NET offre tre strade, adatte a scenari diversi.
La prima è lo strumento globale: grazie alle proprietà PackAsTool e ToolCommandName del file di progetto, il comando dotnet pack produce un pacchetto NuGet installabile con dotnet tool install. Richiede l'SDK o il runtime .NET sulla macchina, ma è la soluzione più comoda per gli sviluppatori.
dotnet pack src/Tasks -c Release -o nupkg
dotnet tool install --global --add-source ./nupkg Example.Tasks
tasks --version
La seconda è la pubblicazione in un unico file, autonomo rispetto al runtime installato: l'eseguibile contiene il runtime e l'applicazione, ma il codice resta IL compilato just in time.
La terza, la più interessante per una CLI, è Native AOT: il codice viene compilato in anticipo in un eseguibile nativo, con tempi di avvio paragonabili a quelli di un programma in Go o C++ e dimensioni contenute. Il lavoro fatto in precedenza, con la serializzazione generata e una libreria di parsing compatibile, garantisce che la compilazione avvenga senza avvisi.
# Eseguibile autonomo, compilato just in time
dotnet publish src/Tasks -c Release -r linux-x64 --self-contained -p:PublishSingleFile=true
# Eseguibile nativo
dotnet publish src/Tasks -c Release -r linux-x64 -p:PublishAot=true
./src/Tasks/bin/Release/net10.0/linux-x64/publish/tasks list
Native AOT non supporta la compilazione incrociata tra sistemi operativi: l'eseguibile per Windows va prodotto su Windows, quello per macOS su macOS. In una pipeline di integrazione continua si risolve con una matrice di job, uno per ogni piattaforma di destinazione.
Conclusione
Con System.CommandLine 2.0, .NET ha finalmente una libreria ufficiale e stabile per la riga di comando, con un'API molto più semplice delle anteprime. Abbiamo visto come combinarla con le scelte progettuali che rendono affidabile uno strumento: separare il parsing dall'invocazione per controllare i codici di uscita, intercettare gli errori previsti per non mostrare stack trace, iniettare flussi e store per testare l'applicazione in memoria, scrivere i dati in modo atomico. E grazie alla serializzazione generata e a Native AOT, lo stesso codice diventa un eseguibile nativo che non ha nulla da invidiare, quanto a tempi di avvio, agli strumenti scritti in linguaggi tradizionalmente compilati.