Use Cases (Atualizado: 02/06/2026)

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.

Desenvolver ferramentas CLI com Claude Code: Node.js, TypeScript, testes e releases seguros

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

CasoUsoRisco
Processamento de artigosPassar slugs por stdin e receber JSONLogs misturados no stdout quebram parsers
Health check de repositorioRodar check na CIAviso retorna 0 e o release continua
Ferramenta de API internaTrocar endpoint via .clipilotrc.jsonToken gravado no arquivo de config
CLI publicado no npmExpor clipilot via binCaminho 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.

#Claude Code #CLI #Node.js #TypeScript #Commander #GitHub Actions
Grátis

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.

Masa

Sobre o autor

Masa

Engenheiro focado em workflows práticos com Claude Code.