Desenvolver ferramentas CLI com Claude Code: Node.js, TypeScript, testes e releases seguros
Crie uma CLI Node.js/TypeScript com Claude Code: argumentos, config, stdin/stdout, exit codes, testes, CI e segredos.
Uma ferramenta CLI geralmente nasce como um script pessoal. Depois ela ganha flags, arquivo de configuracao, execucao em CI, publicacao no npm e usuarios dentro do time. Nesse ponto, “funciona na minha maquina” ja nao basta. Uma CLI util precisa de parsing previsivel, stdout limpo, avisos em stderr, exit codes claros, testes, modo dry-run e uma regra segura para lidar com segredos.
Claude Code ajuda muito nesse tipo de trabalho porque a implementacao envolve varios arquivos: package.json, TypeScript, testes, GitHub Actions e documentacao. O risco e pedir apenas “crie uma CLI” e receber uma demo: a ajuda aparece, mas a saida nao e estavel, logs se misturam com JSON e a publicacao fica insegura.
Neste guia vamos criar clipilot, uma pequena CLI em Node.js/TypeScript. O foco e mostrar como orientar Claude Code para gerar algo revisavel, testavel e seguro para automacao.
Prompt claro para Claude Code
stdin e entrada padrao, stdout e saida padrao, stderr e saida de erro, e exit code e o numero usado pelo shell ou pela CI para julgar o resultado. Em CLI, esse contrato importa tanto quanto o codigo interno.
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
Revise o resultado usando fontes oficiais: Node.js process, Node.js fs, npm package.json bin, Commander, GitHub Actions e a documentacao do Claude Code. O prompt deve fixar comportamento, nao apenas pedir arquivos.
Estrutura do projeto e package.json bin
Crie primeiro o projeto minimo. O campo bin diz ao npm qual arquivo deve virar comando executavel.
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"]
}
Essa base evita um erro comum de release: compilar para uma pasta e apontar bin para outra. Sempre que Claude Code mudar a build, peca para verificar outDir, shebang e package.json juntos.
CLI TypeScript executavel
Salve o arquivo abaixo como src/cli.ts. Ele cobre Commander, carregamento de config, stdin, separacao de stdout/stderr e exit codes.
#!/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;
});
}
Teste localmente:
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 retorna 2 quando endpoint nao esta configurado. Isso separa aviso de release de erro real.
Config, stdin/stdout e exit codes
Um arquivo de configuracao explicito facilita repetir o comportamento localmente e na CI.
{
"defaultName": "Masa",
"output": "json",
"endpoint": "https://api.example.com"
}
stdout deve conter apenas aquilo que outro comando pode consumir. Em modo JSON, somente JSON. Logs, progresso, avisos e diagnosticos vao para stderr. Exit codes tambem precisam ser documentados: 0 sucesso, 1 erro, 2 configuracao de release incompleta.
Casos de uso praticos
| Caso | Uso | Risco |
|---|---|---|
| Processamento de artigos | Passar slugs por stdin e receber JSON | Logs misturados no stdout quebram parsers |
| Health check de repositorio | Rodar check na CI | Aviso retorna 0 e o release continua |
| Ferramenta de API interna | Trocar endpoint via .clipilotrc.json | Token gravado no arquivo de config |
| CLI publicado no npm | Expor clipilot via bin | Caminho compilado ou shebang errado |
De a Claude Code pelo menos tres cenarios reais. Ele gera comandos melhores quando conhece entrada, saida esperada e falha a evitar. Para continuar, veja criacao de pacote npm, CI/CD com Claude Code e o guia inicial de Claude Code.
Testes com Vitest
Teste CLI como processo: stdout, stderr, status e 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");
});
});
Esses testes pegam regressao cedo: banner antes do JSON, prioridade errada entre stdin e argumento, ou falha terminando com 0.
Dry-run no GitHub Actions
A CI deve executar a CLI em modo seguro antes de publicar.
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
Dry-run mostra o que aconteceria sem escrever arquivos, chamar API de producao ou fazer deploy. Segredos nao devem ser passados como --token xxx, pois argumentos podem aparecer em historico do shell, logs de CI e lista de processos. Prefira variaveis de ambiente, GitHub Actions Secrets ou stdin.
Armadilhas e CTA
As armadilhas mais comuns sao: logs humanos no stdout, erro retornando 0, pergunta interativa durante execucao por pipe, token salvo em config, bin quebrado e --dry-run fazendo chamada real. Peca uma revisao de release ao Claude Code com esses itens explicitos.
Para comecar com uma referencia diaria, use a cola gratuita. Para prompts e configuracoes reutilizaveis, veja o Claude Code setup guide.
Masa testou esse fluxo em uma pequena CLI de processamento de conteudo. O principal ganho nao foi velocidade de escrita, mas clareza de revisao. Com stdin, stdout, stderr, exit codes, dry-run e secrets definidos no prompt, a saida do Claude Code podia ser avaliada por contrato.
Resumo
Com Claude Code, a qualidade de uma CLI depende do contrato inicial. Defina argumentos, precedencia de config, stdin/stdout/stderr, exit codes, npm bin, testes, dry-run em CI e tratamento de segredos antes de gerar o codigo.
Uma CLI pequena vira automacao de producao quando entra na CI ou no terminal de um colega. Trate assim desde o primeiro prompt.
PDF grátis: cheatsheet do Claude Code
Informe seu e-mail e baixe uma página com comandos, hábitos de revisão e workflows seguros.
Cuidamos dos seus dados e não enviamos spam.
Sobre o autor
Masa
Engenheiro focado em workflows práticos com Claude Code.
Artigos relacionados
Workflow Obsidian para CLAUDE.md com Claude Code
Transforme notas de trabalho do Obsidian em notas operacionais CLAUDE.md para preservar contexto.
Claude Code Revenue CTA Routing: artigos para PDF, Gumroad e consultoria
Um fluxo com Claude Code para levar leitores ao PDF grátis, Gumroad ou consultoria conforme intenção.
Regras de handoff para equipes com Claude Code: evidências, permissões, rollback e receita
Formato prático para entregar trabalho do Claude Code com prova, permissões, rollback, PDF grátis, Gumroad e consultoria.