Améliorer la découvrabilité des CLI : l’auto-complétion

Les CLI sont peu découvrables. Voyons comment écrire de l'auto-complétion custom pour améliorer tout ça avec l'exemple de tricount

Le problème

Très rapidement : les outils en ligne de commande sont peu découvrables. On tape une commande puis quoi ?

Un terminal, texte blanc sur fond noir. On y voit la commande mv tapée, le
curseur juste derrière avec rien d'autre d'écrit

On lit le manuel GNU de ses morts ? On tente peut-être un -h ou un --help qui listera un sous-ensemble des options sans trop d’explications, ou au contraire, imprimera la bible toute entière1 ? Si l’outil est pas trop compliqué, mettons mv, c’est jouable. Et encore, il faut savoir que man mv existe, désepérer du fait que ce soit un manuel de référence, éventuellement savoir que info mv existe, probablement plutôt aller voir des tutos sur internet et se rendre compte que, puisque l’on est en 2026, la plupart sont générés par IA. Bref pas facile.

Une solution pourrait être de faire de l’auto-complétion. Grâce à la magie de ce qu’il se passe lorsqu’on appuie sur la touche tabulation en écrivant une commande dans un shell moderne2 le shell peut nous indiquer ce qu’il est possible d’écrire à cet endroit de la commande.

J’ai trouvé très peu de tutoriels satisfaisant sur la question donc j’en écris un :)

Les limites

Dans cet article je partage ce que j’ai appris en écrivant l’auto-complétion zsh pour l’outil tricount. Il en découle :

Par ailleurs j’émets l’hypothèse que l’auto-complétion est significativement utile pour la découvrabilité d’une commande mais je ne l’ai pas réellement testé. Pour pouvoir réellement mesurer l’intérêt de l’auto-complétion pour la découvrabilité il faudrait que je le fasse tester à des copaines sans et avec l’auto-complétion mais on est pas ici pour faire de la recherche lol 🤓

Le résultat

L’objectif est de passer de ça :

à ça (vidéo de 500Ko, cliquez dessus pour lancer) :

On remarquera que l’outil fonctionne avec une syntaxe de commande, sous-commande et objet. Les sous-commandes et objets disponibles dépendent de la commande choisie. Par exemple la commande creer ne nécessite rien d’autre mais si l’on choisit la commande ajouter il faudra ensuite choisir un objet entre dépense et personne. Si l’objet est dépense il faudra choisir le nom d’une personne puis un montant etc.

