Construire un outil CLI professionnel avec Node.js et TypeScript en 2026

AI Bot
Par AI Bot ·

Chargement du lecteur de synthèse vocale...

Les outils en ligne de commande restent la colonne vertébrale des workflows de développement. De git et npm à eslint et prettier, les CLI alimentent toute l'expérience de développement moderne. Dans ce tutoriel, vous allez construire un outil CLI de qualité professionnelle à partir de zéro avec Node.js et TypeScript — incluant le parsing des arguments, les prompts interactifs, la sortie colorée et tout ce qu'il faut pour le publier sur npm.

Ce que vous allez construire

Nous allons créer taskr — un gestionnaire de tâches CLI qui permet aux développeurs de gérer les tâches de leurs projets directement depuis le terminal. À la fin de ce guide, vous aurez un outil qui supporte :

  • Ajout, affichage, complétion et suppression de tâches
  • Mode interactif avec des prompts intelligents
  • Sortie colorée et formatée dans le terminal
  • Stockage persistant via un fichier JSON
  • Gestion des erreurs et textes d'aide
  • Un package npm prêt à publier

Prérequis

Avant de commencer, assurez-vous d'avoir :

  • Node.js 20+ installé
  • Le gestionnaire de packages npm ou pnpm
  • Des connaissances de base en TypeScript
  • Un terminal avec lequel vous êtes à l'aise
  • Un éditeur de code (VS Code recommandé)

Étape 1 : Configuration du projet

Créez un nouveau répertoire et initialisez le projet :

mkdir taskr && cd taskr
npm init -y

Installez TypeScript et les dépendances requises :

npm install commander chalk inquirer conf ora
npm install -D typescript @types/node @types/inquirer tsx tsup

Voici le rôle de chaque package :

PackageObjectif
commanderParsing des commandes et arguments
chalkSortie colorée du terminal
inquirerPrompts interactifs
confStockage persistant de configuration
oraSpinners élégants pour le terminal
tsxExécuter TypeScript directement en développement
tsupBundler TypeScript pour la distribution

Étape 2 : Configuration de TypeScript

Créez un fichier tsconfig.json :

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ES2022",
    "moduleResolution": "bundler",
    "lib": ["ES2022"],
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true,
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}

Étape 3 : Définir le modèle de données

Créez le répertoire source et les types de données :

mkdir -p src

Créez src/types.ts :

export interface Task {
  id: string;
  title: string;
  description?: string;
  status: "todo" | "in-progress" | "done";
  priority: "low" | "medium" | "high";
  createdAt: string;
  completedAt?: string;
}
 
export interface TaskStore {
  tasks: Task[];
}

Étape 4 : Construire la couche de stockage

Créez src/store.ts pour gérer le stockage persistant :

import Conf from "conf";
import { randomUUID } from "node:crypto";
import type { Task, TaskStore } from "./types.js";
 
const config = new Conf<TaskStore>({
  projectName: "taskr",
  defaults: {
    tasks: [],
  },
});
 
export function getAllTasks(): Task[] {
  return config.get("tasks");
}
 
export function getTaskById(id: string): Task | undefined {
  return getAllTasks().find((t) => t.id === id || t.id.startsWith(id));
}
 
export function addTask(
  title: string,
  options: { description?: string; priority?: Task["priority"] } = {}
): Task {
  const task: Task = {
    id: randomUUID().slice(0, 8),
    title,
    description: options.description,
    status: "todo",
    priority: options.priority ?? "medium",
    createdAt: new Date().toISOString(),
  };
 
  const tasks = getAllTasks();
  tasks.push(task);
  config.set("tasks", tasks);
  return task;
}
 
export function completeTask(id: string): Task | null {
  const tasks = getAllTasks();
  const task = tasks.find((t) => t.id === id || t.id.startsWith(id));
 
  if (!task) return null;
 
  task.status = "done";
  task.completedAt = new Date().toISOString();
  config.set("tasks", tasks);
  return task;
}
 
export function removeTask(id: string): boolean {
  const tasks = getAllTasks();
  const index = tasks.findIndex((t) => t.id === id || t.id.startsWith(id));
 
  if (index === -1) return false;
 
  tasks.splice(index, 1);
  config.set("tasks", tasks);
  return true;
}
 
export function clearAllTasks(): void {
  config.set("tasks", []);
}

La bibliothèque conf détermine automatiquement le chemin de stockage approprié pour chaque système d'exploitation — ~/.config/taskr sur Linux, ~/Library/Preferences/taskr sur macOS et %APPDATA%/taskr sur Windows.

Étape 5 : Créer le formateur de sortie

Créez src/formatter.ts pour une belle sortie terminal :

import chalk from "chalk";
import type { Task } from "./types.js";
 
