Claude Code로 CLI 도구 개발하기: Node.js, TypeScript, 테스트, 안전한 릴리스
Claude Code로 Node.js/TypeScript CLI를 만든다. 인자, 설정, stdin/stdout, 종료 코드, 테스트, CI, 보안을 다룬다.
CLI 도구는 보통 작은 개인 스크립트에서 시작합니다. 하지만 옵션이 늘고, 설정 파일을 읽고, CI에서 실행하고, npm 패키지로 배포하기 시작하면 단순한 스크립트로는 부족합니다. 제대로 된 CLI는 인자 파싱, 깨끗한 stdout, stderr로 분리된 경고, 의미 있는 종료 코드, 테스트, dry-run, 그리고 안전한 비밀 정보 처리까지 갖춰야 합니다.
Claude Code는 이런 작업에 잘 맞습니다. CLI 개발은 package.json, TypeScript 엔트리 파일, 테스트, GitHub Actions, 문서가 함께 움직이는 일이기 때문입니다. 다만 “CLI 하나 만들어줘”라고만 요청하면 데모 코드는 빠르게 나오지만, 운영에서 필요한 출력 규칙과 릴리스 안전성은 빠질 수 있습니다.
이 글에서는 Node.js/TypeScript로 clipilot이라는 작은 CLI를 만듭니다. 목표는 빠른 생성이 아니라, Claude Code가 만든 코드를 사람이 검토하고 CI에 넣을 수 있는 형태로 만드는 것입니다.
Claude Code에 줄 요구사항
stdin은 표준 입력, stdout은 표준 출력, stderr는 표준 에러 출력입니다. exit code는 쉘과 CI가 성공 여부를 판단하는 숫자입니다. CLI는 화면이 작기 때문에 이 약속이 곧 품질입니다.
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
생성된 코드는 공식 문서와 함께 검토합니다. Node.js process, Node.js fs, npm package.json의 bin, Commander, GitHub Actions, Claude Code 문서를 기준으로 보면 됩니다.
프로젝트 골격과 package.json bin
먼저 최소 프로젝트를 만듭니다. bin은 npm이 설치 시 실행 명령을 연결하는 필드입니다. 여기에 지정된 빌드 파일은 Node shebang을 가져야 합니다.
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"]
}
이 구조는 tsc가 만든 파일 위치와 package.json의 bin 경로가 어긋나는 문제를 막습니다. Claude Code가 빌드 설정을 바꿀 때는 항상 outDir, shebang, bin을 함께 확인하게 하세요.
실행 가능한 TypeScript CLI
아래 파일을 src/cli.ts에 붙여 넣으면 실행할 수 있습니다. Commander 사용법, 설정 로딩, stdin 처리, stdout/stderr 분리, 종료 코드 제어가 한 파일에 들어 있습니다.
#!/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;
});
}
로컬 실행 예시는 다음과 같습니다.
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는 endpoint가 없을 때 종료 코드 2를 반환합니다. 이는 예외가 아니라 릴리스 조건 미충족이라는 의미입니다.
설정 파일, stdin/stdout, 종료 코드
설정 파일은 프로젝트의 .clipilotrc.json, 명시적 --config, 홈 디렉터리 설정, 기본값 순서로 처리하면 예측 가능합니다.
{
"defaultName": "Masa",
"output": "json",
"endpoint": "https://api.example.com"
}
stdout에는 다음 명령이 읽을 결과만 둡니다. JSON 모드라면 JSON만 출력해야 합니다. 진행 상황, 경고, 디버그 메시지는 stderr로 보냅니다. 종료 코드는 0 성공, 1 예외, 2 릴리스 설정 경고로 고정합니다. 숫자를 다르게 정해도 되지만 문서와 테스트가 같은 의미를 봐야 합니다.
실제 유스케이스
| 유스케이스 | 사용 방식 | 위험 |
|---|---|---|
| 콘텐츠 일괄 처리 | stdin으로 글 slug를 넣고 JSON 결과를 받는다 | stdout에 로그가 섞이면 파서가 깨진다 |
| 저장소 품질 점검 | CI에서 check를 실행한다 | 경고가 0으로 끝나 릴리스가 계속된다 |
| API 운영 도구 | .clipilotrc.json으로 endpoint를 바꾼다 | 토큰을 설정 파일에 저장한다 |
| npm 배포 개발 도구 | bin으로 clipilot 명령을 제공한다 | 빌드 경로나 shebang이 틀린다 |
Claude Code에는 이런 예시를 세 개 이상 제공하세요. 입력, 기대 출력, 실패 조건을 알면 더 현실적인 CLI를 만듭니다. 관련 작업은 npm 패키지 만들기, Claude Code CI/CD 설정, Claude Code 시작 가이드도 함께 보면 좋습니다.
Vitest로 CLI 테스트하기
CLI 테스트는 프로세스의 결과를 봐야 합니다. stdout, stderr, 종료 코드, 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");
});
});
이 테스트는 Claude Code가 나중에 JSON 앞에 배너를 출력하거나, stdin보다 인자를 우선하게 바꾸거나, 실패를 0으로 끝내는 일을 막아 줍니다.
GitHub Actions dry-run
CI에서는 실제 부작용이 없는 dry-run을 실행합니다. 파일 쓰기, API 호출, 이메일 발송, 배포를 하지 않고 실행 형태만 확인하는 모드입니다.
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
비밀 정보도 릴리스 안전성의 일부입니다. --token xxx 같은 인자는 셸 히스토리, CI 로그, 프로세스 목록에 남을 수 있습니다. 환경 변수, GitHub Actions Secrets, stdin을 기본 경로로 두는 편이 안전합니다.
흔한 함정과 CTA
가장 흔한 함정은 stdout에 사람이 읽는 로그를 섞는 것입니다. 두 번째는 실패했는데 종료 코드가 0인 경우입니다. 세 번째는 파이프 입력을 읽은 뒤 대화형 질문을 띄워 CI를 멈추는 일입니다. 네 번째는 토큰을 설정 파일에 저장하는 일이고, 다섯 번째는 tsconfig와 bin 경로가 어긋나는 일입니다. Claude Code에 릴리스 전 리뷰를 시킬 때 이 항목을 명시하세요.
팀에서 Claude Code 기반 내부 도구와 CI 검토 흐름을 정리하려면 무료 치트시트로 일상 명령과 리뷰 관점을 먼저 확인할 수 있습니다. 프롬프트와 설정 템플릿이 필요하다면 Claude Code setup guide도 검토할 만합니다.
Masa가 이 흐름으로 작은 콘텐츠 처리 CLI를 만들었을 때 가장 큰 효과는 생성 속도가 아니라 리뷰 기준의 명확성이었습니다. stdin, stdout, stderr, 종료 코드, dry-run, secrets 규칙을 먼저 고정하자 Claude Code의 결과물을 “동작한다”가 아니라 “자동화에 넣어도 안전하다”로 판단할 수 있었습니다.
정리
Claude Code로 CLI를 만들 때는 코드보다 계약이 먼저입니다. Node.js/TypeScript, Commander, 설정 파일, stdin/stdout/stderr, 종료 코드, npm bin, Vitest, GitHub Actions dry-run, 비밀 정보 처리를 한 설계로 묶어야 합니다.
작은 CLI라도 CI와 팀원의 터미널에서 실행되는 순간 운영 자동화가 됩니다. 첫 프롬프트부터 그 기준으로 작성하세요.
무료 PDF: Claude Code 치트시트
이메일을 입력하면 명령, 리뷰 습관, 안전한 워크플로를 정리한 PDF를 받을 수 있습니다.
개인정보를 안전하게 관리하며 스팸을 보내지 않습니다.
작성자 소개
Masa
Claude Code 실무 워크플로와 팀 도입을 검증하는 엔지니어입니다.
관련 글
Obsidian 메모를 CLAUDE.md로 바꾸는 Claude Code 워크플로
Obsidian 작업 메모를 CLAUDE.md 운영 노트로 정리해 Claude Code 세션의 문맥 반복을 줄입니다.
Claude Code Revenue CTA Routing: 글에서 PDF, Gumroad, 상담으로 보내기
독자 의도에 따라 무료 PDF, Gumroad 상품, 상담으로 나누는 Claude Code CTA 설계입니다.
Claude Code 팀 인계 규칙: 리뷰 증거, 권한, 롤백, 수익 경로까지 넘기는 법
Claude Code 작업을 팀에 넘길 때 필요한 증거, 권한 규칙, 롤백, 무료 PDF, Gumroad, 상담 경로 체크리스트.