Ce qui rend l’auto-complétion vraiment utile est qu’elle est entièrement contextuelle. C’est ce que fait par exemple l’auto-complétion zsh de la commande sed. L’auto-complétion sait si l’on est en position d’écrire une nouvelle commande (ici s///) ou d’apporter une modification à une commande s (g et i par exemple) :

Le code

La base, compadd

L’auto-complétion de zsh repose sur des fonctions d’auto-complétion3. Ces fonctions sont des fonctions shell classiques appelées à chaque fois que l’utilisateurice appuie sur la touche tabulation4. Elles commencent habituellement par un _ et il est apparemment conventionnel de les nommer du nom de la commande qu’elles complètent bien que ce ne soit pas obligatoire. Imaginons donc une fonction de complétion tout à fait inutile affichant a, destinée à compléter la commande a5 :

_a(){ printf "a"; }

Pour l’associer à la commande a (inexistante mais c’est accessoire pour le moment) il existe une commande compdef qui prend en argument le nom d’une fonction puis le nom de la commande qu’elle doit compléter :

compdef _a a

Pour vérifier le résultat il suffit d’initier une commande a, de mettre un espace puis d’appuyer sur tabulation :

C’est très bien mais ça ne permet pas l’auto-complétion. Pour programmer ce que l’on souhaite, c’est à dire la sélection des options et arguments parmi un ensemble restreint il faut utiliser une seconde fonction zsh, compadd6.

Dans sa forme la plus simple compadd s’utilise en lui passant en argument les “candidats” à l’auto-complétion. Si l’on ajoute 1234, 5678 et 91011 alors ces trois chaînes seront proposées à l’auto-complétion via un petit menu s’affichant en dessous de la ligne de commande en cours et navigable avec les flèches. On verra plus tard qu’il est possible de modifier l’affichage de ce menu pour par exemple donner des informations à propos des candidats.

Si l’on commence à écrire un argument et que le début match avec l’un de ces candidats de manière non ambiguë, l’auto-complétion l’ajoutera automatiquement :

Puisque la fonction dans laquelle on l’écrit est une fonction shell classique on peut entourer les compadd de toute la logique nécessaire pour trouver les bons candidats et les afficher au bon moment7. Reste alors à connaître les différentes options pratiques de compadd et les variables disponibles pour connaitre l’état de la commande en cours d’écriture.

Ses options

Afficher des message d’information ou d’erreurs

Pour ajouter un texte explicatif avant les candidats on peut utiliser l’option -X ou -x de compadd. -X s’affichera uniquement s’il existe des candidats, -x s’affichera quoi qu’il arrive. -x est donc assez utile pour afficher des messages “d’erreurs”. Ainsi dans la vidéo suivante on utilise -X pour afficher le message “Choisir une personne à retirer” mais -x pour afficher “Personne à retirer” :

Auto-compléter plusieurs candidats d’un coup avec -Q

Il peut être utile qu’un unique candidat soit lui même la concaténation de plusieurs arguments. Disons par exemple qu’à un état de l’auto-complétion on veuille faire exécuter la sous-commande “cmd1” suivi de la sous-sous-commande “cmd2” à l’utilisateurice. Si l’on ajoute naïvement la chaîne “cmd1 cmd2” en candidat le système le considère en un seul bloc et échappe l’espace entre les deux :

a cmd1\ cmd2

Pour y remédier il faut utiliser l’option -Q :

compadd -Q "cmd1 cmd2"

Qui auto-complètera :

a cmd1 cmd2

A l’éxécution la commande a comprendra bien cmd1 et cmd2 comme deux arguments différents.

Cette astuce est utilisée au tout début de la vidéo du résultat pour automatiquement ajouter nom-bdd creer en l’absence de bdd existante.

Ne pas trier automatiquement les candidats avec -J arg -o nosort

Par défaut le système d’auto-complétion trie les candidats par ordre alphabétique. Si ce n’est pas souhaitable et qu’on veut les afficher dans l’ordre fourni à compadd il faut associer l’option -o nosort avec l’option -J qui prend un argument. -o ne fonctionne pas seul, il faut créer un “groupe” de candidat avec l’option -J en écrivant, par exemple, -J a. Je n’ai pas eu besoin d’utiliser les groupes de candidats donc je n’en sais pas plus.

Avec la commande compadd -J a -o nosort b c a le système nous proposera les candidats dans l’ordre b c a plutôt que a b c.

Décorréler l’affichage des candidats de l’argument auto-complété avec -ld

Par défaut ce qui s’affichage dans le menu d’auto-complétion est identique à la valeur des candidats. Si l’on a pour candidats a b c le mnu proposera a b c et notre choix complètera a, b ou c.

Il est parfois utile de décorréler ce qui est affiché dans le menu de choix et ce qui est réellement complété. Pour cela il faut associer les options -l et -d. -l demande à ce qu’un seul candidat soit affiché par ligne et -d permet de renseigner un tableau de valeurs séparées par des espaces. Le système va associer une à une les valeurs de ce tableau et les candidats qu’il a récupéré en argument de compadd. Le menu de choix affichera visuellement le contenu du tableau -d mais ce sera les candidats associés qui seront ajoutés dans la ligne de commande. Ainsi avec le tableau et l’appel à compadd suivants :

desc="(
    creer\ \ \ \ --\ Créer\ une\ nouvelle\ base\ de\ donnée
    ajouter\ \ --\ Ajouter\ une\ dépense\ ou\ une\ personne
    retirer\ \ --\ Retirer\ une\ dépense\ ou\ une\ personne
    lister\ \ \ --\ Afficher\ le\ contenu\ de\ la\ bdd
    calculer\ --\ Calculer\ qui\ doit\ combien\ à\ qui
)"

