@ryoppippi

My JS CLI Stack 2025 (日本語)

12 Aug 2025 ・ 17 min read


こんに​ちは、​ryoppippi です。​はじめましての方は​はじめまして。

この​数年、​OSSと​して​色々な​CLIツールを​作ってきました。

JSでは​常に​様々な​ライブラリが​現れては​消えていきます。​自分も​色々な​ものを​試してきましたが、​2025年現在の​CLIツール開発に​おいて​自分が​使っている​スタックを​紹介します。

心がけている​こと

自分が​OSSと​して​CLIツールを​作る​際に​心がけている​ことは​以下の​通りです。

  • 型安全が​保証されるような​ライブラリを​選ぶ
  • バンドルサイズは​小さければ​小さい​ほど​よい
  • ドキュメントを​充実させる
  • 悪意の​ある​コードが​混入しないように​最大限配慮する

Stack

Package Manager

自分は​ bun を​使っています。​理由は​以下の​通りです。

  • 高速な​インストール: とに​かくめちゃくちゃ​速いです。​localでも​CIでも​爆速に​環境構築できます
  • TypeScriptが​そのまま​使える: bun run コマンドで​TypeScriptファイルを​直接実行できる​ため、​開発時の​体験が​向上します
  • 優れた​互換性: nodeとの​互換性が​とても​高く、​互換性の​問題で​困った​ことは​今の​所ありません
  • 独自拡張が​便利: 特に​ bun shell が​便利です。​簡単な​スクリプトを​書いたり package.json 内に​ shell scriptを​書いて​実行するのに​とても​便利です

モノレポと​して​使う​場合は​ pnpm に​軍配が​上がっていましたが、​最近​ pnpm style isolated install の​実装が​進んで​おり、​これが​安定すれば​bunでも​モノレポが​使いやすくなるでしょう。

Bundler

現在は​ tsdownを​使っています。​tsdown とは​ JS/TSの​ための​bundlerで、​Rustで​書かれた​ rolldown を​ベースに​しています。

rolldownはrollupを​Rustで​再実装する​ことを​目指している​プロジェクトです。​まだ​開発途中では​ありますが、​rollupの​優れた​tree shaking機能を​Rustの​性能で​実現する​ことを​目標と​している​ため、​将来性が​期待できます。

なぜ tsdown を​選んでいるかと​いうと、​以下の​理由が​あります。

  • ビルドが​爆速: Rustベースの​ため圧倒的な​速度
  • 優れた​tree shaki​ng性能: rollupの​設計思想を​継承している​ため、​esbuild や​ bun build よりも​精度の​高い​tree shakingが​期待できる
  • 品質保証ツールとの​連携: publint や​ unplugin-unused など、​packageの​品質を​保つための​ツールとの​連携が​充実
  • rollupプラグインの​互換性: unplugin 系の​プラグインが​そのまま​使えるのは​大きな​メリット
  • シンプルな​設定: 型生成や​source mapの​生成も​含めて、​設定ファイルが​非常に​シンプル
  • 継続的な​改善: rolldownの​更新に​より、​バンドルサイズの​削減などの​恩恵を​受けられる

実際に​ bundle sizeや​ビルドの​時間を​比較してみると、unbuildmkdisttsupbun build よりも​良い​結果が​得られています。

特に​esbuildベースの​ものは​ tree shakingの​精度が​低く、​bundle sizeが​大きくなりが​ちです。

実際、​tsdownと​rolldownの​更新に​よって​バンドルサイズが​改善された​例も​あります:

rolldownが​登場するまでは​ bun build を​愛用し、​bun 用の​pluginを​作っていた​時期も​ありますが、​tsdown の​方が​便利だったので​移行しました。

転職先でも​大活躍です!

以前はtsupを​度々​使っていましたが、​作者様も​ tsdown に​乗り換えたようなので、​今後は​ tsdownを​使うことが​多くなるでしょう。

バンドル戦略

CLIツール配布時には、​全ての​依存パッケージを​bundleして、​dependenciesを​ゼロに​しています。​これには​明確な​理由が​あります。

  • インストール速度の​向上: dependenciesの​解決は​遅い。bun では​高速ですが、npm や​ deno では​顕著に​遅くなります
  • 効率的な​コード配布: tree-shakeに​より​実際に​使用する​コードのみを​含められる。​dependenciesと​して​配布すると​不要な​コードまで​ダウンロードする​ことになります
  • 動作の​安定性: バージョンの​不一致に​よる​不具合を​回避。​CLIツールの​ユーザは​依存パッケージの​バージョンを​意識する​必要が​ないため、​ある​時点の​パッケージを​全て​内包する​ことで​動作を​保証できます

実際に​この​戦略に​より、​配布サイズは​大幅に​削減されます。

