System.CommandLine CLI Developer Skill
Vous travaillez sur une application CLI .NET construite avec System.CommandLine v2.x.x, ciblant .NET 8 ou ultérieur ou toute implémentation .NET Standard 2.0, incluant .NET Framework 4.6.1 ou ultérieur et .NET Core 2.0 ou ultérieur. Suivez strictement ces règles et patrons lors de la création ou de la modification de commandes CLI.
Vue d'ensemble de l'architecture
<CLI Project>/
├── Program.cs # Point d'entrée et invocation des commandes
└── Commands/
├── CommandBase.cs # Classe de base pour toutes les commandes
├── GlobalOptions.cs # Définit les options globales pour la CLI
├── RootCommand.cs # Enregistre les commandes de premier niveau
└── <Group>/ # Un dossier par groupe de commandes
├── <Group>Command.cs # Commande parente qui enregistre ses enfants
└── <Group><Verb>Command.cs # Commande feuille avec son gestionnaire
RÈGLE 1 — Préférer une classe CommandBase spécifique au projet
Préférez définir une CommandBase abstraite spécifique au projet qui hérite de System.CommandLine.Command. Les commandes concrètes doivent hériter de cette classe de base pour que le comportement partagé et les conventions restent centralisés.
internal abstract class CommandBase : Command
{
protected CommandBase(string name, string? description = null)
: base(name, description)
{
}
}
internal sealed class MyCommand : CommandBase
{
public MyCommand()
: base("command-name", "Texte d'aide affiché dans --help")
{
this.SetAction(CommandHandler);
}
private async Task<int> CommandHandler(
ParseResult parseResult,
CancellationToken cancellationToken)
{
// implémentation
return 0;
}
}
Lorsque le projet possède déjà une classe de base de commande, préservez ses conventions établies. Sinon, introduisez-en une lorsque les commandes ont besoin d'un comportement partagé ; les applications simples peuvent hériter directement de Command lorsqu'une classe de base n'ajoute aucune valeur significative.
RÈGLE 2 — Options et arguments
Définir des options
private readonly Option<string> _myOption;
// Dans le constructeur :
_myOption = new Option<string>("--my-option")
{
Description = "Description claire de ce que cette option fait",
Required = true, // ou false
};
_myOption.Aliases.Add("-m"); // Ajouter un alias court
this.Options.Add(_myOption);
Définir des arguments (positionnels)
private readonly Argument<string> _fileArgument;
// Dans le constructeur :
_fileArgument = new Argument<string>("file")
{
Description = "Chemin vers le fichier d'entrée"
};
this.Arguments.Add(_fileArgument);
Lire les valeurs dans les gestionnaires
// Option/argument requis — utilisez GetValue :
var value = parseResult.GetValue(_myOption);
RÈGLE 3 — Patron de gestionnaire de commande
Les gestionnaires sont des méthodes async câblées via SetAction :
this.SetAction(CommandHandler);
private async Task<int> CommandHandler(ParseResult parseResult, CancellationToken cancellationToken)
{
// 1. Lire les valeurs des options/arguments
// 2. Charger les paramètres de session (si nécessaire)
// 3. Valider la configuration en amont — échouer rapidement avec un message clair
// 4. Exécuter la logique métier
// 5. Afficher les résultats avec Console
return 0; // ou code de sortie non-zéro
}
RÈGLE 4 — Groupe de commandes (parent avec sous-commandes)
Une commande de groupe enregistre les enfants mais n'appelle pas SetAction :
internal class MyGroupCommand : CommandBase
{
public MyGroupCommand()
: base("mygroup", "Gère les ressources my-group")
{
this.Subcommands.Add(new MyGroupListCommand());
this.Subcommands.Add(new MyGroupCreateCommand());
this.Subcommands.Add(new MyGroupDeleteCommand());
}
}
Une commande peut définir à la fois une action et des sous-commandes lorsque l'invocation directe a un comportement significatif.
RÈGLE 5 — Enregistrement
-
Commandes de premier niveau → enregistrez dans
RootCommand.cs:this.Subcommands.Add(new MyGroupCommand()); -
Sous-commandes → enregistrez dans le constructeur de la commande parente :
this.Subcommands.Add(new MyGroupCreateCommand());
RÈGLE 6 — Confirmation de l'utilisateur pour les opérations destructrices
Console.WriteLine("Êtes-vous sûr de vouloir supprimer X ? Cette action ne peut pas être annulée. (yes/no)");
var confirmation = Console.ReadLine();
if (confirmation?.ToLower() != "yes" && confirmation?.ToLower() != "y")
{
Console.WriteLine("Opération annulée.");
return 0;
}
RÈGLE 7 — Logique de commande
La logique de chaque commande doit se trouver dans une ou plusieurs classes de service qui implémentent des interfaces. La commande reçoit les interfaces via injection de dépendances (DI), pas les implémentations concrètes. Le gestionnaire de commande ne doit pas contenir de logique métier. Le gestionnaire de commande doit être mince, responsable uniquement de :
- Analyser l'entrée
- Valider la configuration
- Appeler la méthode de service
- Afficher les résultats
La classe de service doit être injectée dans le constructeur de la commande via DI, pas instanciée directement.
RÈGLE 8 — Injection de dépendances
Les services sont enregistrés dans Program.cs :
serviceCollection.TryAddSingleton<IMyService, MyServiceImpl>();
Ajoutez une extension de commodité dans ServiceProviderExtensions.cs :
public static IMyService GetMyService(this ServiceProvider provider)
=> provider.GetRequiredService<IMyService>();
RÈGLE 9 — Conventions de nommage
| Élément | Convention | Exemple |
|---|---|---|
| Nom de commande CLI | lowercase kebab-case | agent create, set show |
| Classe de commande | PascalCase + suffixe Command |
AgentCreateCommand |
| Champ d'option | _camelCaseOption (private readonly) |
_projectNameOption |
| Nom long d'option | --kebab-case |
--project-name |
| Alias court d'option | -x (1-2 caractères) |
-p, -id, -md |
| Champ d'argument | _camelCaseArgument |
_fileArgument |
| Namespace | MyProject.Commands.<Group> |
MyProject.Commands.Agent |
| Dossier | Commands/<Group>/ |
Commands/Agent/ |
RÈGLE 10 — Visibilité
- Toutes les classes de commande sont
internal.
RÈGLE 11 — Options globales et validation
Définissez les options partagées par l'ensemble de l'arborescence des commandes une seule fois dans GlobalOptions.cs. Réutilisez la même instance Option<T> lors de l'enregistrement, de la validation et de la lecture de l'option.
internal static class GlobalOptions
{
public static readonly Option<string> EndpointOption = CreateEndpointOption();
private static Option<string> CreateEndpointOption()
{
var option = new Option<string>(...);
// ajouter la description de l'option, les alias et le drapeau Required
// Ajouter la validation à la collection Validators de l'option
return option;
}
}
Utiliser les options globales dans une commande
Exposez l'analyse ou la conversion répétée à travers des assistants CommandBase protégés :
/// <summary>Résout le point de terminaison validé à partir de l'option globale.</summary>
protected Uri GetEndpoint(ParseResult parseResult)
{
var baseUrl = parseResult.GetValue(GlobalOptions.EndpointOption)!;
return new Uri(baseUrl);
}
/// <summary>Résout la clé optionnelle à partir de l'option globale.</summary>
protected string? GetKey(ParseResult parseResult)
=> parseResult.GetValue(GlobalOptions.KeyOption);
Consommez ces assistants à partir du gestionnaire de la commande feuille. La commande ne doit pas ajouter les options globales à sa propre collection Options ; l'enregistrement récursif sur la racine les rend déjà disponibles dans son ParseResult.
private async Task<int> CommandHandler(
ParseResult parseResult,
CancellationToken cancellationToken)
{
var endpoint = GetEndpoint(parseResult);
var key = GetKey(parseResult);
...
return 0;
}
Lisez une option globale directement dans un gestionnaire feuille uniquement lorsqu'aucune logique de conversion ou de secours partagée n'est nécessaire.
Toujours utiliser le symbole statique GlobalOptions ; ne jamais créer un second Option<T> avec les mêmes alias.
Suivez ces exigences :
- Définissez
Recursive = truepour que l'option soit acceptée pour chaque commande descendante. - Ajoutez chaque option globale exactement une fois à
RootCommand.Options; ne la dupliquez pas sur les commandes feuilles. - Lisez les valeurs via le symbole partagé, par exemple
parseResult.GetValue(GlobalOptions.Endpoint), de préférence derrière un assistantCommandBase. - Ajoutez la validation à la collection
Validatorsde l'option pour que l'entrée invalide devienne une erreur d'analyse et le gestionnaire de commande ne soit pas invoqué. Ne comptez pas sur les exceptions denew Uri(...)ou des services en aval. - Validez les options de point de terminaison comme des URI
httpouhttpsabsolues non vides. Rejetez les schémas non supportés, les URI relatives, les chaînes de requête et les fragments car l'ajout d'un chemin de point de terminaison fixe changerait leur signification. - Pour les options de secret optionnelles comme
--key, autorisez l'omission mais rejetez une valeur vide ou contenant uniquement des espaces fournie explicitement. Validez la valeur sans la journaliser, l'afficher, la découper ou la modifier d'une autre manière. - Séparez la validation de la dérivation.
- Utilisez des messages de validation stables et exploitables qui nomment l'option et le format accepté.
- Testez les options globales via l'analyseur racine, incluant la valeur par défaut, les valeurs valides explicites, les valeurs invalides, et la plaçabilité avant et après une sous-commande représentative. Vérifiez que l'entrée invalide empêche l'exécution du gestionnaire.
RÈGLE 12 — Liste de contrôle pour les nouvelles commandes
Lors de la création d'une nouvelle commande, vérifiez :
- ✅ Hérite de la classe de base de commande du projet lorsqu'une existe ou fournit un comportement partagé significatif
- ✅ Le constructeur passe
name,descriptionà la base - ✅ Toutes les options ont
Description,Required - ✅ Gestionnaire câblé via
this.SetAction(CommandHandler) - ✅ Signature du gestionnaire :
async Task<int> CommandHandler(ParseResult, CancellationToken) - ✅ Commande enregistrée dans la commande parente (RootCommand ou commande de groupe)
- ✅ La classe est
internal - ✅ Fichier placé dans le dossier
Commands/<Group>/ - ✅ Namespace correspond au dossier :
MyProject.CLI.Commands.<Group>