Commandes CLI avec google/subcommands

Image évoquant un prompt UNIX/Linux
Crédit image: Codeburst.io

Comment créer très simplement des commandes CLI, avec commandes, drapeaux, et sous-commands multiples ?

C’est facile avec google/subcommands.

Voyons comment.

Les données du problème

Tout d’abord, de quoi parle-t-on ?

N’importe quel programme Go est - au moins conceptuellement - lancé depuis une ligne de commande, que ce soit la réalité depuis un shell, ou depuis un outil d’automatisation utilisant l’appel système execve(2).

D’un point de vue terminologie, prenons l’exemple d’une commande typique:

docker -D container inspect -s some_container
  • docker est le programme, ou application
  • -D est un drapeau (flag) global
  • container est une commande racine, ou de premier niveau
  • inspect est une sous-commande de la commande container; c’est plus généralement une commande elle aussi, mais de niveau 2
  • -s est un drapeau local (local flag) de la sous-commande inspect
  • some_container est un argument de la sous-commande inspect

Pour interpréter une telle structure, le code Go doit se fonder sur une seule variable globale, os.Args, dont le premier élément est le chemin absolu du programme lancé, et le reste l’ensemble des arguments qui lui sont passés.

Pour éviter d’avoir à réaliser dans chaque programme ce travail extrêmement encadré par les usages, diverses bibliothèques plus ou moins complexes existent, dont les plus populaires sont:

ModuleDép. dir.
spf13/cobra2540022005
urfave/cli1740015003
alecthomas/kingpin32002405
google/subcommands594510

Les trois premières sont de loin les plus connues et les plus riches fonctionnellement, mais elles alourdissent les projets d’un graphe de dépendances indirectes parfois important (3136 pour Cobra 1.3). Justifiable dans des projets de grande complexité comme Docker ou Kubernetes, cette complexité n’est pas souhaitable dans des projets typiques comme de petits utilitaires CLI ou des microservices.

C’est là qu’intervient google/subcommands, qui fournit l’essentiel des services de création de commandes sans ajouter aucune dépendance, en un seul module de 500 lignes environ, d’intégration très simple et de poids négligeable, même pour les projets les plus simples et légers. C’est celui que nous allons examiner dans ce didacticiel.

Tout au long des niveaux successifs de raffinement de cette démo, nous allons enrichir le même exemple, disponible sur https://github.com/fgm/subcommands_demo, dont chaque branche correspond à la section du même nom dans ce didacticiel, et où vous trouverez l’ensemble du code source pour le niveau en cours, et un Makefile configuré pour démontrer le sujet traité par le niveau concerné.

Ce didacticiel couvre 100% de la fonctionnalité disponible dans google/subcommands, d’où sa longueur ; mais vous aurez de quoi créer une application complète typique dès le niveau 1.2 : les niveaux suivants ajoutent des fonctionnalité destinées à des projets progressivement plus étendus et complexes.

Niveau 1: Commandes simples

Au Niveau 1, nous allons créer des commandes de premier niveau, en organisant notre code dans deux répertoires, la racine et cmd/, dont les principaux fichiers sont les suivants :

CheminContenu
go.modle fichier de description du module
main.gole point d’entrée du programme
Makefilele fichier de commandes
cmd/le répertoire des commandes
cmd/root.gole code de lancement des commandes

Recommandation: Dans chaque branche, la commande make exécute une séquence par défaut différente, qui illustre les changements apportés par la branche en cours: il est recommandé de l’utiliser sur votre machine à chaque changement de branche pour observer l’impact du nouveau code.

1.1 Créer des commandes en quelques lignes

Le code de ce niveau est disponible sur level1.1-builtin_commands.

À ce niveau, le code se compose de deux fichiers: le point d’entrée main.go, ci-dessous:

1
2
3
4
5
func main() {
	ctx := context.Background()
	sts := cmd.Execute(ctx) // Exécute la commande désignée sur la ligne de commande
	os.Exit(int(sts))       // Renvoie son résultat au processus appelant
}

…et le code de lancement des commandes dans cmd/root.go :

10
11
12
13
14
15
16
17
18
19
20
21
22
// Execute sets up the command chain and runs it.
func Execute(ctx context.Context) subcommands.ExitStatus {
	for _, command := range [...]subcommands.Command{
		subcommands.CommandsCommand(), // Implement "commands"
		subcommands.FlagsCommand(),    // Implement "flags"
		subcommands.HelpCommand(),     // Implement "help"
	} {
		subcommands.Register(command, "")
	}

	flag.Parse()
	return subcommands.Execute(ctx)
}

Ce code enregistre les trois commandes facultatives fournies par subcommands lui-même, qui permettent de lister les commandes et drapeaux disponibles et d’activer l’aide en ligne, puis il analyse les drapeaux globaux et passe le contrôle à la commande désignée par les arguments de ligne de commande, dont il renvoie le résultat.

À ce stade, notre squelette est prêt et reconnaît les commandes commands, flags, et help.

$ go run . help
Usage: subcommands_demo <flags> <subcommand> <subcommand args>

      Subcommands:
      commands         list all command names
      flags            describe all known top-level flags
      help             describe subcommands and their syntax
$

C’est maintenant à nous de créer notre première commande.

1.2 Créer ses propres commandes

Le code de ce niveau est disponible sur level1.2-custom_commands.

La meilleure pratique pour organiser ses commandes consiste à créer un fichier par commande, pour faciliter la localisation et la comparaison du code des commandes entre elles. Nous allons donc ajouter deux fichiers pour les commandes racines que nous allons créer:

  • top1 qui affiche un message fixe
  • top2 qui affiche un message lié aux arguments qui lui sont passés
CheminContenu
cmd/top1.gola commande top1
cmd/top2.gola commande top1

Pour google/subcommands, une commande est une variable, d’un type implémentant l’interface subcommands.Command. Nos deux fichiers seront donc très similaires, sur la structure du fichier cmd/top2.go suivant :

15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
type top2 struct{}

func (cmd *top2) Name() string {
	return "top2"
}

func (cmd *top2) Synopsis() string {
	return "top2 is an example top-level custom command with arguments"
}

func (cmd *top2) Usage() string {
	return fmt.Sprintf("%s arg1 arg2 ...", cmd.Name())
}

