Use Cases (Actualizado: 2/6/2026)

Desarrollar herramientas CLI con Claude Code: Node.js, TypeScript, tests y releases seguros

Crea un CLI Node.js/TypeScript con Claude Code: argumentos, config, stdin/stdout, exit codes, tests, CI y secretos.

Desarrollar herramientas CLI con Claude Code: Node.js, TypeScript, tests y releases seguros

Una herramienta CLI suele empezar como un script personal. Luego aparecen flags, archivos de configuracion, ejecucion en CI, publicacion en npm y usuarios dentro del equipo. En ese momento, “funciona en mi maquina” deja de ser suficiente. Un CLI util necesita parseo de argumentos predecible, stdout limpio, advertencias en stderr, codigos de salida claros, tests, modo dry-run y una politica segura para secretos.

Claude Code encaja muy bien en este tipo de desarrollo porque el cambio cruza varios archivos: package.json, TypeScript, tests, GitHub Actions y documentacion. El riesgo es pedir “crea un CLI” y recibir una demo: muestra ayuda, pero no define contratos de salida, no separa logs de JSON y no protege el release.

En esta guia crearemos clipilot, un CLI pequeno en Node.js/TypeScript. La idea no es mostrar un generador vistoso, sino una base que se pueda revisar, automatizar y publicar con menos sorpresas.

Prompt preciso para Claude Code

stdin significa entrada estandar, stdout es la salida estandar, stderr es la salida de errores y exit code es el numero que usa la shell o CI para decidir si el comando tuvo exito. En un CLI, estos detalles son el contrato principal.

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

Al revisar la respuesta, usa fuentes oficiales: Node.js process, Node.js fs, npm package.json bin, Commander, GitHub Actions y la documentacion de Claude Code. El prompt debe fijar comportamientos, no solo pedir archivos.

Estructura del proyecto y bin

Primero crea el proyecto. El campo bin permite que npm instale un comando ejecutable apuntando al JavaScript compilado.

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"]
}

Esta base evita un fallo frecuente: compilar a una ruta y publicar otra en bin. Pide a Claude Code que mantenga alineados outDir, shebang y package.json cada vez que cambie la compilacion.

CLI TypeScript ejecutable

Guarda este archivo como src/cli.ts. Incluye Commander, carga de configuracion, stdin, separacion stdout/stderr y control de codigos de salida.

#!/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;
  });
}

Ejecutalo asi:

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 devuelve codigo 2 si falta endpoint. Es una advertencia de release, no una excepcion.

Configuracion, stdin/stdout y exit codes

El archivo de configuracion puede ser local al proyecto o explicito con --config.

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

stdout debe contener lo que otro programa va a consumir. En modo JSON, solo JSON. Los mensajes humanos, advertencias y diagnosticos van a stderr. Los exit codes tambien deben ser estables: 0 exito, 1 fallo real, 2 configuracion de release incompleta. Lo importante no es el numero exacto, sino que documentacion, tests y CI lo interpreten igual.

Casos de uso reales

CasoUso del CLIRiesgo
Procesamiento de articulosPasar slugs por stdin y recibir JSONMezclar logs en stdout rompe parsers
Health check de repositorioEjecutar check en CIUna advertencia termina con 0 y se publica igual
Herramienta para API internaCambiar endpoint con .clipilotrc.jsonGuardar tokens en el archivo de config
CLI distribuido por npmExponer clipilot con binRuta compilada o shebang incorrectos

Da a Claude Code tres o mas casos concretos. Mejora mucho cuando conoce la entrada, la salida esperada y el fallo que debe proteger. Para temas cercanos, revisa crear paquetes npm, configurar CI/CD con Claude Code y la guia inicial de Claude Code.

Tests con Vitest

Un CLI se prueba como proceso. Hay que inspeccionar stdout, stderr, status y 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");
  });
});

Estos tests impiden regresiones silenciosas: banners antes del JSON, cambios de prioridad entre stdin y argumento, o fallos que terminan con 0.

Dry-run en GitHub Actions

El CI debe ejecutar el CLI en modo seguro antes de publicar. Dry-run significa mostrar lo que pasaria sin escribir archivos, llamar APIs reales ni desplegar.

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

Consulta tambien la guia oficial de secrets en GitHub Actions. No conviertas --token xxx en el camino normal. Los argumentos pueden quedar en historial de shell, logs o listas de procesos.

Errores comunes y CTA

Los errores mas comunes son mezclar logs en stdout, devolver 0 cuando una comprobacion fallo, leer stdin y luego esperar una pregunta interactiva, guardar tokens en config, romper la ruta de bin, o hacer llamadas reales durante --dry-run. Pide a Claude Code una revision de release con esos puntos escritos uno por uno.

Si quieres aplicar Claude Code a herramientas internas, CI y revisiones de release, empieza con la chuleta gratuita. Para plantillas y prompts reutilizables, revisa la guia de configuracion de Claude Code.

Masa probo este flujo con un CLI pequeno para procesar contenido. La mayor mejora no fue escribir mas rapido, sino revisar con menos ambiguedad. Cuando stdin, stdout, stderr, exit codes, dry-run y secretos estaban definidos desde el prompt, el resultado se podia juzgar por contrato, no por intuicion.

Resumen

Claude Code puede crear un CLI rapido, pero la calidad depende del contrato inicial. Define argumentos, prioridad de config, stdin/stdout/stderr, exit codes, npm bin, tests, dry-run en CI y manejo de secretos antes de generar codigo.

Un CLI pequeno se convierte en automatizacion de produccion cuando entra en CI o en la terminal de un companero. Disenalo con ese nivel desde el primer prompt.

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

PDF gratis: cheatsheet de Claude Code

Introduce tu email y descarga una hoja con comandos, hábitos de revisión y flujos seguros.

Cuidamos tus datos y no enviamos spam.

Masa

Sobre el autor

Masa

Ingeniero enfocado en workflows prácticos con Claude Code.