Use Cases (Mis à jour: 02/06/2026)

Developper des outils CLI avec Claude Code : Node.js, TypeScript, tests et release sure

Construisez un CLI Node.js/TypeScript avec Claude Code : arguments, config, stdin/stdout, tests, CI et secrets.

Developper des outils CLI avec Claude Code : Node.js, TypeScript, tests et release sure

Un outil CLI commence souvent comme un script personnel. Puis il recoit des options, un fichier de configuration, une execution en CI, une publication npm et des utilisateurs dans l’equipe. A ce stade, “ca marche chez moi” ne suffit plus. Un bon CLI doit avoir un parsing d’arguments previsible, un stdout propre, des avertissements sur stderr, des codes de sortie explicites, des tests, un mode dry-run et une gestion prudente des secrets.

Claude Code est utile pour ce travail car un CLI touche plusieurs fichiers en meme temps : package.json, TypeScript, tests, GitHub Actions et documentation. Mais une demande vague comme “cree un CLI” produit souvent une demo : l’aide s’affiche, mais le contrat de sortie est flou, les logs se melangent au JSON et la release n’est pas protegee.

Dans cet article, nous construisons clipilot, un petit CLI Node.js/TypeScript. L’objectif est de montrer comment cadrer Claude Code pour obtenir un outil testable, automatisable et publiable.

Demander le bon contrat a Claude Code

stdin est l’entree standard, stdout la sortie standard, stderr la sortie d’erreur, et exit code le nombre que le shell ou la CI lit pour juger le resultat. Pour un CLI, ces details sont le vrai contrat.

Create a Node.js 22 + TypeScript CLI named clipilot.
Requirements:
- Implement init, run, and check subcommands with commander
- Configure package.json bin so it can be installed as an npm CLI
- Load .clipilotrc.json or an explicit --config JSON file
- If stdin is present, use it as the run command input
- Write machine-readable results to stdout and warnings/errors to stderr
- Use exit code 0 for success, 2 for release configuration warnings, and 1 for exceptions
- Do not accept secrets through command-line flags; use environment variables or stdin
- Add Vitest coverage for help, stdin, config loading, and failure behavior
- Add a GitHub Actions workflow that runs test, build, and a dry-run command

Relisez la sortie avec les sources officielles : Node.js process, Node.js fs, npm package.json bin, Commander, GitHub Actions et la documentation Claude Code. Le prompt doit fixer les comportements qui ne doivent pas changer.

Squelette du projet et bin npm

Commencez par creer le projet. Le champ bin indique a npm quel fichier executable exposer apres installation.

mkdir clipilot
cd clipilot
npm init -y
npm i commander
npm i -D typescript tsx vitest @types/node
mkdir src tests
{
  "name": "clipilot",
  "version": "0.1.0",
  "type": "module",
  "private": true,
  "bin": {
    "clipilot": "./dist/cli.js"
  },
  "scripts": {
    "dev": "tsx src/cli.ts",
    "build": "tsc -p tsconfig.json",
    "test": "vitest run"
  },
  "dependencies": {
    "commander": "^12.1.0"
  },
  "devDependencies": {
    "@types/node": "^22.0.0",
    "tsx": "^4.19.0",
    "typescript": "^5.6.0",
    "vitest": "^2.1.0"
  }
}
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "rootDir": "src",
    "outDir": "dist",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true
  },
  "include": ["src/**/*.ts"]
}

Cette base evite une erreur frequente : compiler vers dist/cli.js mais pointer bin vers un autre fichier. Quand Claude Code modifie la build, demandez-lui de verifier outDir, shebang et package.json ensemble.

CLI TypeScript complet

Placez ce code dans src/cli.ts. Il montre Commander, le chargement de config, stdin, la separation stdout/stderr et les codes de sortie.

#!/usr/bin/env node
import { access, readFile, writeFile } from "node:fs/promises";
import { constants } from "node:fs";
import { homedir } from "node:os";
import path from "node:path";
import process from "node:process";
import { pathToFileURL } from "node:url";
import { Command } from "commander";

type OutputMode = "text" | "json";

type AppConfig = {
  defaultName: string;
  output: OutputMode;
  endpoint?: string;
};

type RawConfig = Partial<AppConfig> & Record<string, unknown>;

const defaultConfig: AppConfig = {
  defaultName: "world",
  output: "text"
};

async function exists(filePath: string) {
  try {
    await access(filePath, constants.F_OK);
    return true;
  } catch {
    return false;
  }
}

function normalizeConfig(raw: RawConfig, source: string): AppConfig {
  const output = raw.output ?? defaultConfig.output;
  if (output !== "text" && output !== "json") {
    throw new Error(`Invalid output in ${source}: expected "text" or "json"`);
  }

  const defaultName =
    typeof raw.defaultName === "string" && raw.defaultName.trim()
      ? raw.defaultName
      : defaultConfig.defaultName;

  const endpoint =
    typeof raw.endpoint === "string" && raw.endpoint.trim()
      ? raw.endpoint
      : undefined;

  return {
    defaultName,
    output,
    ...(endpoint ? { endpoint } : {})
  };
}