compadd -J a -o nosort -ld $desc creer ajouter retirer lister calculer

Le système proposera visuellement l’option creer -- Créer une nouvelle base de donnée pour le candidat creer et ainsi de suite. Puisque les éléments du tableau du menu de choix sont séparés par des esapces il faut bien en échapper les espaces.

Cette technique est utilisée à deux reprises dans la vidéo du résultat, d’abord pour les commandes vues dans l’exemple ici, ensuite pour sélectionner la dépense à retirer (l’utilisateurice navigue dans la bdd ligne par ligne mais uniquement l’identifiant de la dépense est auto-complété).

Ne pas supprimer les candidats doublons avec -2

Par défaut le système supprime les candidats doublons. Si l’on fait compadd 1 1 2 seul deux candidats seront proposés, 1 et 2. Il peut être utile de ne pas les dédoublonner, en particulier en combinaison avec l’option précédente -d. On peut par exemple vouloir proposer le retrait d’un élément qui apparaît à plusieurs endroits dans une base de donnée sous plusieurs formes différentes. Il faut donc que le candidat puisse apparaître plusieurs fois pour être associé correctement au tableau du menu de choix. Pour ne pas dédoublonner il faut utiliser l’option -2 :

desc="(
    dépense1\ blabla\ truc
    dépense1\ bidule\ machin
    dépense2\ chouette\ aaaa
)"
compadd -J a -2 -ld $desc 1 1 2

A noter que, comme l’option -o, cette option nécessite l’option -J. Cette option est utilisée lors du retrait d’une dépense, vers la seconde 52 de la vidéo de résultat. On y voit trois options dans le menu dont les deux premières sont associée à deux candidats de valeurs 1. Avec dédoublonnage cela n’aurait pas été possible.

Les variables d’environnements

Savoir à quel numéro d’argument on en est avec $CURRENT

Dans la variable $CURRENT se trouve le numéro du mot que l’on est en train d’écrire/auto-compléter. Le décompte commence à 1 et le premier mot est toujours la commande en cours. Le premier paramètre (argument ou option, peu importe) est donc à 2 et ainsi de suite. En utilisant compadd -x pour visualiser la valeur de la variable en dessous de la commande :

On peut utiliser le contenu de cette variable pour faire varier l’auto-complétion, voir l’exemple ci-dessous.

Connaître la valeur d’un mot déjà rempli avec $words

Il peut être utile de savoir quelle est la valeur d’un paramètre déjà écrit. Pour cela on peut lire dans le tableau $words à l’indice correspondant. Le premier éléments ($words[1]) est toujours le nom de la commande en cours, le second (words[2]) le premier paramètre etc. En utilisant la même astuce que précédemment :

Tout mettre ensemble, l’exemple de tricount

Voyons comment utiliser tout ça pour reconstruire l’auto-complétion de tricount. Je commente le code ligne par ligne en passant vite sur les aspects purement shell.

D’abord, déclarer la fonction au nom _commande et inscrire quelle commande elle auto-complète :