バンドルサイズを​小さく​保つために、​ライブラリ選択時には​以下を​重視しています:

  • なるべく​小さく、​依存が​少ない​もの
  • tree shakingが​効果的に​働く​もの
  • 必要な​機能を​過不足なく​提供する​もの

例えば、​ccusageは​minifyを​していないにも​関わらず、​1MBを​超えないようになっています。

install size

また、​よく​使う​ツールに​対して​contributeして​バンドルサイズを​小さく​する​活動も​行っています。

CLI Framework

これまで​JS用は​色々試してきました。

その​中で、​現在主に​使っているのは​ KAZUPON さんの​ gunshi です。

  • 型安全な​API: parseArgs likeな​APIで、​型安全に​コマンドライン引数を​パース
  • 充実した​機能: negatable、​enum、​alias、​type checkingなどの​機能が​揃っている
  • 小さな​bundle size: 他の​フレームワークと​比較して​軽量
  • 活発な​開発: pluginシステムなど​革新的な​機能の​追加が​進んでいる
  • 将来性: shell補完、​i18n、​helpの​カスタマイズなどの​開発も​進行中

元々は​ cleye を​使っていましたが、gunshiは​似た​インターフェースを​保ちつつ、​より​軽量で​高機能である​ことから​移行しました。

gunshiを​curxyで​使用している​例

https://github.com/ryoppippi/curxy/blob/7073bf01ce6c5b87f068d36bf3d9bb247af8f998/main.ts#L15C1-L90C4

const command = define({
	toKebab: true,
	args: {
		endpoint: {
			type: 'custom',
			alias: 'e', // aliasの設定
			default: 'http://localhost:11434',
			description: 'The endpoint to Ollama server.',
			parse: validateURL, // validation 用のcustom function を設定可能
		},
		openaiEndpoint: {
			type: 'custom',
			alias: 'o',
			default: 'https://api.openai.com',
			description: 'The endpoint to OpenAI server.',
			parse: validateURL,
		},
		port: {
			type: 'number',
			alias: 'p',
			default: await getRandomPort(),
			description: 'The port to run the server on. Default is random',
		},
		hostname: {
			type: 'string',
			default: '127.0.0.1',
			description: 'The hostname to run the server on.',
		},
		cloudflared: {
			type: 'boolean',
			alias: 'c',
			default: true,
			negatable: true, // --cloudflared` オプションから `--no-cloudflared` を自動的に生成(https://gunshi.dev/guide/essentials/declarative-configuration#negatable-boolean-options)。
			description: 'Use cloudflared to tunnel the server',
		},
	},
	examples: ['curxy'].join('\n'),

	// 型安全な引数の型定義
	async run(ctx) {
		const app = createApp({
			openAIEndpoint: ctx.values.openaiEndpoint,
			ollamaEndpoint: ctx.values.endpoint,
			OPENAI_API_KEY,
		});

		await Promise.all([
			Bun.serve(
				{ port: ctx.values.port, hostname: ctx.values.hostname },
				app.fetch,
			),
			ctx.values.cloudflared
			&& startTunnel({ port: ctx.values.port, hostname: ctx.values.hostname })
				.then(async tunnel => ensure(await tunnel?.getURL(), is.String))
				.then(url =>
					console.log(
						`Server running at: ${bold(terminalLink(url, url))}\n`,
						green(
							`enter ${bold(terminalLink(`${url}/v1`, `${url}/v1`))} into ${
								italic(`Override OpenAl Base URL`)
							} section in cursor settings`,
						),
					)
				),
		]);
	},
});

Log

ログの​表示は​ consola を​使っています。​簡単に​リッチな​ログを​出力できるのが​魅力的です。

  • success、​info、​errorなどの​豊富な​ログレベル
  • boxや​tableなどの​リッチな​ログ出力
  • promptを​使った​簡単な​ユーザ入力の​取得

バンドルサイズの​観点では​最小では​ありませんが、​機能との​バランスを​考えて​選択しています。

より​対話的な​interfaceが​必要な​場合は​ @clack/promptsを​使うこともあります。

テスト

CLIツールの​テストには​ Vitest を​使っています。​Vitestは​CLIツール開発に​おいて​以下のような​利点が​あります:

  • 高い​パフォーマンス: native ES modulesサポートに​より​極めて​高速に​動作する
  • 安全な​環境変数の​モック: 環境設定に​依存する​CLIツールに​とって​重要な、​安全で​分離された​環境変数の​モックが​可能
  • In-source testing: if (import.meta.vitest) を​使って​ソースコードと​直接​並べて​テストを​書ける​機能に​より、​テストの​ためだけに​関数を​exportする​必要が​ない

特に​in-source testingは、​CLIツールに​とって​価値が​あります。​パブリックAPIを​汚すことなく​内部​関数を​テストでき、​実装の​詳細を​プライベートに​保ちながら​包括的な​テストカバレッジを​確保できます。

