Créer un outil CLI avec Node.js et Commander
Mark Toledo
1 avril 2026

Les outils en ligne de commande font partie de mon quotidien depuis que je code. Au début, j'écrivais des scripts shell basiques. Puis j'ai découvert qu'on pouvait créer des CLIs vraiment solides avec Node.js — avec de l'autocomplétion, une gestion d'erreurs propre, des messages d'aide générés automatiquement, et tout ce qu'on attend d'un bon outil.
Le déclic a eu lieu quand j'ai eu besoin d'automatiser des tâches de migration de base de données pour un client. J'aurais pu écrire un script bash, mais j'avais besoin de logique complexe, d'accès à des APIs, et d'une interface utilisable par l'équipe entière. Node.js avec Commander, c'était la réponse évidente.
Initialiser le projet
mkdir mon-cli
cd mon-cli
npm init -y
npm install commander
Le fichier package.json a besoin de quelques ajustements pour qu'on puisse appeler notre CLI directement depuis le terminal :
{
"name": "mon-cli",
"version": "1.0.0",
"bin": {
"mon-cli": "./bin/index.js"
},
"type": "module"
}
Le champ bin indique à npm quels fichiers rendre exécutables. Après npm link (développement) ou npm install -g (production), la commande mon-cli sera disponible globalement.
La structure du projet
mon-cli/
bin/
index.js # Point d'entrée
src/
commands/
init.js # Commande init
generer.js # Commande generer
utils/
afficher.js # Helpers affichage
package.json
Le point d'entrée
// bin/index.js
#!/usr/bin/env node
import { Command } from 'commander';
import { commandeInit } from '../src/commands/init.js';
import { commandeGenerer } from '../src/commands/generer.js';
const programme = new Command();
programme
.name('mon-cli')
.description('Outil CLI pour automatiser mes tâches courantes')
.version('1.0.0');
programme
.command('init')
.description('Initialiser un nouveau projet')
.argument('<nom>', 'Nom du projet')
.option('-t, --template <type>', 'Template à utiliser', 'default')
.option('--git', 'Initialiser un dépôt git')
.action(commandeInit);
programme
.command('generer')
.description('Générer des fichiers depuis un template')
.argument('<type>', 'Type de fichier à générer')
.option('-n, --nom <nom>', 'Nom du fichier généré')
.option('-f, --force', 'Écraser si le fichier existe')
.action(commandeGenerer);
programme.parse();
La première ligne #!/usr/bin/env node est essentielle — c'est le shebang qui indique au système d'exploitation quel interpréteur utiliser pour exécuter le fichier.
Implémenter une commande
// src/commands/init.js
import fs from 'fs/promises';
import path from 'path';
import { execSync } from 'child_process';
export async function commandeInit(nom, options) {
console.log(`Création du projet "${nom}"...`);
const dossier = path.join(process.cwd(), nom);
try {
await fs.mkdir(dossier, { recursive: true });
// Créer la structure de base
const sousDossiers = ['src', 'tests', 'docs'];
await Promise.all(
sousDossiers.map(d => fs.mkdir(path.join(dossier, d)))
);
// Générer le package.json
const packageJson = {
name: nom,
version: '0.1.0',
type: 'module',
scripts: {
start: 'node src/index.js',
test: 'node --test'
}
};
await fs.writeFile(
path.join(dossier, 'package.json'),
JSON.stringify(packageJson, null, 2)
);
if (options.git) {
execSync('git init', { cwd: dossier, stdio: 'ignore' });
console.log('Dépôt git initialisé.');
}
console.log(`✓ Projet "${nom}" créé dans ./${nom}/`);
} catch (erreur) {
if (erreur.code === 'EEXIST') {
console.error(`Erreur : le dossier "${nom}" existe déjà.`);
process.exit(1);
}
throw erreur;
}
}
Améliorer l'expérience utilisateur avec ora et chalk
Un CLI sans retour visuel, c'est anxiogène. L'utilisateur ne sait pas si l'outil tourne ou s'il est planté. Deux packages rendent l'expérience bien meilleure :
npm install ora chalk
import ora from 'ora';
import chalk from 'chalk';
export async function commandeAvecProgress(argument, options) {
const spinner = ora('Téléchargement des données...').start();
try {
const donnees = await recupererDonnees(argument);
spinner.succeed(chalk.green('Données récupérées avec succès'));
spinner.start('Traitement en cours...');
const resultat = await traiter(donnees);
spinner.succeed(chalk.green(`${resultat.length} éléments traités`));
console.log(chalk.cyan('\nRésumé :'));
console.log(` Traités : ${chalk.bold(resultat.length)}`);
console.log(` Durée : ${chalk.bold(resultat.duree)}ms`);
} catch (erreur) {
spinner.fail(chalk.red(`Erreur : ${erreur.message}`));
process.exit(1);
}
}
Lire la configuration depuis un fichier
La plupart des bons CLIs permettent une configuration persistante. Voici un pattern simple pour lire depuis un fichier .mon-cli.json dans le répertoire home de l'utilisateur.
// src/utils/config.js
import fs from 'fs/promises';
import os from 'os';
import path from 'path';
const CONFIG_PATH = path.join(os.homedir(), '.mon-cli.json');
export async function lireConfig() {
try {
const contenu = await fs.readFile(CONFIG_PATH, 'utf-8');
return JSON.parse(contenu);
} catch {
return {}; // Pas de config = valeurs par défaut
}
}
export async function ecrireConfig(config) {
await fs.writeFile(CONFIG_PATH, JSON.stringify(config, null, 2));
}
Prompts interactifs avec @inquirer/prompts
Pour les commandes qui nécessitent des saisies utilisateur, Inquirer.js (maintenant modulaire) est la référence.
npm install @inquirer/prompts
import { input, select, confirm } from '@inquirer/prompts';
export async function commandeInteractive() {
const nom = await input({
message: 'Nom du projet :',
validate: (val) => val.length > 0 || 'Le nom ne peut pas être vide'
});
const template = await select({
message: 'Choisir un template :',
choices: [
{ name: 'API REST (Express)', value: 'api' },
{ name: 'CLI (Commander)', value: 'cli' },
{ name: 'Application web (Astro)', value: 'web' }
]
});
const confirme = await confirm({
message: `Créer "${nom}" avec le template "${template}" ?`,
default: true
});
if (!confirme) {
console.log('Annulé.');
return;
}
await creerProjet(nom, template);
}
Rendre le fichier exécutable
Avant de tester, il faut rendre le fichier bin exécutable sur Linux/macOS :
chmod +x bin/index.js
npm link # Installe le CLI globalement en mode développement
Tu peux maintenant appeler mon-cli --help depuis n'importe quel répertoire.
Tests des commandes CLI
Tester un CLI, c'est souvent délicat. J'utilise execa pour appeler les commandes dans les tests et vérifier stdout/stderr.
import { execa } from 'execa';
import { describe, it } from 'node:test';
import assert from 'node:assert';
describe('commande init', () => {
it('crée un dossier de projet', async () => {
const { stdout, exitCode } = await execa('./bin/index.js', [
'init', 'projet-test'
]);
assert.equal(exitCode, 0);
assert.match(stdout, /Projet "projet-test" créé/);
// Nettoyer
await fs.rm('./projet-test', { recursive: true });
});
});
Publication sur npm
Une fois le CLI prêt, le publier sur npm prend deux minutes :
npm login
npm publish
Les utilisateurs peuvent ensuite l'installer avec npm install -g mon-cli ou l'utiliser sans installation via npx mon-cli.
Quelques points importants pour un CLI publié : bien remplir la section engines dans package.json pour indiquer la version Node.js minimale requise, et inclure un README.md avec des exemples d'utilisation clairs.
Ce qu'on retient
Créer un CLI avec Node.js et Commander n'est pas plus compliqué que n'importe quel autre projet Node.js. L'essentiel : penser à l'expérience utilisateur dès le début (messages d'aide, spinners, couleurs), gérer les erreurs avec des codes de sortie explicites, et tester les commandes comme n'importe quelle autre interface.
Un bon CLI, c'est un outil que les autres membres de l'équipe adoptent naturellement parce qu'il fait une chose, bien, avec une interface claire.
