Use Cases (Updated: 6/2/2026)

CLI Tool Development with Claude Code: Node.js, TypeScript, Tests, and Safe Releases

Build a Node.js/TypeScript CLI with Claude Code: args, config, stdin/stdout, exit codes, tests, CI, and secrets.

CLI Tool Development with Claude Code: Node.js, TypeScript, Tests, and Safe Releases

A CLI tool often starts as a private script. Then it gains flags, configuration files, CI usage, npm packaging, and a second user. At that point, “it runs on my machine” is not enough. A useful CLI needs predictable argument parsing, clean stdout, warnings on stderr, meaningful exit codes, tests, dry-run behavior, and a safe way to handle secrets.

Claude Code is strong at this kind of work because the implementation spans many files. It can create package.json, TypeScript source, tests, and GitHub Actions in one pass. The risk is that a vague prompt creates demo code: nice help text, but unclear output contracts, no release checks, and secrets passed through command arguments.

This guide builds a small Node.js/TypeScript CLI named clipilot. The goal is not to create a toy generator. The goal is to show how to use Claude Code as a disciplined CLI development partner: define the contract first, write runnable code, test process behavior, and keep release safety visible.

Start With a Precise Claude Code Prompt

Do not start with “build a CLI.” Start with the behaviors that automation depends on. stdin means standard input, stdout means standard output, stderr means standard error output, and an exit code is the number a shell or CI job uses to decide whether the command succeeded.

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

Review the generated code against primary sources: Node.js process, Node.js fs, npm package.json bin, Commander, GitHub Actions, and the Claude Code documentation. The prompt tells Claude Code what must remain stable, not just what files to create.

Project Skeleton and package.json bin

Create a small project first. The bin field is the important packaging detail: npm creates an executable command that points to the compiled JavaScript file. The target file must start with a Node shebang.

mkdir clipilot
cd clipilot
npm init -y
npm i commander
npm i -D typescript tsx vitest @types/node
mkdir src tests

Use this package.json.

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

This skeleton avoids a common release bug: building to one path while bin points to another. Ask Claude Code to keep outDir, the shebang, and package.json aligned whenever it changes the build setup.

Runnable TypeScript CLI with Commander

Paste the following into src/cli.ts. It demonstrates Commander usage, config loading, stdin handling, stdout/stderr separation, and exit code control in one file.

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

Run it locally:

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

The check command exits with code 2 when endpoint is missing. That gives CI a way to distinguish a warning-level release problem from a runtime exception.

Config Files, stdin/stdout, and Exit Codes

The config file can be project-local, home-directory based, or explicit through --config. Keep the order deterministic. A human should not have to guess which value was used.

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

stdout should contain the thing another command may consume. In JSON mode, that means JSON only. Progress, warnings, and diagnostics belong on stderr. This sounds strict, but it is what makes clipilot | jq or a GitHub Actions step reliable.

Exit codes are equally important. Code 0 means success. Code 1 means an exception or a real command failure. Code 2 means this sample ran but did not satisfy a release-sensitive check. You can choose different numbers, but document them and test them.

Practical Use Cases

The same structure works beyond the sample:

Use caseHow the CLI is usedWhat can go wrong
Content batch processingPipe article slugs through stdin and return JSON resultsHuman logs mixed into stdout break parsers
Repository health checksRun check in CI before releaseA warning exits with 0 and the release continues
API client operationsSwitch endpoints with .clipilotrc.jsonTokens are accidentally stored in config
npm-distributed developer toolsInstall clipilot through binThe built file path or shebang is wrong

Give Claude Code at least three real examples like these. It will produce better commands when it knows the user path, the expected output, and the failure mode. For adjacent workflows, see npm package creation, Claude Code CI/CD setup, and the Claude Code getting started guide.

Vitest Tests for Process Behavior

Testing a CLI is different from testing a helper function. You need to inspect stdout, stderr, status codes, and stdin. This test file runs the CLI through npx tsx so it behaves like a real command.

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");
  });
});

These tests force the generated CLI to keep its public contract. If Claude Code later changes stdin priority or logs a banner before JSON output, the tests fail immediately.

GitHub Actions Dry-Run

CI should run the command in a safe mode before anyone publishes it. A dry-run means the CLI shows what would happen without writing files, sending email, deploying code, or calling production APIs.

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

Use the official GitHub Actions quickstart, workflow syntax, and secrets guide when reviewing this file.

Release Safety and Secrets

Secrets are where many otherwise clean CLIs become risky. Do not design --token my-secret as the normal path. Command-line arguments can appear in shell history, CI logs, and process lists. Prefer environment variables, GitHub Actions Secrets, or stdin for sensitive values.

Before release, ask Claude Code for a review with specific checks:

Review this CLI before release.
Check:
- stdout stays machine-readable in --json mode
- stderr never prints tokens, keys, or secret values
- exit codes 0/1/2 match the tests and docs
- --dry-run performs no external API call and no file write
- npm pack --dry-run includes only the intended files
- package.json bin points to a built file with a shebang

Common pitfalls are predictable. Mixing logs into stdout breaks downstream automation. Returning 0 after a failed check lets CI continue. Reading stdin and then asking an interactive question can hang CI. Saving tokens in config files creates accidental leaks. Changing tsconfig without updating bin breaks installed commands. These are the exact issues Claude Code should review, not after the release but before it.

Monetization CTA

CLI readers usually have a practical workflow problem. If your team wants Claude Code workflows for internal tools, release checks, and CI review, start with Claude Code training and consultation. If you want a daily reference before adopting a larger workflow, use the free cheatsheet. For prompt packs and implementation templates, review the products page.

Masa tested this pattern on a small content-processing CLI. The biggest gain was not typing speed. It was fewer review arguments. Once stdin, stdout, stderr, exit codes, dry-run behavior, and secrets handling were fixed in the prompt, Claude Code’s output could be judged against an explicit contract. The local command, Vitest, and GitHub Actions all checked the same behavior.

Summary

Claude Code can produce a useful CLI quickly, but the quality comes from the contract you give it. Define arguments, config precedence, stdin/stdout/stderr behavior, exit codes, npm bin, tests, dry-run CI, and secret handling before the code is generated.

A CLI may look small, but once it runs in CI or a teammate’s terminal, it becomes production automation. Treat it that way from the first prompt.

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

Free PDF: Claude Code Cheatsheet

Enter your email and download the one-page Claude Code cheatsheet for commands, review habits, and safe workflows.

We handle your data with care and never send spam.

Level up your Claude Code workflow

Start with the free PDF, use Gumroad guides when you need repeatable workflows, and book consultation when rollout or revenue paths need human judgment.

Masa

About the Author

Masa

Engineer focused on practical Claude Code workflows. Runs claudecode-lab.com, a 10-language technical media site.