func (cmd *top2) SetFlags(fs *flag.FlagSet) {}

func (cmd *top2) Execute(_ context.Context, fs *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
	// Notice how the command line arguments are taken on the flag set, not on the variadic.
	fmt.Printf("In %s %v\n", cmd.Name(), fs.Args())
	return subcommands.ExitSuccess
}

Ici, top2 implémente strictement l’interface subcommands.Command et rien de plus:

  • top2.Name() renvoie le nom de la commande
  • top2.Synopsis renvoie une description résumée de la commande, sur une seule ligne
  • top2.Usage() renvoie un exemple d’utilisation de la commande, qui peut s’étaler sur plusieurs lignes. Elle est utilisé par la commande help.
  • top2.SetFlags() définit les drapeaux locaux de la commande, en l’occurence aucun.
  • top2.Execute() réalise la fonctionnalité proprement dite de la commande.
    • le context.Context permet de passer des variables d’invocation globales, qui seront héritées par les éventuelles sous-commandes, et de transmettre des timeouts comme dans le traitement de requêtes réseau.
    • le flag.FlagSet contient les drapeaux locaux et arguments de la commande.
      • À ce stade, le nom du programme et les drapeaux globaux comme les drapeaux locaux de la commande ont été analysés, et fs.Args() contient les arguments de la commande.
      • En invoquant demo top2 hello world, le résultat de fs.Args() sera []string{"hello", "world"}.
    • le troisième paramètre n’est pas utilisé à ce stade.

Il ne reste plus qu’à déclarer les 3 commandes (top1, top2, et l’alias 1) à subcommands, en les ajoutant à la liste des commandes enregistrées aux côtés de commands, flags et help, dans root.go :