#compdef tricount
_tricount() {

Ensuite écrire éventuellement en dur l’endroit où se trouve les bases de données et récupérer la valeur de la base courante, de l’action à mener dessus et l’objet :

local BDDFOLDER="/srv/tricount"
local curbdd="$words[2]"
local action="$words[3]"
local objet="$words[4]"

Puisque cette fonction est appelée à chaque fois que la touche tabulation est lancée les variables curbdd, action et objet peuvent très bien être vides parce que la liste $words n’est pas encore remplie.

Si on est au deuxième argument c’est que l’on cherche à choisir une base de donnée. Il faut récupérer la liste en regardant dans le dossier correspondant. S’il y en a au moins une on les ajoute en tant que candidats avec un message explicatif. S’il n’y en a pas on peut pré-remplir la commande pour en créer une avec un nom bidon modifiable :

if [ "$CURRENT" = 2 ];then
    bdds=$(find $BDDFOLDER -type f | cut -d'/' -f4 | sort)
    if [ "$bdds" ];then
        compadd -X "Choisir une base de donnée" $bdds
    else
        compadd -Qx "Aucune bdd de dispo, go en créer une" "nom-bdd creer"
    fi
fi

Si on est au troisième argument c’est que l’on cherche à effectuer une commande sur une bdd existante ou pas. Si la bdd n’existe pas on ajoute techniquement l’action creer en tant que candidat. Sinon on prépare les variables contenant les candidats et leurs descriptions en faisant usage de -l et -d.

if [ "$CURRENT" = 3 ];then
    if ! [ -f $BDDFOLDER/$curbdd ];then
        compadd creer
    else
        actions=(ajouter retirer lister calculer)
        desc="(
            ajouter\ \ --\ Ajouter\ une\ dépense\ ou\ une\ personne
            retirer\ \ --\ Retirer\ une\ dépense\ ou\ une\ personne
            lister\ \ \ --\ Afficher\ le\ contenu\ de\ la\ bdd
            calculer\ --\ Calculer\ qui\ doit\ combien\ à\ qui
        )"
        compadd -X "Choisir une action" -J a -o nosort -ld $desc $actions
    fi
fi

Si on est au quatrième argument et que l’action (le troisième) est ajouter ou retirer alors on propose un objet à ajouter ou retirer :

if [ "$CURRENT" = 4 ] && { [ "$action" == "ajouter" ] || [ "$action" == "retirer" ]; };then
    compadd -X "Choisir un objet à $action" depense personne
fi

Si l’action est retirer et l’objet depense alors on récupère les identifiants des dépenses et on construit ensuite un tableau pour l’affichage du menu en réorganisant les lignes de dépenses de la bdd et en échappant les espaces8. Finalement on ajoute les identifiants en tant que candidats et les lignes de dépenses en tant qu’option visuelles. Aussi on retire le tri par défaut qui mélangerait toutes les dépenses et on demande à ne pas supprimer les candidats doublons. Ainsi les éventuelles multiples lignes pour une dépense donnée auront bien l’identifiant de la dépense en candidat en face.

if [ "$action" = "retirer" ] && [ "$objet" = "depense" ];then
    ids=($(< "$BDDFOLDER/$curbdd" cut -f5 | sed 1d | paste -s -d' '))
    # Ex: ids="1 1 2"
    desc="(
        $(< "$BDDFOLDER/$curbdd" awk 'BEGIN{OFS="\t"};NR>1{print $5,$1,$2,$3,$4}' | column -ts' ' | sed 's# #\\ #g')
    )"
    # Ex: desc="(
    #     dépense1\ blabla\ truc
    #     dépense1\ bidule\ machin
    #     dépense2\ chouette\ aaaa
    # )"
    compadd -X "Choisir une dépense à retirer" -J a -2 -o nosort -ld $desc $ids
fi

Si l’action est retirer et l’objet de retrait une personne alors on récupère la liste des personnes dans la base de donnée. Si la liste est vide on affiche un message d’erreur comme quoi il n’y a personne à retirer, sinon on ajoute la liste en candidats :

if [ "$action" = "retirer" ] && [ "$objet" = "personne" ];then
    personnes=($(< "$BDDFOLDER/$curbdd" head -n1 | tr ' ' '\n' | sed 1d | sort))
    if ! [ "$personnes" ];then
        compadd -x "Il n'y a aucune personne à retirer"
    else
        compadd -X "Choisir une personne à retirer" -o nosort $personnes
    fi
fi

Si l’action est ajouter et l’objet une depense alors on récupère la liste des personnes de la base de donnée :