export async function loadConfig(configPath?: string): Promise<AppConfig> {
  const candidates = configPath
    ? [path.resolve(configPath)]
    : [
        path.resolve(process.cwd(), ".clipilotrc.json"),
        path.join(homedir(), ".clipilotrc.json")
      ];

  for (const candidate of candidates) {
    if (await exists(candidate)) {
      const raw = JSON.parse(await readFile(candidate, "utf8")) as RawConfig;
      return normalizeConfig(raw, candidate);
    }

    if (configPath) {
      throw new Error(`Config file not found: ${candidate}`);
    }
  }

  return defaultConfig;
}

async function readStdin() {
  if (process.stdin.isTTY) return "";

  const chunks: Buffer[] = [];
  for await (const chunk of process.stdin) {
    chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
  }

  return Buffer.concat(chunks).toString("utf8").trim();
}

export function createProgram() {
  const program = new Command();

  program
    .name("clipilot")
    .description("A safe sample CLI generated with Claude Code")
    .version(process.env.npm_package_version ?? "0.1.0")
    .showHelpAfterError()
    .option("-c, --config <path>", "Path to a JSON config file");

  program
    .command("init")
    .description("Create .clipilotrc.json in the current directory")
    .option("-f, --force", "Overwrite an existing config file")
    .action(async (options: { force?: boolean }) => {
      const filePath = path.resolve(process.cwd(), ".clipilotrc.json");
      if ((await exists(filePath)) && !options.force) {
        throw new Error(`${filePath} already exists. Use --force to overwrite it.`);
      }

      await writeFile(filePath, `${JSON.stringify(defaultConfig, null, 2)}\n`, "utf8");
      process.stdout.write(`Created ${filePath}\n`);
    });

  program
    .command("run")
    .description("Read a message from an argument or stdin")
    .argument("[message]", "Message to process")
    .option("--json", "Print machine-readable JSON")
    .option("--dry-run", "Show what would happen without side effects")
    .action(async (message: string | undefined, options: { json?: boolean; dryRun?: boolean }) => {
      const globalOptions = program.opts<{ config?: string }>();
      const config = await loadConfig(globalOptions.config);
      const pipedInput = await readStdin();
      const text = (pipedInput || message || `hello ${config.defaultName}`).trim();

      const payload = {
        ok: true,
        dryRun: Boolean(options.dryRun),
        message: text,
        endpoint: config.endpoint ?? null
      };

      if (options.json || config.output === "json") {
        process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
        return;
      }

      process.stdout.write(`[${payload.dryRun ? "dry-run" : "run"}] ${payload.message}\n`);
    });

  program
    .command("check")
    .description("Validate release-sensitive configuration")
    .action(async () => {
      const globalOptions = program.opts<{ config?: string }>();
      const config = await loadConfig(globalOptions.config);

      if (!config.endpoint) {
        process.stderr.write("WARN endpoint is not set; release check failed.\n");
        process.exitCode = 2;
        return;
      }

      process.stdout.write("Configuration OK\n");
    });

  return program;
}

async function main(argv = process.argv) {
  await createProgram().parseAsync(argv);
}

if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
  main().catch((error: unknown) => {
    const message = error instanceof Error ? error.message : String(error);
    process.stderr.write(`${message}\n`);
    process.exitCode = 1;
  });
}

Commandes de verification :

npm run dev -- --help
npm run dev -- init
npm run dev -- run "hello cli"
printf "from stdin\n" | npm run dev -- run --json --dry-run
npm run dev -- check

check retourne 2 si endpoint manque. Cela represente un probleme de configuration de release, pas une exception.

Config, stdin/stdout et codes de sortie

Une configuration explicite rend le CLI plus simple a automatiser.

{
  "defaultName": "Masa",
  "output": "json",
  "endpoint": "https://api.example.com"
}

stdout doit contenir ce qu’un autre outil peut consommer. En mode JSON, il ne doit contenir que du JSON. Les messages humains, diagnostics et avertissements vont sur stderr. Les codes de sortie doivent aussi rester stables : 0 pour succes, 1 pour echec reel, 2 pour une configuration de release incomplete.

Cas d’usage concrets

CasUsageRisque
Traitement d’articlesEnvoyer des slugs par stdin et recevoir du JSONDes logs dans stdout cassent le parseur
Controle qualite de repoExecuter check en CIUne alerte finit en 0 et la release continue
Outil client APIChanger endpoint via .clipilotrc.jsonStocker un token dans le fichier
Outil developpeur npmPublier clipilot via binChemin compile ou shebang incorrect

Donnez au moins trois cas comme ceux-ci a Claude Code. Il generera de meilleurs sous-commandes si l’entree, la sortie et les echecs attendus sont connus. Pour la suite, consultez creer un package npm, configurer CI/CD avec Claude Code et le guide de demarrage Claude Code.