配布に​ついて

npm

パッケージの​配布はnpmに​アップロードしています。

以前は​ JSR-IO に​期待を​していました。​JSRは​ビルド不要で​TypeScriptを​そのまま​公開でき、​自動的に​ドキュメントを​生成してくれる​など​魅力的な​機能が​ありました。​しかし、​CLIツールの​配布用途では​以下の​問題が​ありました​:

  • jsr上の​ツールを​実行するには​事実上 deno を​使う​以外の​選択肢が​ない
  • CLIツールの​ユーザは​必ずしも​denoを​使っているわけではない
  • 自分で​ビルドプロセスや​ドキュメント生成を​制御できる​場合、​JSRの​利点が​薄れる

その​ため、​汎用性を​重視して​npmに​戻りました。

安全性

npx での​実行に​ついては​度々セキュリティの​懸念が​指摘されています。​その​ため、OIDC に​よる​認証を​行い、​GitHub Actionsでの​CI/CDを​通じて、​パッケージの​安全性を​明示しています。​これに​より​ユーザは、​配布されている​パッケージが​信頼できる​ものである​ことを​確認できます。

bunx の​推奨

bunxは、​bunが​提供する​パッケージ実行ツールで、npxの​bun版です。​npmレジストリから​パッケージを​一時的に​ダウンロードして​実行する​機能を​提供します。​以下のような​特徴が​あります:

自分の​OSSでは、npm に​上がっている​CLIツールを​実行する​際には、bunx を​使う​ことを​推奨しています。​また、​自分の​パッケージでは、​基本的には​ npm i -g <package> のような​global installを​推奨していません。

理由は​以下の​通りです:

  • 高速な​インストール: bun installを​用いている​ため、deno npm:foo や​ npx -y foo と​比較して​顕著に​高速。​特に​依存パッケージが​多い​ツールで​その​差が​明確
  • 互換性の​維持: shebangに​nodeが​指定されている​ならばnodeで​実行されるため、​実行時の​互換性を​保ちながらインストール時の​高速化を​享受
  • 環境の​クリーンさ: キャッシュを​ /private/tmp に​作成する​ため、​ユーザ環境を​汚染しない
  • 自動更新: キャッシュは​24時間で​自動的に​revalidateされる​ため、​常に​最新版を​使用できる​(global installに​対する​明確な​優位性)

パッケージの​バンドルサイズを​適切に​保ち、​頻繁な​バージョン固定が​不要な​用途では、bunx foo を​用いた​CLI実行は、​ユーザの​利便性と​メンテナの​負担軽減を​両立できる​選択肢です。​特にccusageのような​頻繁に​更新する​CLIツールではbunxに​よる​実行が​最適だと​信じています。

先日、自分の​知らない​ところで​ ccusage が​ homebrew に​追加された​ことがありました​が、​自分と​しては​推奨の​方法ではないので​ Document にも​追記していません。

Document

基本的には​Claude Codeを​使って​READMEを​充実させています。​大規模に​なってきたら​ vitepress を​使って​ドキュメントを​作成する​こともあります。

vitepressは​単なる​静的サイトジェネレータではなく、​以下の​点で​優れています:

  • typedoc との​連携
  • llms.txt の​生成などの​機能を​pluginと​して​追加可能
  • shiki を​使った​美しい​コードハイライト

その​他の​ツール

  • bumpp: semantic versioningを​簡単に​行う​ための​ツール
  • publint: パッケージの​品質を​保つための​ツール
  • clean-pkg-json: publish前に​余計な​package.jsonの​fieldを​削除
  • changelogithub: 綺麗な​GitHub Releaseを​作成
  • renovate: 依存パッケージの​更新を​自動化。​細かく​設定でき、​自動マージも​可能
  • eslint: コードの​品質を​保つための​ツール。​contributorが​少ない​場合は​ biome を​使うが、​多くなってくると​ルールで​縛る​方が​レビューが​楽に​なる。​ルールは​ @ryoppippi/eslint-configで​管理。oxlintの​type-aware ruleの​開発に​期待
  • pkg-pr-new: commitごとに​npm互換の​registryに​packageを​自動で​publish。​手元で​試すのが​楽
  • bun-only: bun のみで​動作する​ツールを​作った​時に​使用

まとめ

2025年現在の​CLIツール開発に​おいて、​自分が​使っている​スタックを​紹介しました。​振り返ってみると、​改めて​色々な​ライブラリや​エコシステムに​支えられて​開発を​進めている​ことを​実感します。

これからも​新しい​ライブラリや​ツールが​登場する​ことで、​CLIツール開発が​より​便利に​なっていく​ことを​期待しています。

追記

もし ccusage の​内部を​知りたい方は、deepwikiを​ご覧ください。

comment on bluesky / twitter
CC BY-NC-SA 4.0 2022-PRESENT © ryoppippi