11
12
13
14
15
16
17
18
19
20
21
func Execute(ctx context.Context) subcommands.ExitStatus {
	for _, command := range [...]subcommands.Command{
		subcommands.CommandsCommand(),   // Implement "commands"
		subcommands.FlagsCommand(),      // Implement "flags"
		subcommands.HelpCommand(),       // Implement "help"
		&top1{},                         // Our first top-level command, without args
		&top2{},                         // Our second top-level command, with args
		subcommands.Alias("1", &top1{}), // An alias for our top1 command
	} {
		subcommands.Register(command, "")
	}

À ce stade, notre programme reconnaît ces deux commandes spécifiques, ainsi que l’alias "1" pour la commande top1

Beaucoup d’applications n’auront pas besoin de plus.

$ go run . commands
commands
flags
help
top1
top2
1
$ go run . top2 hello world
In top2 [hello world]
$

1.3 Transmettre des arguments non-CLI

Le code de ce niveau est disponible sur level1.3-non_cli_arguments.

Dans l’exemple précédent, la commande top2 recevait des arguments passés par l’utilisateur du programme depuis la ligne de commande.

Dans certains cas, toutefois, le code peut avoir besoin de transmettre des arguments communs à toutes les commandes, calculés durant la mise en place des commandes et indépendants des arguments de ligne de commande.

C’est là le rôle du paramètre variadique de subcommands.Execute, qui se traduit sous la forme de l’argument variadique reçu par les implémentations de la méthode Command.Execute(). Ici, nous allons l’illustrer en passant deux valeurs

11
12
13
14
15
func Execute(ctx context.Context) subcommands.ExitStatus {
	for _, command := range [...]subcommands.Command{
		/* ...snip... */
	flag.Parse()
	return subcommands.Execute(ctx, "meaning", 42)

Celles-ci seront traitées dans top1 :

31
32
33
34
35
36
func (cmd *top1) Execute(_ context.Context, _ *flag.FlagSet, args ...interface{}) subcommands.ExitStatus {
	// The variadic arguments are the ones passed to subcommands.Execute().
	// Unlike the CLI args, they are always a []interface{}.
	fmt.Printf("In %s.\nNon-CLI args: %#v\n", cmd.Name(), args)
	return subcommands.ExitSuccess
}
$ go run . top1
In top1.
Non-CLI args: []interface {}{"meaning", 42}
$

Le point important ici est la nécessité de coordonner les types et l’ordre des valeurs passées ainsi, puisqu’elles sont transmises sous forme de type interface{} (any à partir de Go 1.18), ce qui nécessitera en général dans la méthode Execute une assertion de type pour obtenir la valeur typée prête à l’emploi.

1.4 Grouper les commandes

Le code de ce niveau est disponible sur level1.4-command_groups.

Dans un projet de complexité croissante, il peut être utile de regrouper les commandes en groupes ayant un thème commun. C’est le rôle du second paramètre string de la fonction subcommands.Register, que nous utilisons dans root.go : il désigne pour chaque commande le subcommands.Group auquel la commande appartient.

Dans cet exemple, nous séparons les commandes fournies par google/subcommands, dans le groupe help, de nos commandes spécifiques, dans le groupe top.

11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
func Execute(ctx context.Context) subcommands.ExitStatus {
	for _, command := range [...]struct {
		group string
		subcommands.Command
	}{
		{"help", subcommands.CommandsCommand()},  // Implement "commands"
		{"help", subcommands.FlagsCommand()},     // Implement "flags"
		{"help", subcommands.HelpCommand()},      // Implement "help"
		{"top", &top1{}},                         // Our first top-level command, without args
		{"top", &top2{}},                         // Our second top-level command, with args
		{"top", subcommands.Alias("1", &top1{})}, // An alias for our top1 command

	} {
		subcommands.Register(command.Command, command.group)
	}

La commande help affiche maintenant nos commandes dans deux groupes séparés :

$ go run . help
Usage: subcommands_demo <flags> <subcommand> <subcommand args>

Subcommands for help:
        commands         list all command names
        flags            describe all known top-level flags
        help             describe subcommands and their syntax

Subcommands for top:
        top1, 1          top1 is an example top-level custom command without arguments
        top2             top2 is an example top-level custom command with arguments
$

1.5 Ajouter des drapeaux globaux ou locaux

Le code de ce niveau est disponible sur level1.5-flags.

Pour beaucoup de programmes, il est important de pouvoir définir des drapeaux globaux, comme -v qui augmente souvent la quantité d’informations affichée par les commandes ; ou des drapeaux locaux qui permettent de préciser le comportement d’un programme.

Ajoutons tout d’abord un drapeau global booléen nommé -v à nos deux commandes. Cela se passe en fait avec le paquet standard, flag couplé — de façon invisible de l’extérieur — avec google/subcommands.

Pour cela, en application des bonnes pratiques Go, nous définissons un type spécifique non exporté pour la clef de contexte, et une variable de ce type, dans notre fichier root.go :

10
11
12
type verboseKey struct{}

var VerboseKey = verboseKey{}

Ensuite, dans notre fonction Execute, nous définissons un drapeau avec flag, et nous l’ajoutons au contexte :

21
22
23
24
25
26
27
28
func Execute(ctx context.Context) subcommands.ExitStatus {
	for _, command := range [...]struct {
		/* ...snip... */
  }

	verbose := flag.Bool("v", false, "Be more verbose")
	flag.Parse()
	ctx = context.WithValue(ctx, VerboseKey, *verbose)

Il ne reste plus aux commandes qu’à obtenir la valeur depuis le contexte, comme ici dans top1.go :

36
37
38
39
func (cmd *top1) Execute(ctx context.Context, _ *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
	if ctx.Value(VerboseKey).(bool) {
		fmt.Printf("In %s.\n", cmd.Name())
	}

Comme nous avons défini le type du drapeau comme booléen et que la clef, grâce à son type unique, ne peut pas rencontrer de collision même dans un plus grand programme, l’assertion de type réussira obligatoirement.

Pour ce drapeau global, nous n’avons donc pas utilisé google/subcommands du tout, et il est bien reconnu par la commande flags intégrée, mais pas listé dans l’aide intégrée fournie par la commande help :

$ go run . help
Usage: subcommands_demo <flags> <subcommand> <subcommand args>

Subcommands for help:
        commands         list all command names
        flags            describe all known top-level flags
        help             describe subcommands and their syntax

Subcommands for top:
        top1, 1          top1 is an example top-level custom command without arguments
        top2             top2 is an example top-level custom command with arguments


Use "subcommands_demo flags" for a list of top-level flags
$ go run . flags
  -v    Be more verbose
$

En revanche, définissons maintenant un drapeau local sur la commande top1. Comme il est propre à la commande, et se place plus loin dans la liste des arguments du programme, il n’est pas possible d’utiliser directement le paquet flag sur les arguments de ligne de commande comme pour les drapeaux globaux.

Les drapeaux locaux de chacune des commandes sont définis par les commandes elles-mêmes, en implémentant leur méthode SetFlags pour positionner une valeur dans les données d’instance de la commande.

Pour cela, dans le fichier top1.go, nous allons ajouter un champ au type implémentant notre commande, pour pouvoir stocker cette valeur qui sera un champ string.

16
17
18
type top1 struct {
	prefix string
}

Ensuite, nous implémentons la méthode top1.SetFlags pour définir ce drapeau de façon à modifier ce champ sur l’instance de commande :

32
33
34
35
36
37
38
39
40
41
42
func (cmd *top1) SetFlags(fs *flag.FlagSet) {
	fs.StringVar(&cmd.prefix, "prefix", "", "Add a prefix to the result")
}

func (cmd *top1) Execute(ctx context.Context, _ *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
	if ctx.Value(VerboseKey).(bool) {
		fmt.Printf("In %s.\n", cmd.Name())
	}
	fmt.Println(strings.Join(append([]string{cmd.prefix}, "hello"), ": "))
	return subcommands.ExitSuccess
}

La commande help dispose dès lors de notre drapeau local:

$ go run . help top1
top1  -prefix string
        Add a prefix to the result
$

Lorsque subcommands.Execute invoque notre instance de commande top1, il commence par créer un flag.FlagSet spécifique, qu’il passe à la méthode SetFlags de l’instance de commande, ce qui lui permet de positionner la valeur des propriétés définies sur l’instance de commande, valeurs que nous pouvons ensuite récupérer dans notre méthode top1.Execute, en accédant directement à la valeur du champ sur l’instance de commande comme à la ligne 40.

$ go run . top1 -prefix today
today: hello
$

Il aurait aussi été possible d’extraire directement la valeur depuis le paramètre fs *FlagSet de la méthode top1.Execute, en ne définissant par d’emplacement de stockage pour le drapeau, mais le code correspondant serait beaucoup moins lisible. C’est pourquoi il est préférable d’utiliser la méthode précédente.

func (cmd *top1) SetFlags(fs *flag.FlagSet) {
  _ = fs.String("prefix", "", "Add a prefix to the result")
}

func (cmd *top1) Execute(ctx context.Context, fs *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
	prefix := fs.Lookup("prefix").Value.(fmt.Stringer).String()
	fmt.Println(strings.Join(append([]string{cmd.prefix}, "hello"), ": "))
	return subcommands.ExitSuccess
}

1.6 Définir des drapeaux comme importants

Le code de ce niveau est disponible sur level1.6-important_flags.

Certains drapeaux globaux peuvent être d’usage fréquent, et il est possible de les faire afficher par help.

Imaginons que notre drapeau global -v soit important, et que nous ayons aussi un drapeau -debug non important. Nous pouvons le signaler à google/subcommands lors de la déclaration des commandes dans notre fichier root.go :

21
22
23
24
25
26
27
28
29
func Execute(ctx context.Context) subcommands.ExitStatus {
	for _, command := range [...]struct {
		/* ...snip... */
	}

	debug := flag.Bool("debug", false, "Show debug information")
	verbose := flag.Bool("v", false, "Be more verbose")
	subcommands.ImportantFlag("v")
	flag.Parse()

Sans la ligne subcommands.ImportantFlag("v"), la commande help ne liste aucun drapeau, comme nous l’avons vu au niveau précédent. Mais avec cette ligne, le drapeau important -v est listé tandis que le drapeau ordinaire -debug demeure ignoré.

$ go run . help
Usage: subcommands_demo <flags> <subcommand> <subcommand args>

Subcommands for help:
        commands         list all command names
        flags            describe all known top-level flags
        help             describe subcommands and their syntax

Subcommands for top:
        top1, 1          top1 is an example top-level custom command without arguments
        top2             top2 is an example top-level custom command with arguments


Top-level flags (use "subcommands_demo flags" for a full list):
  -v=false: Be more verbose
$

Les deux drapeaux demeurent visibles dans la commande flags dédiée à leur affichage:

$ go run . flags
  -debug
        Show debug information
  -v    Be more verbose
$

À ce stade, tous les besoins de la majorité des petites applications sont couverts. Les niveaux suivants concernent les projets plus complexes, ou avec des exigences techniques plus fortes comme la présence de tests unitaires jusque sur les commandes, et non seulement sur le code fonctionnel invoqué par celles-ci, ce qui est la situation la plus courante.

Niveau 2 : Réutiliser du code de commande

Le code de ce niveau est disponible sur level2.1-reuse.

Jusqu’à présent, nous avons créé un type défini pour chaque commande, hormis les alias, comme le suggère l’exemple fourni par la documentation — minimale — de google/subcommands. Cela permet de stocker les propriétés spécifiques telles que les drapeaux locaux sur le type défini pour chaque commande.

En pratique, toutefois, nombre de commandes n’ont pas de drapeaux, ou partagent les mêmes drapeaux, et cette répétition de code n’est pas entièrement satisfaisante. Mais rien n’oblige à avoir des types spécifiques pour des commandes distinctes, surtout si elles partagent la même structure.

La principale raison d’avoir des types distincts est la méthode Command.Execute, qui diffère d’une commande à l’autre puisque les commandes exécutent des tâches différentes. Il suffit donc de rendre cette méthode variable par instance.

Diverses approches sont possibles. Par exemple, en utilisant un type unique, disons top, il est possible d’avoir une méthode Execute unique qui va examiner une propriété sur l’instance de commande pour sélectionner une fonction exécutrice concrète, comme dans cet exemple:

11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// Dans top.go
type top struct {
	// Divers champs communs
	name string // nom de la commande
}

// Ajouter les méthodes Name, Synopsis, et SetFlags de subcommands.Command.

// NewTop remplace à la fois NewTop1 et NewTop2.
func NewTop(name string) {
	return &top{name: name}
}

func (t *top) Execute(ctx context.Context, fs *flag.FlagSet, args ...any) {
	switch t.name {
	case "top1":
		Top1Execute(ctx, fs, args) // Une fonction dans top1.go
  case "top2":
	  Top2Execute(ctx, fs, args) // Une fonction dans top2.go
	default:
		panic("Unexpected command %s, should never happen", t.name)
	}
}
20
21
22
23
24
25
26
27
28
29
30
// Dans root.go,
func Execute(ctx context.Context) subcommands.ExitStatus {
	for _, command := range [...]struct {
		group string
		subcommands.Command
	}{
		{"help", subcommands.CommandsCommand()},    // Implement "commands"
		{"help", subcommands.FlagsCommand()},       // Implement "flags"
		{"help", subcommands.HelpCommand()},        // Implement "help"
		{"top", NewTop("top1")},                    // Our first top-level command, without args
		{"top", NewTop("top2")},                    // Our second top-level command, with args

Un défaut de cette approche est le couplage entre

  • le code de la fonction partagée Execute et les diverses commandes.
  • le code de la fabrique partagée NewTop, susceptible de nécessiter des ajustements pour les diverses commandes.

Le modèle recommandé sera plus direct, évitant ce couplage, et conservant les fonctions fabriques pour chaque commande, au moyen des champs fonctions. Ainsi, dans root.go, nous retrouvons les fabriques dédiées :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
func Execute(ctx context.Context) subcommands.ExitStatus {
	for _, command := range [...]struct {
		group string
		subcommands.Command
	}{
		{"help", subcommands.CommandsCommand()},    // Implement "commands"
		{"help", subcommands.FlagsCommand()},       // Implement "flags"
		{"help", subcommands.HelpCommand()},        // Implement "help"
		{"top", NewTop1()},                         // Our first top-level command, without args
		{"top", NewTop2()},                         // Our second top-level command, with args

Le fichier top.go va contenir le type réutilisable top, avec des champs pour stocker :

  • les valeurs dont les méthodes Name, Synopsis et Usage seront les getters.
  • les drapeaux locaux communs aux deux commandes, ici prefix, déclaré par SetFlags
  • et surtout, un champ fonction stockant l’executeur concret pour la méthode Execute.
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
type top struct {
	name, synopsis, usage string // Reuse support
	prefix                string // Actual features
	run                   func(context.Context, *top, *flag.FlagSet, ...interface{}) subcommands.ExitStatus
}

func (cmd top) Name() string {
	return cmd.name
}

func (cmd top) Synopsis() string {
	return cmd.synopsis
}

func (cmd top) Usage() string {
	return cmd.usage
}

func (cmd *top) SetFlags(fs *flag.FlagSet) {
	fs.StringVar(&cmd.prefix, "prefix", "", "Add a prefix to the result")
}

func (cmd *top) Execute(ctx context.Context, fs *flag.FlagSet, args ...interface{}) subcommands.ExitStatus {
	if cmd.run == nil {
		log.Printf("command %s is not runnable", cmd.name)
		return subcommands.ExitFailure
	}
	return cmd.run(ctx, cmd, fs, args)
}

La limitation des champs fonction en comparaison avec les méthodes est le fait que les fonctions invoquées ainsi ne reçoivent pas l’instance comme premier paramètre caché, à la différence des méthodes, de sorte qu’elles n’y ont pas naturellement accès. L’instance de commande doit donc leur être passée comme paramètre additionnel explicite, d’où le paramètre *top dans la signature, ligne 15.

Pour imiter au plus près une expression de méthode standard, ce paramètre devrait être le premier des fonctions, mais cela entre en conflit avec l’usage voulant que le contexte soit toujours le premier paramètre d’une fonction, d’où le placement ici en deuxième position.

Les commandes utilisant le type partagé, comme top2, définissent une fonction exécutrice avec cette signature, pour disposer de l’accès à l’instance, et leur fonction fabrique affecte cette fonction à l’instance lors de son initialisation, comme ici dans top2.go :

12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
func top2Execute(ctx context.Context, cmd *top, fs *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
	if ctx.Value(VerboseKey).(bool) {
		fmt.Printf("In %s.\n", cmd.Name())
	}
	fmt.Println(strings.Join(append([]string{cmd.prefix}, fs.Args()...), ": "))
	return subcommands.ExitSuccess
}

func NewTop2() *top {
	const name = "top2"
	return &top{
		name:     name,
		synopsis: fmt.Sprintf("%s is an exemple top-level custom command with arguments", name),
		usage:    fmt.Sprintf("%s arg1 arg2 ...", name),
		prefix:   "",
		run:      top2Execute,
	}
}

De la sorte le couplage disparaît. Un nombre illimité de commandes peut réutiliser le type de commande partagé, sans avoir à modifier la méthode top.Execute.

$ go run . top1 -prefix today
today: hello
$ go run . top2 -prefix "the answer is" 42
the answer is: 42
$

Niveau 3: Les commandeurs

3.1 API procédurale et API objet

Le code de ce niveau est disponible sur level3.1-object_api.

Jusqu’à présent, nous avons utilisé les fonctions publiques de google/subcommands:

  • subcommands.Alias et subcommands.Register pour enregistrer les commandes
  • subcommands.ImportantFlag pour marquer les drapeaux globaux
  • subcommands.CommandsCommand, subcommands.FlagsCommand et subcommands.HelpCommand pour le mécanisme intégré d’aide et documentation

Cette API procédurale n’est en fait qu’une façade simplifiant l’API objet sous-jacente de google/subcommands : hormis Alias, qui ne dépend pas d’un Commander, chacune de ces fonctions est une simple invocation de la méthode homonyme du type subcommands.Commander sur l’instance par défaut de ce type, subcommands.DefaultCommander, créée lors de l’import du paquet dans subcommands.go par la pseudo-fonction init, comme l’illustre de fragment du fichier subcommands.go :

458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
// DefaultCommander is the default commander using flag.CommandLine for flags
// and os.Args[0] for the command name.
var DefaultCommander *Commander

func init() {
	DefaultCommander = NewCommander(flag.CommandLine, path.Base(os.Args[0]))
}

// Register adds a subcommand to the supported subcommands in the
// specified group. (Help output is sorted and arranged by group
// name.)  The empty string is an acceptable group name; such
// subcommands are explained first before named groups. It is a
// wrapper around DefaultCommander.Register.
func Register(cmd Command, group string) {
	DefaultCommander.Register(cmd, group)
}

C’est là l’application d’un modèle fréquent en Go, utilisé par exemple par les paquets flag (flag.CommandLine) ou http (http.DefaultClient) pour les plus connus.

Nous pouvons donc basculer sur ce modèle en modifiant seulement le fichier root.go, dans lequel nous remplaçons chaque appel procédural (sauf Alias) par une invocation de méthode sur l’instance de Commander par défaut:

21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
func Execute(ctx context.Context) subcommands.ExitStatus {
	commander := subcommands.DefaultCommander

	for _, command := range [...]struct {
		group string
		subcommands.Command
	}{
		{"help", commander.CommandsCommand()},      // Implement "commands"
		{"help", commander.FlagsCommand()},         // Implement "flags"
		{"help", commander.HelpCommand()},          // Implement "help"
		{"top", NewTop1()},                         // Our first top-level command, without args
		{"top", NewTop2()},                         // Our second top-level command, with args
		{"top", subcommands.Alias("1", NewTop1())}, // An alias for our top1 command

	} {
		commander.Register(command.Command, command.group)
	}

	debug := flag.Bool("debug", false, "Show debug information")
	verbose := flag.Bool("v", false, "Be more verbose")
	commander.ImportantFlag("v")
	flag.Parse()

	ctx = context.WithValue(ctx, DebugKey, *debug)
	ctx = context.WithValue(ctx, VerboseKey, *verbose)

	return commander.Execute(ctx, "meaning")

Le programme fonctionne de façon strictement identique à la version précédente.

3.2 Utiliser des commandeurs personnalisés

Le code de ce niveau est disponible sur level3.2-custom_commander.

Pour créer un Commander personnalisé plutôt que d’utiliser l’instance globale mutable fournie par google/subcommands, dont nous ne maîtrisons pas l’initialisation ni les mutations, il est préférable de créer une instance en propre dans la fonction Execute de notre root.go :

22
23
24
func Execute(ctx context.Context) subcommands.ExitStatus {
	// See subcommands.DefaultCommander
	commander := subcommands.NewCommander(flag.CommandLine, os.Args[0])

Rien d’autre ne change par rapport à la version précédente, mais nous avons fait un pas de plus vers une structure de code testable, en nous affranchissant de la dépendance à la variable globale subcommands.DefaultCommander

Il est temps de sauter le pas et de rendre Execute totalement testable en retirant les autres dépendances à des variables globales.

3.3 Créer une structure de commande testable

Le code de ce niveau est disponible sur level3.3.

Les obstacles à la testabilité dans notre code sont encore multiples :

ProblèmeSolution
sorties standard et d’erreur globalesinjecter depuis main
os.Args globaleinjecter depuis main
flag.CommandLine globaleremplacer par un flag.FlagSet spécifique
flag.Bool, flag.Parse globalesremplacer par les méthode homonymes du flag.FlagSet
utilisation masquée de log.stdremplacer par un log.Logger spécifique
invocation d’os.Exit par flagmodifier les options de création du flag.FlagSet
commandes instanciées dans Executeinjecter depuis une fabrique

Notre nouvelle version de main.go devient donc le seul emplacement utilisant des variables globales:

11
12
13
14
func main() {
	sts := cmd.Execute(context.Background(), os.Stdout, os.Stderr, os.Args, log.LstdFlags, cmd.Describe)
	os.Exit(int(sts))
}

Notre fichier root.go va dorénavant comporter la fabrique de commandes Describe :

22
23
24
25
26
27
28
29
30
31
32
33
type description struct {
  group   string
  command subcommands.Command
}

func Describe(outW io.Writer, logger *log.Logger) []description {
	return []description{
		{"top", NewTop1(outW, logger)},                         // Our first top-level command, without args
		{"top", NewTop2(outW, logger)},                         // Our second top-level command, with args
		{"top", subcommands.Alias("1", NewTop1(outW, logger))}, // An alias for our top1 command
	}
}

…et une nouvelle version d’Execute recevant toutes ses données par injection au lieu d’aller référencer des variables importées ou de les créer elle-même.

39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
func Execute(ctx context.Context,
	outW io.Writer, // Standard output for command results
	errW io.Writer, // Error output for logs
	args []string,  // CLI args to avoid depending on the flag global
	logFlags int,   // Log flags to make error message testing easier
	describe func(outW io.Writer, logger *log.Logger) []description, // Command registration descriptions
) subcommands.ExitStatus {
	// Do not depend on log.Default().
	logger := log.New(errW, "", logFlags)

	// Create a flag.FlagSet from args to avoid depending on global os.Args.
	// Continue on error to support testing instead of the ExitOnError on flag.CommandLine
	if len(args) < 1 {
		logger.Printf("Expected at least one argument for the program name, got none")
		return subcommands.ExitFailure
	}
	fs := flag.NewFlagSet(args[0], flag.ContinueOnError)

	// Create a custom commander to avoid depending on global flag.CommandLine and os.Args
	commander := subcommands.NewCommander(fs, args[0])

	descriptions := []description{
		{"help", commander.CommandsCommand()}, // Implement "commands"
		{"help", commander.FlagsCommand()},    // Implement "flags"
		{"help", commander.HelpCommand()},     // Implement "help"
	}
	if describe != nil {
		descriptions = append(descriptions, describe(outW, logger)...)
	}
	for _, command := range descriptions {
		commander.Register(command.command, command.group)
	}

	debug := fs.Bool("debug", false, "Show debug information")
	verbose := fs.Bool("v", false, "Be more verbose")
	commander.ImportantFlag("v")

	// Parse must not receive the program name, hence the slice.
	if err := fs.Parse(args[1:]); err != nil {
		// Our logger has been configured above.
		logger.Printf("Error parsing CLI flags: %v", err)
		return subcommands.ExitUsageError
	}

	ctx = context.WithValue(ctx, DebugKey, *debug)
	ctx = context.WithValue(ctx, VerboseKey, *verbose)

	return commander.Execute(ctx, "meaning", 42)
}
  • 40-41: outW et errW sont les deux sorties reçues depuis main().
  • 41,47: errW est utilisée pour créer un log.Logger
  • 44: describe est la fabrique de descriptions de commande, reçue depuis main()
  • 55: un FlagSet est créé à partir des paramètres pour retirer la dépendance à os.Args
  • 58: un Commander est créé à partir du FlagSet et des arguments pour retirer la dépendance à flag.CommandLine, et indirectement à os.Args
  • 66: outW et le logger sont passées à describe pour qu’il les injecte dans les fabriques de commandes, afin que les commandes reçoivent elles aussi des sorties injectées au lieu d’utiliser des variables globales.
  • 72, 73: les drapeaux globaux sont définis sur le FlagSet créé au lieu de flag.CommandLine
  • 77: l’analyse des drapeaux est menée sur le FlagSet créé et non sur flag.CommandLine

Execute n’utilise plus aucune variable globale, et transmet les dépendances injectées aux commandes top1 et top2. Ces dernières sont donc modifiées en conséquence pour utiliser ces éléments injectés. Ainsi, dans top1.go :

15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
func top1Execute(ctx context.Context, cmd top, fs *flag.FlagSet, _ ...any) subcommands.ExitStatus {
	if ctx.Value(VerboseKey).(bool) {
		cmd.logger.Printf("In %s.\n", cmd.Name())
	}
	if l := fs.NArg(); l != 0 {
		cmd.logger.Printf("%s expects no arguments, called with %d: %v", cmd.Name(), l, fs.Args())
		return subcommands.ExitFailure
	}
	message := "hello"
	if cmd.prefix != "" {
		message = strings.Join(append([]string{cmd.prefix}, message), ": ")
	}
	fmt.Fprintln(cmd.outW, message)
	return subcommands.ExitSuccess
}

func NewTop1(outW io.Writer, logger *log.Logger) *top {
	const name = "top1"
	return &top{
		logger:   logger,
		name:     name,
		outW:     outW,
		prefix:   "",
		run:      top1Execute,
		synopsis: fmt.Sprintf("%s is an exemple top-level custom command without arguments", name),
		usage:    name,
	}
}

Le code de top2 est similaire, et le type top dans top.go a été étendu comme montré aux lignes 34-40 ci-dessus pour stocker les dépendances injectées. Ceci permet d’obtenir une couverture de test de 100% très simplement, sans nécessiter d’outil de mocking.

$ go test -race -count=1 -cover ./cmd
ok  	github.com/fgm/subcommands_demo/cmd	0.035s	coverage: 100.0% of statements
$

Niveau 4: Ajouter des sous-commandes

Le code de ce niveau est disponible sur level4.1-nesting.

Les applications les plus complexes présentent généralement une hiérarchie de commandes et sous-commandes, mais la fonctionnalité de base de google/subcommands ne décrit pas leur mise en œuvre.

Elle est en fait la conséquence de l’utilisation des paramètres injectés à subcommands.NewCommander : puisque les drapeaux en sont définis par un FlagSet quelconque et par les arguments passés à cette fonction, il suffit, dans l’implémentation d’une commande devant accepter des sous-commandes, de créer un Commander en lui passant les arguments qui n’ont pas encore été consommés.

C’est ce que réalise notre nouvelle commande top3, avec ses sous-commandes sub31 et sub32, dans top3.go. Sur le fond, elle est similaire à top1 et top2, mais c’est dans sa fonction exécutrice top31Execute que va se trouver la logique de sous-commandes:

23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
func top3Execute(ctx context.Context, cmd top, topFS *flag.FlagSet, args ...any) subcommands.ExitStatus {
	name := cmd.Name()
	if ctx.Value(VerboseKey).(bool) {
		cmd.logger.Printf("In %s.\n", cmd.Name())
	}
	// Handle command called without subcommands.
	if topFS.NArg() == 0 {
		return top3Internal(ctx, cmd, topFS, args)
	}

	// Create a flag.FlagSet from args to use only remaining args
	// Continue on error to support testing.
	fs := flag.NewFlagSet(cmd.Name(), flag.ContinueOnError)

	// Create a custom commander to restart evaluation below this command.
	commander := subcommands.NewCommander(fs, name)

	descriptions := []description{
		{name, commander.CommandsCommand()}, // Implement "commands"
		{name, commander.FlagsCommand()},    // Implement "flags"
		{name, commander.HelpCommand()},     // Implement "help"
		{name, NewSub31(cmd.outW, cmd.logger)},
		{name, NewSub32(cmd.outW, cmd.logger)},
	}
	for _, command := range descriptions {
		commander.Register(command.command, command.group)
	}

	// Parse must not receive the command name.
	if err := fs.Parse(topFS.Args()); err != nil {
		cmd.logger.Printf("Error parsing %s flags: %v", name, err)
		return subcommands.ExitUsageError
	}

	return commander.Execute(ctx, fs)
}
  • dans un premier temps (29-31), la commande vérifie si elle a reçu des arguments, qui pourraient être des sous-commandes. Si ce n’est pas le cas, elle transfère l’exécution à son exécution en propre, qui réalise le comportement souhaité en cas d’absence de sous-commande.
  • dans le cas contraire, elle reprend la structure de notre fonction racine Execute :
    • créer un FlagSet,
    • créer un Commander qui l’utilise ainsi que les arguments disponibles,
    • enregistrer les sous-commandes dessus, dont les trois commandes intégrées,
    • analyser les arguments disponibles pour rechercher les drapeaux locaux des sous-commandes
    • exécuter la sous-commande désignée par les arguments.
$ go run . top3
hello top3
$ go run . top3 commands
commands
flags
help
sub31
sub32
$ go run . top3 help sub31
sub31  -prefix string
    	Add a prefix to the result
$ go run . top3 sub31 -prefix today
today: hello sub31
$

Niveau 5: Au-delà de NewCommander

5.1 Maîtriser les sorties

Le code de ce niveau est disponible sur level5.1-newcommander.

À ce stade, nous avons bien injecté tout ce qui semblait être injectable, mais certaines sorties demeurent non maîtrisées, émises par subcommands ou flag dans les situations d’erreur, et visibles en particulier durant les tests, ce qui était prévisible puisque NewCommander ne permet pas d’injecter les sorties standard ou d’erreur dans l’instance qu’elle renvoie.

$ go test -race -count=1 -v  ./cmd 2>&1 | grep -vE '(CONT|RUN|PASS|PAUSE)'
flag provided but not defined: -bad
Usage: Test_Execute <flags> <subcommand> <subcommand args>

Subcommands for help:

  ...snip (33 lines total)...

flag provided but not defined: -bad
top1  -prefix string
    	Add a prefix to the result
flag provided but not defined: -bad
c  -prefix string
    	Add a prefix to the result
ok  	github.com/fgm/subcommands_demo/cmd	0.036s
$

Dans les faits, tant les FlagSet que les Commander acceptent de rediriger leur sortie d’erreur, mais cela doit être appliqué après la création, ici dans root.go pour les commandes de premier niveau et les drapeaux globaux:

40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
func Execute(ctx context.Context,
	outW io.Writer, // Standard output for command results
	errW io.Writer, // Error output for logs
	args []string, // CLI args to avoid depending on the flag global
	logFlags int, // Log flags to make error message testing easier
	describe func(outW io.Writer, logger *log.Logger) []description, // Command registration descriptions
) subcommands.ExitStatus {
  /* ...snip... */
	fs := flag.NewFlagSet(args[0], flag.ContinueOnError)
	fs.SetOutput(errW)

	// Create a custom commander to avoid depending on global flag.CommandLine and os.Args
	commander := subcommands.NewCommander(fs, args[0])
	commander.Output = outW
	commander.Error = errW

La redirection de ces propriétés additionnelles permet d’éliminer le plus gros des messages parasites:

$ go test -race -count=1 -v  ./cmd 2>&1 | grep -vE '(CONT|RUN|PASS|PAUSE)'
flag provided but not defined: -bad
flag provided but not defined: -bad
ok  	github.com/fgm/subcommands_demo/cmd	0.045s
$

Les deux messages résiduels ne sont pas capturables avec la version 1.2.0 de google/subcommands, ce qui est a priori un bogue du paquet pour lequel une requête de fusion existe.

Nous sommes à ce stade au bout des fonctionnalités utiles du paquet. Restent deux fonctionnalités à l’utilisation improbable.

5.2 Introspection des commandeurs

Le code de ce niveau est disponible sur level5.2-visit.

Le type subcommands.Commander dispose de méthodes publiques destinées à permettre aux utilisateurs du paquet d’examiner la structure interne du jeu de commandes et de drapeaux.

Nous allons donc créer une commande de premier niveau visit, similaire à toutes celles que nous avons vues jusqu’à présent, et qui démontre ces méthodes: tout se trouve dans le fichier visit.go

  • La méthode VisitAll est un Visiteur, qui permet d’examiner l’état des drapeaux du Commander en lui passant une fonction invoquée pour chaque drapeau:
21
22
23
24
25
26
27
28
29
30
func visitAll(commander *subcommands.Commander, w io.Writer) {
	fmt.Fprintln(w, "VisitAll show all the commander flags:")
	tw := tabwriter.NewWriter(w, 0, 0, 1, ' ', tabwriter.Debug)
	fmt.Fprintln(tw, "\tName\tDefault\tValue\tUsage\t")
	commander.VisitAll(func(f *flag.Flag) {
		fmt.Fprintf(tw, "\t%s\t%s\t%s\t%s\t\n", f.Name, f.DefValue, f.Value, f.Usage)
	})
	tw.Flush()
	fmt.Fprintln(w)
}
Running "demo visit":
VisitAll show all the commander flags:
 |Name  |Default |Value |Usage                  |
 |debug |false   |false |Show debug information |
 |v     |false   |true  |Be more verbose        |
  • La méthode VisitAllImportant est similaire, mais limitée aux drapeaux marqués comme importants
VisitAllImportant only shows the "important" flags:
 |Name |Default |Value |Usage           |
 |v    |false   |true  |Be more verbose |
  • La méthode VisitGroups est un Visiteur, qui permet d’examiner les propriétés des CommandGroup créés à partir des noms de groupes déclarés lors de l’inscription des commandes. Son intérêt est limité, les méthodes publiques du type CommandGroup ne permettant pas l’introspection des commandes présentes dans ces groupes.
43
44
45
46
47
48
49
50
51
52
func visitGroups(commander *subcommands.Commander, w io.Writer) {
	fmt.Fprintln(w, "VisitGroups only visits the command groups, not the commands:")
	tw := tabwriter.NewWriter(w, 0, 0, 1, ' ', tabwriter.Debug)
	fmt.Fprintln(tw, "\tName\tLen\t")
	commander.VisitGroups(func(group *subcommands.CommandGroup) {
		fmt.Fprintf(tw, "\t%s\t%d\t\n", group.Name(), group.Len())
	})
	tw.Flush()
	fmt.Fprintln(w)
}
VisitGroups only visits the command groups, not the commands:
 |Name |Len |
 |help |3   |
 |top  |5   |
  • La méthode VisitCommands est un Visiteur qui remédie aux limitations de VisitGroups, en parcourant les commandes enregistrées sur le Commander, y compris leur CommandGroup, en fournissant l’accès à la commande elle-même, ce qui permet d’en observer les drapeaux:
43
44
45
46
47
48
49
50
51
52
53
54
55
56
func visitCommands(commander *subcommands.Commander, w io.Writer) {
	fmt.Fprintln(w, "VisitCommands visits the commands themselves:")
	tw := tabwriter.NewWriter(w, 0, 0, 1, ' ', tabwriter.Debug)
	fmt.Fprintln(tw, "\tGroup\tName\tSynopsis\tFlags\t")
	commander.VisitCommands(func(group *subcommands.CommandGroup, c subcommands.Command) {
		fs := flag.NewFlagSet("visit", flag.ContinueOnError)
		c.SetFlags(fs)
		var flags []string
		fs.VisitAll(func(f *flag.Flag) { flags = append(flags, f.Name) })
		fmt.Fprintf(tw, "\t%s\t%s\t%s\t%s\t\n", group.Name(), c.Name(), c.Synopsis(), strings.Join(flags, ", "))
	})
	tw.Flush()
	fmt.Fprintln(w, "(command usage omitted for readability)")
}
VisitCommands visits the commands themselves:
 |Group |Name     |Synopsis                                                            |Flags  |
 |help  |commands |list all command names                                              |       |
 |help  |flags    |describe all known top-level flags                                  |       |
 |help  |help     |describe subcommands and their syntax                               |       |
 |top   |top1     |top1 is an exemple top-level custom command without arguments       |prefix |
 |top   |top2     |top2 is an exemple top-level custom command with arguments          |prefix |
 |top   |1        |top1 is an exemple top-level custom command without arguments       |prefix |
 |top   |top3     |top3 is an exemple top-level custom command with nested subcommands |prefix |
 |top   |visit    |demoes commander Visit* functions                                   |       |
(command usage omitted for readability)

Pour disposer de l’accès à l’instance de Commander active, la commande visit a besoin de se voir injecter le commandeur, puisque les commandes n’ont par défaut pas connaissance du Commander qui les invoque. Ceci est réalisé dans root.go :

27
28
29
type CommanderAware interface {
	SetCommander(commander *subcommands.Commander)
}
45
46
47
48
49
50
51
52
func Execute(ctx context.Context,
  /* ...snip... */
	for _, command := range descriptions {
		if vc, ok := command.command.(CommanderAware); ok {
			vc.SetCommander(commander)
		}
		commander.Register(command.command, command.group)
	}

Notre commande visit est d’un type visitCmd avec un champ de type subcommands.Commander et un setter visitCmd.SetCommander qui en fait une implémentation de CommanderAware, ce qui permet à Execute de lui injecter le Commander actif.

5.3 Remplacer les briques de base

Le code de ce niveau est disponible sur level5.3-explain.

Enfin, dernier niveau esotérique de google/subcommands, le remplacement des commands intégrées CommandsCommand, FlagsCommand et HelpCommand est également possible, grâce aux champs fonctions du type subcommands.Commander :

  • Explain est un champ de type func(io.Writer), initialisé dans NewCommander par l’expression de méthode privée cdr.writer
  • ExplainGroup est un champ de type func(io.Writer, *CommandGroup), initialisé dans NewCommander par la fonction privée explainGroup
  • ExplainCommand est un champ de type func(w io.Writer, c subcommands.Command) initialisé dans NewCommander par la fonction privée explain

Notre nouvelle commande explain, dans cmd/explain.go, est CommanderAware, et procède à ce remplacement sur le Commander reçu par injection :

Tous les exemples illustrent l’appel intégré, suivi de l’appel personnalisé:

  • ExplainCommand : version intégrée, puis version API au format YAML:
Running "demo explain":

Demoes overriding ExplainCommand to describe top3.
- Builtin version using private explain:
top3  -prefix string
    	Add a prefix to the result
- Custom version in YAML format:
top3:
    flags:
        - name: prefix
          default: ""
          usage: Add a prefix to the result
    synopsis: top3 is an exemple top-level custom command with nested subcommands
    usage: top3
  • ExplainGroup : version intégrée, puis version API au format YAML:
Demoes overriding ExplainGroup.
- Builtin version using private explainGroup:
Subcommands for help:
	commands         list all command names
	flags            describe all known top-level flags
	help             describe subcommands and their syntax	
...snip...
- Custom version in YAML format, without access to group contents:
help: 3
top: 6
  • Explain : version intégrée, puis version neutre invitant à l’écriture
Demoes overriding Explain.
- Builtin version using private commander.explain:
Usage: demo <flags> <subcommand> <subcommand args>


Top-level flags (use "demo flags" for a full list):
  -v=false: Be more verbose

- Custom version, build from commander methods:
Use any commander.(Explain|Visit)* methods

Conclusions

  • Les plus
    • google/subcommands est très bien adapté aux projets simples
    • il est particulièrement léger et très stable, du fait de l’absence de dépendances
    • il n’est pas bloquant lorsque les projets s’enrichissent et nécessitent une hiérarchie de commandes à plusieurs niveaux, ou un mélange de drapeaux locaux et globaux
    • il n’empêche pas de créer des jeux de commande intégralement testables
  • Les moins
    • il n’intègre pas directement la fusion des drapeaux CLI avec les variables d’environnement
    • il n’intègre par directement le chargement de fichiers de configuration et leurs fusion
    • la création de commandes complexes avec des étapes de validation n’est pas supportée
    • il n’intègre pas directement la notion de drapeaux persistants, c’est à dire définis à un niveau et également transmis aux sous-commandes inférieures
    • la création de hiérarchies de commandes multi-niveaux est plus complexe qu’avec d’autres outils

C’est donc probablement votre meilleur outil pour le prochain utilitaire CLI ou le prochain micro-service, mais pas le meilleur pour un grand projet monolithique.