const priorityColors = {
  low: chalk.gray,
  medium: chalk.yellow,
  high: chalk.red,
};
 
const statusIcons = {
  todo: "○",
  "in-progress": "◑",
  done: "●",
};
 
export function formatTask(task: Task): string {
  const icon = statusIcons[task.status];
  const priority = priorityColors[task.priority](`[${task.priority}]`);
  const id = chalk.dim(`#${task.id}`);
  const title =
    task.status === "done" ? chalk.strikethrough(task.title) : task.title;
 
  let line = `  ${icon} ${id} ${title} ${priority}`;
 
  if (task.description) {
    line += `\n    ${chalk.dim(task.description)}`;
  }
 
  return line;
}
 
export function formatTaskList(tasks: Task[]): string {
  if (tasks.length === 0) {
    return chalk.dim("  Aucune tâche trouvée. Ajoutez-en une avec : taskr add <titre>");
  }
 
  const groups = {
    "En cours": tasks.filter((t) => t.status === "in-progress"),
    "À faire": tasks.filter((t) => t.status === "todo"),
    "Terminées": tasks.filter((t) => t.status === "done"),
  };
 
  const lines: string[] = [];
 
  for (const [label, group] of Object.entries(groups)) {
    if (group.length === 0) continue;
    lines.push(`\n${chalk.bold.underline(label)} (${group.length})`);
    group.forEach((task) => lines.push(formatTask(task)));
  }
 
  return lines.join("\n");
}
 
export function success(message: string): void {
  console.log(chalk.green("✓"), message);
}
 
export function error(message: string): void {
  console.error(chalk.red("✗"), message);
}
 
export function info(message: string): void {
  console.log(chalk.blue("ℹ"), message);
}

Étape 6 : Ajouter le mode interactif

Créez src/interactive.ts pour les prompts interactifs :

import inquirer from "inquirer";
import type { Task } from "./types.js";
import { addTask, getAllTasks, completeTask, removeTask } from "./store.js";
import { formatTaskList, success, error } from "./formatter.js";
 
export async function interactiveMode(): Promise<void> {
  const { action } = await inquirer.prompt([
    {
      type: "list",
      name: "action",
      message: "Que souhaitez-vous faire ?",
      choices: [
        { name: "📋 Lister toutes les tâches", value: "list" },
        { name: "➕ Ajouter une nouvelle tâche", value: "add" },
        { name: "✅ Compléter une tâche", value: "complete" },
        { name: "🗑️  Supprimer une tâche", value: "remove" },
        { name: "🚪 Quitter", value: "exit" },
      ],
    },
  ]);
 
  switch (action) {
    case "list":
      console.log(formatTaskList(getAllTasks()));
      break;
 
    case "add":
      await interactiveAdd();
      break;
 
    case "complete":
      await interactiveComplete();
      break;
 
    case "remove":
      await interactiveRemove();
      break;
 
    case "exit":
      return;
  }
 
  // Retour au menu principal
  await interactiveMode();
}
 
async function interactiveAdd(): Promise<void> {
  const answers = await inquirer.prompt([
    {
      type: "input",
      name: "title",
      message: "Titre de la tâche :",
      validate: (input: string) =>
        input.trim().length > 0 || "Le titre ne peut pas être vide",
    },
    {
      type: "input",
      name: "description",
      message: "Description (optionnel) :",
    },
    {
      type: "list",
      name: "priority",
      message: "Priorité :",
      choices: ["low", "medium", "high"],
      default: "medium",
    },
  ]);
 
  const task = addTask(answers.title, {
    description: answers.description || undefined,
    priority: answers.priority,
  });
 
  success(`Ajoutée : ${task.title} (#${task.id})`);
}
 
async function interactiveComplete(): Promise<void> {
  const tasks = getAllTasks().filter((t) => t.status !== "done");
 
  if (tasks.length === 0) {
    error("Aucune tâche en attente à compléter.");
    return;
  }
 
  const { taskId } = await inquirer.prompt([
    {
      type: "list",
      name: "taskId",
      message: "Sélectionnez la tâche à compléter :",
      choices: tasks.map((t) => ({
        name: `${t.title} [${t.priority}]`,
        value: t.id,
      })),
    },
  ]);
 
  const task = completeTask(taskId);
  if (task) {
    success(`Complétée : ${task.title}`);
  }
}
 
async function interactiveRemove(): Promise<void> {
  const tasks = getAllTasks();
 
  if (tasks.length === 0) {
    error("Aucune tâche à supprimer.");
    return;
  }
 
  const { taskId } = await inquirer.prompt([
    {
      type: "list",
      name: "taskId",
      message: "Sélectionnez la tâche à supprimer :",
      choices: tasks.map((t) => ({
        name: `${t.title} (${t.status})`,
        value: t.id,
      })),
    },
  ]);
 
  const { confirm } = await inquirer.prompt([
    {
      type: "confirm",
      name: "confirm",
      message: "Êtes-vous sûr ?",
      default: false,
    },
  ]);
 
  if (confirm) {
    removeTask(taskId);
    success("Tâche supprimée.");
  }
}

