Use Cases (Diperbarui: 2/6/2026)

Membangun CLI Tool dengan Claude Code: Node.js, TypeScript, Test, dan Rilis Aman

Bangun CLI Node.js/TypeScript dengan Claude Code: argumen, config, stdin/stdout, exit codes, test, CI, dan secrets.

Membangun CLI Tool dengan Claude Code: Node.js, TypeScript, Test, dan Rilis Aman

CLI tool biasanya dimulai dari script pribadi. Lama-lama script itu punya flag, file konfigurasi, dijalankan di CI, dibagikan lewat npm, lalu dipakai anggota tim lain. Pada titik itu, “jalan di laptop saya” tidak cukup. CLI yang bisa dipakai kerja butuh parsing argumen yang jelas, stdout yang bersih, warning di stderr, exit code yang bermakna, test, mode dry-run, dan cara aman menangani secrets.

Claude Code cocok untuk pekerjaan seperti ini karena pengembangan CLI menyentuh banyak file sekaligus: package.json, source TypeScript, test, GitHub Actions, dan dokumentasi. Risiko utamanya adalah prompt yang terlalu umum. Jika hanya menulis “buatkan CLI”, hasilnya sering berupa demo: help muncul, tetapi kontrak output tidak jelas, log bercampur dengan JSON, dan rilis tidak aman.

Artikel ini membuat CLI kecil bernama clipilot dengan Node.js/TypeScript. Fokusnya bukan membuat generator yang terlihat keren, tetapi membangun dasar yang bisa direview, dites, dan dimasukkan ke otomasi dengan aman.

Prompt yang Jelas untuk Claude Code

stdin adalah standard input, stdout adalah standard output, stderr adalah output untuk error, dan exit code adalah angka yang dibaca shell atau CI untuk menentukan sukses atau gagal. Untuk CLI, aturan ini adalah kontrak utama.

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

Saat mereview hasilnya, gunakan dokumen resmi: Node.js process, Node.js fs, npm package.json bin, Commander, GitHub Actions, dan dokumentasi Claude Code. Prompt harus mengunci perilaku, bukan hanya meminta file.

Struktur Proyek dan package.json bin

Buat proyek minimal terlebih dahulu. Field bin memberi tahu npm file mana yang akan dijadikan command executable.

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

Struktur ini mencegah bug rilis yang sering terjadi: TypeScript build ke satu lokasi, tetapi bin menunjuk lokasi lain. Saat Claude Code mengubah build, minta ia mengecek outDir, shebang, dan package.json bersama-sama.

CLI TypeScript yang Bisa Langsung Dijalankan

Simpan kode berikut sebagai src/cli.ts. Contoh ini mencakup Commander, loading config, stdin, pemisahan stdout/stderr, dan exit code.

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

Jalankan secara lokal:

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 mengembalikan exit code 2 ketika endpoint belum diset. Itu berarti konfigurasi rilis belum lengkap, bukan exception biasa.

Config, stdin/stdout, dan Exit Code

File config membuat perilaku CLI lebih mudah diulang.

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

stdout harus berisi output yang mungkin dibaca command lain. Dalam mode JSON, isinya hanya JSON. Log, progress, warning, dan diagnostic masuk ke stderr. Exit code juga harus stabil: 0 sukses, 1 error, 2 release warning. Angkanya bisa disesuaikan tim, tetapi dokumentasi, test, dan CI harus membaca makna yang sama.

Use Case Nyata

Use caseCara pakaiRisiko
Batch processing artikelMasukkan slug lewat stdin dan ambil JSONLog di stdout merusak parser
Health check repositoryJalankan check di CIWarning tetap exit 0 dan rilis lanjut
Tool operasi APIGanti endpoint lewat .clipilotrc.jsonToken tersimpan di file config
CLI developer via npmSediakan command clipilot lewat binBuild path atau shebang salah

Berikan minimal tiga contoh seperti ini kepada Claude Code. Ia akan membuat command yang lebih realistis ketika input, output, dan failure mode sudah jelas. Untuk lanjutan, baca membuat package npm, setup CI/CD Claude Code, dan panduan awal Claude Code.

Test CLI dengan Vitest

CLI perlu dites sebagai proses: stdout, stderr, status, dan 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");
  });
});

Test ini menangkap regresi seperti banner sebelum JSON, prioritas stdin berubah, atau error yang keluar dengan status 0.

Dry-run di GitHub Actions

CI sebaiknya menjalankan CLI dalam mode aman sebelum rilis.

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 menampilkan apa yang akan terjadi tanpa menulis file, memanggil production API, atau deploy. Secrets jangan dijadikan argumen seperti --token xxx, karena argumen bisa muncul di shell history, log CI, dan process list. Gunakan environment variables, GitHub Actions Secrets, atau stdin.

Jebakan Umum dan CTA

Jebakan yang sering muncul adalah log manusia masuk ke stdout, failure tetap exit 0, prompt interaktif muncul saat input dipipe, token disimpan di config, bin menunjuk file yang salah, atau --dry-run tetap melakukan API call nyata. Minta Claude Code melakukan review rilis dengan daftar ini secara eksplisit.

Untuk referensi harian, mulai dari cheatsheet gratis. Jika butuh prompt dan pola setup yang bisa dipakai ulang, lihat Claude Code setup guide.

Masa mencoba pola ini pada CLI kecil untuk memproses konten. Manfaat terbesar bukan kecepatan mengetik, tetapi kejelasan review. Setelah stdin, stdout, stderr, exit code, dry-run, dan aturan secrets ditulis sejak prompt, output Claude Code bisa dinilai berdasarkan kontrak, bukan sekadar “sepertinya jalan”.

Ringkasan

Saat membuat CLI dengan Claude Code, kontrak lebih dulu daripada kode. Tentukan argumen, prioritas config, stdin/stdout/stderr, exit code, npm bin, test, dry-run CI, dan handling secrets sebelum meminta implementasi.

CLI kecil pun menjadi otomasi produksi ketika berjalan di CI atau terminal rekan kerja. Desain dari prompt pertama dengan standar itu.

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

PDF gratis: cheatsheet Claude Code

Masukkan email dan unduh satu halaman berisi command, kebiasaan review, dan workflow aman.

Kami menjaga datamu dan tidak mengirim spam.

Masa

Tentang penulis

Masa

Engineer yang berfokus pada workflow Claude Code praktis dan adopsi tim.