if [ "$action" = "retirer" ] && [ "$objet" = "personne" ];then
    personnes=($(< "$BDDFOLDER/$curbdd" head -n1 | tr ' ' '\n' | sed 1d | sort))

Puis on vérifie à quelle étape de la construction de la dépense on est. Si l’on est au paramètre 5 c’est le début et on cherche à ajouter la personne qui paye. S’il n’y a pas de candidats disponible on peut afficher un message d’erreur :

if [ "$CURRENT" = 5 ];then
    if ! [ "$personnes" ];then
        compadd -x "Pas de personnes disponibles :("
        compadd -x "Vous pouvez tout de même entrer un nom, ça fonctionnera"
    else
        compadd -X "Choisir la personne qui paye" $personnes
    fi
fi

Si l’on est à l’argument 6 on cherche à ajouter un montant :

[ "$CURRENT" = 6 ] && compadd -X "Choisir un montant" -J a -o nosort $(seq 1 50)

Si l’on est à l’argument 7 on cherche à ajouter une raison pour la dépense. Il est possible de faire un peu de pré-traitement sur les candidats pour qu’ils évoluent avec la base de donnée. Ici les raisons apparaîtront dans l’ordre de la plus utilisée à la moins utilisée avec quelques raisons d’exemple en bonus à la fin :

if [ "$CURRENT" = 7 ];then
    raisons=($(< "$BDDFOLDER/$curbdd" cut -f4 | sed 1d | sort | uniq -c | awk '{print $2}' | paste -s -d' '))
    compadd -X "Mettre une raison" -J a -o nosort $raisons repas transport courses ...
fi

Puis finalement si l’on est à l’argument 8 on chercher à ajouter les bénéficiaires de la dépense. On peut afficher plusieurs lignes de messages en multipliant les appels à compadd -x. On peut ajouter “à la main” un candidat en plus de ceux récoltés dans une liste comme le candidat toustes ici :

if [ "$CURRENT" -ge 8 ];then
    if ! [ "$personnes" ];then
        compadd -x "Pas de personnes disponibles :("
        compadd -x "Vous pouvez tout de même entrer un nom, ça fonctionnera"
    else
        compadd -X "Choisir une ou plusieurs personnes bénéficiaires" $personnes toustes
    fi
fi

Cet exemple n’est pas parfait. On pourrait factoriser un certain nombre de choses et sortir de la fonction rapidement après avoir ajouté des candidats plutôt que de faire tous les tests alors même que l’on sait qu’ils seront faux. Il est ici à titre d’exemple.

Installation

Pour activer l’auto-complétion zsh dans toutes ces sessions il faut ajouter les lignes suivantes dans son fichier ~/.zshrc9 :

autoload -Uz compinit
compinit
# Pour pouvoir parcourir le menu des suggestions
# avec les flèches comme dans les vidéos
zstyle ':completion:*' menu yes select

Il faut ensuite écrire la fonction d’auto-complétion avec ceci pour toute première ligne :

#compdef cmd
_cmd() {
    [...]
}

Cette première ligne est un “faux” commentaire permettant à zsh de savoir que la fonction qui suit doit être utilisée pour compléter la commande cmd comme si l’on avait lancé compdef _cmd cmd à la main.

Finalement il faut installer le fichier, préférablement nommé _cmd dans le dossier /usr/share/zsh/functions/Completion/Unix/_cmd10. Au lancement zsh lit tous les fichiers présents dans ces dossiers, regarde la première ligne et fait l’association entre la fonction de complétion et la commande.

De l’auto-complétion personnalisée ?

L’un des mécanismes principaux pour faciliter la découvrabilité et l’usage du shell, notamment des commandes les plus complexes, est de restreindre l’espace d’exploration. C’est ce qui est par exemple fait dans cet article11 via un système capable de parser un ensemble restreint de commandes shell, sélectionné par un·e experte, et rendant une GUI exposant les options et arguments utilisé.es par ce sous-ensemble plutôt que la totalité des fonctionnalités de la commande.