Étape 7 : Connecter le point d'entrée CLI

Créez src/cli.ts — le point d'entrée principal :

#!/usr/bin/env node
 
import { Command } from "commander";
import chalk from "chalk";
import ora from "ora";
import {
  addTask,
  getAllTasks,
  completeTask,
  removeTask,
  clearAllTasks,
} from "./store.js";
import { formatTaskList, formatTask, success, error, info } from "./formatter.js";
import { interactiveMode } from "./interactive.js";
 
const program = new Command();
 
program
  .name("taskr")
  .description("Un gestionnaire de tâches minimaliste pour votre terminal")
  .version("1.0.0");
 
// Mode interactif (par défaut quand aucune commande spécifiée)
program
  .action(async () => {
    console.log(chalk.bold("\n🚀 Taskr — Gestionnaire de tâches terminal\n"));
    await interactiveMode();
  });
 
// Commande d'ajout
program
  .command("add <title>")
  .description("Ajouter une nouvelle tâche")
  .option("-d, --description <desc>", "Description de la tâche")
  .option("-p, --priority <level>", "Priorité : low, medium, high", "medium")
  .action((title: string, options) => {
    const task = addTask(title, {
      description: options.description,
      priority: options.priority,
    });
    success(`Ajoutée : ${task.title} ${chalk.dim(`(#${task.id})`)}`);
  });
 
// Commande de liste
program
  .command("list")
  .alias("ls")
  .description("Lister toutes les tâches")
  .option("-s, --status <status>", "Filtrer par statut : todo, in-progress, done")
  .option("-p, --priority <level>", "Filtrer par priorité : low, medium, high")
  .action((options) => {
    let tasks = getAllTasks();
 
    if (options.status) {
      tasks = tasks.filter((t) => t.status === options.status);
    }
    if (options.priority) {
      tasks = tasks.filter((t) => t.priority === options.priority);
    }
 
    console.log(formatTaskList(tasks));
    console.log();
  });
 
// Commande de complétion
program
  .command("done <id>")
  .description("Marquer une tâche comme terminée")
  .action((id: string) => {
    const task = completeTask(id);
    if (task) {
      success(`Complétée : ${task.title}`);
    } else {
      error(`Tâche introuvable : ${id}`);
    }
  });
 
// Commande de suppression
program
  .command("rm <id>")
  .description("Supprimer une tâche")
  .action((id: string) => {
    const removed = removeTask(id);
    if (removed) {
      success("Tâche supprimée.");
    } else {
      error(`Tâche introuvable : ${id}`);
    }
  });
 
// Commande de nettoyage
program
  .command("clear")
  .description("Supprimer toutes les tâches")
  .action(() => {
    const spinner = ora("Suppression de toutes les tâches...").start();
    clearAllTasks();
    spinner.succeed("Toutes les tâches ont été supprimées.");
  });
 
program.parse();

Étape 8 : Configuration du package

Mettez à jour votre package.json pour configurer le CLI :

{
  "name": "taskr-cli",
  "version": "1.0.0",
  "type": "module",
  "bin": {
    "taskr": "./dist/cli.js"
  },
  "files": ["dist"],
  "scripts": {
    "dev": "tsx src/cli.ts",
    "build": "tsup src/cli.ts --format esm --dts --clean",
    "prepublishOnly": "npm run build"
  }
}

Créez tsup.config.ts pour la configuration du build :

import { defineConfig } from "tsup";
 
export default defineConfig({
  entry: ["src/cli.ts"],
  format: ["esm"],
  target: "node20",
  clean: true,
  dts: true,
  banner: {
    js: "#!/usr/bin/env node",
  },
});

Étape 9 : Tester pendant le développement

Exécutez votre CLI en mode développement avec tsx :

# Mode interactif
npx tsx src/cli.ts
 
# Ajouter une tâche
npx tsx src/cli.ts add "Écrire la documentation" -p high
 
# Lister les tâches
npx tsx src/cli.ts list
 
# Compléter une tâche (utilisez l'ID court de la liste)
npx tsx src/cli.ts done a1b2c3d4
 
# Filtrer les tâches
npx tsx src/cli.ts list --status todo --priority high

Vous pouvez aussi le lier globalement pour les tests :

npm run build
npm link
taskr add "Ma première tâche"
taskr ls

Étape 10 : Ajouter des tests unitaires

Installez le framework de test :

npm install -D vitest

Créez src/__tests__/store.test.ts :

import { describe, it, expect, beforeEach } from "vitest";
import { addTask, getAllTasks, completeTask, removeTask, clearAllTasks } from "../store.js";
 
describe("Task Store", () => {
  beforeEach(() => {
    clearAllTasks();
  });
 
  it("should add a task", () => {
    const task = addTask("Tâche de test");
    expect(task.title).toBe("Tâche de test");
    expect(task.status).toBe("todo");
    expect(task.priority).toBe("medium");
  });
 
  it("should list all tasks", () => {
    addTask("Tâche 1");
    addTask("Tâche 2");
    const tasks = getAllTasks();
    expect(tasks).toHaveLength(2);
  });
 
  it("should complete a task", () => {
    const task = addTask("Tâche de test");
    const completed = completeTask(task.id);
    expect(completed?.status).toBe("done");
    expect(completed?.completedAt).toBeDefined();
  });
 
  it("should remove a task", () => {
    const task = addTask("Tâche de test");
    const removed = removeTask(task.id);
    expect(removed).toBe(true);
    expect(getAllTasks()).toHaveLength(0);
  });
 
  it("should find tasks by partial ID", () => {
    const task = addTask("Tâche de test");
    const shortId = task.id.slice(0, 4);
    const completed = completeTask(shortId);
    expect(completed?.title).toBe("Tâche de test");
  });
});

Ajoutez le script de test dans package.json :

{
  "scripts": {
    "test": "vitest run",
    "test:watch": "vitest"
  }
}

Étape 11 : Préparer la publication sur npm

Assurez-vous que votre package.json contient les métadonnées requises :

{
  "name": "taskr-cli",
  "version": "1.0.0",
  "description": "Un gestionnaire de tâches minimaliste pour le terminal",
  "type": "module",
  "bin": {
    "taskr": "./dist/cli.js"
  },
  "files": ["dist"],
  "keywords": ["cli", "task", "todo", "terminal", "productivity"],
  "author": "Votre Nom",
  "license": "MIT",
  "engines": {
    "node": ">=20.0.0"
  }
}

Compilez et publiez :

npm run build
npm login
npm publish

Après la publication, n'importe qui peut installer et utiliser votre CLI :

npx taskr-cli
# ou
npm install -g taskr-cli
taskr add "Bonjour le monde"
taskr ls

Étape 12 : Ajouter un pipeline CI avec GitHub Actions

Créez .github/workflows/ci.yml :

name: CI
 
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
 
jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        node-version: [20, 22]
 
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
      - run: npm ci
      - run: npm test
      - run: npm run build

Dépannage

Erreurs "Cannot find module" lors de l'exécution du code compilé : Assurez-vous que tsconfig.json contient "moduleResolution": "bundler" et que toutes les importations utilisent l'extension .js (même pour les fichiers .ts). TypeScript l'exige pour la sortie ESM.

"Permission denied" lors de l'exécution globale du CLI : Après le build, assurez-vous que le fichier de sortie est exécutable :

chmod +x dist/cli.js

Les couleurs de Chalk ne s'affichent pas : Certains environnements CI ou terminaux minimalistes ne supportent pas les couleurs. Chalk le détecte automatiquement, mais vous pouvez forcer les couleurs avec FORCE_COLOR=1.

Prochaines étapes

Maintenant que vous avez un outil CLI fonctionnel, envisagez ces améliorations :

  • Ajouter des groupes de sous-commandes pour les CLI complexes avec les commandes imbriquées de Commander
  • Implémenter des plugins en chargeant dynamiquement des modules depuis un répertoire de configuration
  • Ajouter l'auto-complétion shell avec les packages omelette ou tabtab
  • Créer une TUI (interface textuelle) avec blessed ou ink pour des interactions plus riches
  • Supporter plusieurs formats de sortie (JSON, tableau, YAML) pour l'intégration avec les scripts

Conclusion

Vous avez construit un outil en ligne de commande complet et professionnel avec TypeScript — de la configuration du projet et du parsing des arguments aux prompts interactifs, la sortie colorée, les tests et la publication sur npm. Les outils CLI sont un moyen puissant d'automatiser les workflows, de partager des utilitaires avec votre équipe et de contribuer à l'écosystème open source. Les patterns que vous avez appris ici — Commander pour le parsing, Chalk pour la sortie, Inquirer pour l'interaction et Conf pour la persistance — forment la boîte à outils standard utilisée par la plupart des CLI Node.js populaires aujourd'hui.


Vous voulez lire plus de tutoriels? Découvrez notre dernier tutoriel sur Créer votre première extension Airtable : Guide étape par étape pour des fonctionnalités personnalisées.

Discutez de votre projet avec nous

Nous sommes ici pour vous aider avec vos besoins en développement Web. Planifiez un appel pour discuter de votre projet et comment nous pouvons vous aider.

Trouvons les meilleures solutions pour vos besoins.

Articles connexes