Tests Vitest

Un CLI doit etre teste comme un processus : stdout, stderr, statut et stdin.

import { spawnSync } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { afterEach, describe, expect, it } from "vitest";

const rootDir = path.resolve(fileURLToPath(new URL("..", import.meta.url)));
const npxBin = process.platform === "win32" ? "npx.cmd" : "npx";
const tempDirs: string[] = [];

function makeTempDir() {
  const dir = mkdtempSync(path.join(tmpdir(), "clipilot-"));
  tempDirs.push(dir);
  return dir;
}

afterEach(() => {
  for (const dir of tempDirs.splice(0)) {
    rmSync(dir, { recursive: true, force: true });
  }
});

function runCli(args: string[], options: { input?: string } = {}) {
  return spawnSync(npxBin, ["tsx", path.join(rootDir, "src/cli.ts"), ...args], {
    cwd: rootDir,
    input: options.input,
    encoding: "utf8"
  });
}

describe("clipilot CLI", () => {
  it("prints help", () => {
    const result = runCli(["--help"]);

    expect(result.status).toBe(0);
    expect(result.stdout).toContain("clipilot");
    expect(result.stdout).toContain("run");
  });

  it("uses stdin before the message argument", () => {
    const result = runCli(["run", "from-argument", "--json", "--dry-run"], {
      input: "from stdin\n"
    });
    const body = JSON.parse(result.stdout);

    expect(result.status).toBe(0);
    expect(body.message).toBe("from stdin");
    expect(body.dryRun).toBe(true);
  });

  it("loads an explicit config file", () => {
    const dir = makeTempDir();
    const configPath = path.join(dir, ".clipilotrc.json");
    writeFileSync(
      configPath,
      JSON.stringify({ defaultName: "Masa", output: "json", endpoint: "https://api.example.com" })
    );

    const result = runCli(["--config", configPath, "run"]);
    const body = JSON.parse(result.stdout);

    expect(result.status).toBe(0);
    expect(body.message).toBe("hello Masa");
    expect(body.endpoint).toBe("https://api.example.com");
  });

  it("returns exit code 1 when the config file is missing", () => {
    const dir = makeTempDir();
    const result = runCli(["--config", path.join(dir, "missing.json"), "run"]);

    expect(result.status).toBe(1);
    expect(result.stderr).toContain("Config file not found");
  });
});

Ces tests empechent les regressions silencieuses : une banniere avant le JSON, une priorite stdin modifiee, ou une erreur qui se termine avec 0.

Dry-run GitHub Actions

La CI doit lancer le CLI en mode sans effet de bord avant publication.

name: clipilot-cli

on:
  pull_request:
  push:
    branches: [main]

permissions:
  contents: read

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
      - uses: actions/setup-node@v6
        with:
          node-version: 22
          cache: npm
      - run: npm ci
      - run: npm test
      - run: npm run build
      - name: Dry-run CLI through stdin
        run: |
          printf 'hello from ci\n' | npm run dev -- run --json --dry-run

Le mode dry-run montre ce qui se passerait sans ecrire de fichiers, appeler une API de production ou deployer. Pour les secrets, evitez --token xxx comme chemin normal : les arguments peuvent apparaitre dans l’historique shell, les logs CI ou la liste des processus. Utilisez variables d’environnement, GitHub Actions Secrets ou stdin.

Pieges et CTA

Les pieges les plus courants sont simples : logs humains dans stdout, sortie 0 apres un echec, question interactive pendant une execution pipee, token dans un fichier de config, chemin bin casse, et --dry-run qui fait encore des appels reels. Demandez a Claude Code une revue de release avec ces points explicites.

Pour commencer avec des reperes quotidiens, utilisez la fiche gratuite. Pour des prompts et modeles de configuration reutilisables, consultez le Claude Code setup guide.

Masa a teste ce flux sur un petit CLI de traitement de contenu. Le gain principal n’etait pas la vitesse de frappe, mais la clarte de revue. Une fois stdin, stdout, stderr, exit codes, dry-run et secrets fixes dans le prompt, la sortie de Claude Code se jugeait par contrat.

Resume

Avec Claude Code, la qualite d’un CLI vient du contrat initial. Definissez les arguments, la priorite de configuration, stdin/stdout/stderr, les codes de sortie, npm bin, les tests, le dry-run CI et la gestion des secrets avant de generer le code.

Un petit CLI devient une automatisation de production des qu’il entre dans la CI ou dans le terminal d’un collegue. Traitez-le ainsi des le premier prompt.

#Claude Code #CLI #Node.js #TypeScript #Commander #GitHub Actions
Gratuit

PDF gratuit: cheatsheet Claude Code

Saisissez votre email et téléchargez une page avec commandes, habitudes de review et workflow sûr.

Nous protégeons vos données et n'envoyons pas de spam.

Masa

À propos de l'auteur

Masa

Ingénieur spécialisé dans les workflows pratiques avec Claude Code.