Claude CodeでCLIツール開発: Node.js/TypeScript実装から安全なリリースまで
Claude CodeでNode.js/TypeScript CLIを作る実践ガイド。引数、設定、stdin、テスト、CI、安全な公開まで解説。
CLIツールは「自分だけが使う小さなスクリプト」から始まります。しかし、引数が増え、設定ファイルを読み、CIで実行し、npmで配布し始めると、単なるスクリプトでは足りません。終了コードが曖昧だと自動化が止まらず、標準出力にログとJSONが混ざると別ツールから扱いにくくなり、秘密情報を引数で渡すと履歴やプロセス一覧に残ります。
Claude CodeはCLI開発と相性が良いです。ファイル構成、package.jsonのbin設定、Commanderによる引数パース、Node.jsのprocess.stdin/stdout、設定ファイル、Vitest、GitHub Actionsまで、複数ファイルをまたぐ作業を一気通貫で依頼できるからです。ただし「CLIを作って」と丸投げすると、デモとして動くが運用で壊れるコードになりがちです。
この記事では、Node.js/TypeScriptでclipilotという小さなCLIを作ります。目的は、Claude Codeに任せる範囲を広げつつ、人間がレビューしやすい安全な足場を作ることです。stdinは標準入力、stdoutは標準出力、stderrは標準エラー出力、exit codeは終了コードという意味です。どれもCLI同士をつなぐための基本ルールです。
Claude Codeに渡す要件
最初にClaude Codeへ渡す要件は、機能名よりも境界条件を明確にします。CLIは画面がない分、入出力の約束が品質そのものです。
Node.js 22 + TypeScriptでclipilotというCLIを作ってください。
要件:
- commanderでinit、run、checkサブコマンドを実装する
- package.jsonのbinでnpm配布できる形にする
- .clipilotrc.jsonまたは--configで設定を読む
- stdinがあればrunコマンドの入力として扱う
- stdoutには機械が読む結果、stderrには警告とエラーを書く
- 成功はexit code 0、設定不備の警告は2、例外は1にする
- 秘密情報は引数ではなく環境変数かstdinで渡す設計にする
- Vitestでhelp、stdin、config、失敗ケースをテストする
- GitHub Actionsでtest、build、dry-runを実行する
このプロンプトの狙いは、Claude Codeに「それっぽいCLI」ではなく「自動化に組み込めるCLI」を作らせることです。公式ドキュメントとして、Node.jsのprocessとfs、npmのpackage.json bin、Commander、GitHub Actionsを確認しながらレビューします。Claude Codeそのものの使い方は公式ドキュメントも参照してください。
最小プロジェクト構成
まず、空のディレクトリで以下を作ります。binは、npmがインストール時に実行コマンドを作るための設定です。npm公式ドキュメントでも、binが指すファイルには#!/usr/bin/env nodeが必要だと説明されています。
mkdir clipilot
cd clipilot
npm init -y
npm i commander
npm i -D typescript tsx vitest @types/node
mkdir src tests
package.jsonは次の形にします。devは開発中にTypeScriptを直接実行し、buildはnpm配布用にdist/cli.jsを生成します。
{
"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"]
}
ここまでで、Claude Codeには「この構成を前提にsrc/cli.tsとテストを書いて」と依頼できます。重要なのは、binの出力先、開発時の実行方法、テスト方法を先に固定することです。
Commanderで引数とサブコマンドを作る
Commanderは、コマンド名、オプション、サブコマンド、ヘルプ、引数エラーを扱うライブラリです。以下のsrc/cli.tsはそのまま貼り付けて動かせる例です。runはstdinを優先し、--jsonなら機械が読みやすいJSONだけをstdoutへ出します。
#!/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になります。これは「実行はできたがリリース条件を満たしていない」という意味にしています。CIではこの違いが重要です。
設定ファイル、stdin/stdout、終了コードの考え方
設定ファイルは.clipilotrc.jsonのようにプロジェクト直下に置けると便利です。--configで明示したパスを優先し、なければカレントディレクトリ、最後にホームディレクトリを見る形にすると、ローカル開発とCIの両方で扱いやすくなります。
{
"defaultName": "Masa",
"output": "json",
"endpoint": "https://api.example.com"
}
stdin/stdout/stderrの分離はCLI品質の中心です。stdoutは次のコマンドに渡されるため、JSON出力モードでは余計なログを混ぜません。警告やエラーはstderrへ書きます。Node.js公式のprocess.stdout、process.stderr、process.stdinの扱いを確認しながらレビューすると、Claude Codeの出力を点検しやすくなります。
終了コードは、シェルやCIが結果を判断するための整数です。この記事の例では、成功を0、例外を1、設定不足の警告を2にしました。大事なのは番号そのものより、チーム内で意味を固定し、テストとCIで同じ扱いにすることです。
3つ以上のユースケース
この設計は、単なるサンプルに見えて実務でよく使えます。
| ユースケース | 使い方 | 注意点 |
|---|---|---|
| 記事生成や翻訳の一括処理 | stdinで対象リストを渡し、JSONで結果を受け取る | stdoutに進捗ログを混ぜない |
| 社内リポジトリの品質チェック | checkをCIで実行し、設定不足を終了コードで検出する | 警告と失敗の終了コードを分ける |
| APIクライアントの運用ツール | .clipilotrc.jsonでendpointを切り替える | トークンは設定ファイルに保存しない |
| npm配布する開発補助CLI | binでclipilotコマンドを提供する | build後のファイルにshebangがあるか確認する |
Claude Codeには、各ユースケースの入力例、失敗例、期待する終了コードを一緒に渡すと精度が上がります。例えば「APIトークンがない場合はstderrへ書いてexit code 1」「endpointだけない場合はexit code 2」のように、実行結果で判断できる仕様にします。
関連する公開後の流れは、npmパッケージ作成ガイドとClaude CodeでCI/CDを設定する方法も合わせて確認してください。最初の導入はClaude Code入門ガイドにまとめています。
VitestでCLIをテストする
CLIテストは「関数の戻り値」だけでなく、プロセスとしての挙動を確認します。stdout、stderr、終了コード、stdinをテストすると、Claude Codeが後で実装を変えても壊れた場所が分かります。
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");
});
});
ここで見るべき点は、テストが「CLIとしての約束」を確認していることです。--helpが出るか、stdinが優先されるか、設定ファイルが読めるか、失敗時に終了コード1になるか。この4つだけでも、公開後の事故はかなり減ります。
GitHub Actionsでdry-runを入れる
GitHub Actionsは、リポジトリ上でビルド、テスト、デプロイなどを自動実行する仕組みです。公式のQuickstartとworkflow syntaxを見ながら、PRで必ずCLIのdry-runを走らせます。
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は「副作用なしで、実行直前の形を確認するモード」です。ファイル作成、API送信、メール送信、デプロイなどのCLIでは必須に近いです。Claude CodeにCIを作らせるときも、最初から「dry-runをPRで走らせる」と書いておくと、危ない実行を避けやすくなります。
秘密情報とリリース安全性
秘密情報は、CLI開発で最も見落とされやすい部分です。トークンをclipilot run --token xxxのように引数で渡すと、シェル履歴、CIログ、プロセス一覧に残る可能性があります。GitHub Actionsの公式ドキュメントでも、Secretsはsecretsコンテキストや環境変数として扱い、ログに出さないことが説明されています。
安全側のルールは次の通りです。
- APIキーは
.clipilotrc.jsonに保存しない。環境変数、Secrets、またはstdinで渡す。 - stdoutに秘密情報を出さない。デバッグログはstderrでも出しすぎない。
--dry-runでもトークン値を表示しない。表示するなら「set」「missing」程度にする。npm publish前にnpm pack --dry-runで含まれるファイルを確認する。binが指すdist/cli.jsにshebangが残っているか確認する。
Claude Codeへのレビュー依頼は具体的にします。
このCLIをリリース前レビューしてください。
確認観点:
- stdoutにJSON以外のログが混ざらないか
- stderrに秘密情報が出ないか
- exit code 0/1/2の意味がテストと一致しているか
- --dry-runで外部APIやファイル書き込みをしないか
- npm pack --dry-runで不要なファイルが入らないか
よくある落とし穴
1つ目は、stdoutに人間向けログを混ぜることです。JSONを受け取る別ツールが壊れます。進捗、警告、デバッグはstderrへ分けます。
2つ目は、終了コードを決めないことです。CIでは「失敗したのに0で終わる」ほうが、例外で止まるより危険です。必ずテストします。
3つ目は、設定の優先順位が曖昧なことです。--config、プロジェクト設定、ホーム設定、デフォルト値の順序を固定します。
4つ目は、stdinを読んだあとに対話プロンプトを出すことです。パイプ実行中にプロンプトを待つとCIが止まります。
5つ目は、npm配布時のbinパスずれです。tscのoutDirとpackage.jsonのbinが一致していないと、インストール後にコマンドが動きません。
6つ目は、秘密情報をサンプルコードに直書きすることです。記事用のコードでも、読者はそのままコピーします。安全でない例は必ず「悪い例」として明示します。
収益化CTAと次の一歩
CLI開発の記事は、読者が「自分のチームでも開発効率を上げたい」と感じたタイミングで次の導線を出すと自然です。Claude Codeを安全にチーム導入したい場合はClaude Code研修・導入相談へ、日々のコマンドやレビュー観点を手元に置きたい場合は無料チートシートへ進めます。CLI、CI、権限、リリースレビューまでまとめて整えたい読者にはプロダクト・テンプレート一覧も役立ちます。
Masaがこの手順を小さな記事処理CLIで試した結果、一番効果があったのはコード生成速度ではなく、レビューの論点が減ったことでした。stdin、stdout、stderr、終了コード、dry-run、Secretsの扱いを先に決めると、Claude Codeの出力を「動くか」ではなく「自動化に組み込んで安全か」で判断できます。結果として、手元の実行、Vitest、GitHub Actionsのdry-runが同じ仕様を見に行くようになり、公開直前の修正がかなり減りました。
まとめ
Claude CodeでCLIツールを作るなら、最初に入出力の約束を決めます。Node.js/TypeScript、Commander、設定ファイル、stdin/stdout/stderr、終了コード、package.jsonのbin、Vitest、GitHub Actionsのdry-runを同じ設計に乗せると、個人用スクリプトから配布可能な開発ツールへ引き上げられます。
速く作ることだけを目的にせず、失敗時の挙動、Secrets、CI、npm配布まで含めてClaude Codeに依頼してください。CLIは小さくても、チームの自動化に入った瞬間から本番運用の一部です。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
無料PDFで基礎を固めたあと、すぐ使えるテンプレート集で試し、必要なら業務自動化や導入相談まで進められます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
ObsidianメモをCLAUDE.mdに変えるClaude Code運用: 文脈を毎回説明しない仕組み
Obsidianの作業メモからCLAUDE.md用の運用ノートを作り、Claude Codeに安定した文脈を渡す方法。
Claude Code Revenue CTA Routing: 記事からPDF、Gumroad、相談へ送る設計
PVだけで終わらせず、読者の状態に合わせて無料PDF、Gumroad教材、導入相談へ分岐するCTA設計です。
Claude Codeチーム引き継ぎルール: レビュー、権限、収益導線まで渡す実務手順
Claude Codeの作業をチームで渡すための証拠、権限、ロールバック、無料PDF/Gumroad/相談導線の実務ルール。