Node.jsでCLIツールを作る: commander引数パースからnpx配布まで
Node.jsのCLIツールを引数パース・対話プロンプト・パイプ対応・色とスピナー・bin/npx配布まで、コピペで動くcommanderのコード付きで手を動かして作る。
初めて自作のCLIを作った日のことを、今でも覚えています。20行くらいのスクリプトで、引数を process.argv[2] で雑に拾っていました。動いた。うれしかった。
その三日後、引数が5個に増えて、--help もなくて、自分でも使い方を忘れました。process.argv[3] が日付なのかオプションなのか分からない。スペースの入った引数を渡したら全部ずれる。挙句、パイプでつないだら標準出力にログが混ざって、後ろのコマンドが盛大に壊れました。
CLIって、最初の20行は天国で、その先が地獄なんです。
でもこの地獄、ほとんどは先人が踏み抜いてくれていて、踏まないための道具がそろっています。引数パースは commander、対話は標準入力、配布は package.json の bin と npx。この記事では、僕が clipilot という小さなツールを例に、その地獄を飛び越える順番を全部出します。npmへの公開そのものは別記事(npmパッケージ作成ガイド)に分けたので、ここは「動くCLIを作って、手元から叩けるようにする」までを濃く書きます。
この記事の要点
- 引数を
process.argvで手パースするのは三日で破綻する。最初から commander(または yargs)に任せる。 - CLIの品質は見た目より 入出力の約束で決まる。結果は標準出力、ログとエラーは標準エラー出力、成否は終了コードで伝える。
- パイプ(
cat file | mytool)に対応すると、CLIは一気に「組み合わせて使える道具」になる。標準入力を読むのは数行で済む。 - 配布は
package.jsonのbinと shebang(#!/usr/bin/env node)の二点セット。npx mytoolで誰でも一発実行できる。 - 色・スピナー・分かりやすいエラーは飾りじゃなく、ユーザーが詰まらないための装備。ただし出力先を間違えると逆効果になる。
なぜ process.argv 手パースをやめるのか
まず、やめたほうがいい書き方から。これは過去の僕です。
// アンチパターン:手パース。すぐ破綻する
const name = process.argv[2];
const verbose = process.argv.includes("--verbose");
一見シンプルですが、これだと --name=foo も -n foo も扱えないし、--help を自分で書く羽目になり、順番を一つ間違えると全部ずれます。引数が増えるたびに if が増えていく未来しかありません。
ここで登場するのが引数パーサです。代表は二つ。
| ライブラリ | 向いている場面 | ひとことで |
|---|---|---|
| commander | サブコマンド構成(git commit のような形) | 宣言的で読みやすい。最初の一本に最適 |
| yargs | 細かいバリデーションや補完を作り込みたい | 高機能なぶん設定は厚め |
どちらも枯れていて安心です。記事執筆時点で commander は v15、yargs は v18 が最新ですが、サブコマンドを素直に書けるぶん、最初の一本には commander を推します。以降は commander で進めます。
まず最小構成を作る
空のフォルダで土台を用意します。TypeScriptで書いて、開発中は tsx でそのまま実行、配布用には dist にビルドする形にします。
mkdir clipilot && cd clipilot
npm init -y
npm i commander
npm i -D typescript tsx @types/node
mkdir src
package.json の要点は bin と scripts の二か所です。bin は「インストールしたら clipilot というコマンドを生やす」という宣言で、npm公式の説明どおり、ここが指すファイルには後述の shebang が要ります。
{
"name": "clipilot",
"version": "0.1.0",
"type": "module",
"bin": {
"clipilot": "./dist/cli.js"
},
"scripts": {
"dev": "tsx src/cli.ts",
"build": "tsc -p tsconfig.json"
}
}
tsconfig.json は素直に。出力先(outDir)が bin の指す先と食い違うと、インストール後にコマンドが動かない事故になるので、dist でそろえます。
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"rootDir": "src",
"outDir": "dist",
"strict": true,
"skipLibCheck": true
},
"include": ["src/**/*.ts"]
}
commanderで引数とサブコマンドを書く
ここが本題です。commanderは、コマンド名・オプション・サブコマンド・ヘルプ・引数エラーをまとめて面倒見てくれます。下の src/cli.ts はそのまま貼って動く一本です。run はパイプ入力を引数より優先し、--json のときは機械が読むJSONだけを標準出力に出します。greet は色付き出力とスピナーのUXを見せるための小さなサンプルです。
外部依存を増やさないため、色は標準の node:util の styleText を使います(Node.js 20.12 以降で利用可、22で安定)。スピナーも依存ゼロで自前の数行にしてあります。
#!/usr/bin/env node
import { readFile, writeFile, access } from "node:fs/promises";
import { constants } from "node:fs";
import { styleText } from "node:util";
import process from "node:process";
import { Command } from "commander";
// 標準入力(パイプ)を読む。TTY(端末直叩き)なら空文字を返す
async function readStdin(): Promise<string> {
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();
}
// 依存ゼロの簡易スピナー。終わったら必ず stop すること
function spinner(label: string) {
const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
let i = 0;
// 進捗表示は stderr へ。stdout はあくまで「結果」のために空けておく
const timer = setInterval(() => {
process.stderr.write(`\r${frames[i++ % frames.length]} ${label}`);
}, 80);
return {
stop(done: string) {
clearInterval(timer);
process.stderr.write(`\r${done}\n`);
},
};
}
async function exists(p: string): Promise<boolean> {
try {
await access(p, constants.F_OK);
return true;
} catch {
return false;
}
}
const program = new Command();
program
.name("clipilot")
.description("手を動かして覚えるための小さなCLIサンプル")
.version("0.1.0")
.showHelpAfterError(); // 入力ミス時にヘルプを出すと親切
// greet: 色とスピナーのUXデモ
program
.command("greet")
.description("名前にあいさつする(色付き)")
.argument("[name]", "あいさつする相手", "world")
.option("-l, --loud", "大文字で叫ぶ")
.action(async (name: string, opts: { loud?: boolean }) => {
const sp = spinner("考え中...");
await new Promise((r) => setTimeout(r, 400)); // 重い処理のフリ
sp.stop(styleText("green", "✔ できました"));
const text = opts.loud ? `HELLO, ${name.toUpperCase()}!` : `hello, ${name}`;
process.stdout.write(`${styleText("cyan", text)}\n`); // 結果は stdout へ
});
// run: パイプ入力を優先して処理する実用コマンド
program
.command("run")
.description("引数か標準入力のメッセージを処理する")
.argument("[message]", "処理するメッセージ")
.option("--json", "機械が読むJSONで出力する")
.action(async (message: string | undefined, opts: { json?: boolean }) => {
const piped = await readStdin();
const text = (piped || message || "").trim();
if (!text) {
process.stderr.write("入力がありません。引数かパイプでメッセージを渡してください。\n");
process.exitCode = 2; // 使い方ミスは 2
return;
}
const payload = { ok: true, length: text.length, message: text };
if (opts.json) {
process.stdout.write(`${JSON.stringify(payload)}\n`);
} else {
process.stdout.write(`受け取りました(${payload.length}文字): ${text}\n`);
}
});
// save: ファイル書き込み。上書きは --force を要求して事故を防ぐ
program
.command("save <file>")
.description("標準入力の内容をファイルへ保存する")
.option("-f, --force", "既存ファイルを上書きする")
.action(async (file: string, opts: { force?: boolean }) => {
if ((await exists(file)) && !opts.force) {
process.stderr.write(`${file} は既にあります。--force で上書きできます。\n`);
process.exitCode = 1;
return;
}
const body = await readStdin();
await writeFile(file, body, "utf8");
process.stdout.write(`保存しました: ${file}\n`);
});
await program.parseAsync(process.argv);
ビルドして動かすときは、配布物に shebang を残すのを忘れずに(tsc はそのまま先頭行を保持します)。開発中は次のように叩けます。
npm run dev -- --help
npm run dev -- greet Masa --loud
npm run dev -- run "hello cli" --json
printf "from stdin\n" | npm run dev -- run
echo "保存される内容" | npm run dev -- save out.txt
-- の後ろがCLIへの引数です(npm scriptに引数を渡す区切り)。greet でスピナーと色が出て、run ではパイプが引数より優先されるのが確認できます。
標準入出力とパイプの約束を守る
CLIをただの実行ファイルから「組み合わせられる部品」に変えるのが、この章です。Unixの道具がパイプでつながるのは、出力の置き場所をみんなで守っているからです。
ルールはたった三つ。Node.jsの process のドキュメントにも同じ整理があります。
- 標準出力(stdout): 次のコマンドに渡る「結果」だけを書く。JSONや一覧。
- 標準エラー出力(stderr): 進捗・警告・エラーを書く。パイプの結果を汚さない。
- 終了コード(exit code): 成否を整数で返す。
0成功、それ以外は失敗。
ここを守ると、たとえばこういう連結が壊れません。
# clipilot の JSON 結果を jq に渡す。stdout が綺麗だから通る
echo "ログ行を解析" | npm run dev -- run --json | jq .length
逆に、進捗バーやスピナーを標準出力に書いてしまうと、上の jq は壊れます。さっきのコードでスピナーを process.stderr に書いたのは、まさにこれが理由です。デバッグログを console.log で出すクセは、CLIでは一回見直してください。console.log は標準出力、console.error は標準エラーに出ます。
終了コードを決める(自動化の生命線)
終了コードは、人間より先に シェルとCIが見ます。mytool && deploy の && は、左が 0 で終わったときだけ右を実行する仕組みです。だから「失敗したのに 0 で終わる」CLIは、自動化の中でいちばん危ない。
僕はこの三段で固定しています。チーム内で意味さえそろえば、番号は何でも構いません。
| 終了コード | 意味 | 例 |
|---|---|---|
0 | 成功 | 正常に処理が完了 |
1 | 実行時エラー | ファイルが無い、API失敗、例外 |
2 | 使い方ミス・前提不足 | 引数不足、設定未投入 |
コードでは process.exit(1) で即殺すより、process.exitCode = 1 を立てて関数を抜けるほうが安全です。書き込み中のストリームが途中で切れる事故を避けられます。上のサンプルもこの書き方にしてあります。
配布する: binとnpxで誰でも一発実行
ここまで来たら、いよいよ手元コマンドにします。やることは二つだけ。
ひとつ目、dist/cli.js の先頭に shebang #!/usr/bin/env node があること。これが「このファイルをnodeで実行せよ」という目印です(ソースの先頭行に書いてあるので、ビルドすれば残ります)。
ふたつ目、package.json の bin がそのファイルを指していること。あとはローカルでリンクすれば、clipilot がそのまま叩けます。
npm run build # dist/cli.js を生成
npm link # ローカルで clipilot コマンドを生やす
clipilot greet you # もう npm run dev -- が要らない
公開済みのパッケージなら、ユーザーは入れずに npx で一発です。npx はパッケージを取ってきて bin を実行してくれます。
npx clipilot greet world
「配布前に何が同梱されるか」を必ず確認してください。npm pack --dry-run で、公開時に含まれるファイル一覧が出ます。dist が入っていて、src やテストが不要なら files フィールドや .npmignore で絞ります。実際の npm publish の手順とバージョニングはnpmパッケージ作成ガイドに分けてまとめました。CIで build と dry-run まで自動化したい人はClaude CodeでCI/CDを設定する方法が続きになります。
UXの装備: 色・スピナー・対話、ただし使いどころ
CLIのUXは「迷わせない」ことが全部です。装備ごとに、効く場面と外す場面を書きます。
色。エラーは赤、成功は緑。ぱっと結果が分かります。今回は依存を増やさず node:util の styleText を使いました。注意点が一つ。出力をファイルやパイプに流したときは色コードが邪魔になります。styleText は出力先が端末でないと自動で色を抜いてくれますが、自前でANSIを書くなら process.stdout.isTTY を見て分岐します。
スピナー。時間のかかる処理で「固まった?」という不安を消します。必ず stop を呼んで消すこと、そして標準エラー出力に出すこと。消し忘れるとプロンプトに残って気持ち悪い表示になります。手の込んだものが要るなら ora が定番です。
対話プロンプト。「上書きしますか? (y/N)」のような確認です。ただし大原則として、パイプ実行中は対話を出さない。cat x | mytool の最中に入力待ちすると、誰もキーを打てずCIが固まります。だから process.stdin.isTTY を見て、端末直叩きのときだけ確認を出す。今回のサンプルが確認の代わりに --force フラグを採用したのは、この事故を根本から避けるためです。対話を作り込むなら @inquirer/prompts が使いやすいです。
僕がやらかした失敗3つ
正直に書きます。CLIで踏んだ地雷は、だいたい同じ三つです。
ひとつ目、標準出力にログを混ぜた。良かれと思って console.log("処理中...") を挟んだら、JSONを受け取る後段のスクリプトが「予期しないトークン」で全滅。以来、結果は標準出力、それ以外は全部 console.error、と体に叩き込みました。
ふたつ目、終了コードを返し忘れた。エラーをキャッチして console.error でメッセージは出すのに、exitCode を立て忘れて 0 で終わっていた。CIは緑のまま、壊れたものがデプロイされる。いちばん怖いやつです。今は失敗パスで必ず process.exitCode を立てます。
みっつ目、bin の指す先がズレていた。tsc の outDir を build に変えたのに package.json の bin は dist のまま。ローカルでは npm run dev で動くから気づかず、公開してインストールした人から「コマンドが無い」と連絡が来ました。npm pack --dry-run を出す習慣で、これは防げます。
Claude Codeに任せるときのコツ
このCLIづくり、Claude Codeに頼むと速いです。複数ファイル(cli.ts・package.json・tsconfig.json)をまたぐ作業を一気にやってくれるので。ただ「CLIを作って」と丸投げすると、デモでは動くけど運用で壊れるコードが出がちです。境界条件を先に渡すのがコツです。
Node.js 22 + TypeScript + commander で clipilot というCLIを作ってください。
- greet / run / save の3サブコマンド
- run は標準入力を引数より優先し、--json で機械可読JSONを stdout に出す
- 結果は stdout、進捗・警告・エラーは stderr に分ける
- 成功は exit code 0、実行時エラーは 1、使い方ミスは 2
- package.json の bin と shebang で npx 実行できる形にする
- save の上書きは --force を要求し、パイプ中は対話プロンプトを出さない
「動くか」ではなく「自動化に組み込んで安全か」でレビューしてほしい、と最初に伝えるのが効きます。導入そのものが初めてならClaude Code入門ガイドからどうぞ。
よくある質問
Q. commanderとyargs、どっちを選べばいい? 最初の一本なら commander。サブコマンドを宣言的に書けて読みやすいです。補完やリッチなバリデーションを作り込みたくなったら yargs を検討、で十分です。両方とも長年使われていて安定しています。
Q. パイプとプロンプト(対話)は両立できる?
できます。process.stdin.isTTY で判定し、端末から直接叩かれたときだけ対話を出します。パイプ実行中は対話を出さず、フラグ(--force など)や設定で代替します。これでCIが固まる事故を防げます。
Q. npm link と npx の違いは?
npm link は開発中の自分のマシンにコマンドを生やす仕組み、npx は公開済み(またはローカルの)パッケージの bin を取ってきて実行する仕組みです。開発は npm link、配布後のユーザー体験確認は npx mytool で見ると分かりやすいです。
Q. 色が出力ファイルに変な文字([36m など)として残る。
ANSIカラーコードがそのまま書き込まれています。node:util の styleText は出力先が端末でないと色を自動で抜きますが、自前でANSIを書くなら process.stdout.isTTY を見て、端末以外では色を付けないようにします。
Q. TypeScriptで書いたものをそのまま配布できる?
ビルドして .js を配るのが基本です。tsc で dist に出し、bin をその .js に向けます。.ts のまま配ると、入れた人の環境で実行できないことがあります。shebang はソース先頭に書いておけばビルド後も残ります。
実際に試した結果
この順番で clipilot を組み直してみて、いちばん効いたのは「標準出力を結果専用に空ける」一点でした。スピナーも進捗も全部 stderr に寄せたら、| jq でつないでも > out.json で保存しても壊れない。CLIが急に「他のコマンドと会話できる道具」になった感触がありました。
終了コードを 0/1/2 で固定してからは、mytool && next のような連結が信用できるようになって、シェルスクリプトを書くのが楽になりました。そして地味に大きかったのが、配布のたびに npm pack --dry-run を出す習慣。bin のズレも余計な同梱も、公開前に全部つかまります。
賢いツールを作る前に、入出力の約束を先に決める。遠回りに見えて、これがいちばん壊れないCLIへの近道でした。手元のコマンドを増やしたくなったら、まずこの記事の cli.ts を一本コピペして、自分の処理を run に差し替えるところから始めてみてください。さらにチームの開発フローごと整えたい人は教材・テンプレート一覧もどうぞ。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
制作会社がClaude Codeに触らせる前に決める権限チェックリスト
クライアントサイトを壊さずにAI編集を使うための、制作会社向け権限と確認の型です。
SaaSサポートのバグ報告をClaude Codeで再現手順に変える実務フロー
問い合わせ文をそのまま開発へ投げず、再現手順、証拠、次の一手に整えるサポート向け手順です。
Obsidianの古いメモをClaude Codeの指示書に変える10分ルーチン
Obsidianに溜めたメモが毎回ゴミになる人へ。事実・決定・未確認に仕分けして、Claude Codeがそのまま動ける指示書に変える朝の10分の型を紹介します。