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.
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
| Caso | Uso del CLI | Riesgo |
|---|---|---|
| Procesamiento de articulos | Pasar slugs por stdin y recibir JSON | Mezclar logs en stdout rompe parsers |
| Health check de repositorio | Ejecutar check en CI | Una advertencia termina con 0 y se publica igual |
| Herramienta para API interna | Cambiar endpoint con .clipilotrc.json | Guardar tokens en el archivo de config |
| CLI distribuido por npm | Exponer clipilot con bin | Ruta 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.
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.
Sobre el autor
Masa
Ingeniero enfocado en workflows prácticos con Claude Code.
Artículos relacionados
Workflow de Obsidian a CLAUDE.md con Claude Code
Convierte notas de trabajo de Obsidian en notas operativas de CLAUDE.md para no repetir contexto.
Claude Code Revenue CTA Routing: de artículos a PDF, Gumroad y consulta
Un flujo con Claude Code para dirigir lectores a PDF gratis, Gumroad o consulta según intención.
Reglas de handoff para equipos con Claude Code: evidencia, permisos, rollback e ingresos
Formato práctico para entregar trabajo de Claude Code con pruebas, permisos, rollback, PDF gratis, Gumroad y consulta.