C’est également en partie l’idée derrière zenu, à savoir mettre des commandes parfois complexes derrière des menus pour faciliter la navigation, l’usage et le partage de ces commandes tout en diminuant la charge cognitive.

Malheureusement les fonctions d’auto-complétion fournies par défaut avec zsh laissent à désirer de côté là. Par exemple lorsque l’on auto-complète la commande convert d’image-magick après avoir écrit un - dans zsh on obtient :

Un terminal, texte blanc sur fond noir, avec une liste assez intimidante de
l'auto-complétion zsh de la commande convert d'image magick. Il y a vraiment
beaucoup d'options

C’est sympa d’avoir des petites descriptions mais la taille de la liste est intimidante et rend la complétion assez peu utile. Ce n’est pas un diss contre les contributeurices des fonctions de complétion de zsh, cela s’explique par le fait que :

  1. zsh est utilisé par des (dizaines de ?) millions de personne dans le monde. Il n’est donc pas possible de créer une fonction de complétion qui convienne à tout le monde. À défaut la philosophie retenue semble être de faire une fonction assez peu directive mais exhaustive.
  2. Image-magick comporte un nombre incalculable de fonctionnalités (peut-être trop).

J’émets l’hypothèse qu’en adoptant une approche située, en sachant pour qui et quels usages on créé les fonctions de complétion, il serait possible d’en faire des outils pédagogiques intéressants. Il n’y aurait pas une fonction d’auto-complétion exhaustive mais ne satisfaisant réellement personnes mais une multitude de fonctions que l’on se partagerait selon nos pratiques ou notre niveau de familiarité avec l’outil.

Bien sûr cela nécessite d’écrire du code mais c’est pour ça que je fais ce tuto :)

Références

La doc zsh de référence à propos de compadd est ici : https://zsh.sourceforge.io/Doc/Release/Completion-Widgets.html#Completion-Builtin-Commands


  1. yt-dlp -h | wc -l = 892. Et en plus faut attendre deux secondes pour que ça s’affiche. 

  2. Par moderne j’entends sous la forme que l’on utilise aujourd’hui depuis au moins la version de zsh datant de 1999 et depuis bien plus longtemps de manière plus primitive dans d’autres shells

  3. J’utilise “auto-complétion” et “complétion” de manière interchangeable 

  4. aussi connue sous le nom de la touche “flèche flèche” 

  5. en zsh pas besoin du ; de fin quand on écrit des fonctions sur une seule ligne mais en, team posix ici 

  6. la documentation zsh précise que compadd est assez “bas niveau” et qu’il est souvent préférable d’utiliser les très nombreuses autres fonctions proposées par zsh wrappant compadd. Le souci est qu’elles sont très nombreuses, compliquées et qu’elles sont surtout utiles pour faire de l’auto-complétion de commandes respectant des conventions bien établies (combo d’option courtes et longues - --, certaines des flags, d’autres suivies d’une chaÎne de caractère, parfois des fichiers, parfois des IP etc). Puisque l’on fait un peu n’imp avec tricount et que la documentation est assez opaque j’ai trouvé qu’il était plus facile d’apprendre uniquement compadd en partant du principe que tout était possible avec. 

  7. Ou carrément exécuter des commandes pour lancer un logiciel ou je ne sais trop quoi d’autre. C’est étrange parce que ce n’est clairement pas ce qui est attendu par les utilisateurices mais peut-être que ça peut convenir à votre manière de travailler. 

  8. Un peu de quoting hell ici puisque je connais très mal zsh et ses structures de données. Je pense qu’on peut faire mieux 

  9. Ou tout autre endroit qui est sourcé à la création de chaque shell interactif, si vous sachez vous sachez 

  10. du moins pour Debian, probablement pour tous les linux. 

  11. A propos duquel je pense écrire un article de blog bientôt d’ailleurs