dart-build-cli-app について
Dart のコマンドラインアプリを作る開発者向けのガイドです。設計、引数処理、テスト、配布に加え、非同期終了、端末の復元、AOT ビルド時のサブプロセス処理を扱います。
主な機能
- 01CLI のエントリポイントと構成設計
- 02引数解析とサブコマンドの振り分け
- 03標準出力とエラー出力の分離
- 04シグナル処理と端末状態の復元
- 05コマンド処理とプロセスのテスト
- 06CLI プログラムのコンパイルと配布
活用シーン
- 01新しい Dart CLI プロジェクトの設計
- 02既存 CLI の終了処理の見直し
- 03コマンドラインツールのテスト
- 04AOT コンパイルと配布の準備
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ flutter/ agent-plugins --skill dart-build-cli-appインストール後、Claude Code で/dart-build-cli-appを入力して読み込まれたか確認します。
同梱ファイル
SKILL.md と一緒にインストールされる参考資料やスクリプト。
examples/multi_command_runner.dartsingle_command_tool.dartreferences/aot_sdk_discovery.mdsignals_and_terminal.md詳細
Dart CLI アプリケーションの構築
目次
- 1. コアアーキテクチャとプロセスのライフサイクル
- 2. ストリーム、診断、フォーマット
- 3. プロジェクトの構成とパッケージ化
- 4. 引数の解析とコマンドのルーティング
- 5. ネイティブ非同期処理と最新のスタックトレース
- 6. サブプロセスの起動と AOT の耐障害性
- 7. シグナル処理とターミナルの終了処理
- 8. CLI アプリケーションのテスト
- 9. 最新のコンパイルと配布
- 10. ワークフローと監査チェックリスト
- 参考資料と例
1. コアアーキテクチャとプロセスのライフサイクル
破壊的な終了(exit(N))を避ける
dart:io の exit(int code) を呼び出すと、C++ ランタイムで Platform::Exit(code) が実行されます。Dart のスタックを巻き戻さずに、OS プロセスを直ちに終了します。
- デバッガーの切断:
--pause-isolates-on-exitを指定して起動すると、VM Service は IDE で調査できるよう、シャットダウン前にアイソレートを一時停止します。exit()は VM Service が一時停止や状態の調査を行う前に、OS プロセスを終了させます。 - カバレッジの損失:
package:coverageは、一時停止状態での VM Service RPC を通じて実行行を取得します。exit()は RPC による取得前にプロセスを破棄するため、カバレッジは 0% になります。 - バッファーの切り捨て:
stdoutとstderrは、非同期でバッファリングされるIOSinkストリームです。exit()は未フラッシュのバイトを破棄します。 - リソースリーク:
finallyブロック(ロックの解放、一時ディレクトリの削除など)が実行されません。
ルール:通常の実行中に exit(code) を直接呼び出すのは避けてください。exitCode = code を設定するか、CommandRunner<int>(package:args から)から終了コードを整数で返し、非同期の main() 関数が自然に戻るようにします。未処理のエラーに対して exit() を呼び出してはいけません。ランタイムが適切にスタックを巻き戻してゼロ以外のステータスで終了できるよう、未処理の Error または例外をスローしてください。
標準 POSIX 終了コード(/usr/include/sysexits.h):
0:成功(EX_OK/ExitCode.success.code)64:コマンドラインの使用方法エラー(EX_USAGE/ExitCode.usage.code)65:データ形式エラー(EX_DATAERR/ExitCode.data.code)70:ソフトウェア内部のクラッシュ(EX_SOFTWARE/ExitCode.software.code)78:構成エラー(EX_CONFIG/ExitCode.config.code)
注:マジックナンバーの整数リテラルではなく、package:io/io.dart をインポートし、ExitCode 定数(例:ExitCode.usage.code、ExitCode.software.code)を使用してください。パッケージへの依存関係がない最小限の単独スクリプトでは、標準 POSIX の整数リテラル(0、64、70)を使用してもかまいません。
import 'dart:io';
import 'package:args/command_runner.dart';
import 'package:io/io.dart' show ExitCode; // Provides standard POSIX ExitCode constants
Future<void> main(List<String> args) async {
final runner = CommandRunner<int>('tool', 'CLI tool description.');
try {
final status = await runner.run(args);
exitCode = status ?? ExitCode.success.code;
} on UsageException catch (e) {
stderr
..writeln(e.message)
..writeln(e.usage);
exitCode = ExitCode.usage.code;
}
}
薄いエントリーポイントのパターン(bin/ と lib/src/)
bin/*.dart ファイルは、ランナーをインスタンス化し、args を渡して終了コードを待つだけの、最小限のエントリーポイントの中継役として維持してください。すべてのコマンド定義、引数パーサー、フォーマッター、ビジネスロジックは lib/src/ に配置します。
- 理由:
bin/のコードはpackage:URI 経由で簡単にインポートできません。ロジックをlib/src/に移すことで、OS のサブプロセスを起動せずに、コマンドランナー全体、サブコマンド階層、ビジネスロジックをインメモリで数ミリ秒(< 2ms)以内にユニットテストできます。
// bin/my_cli.dart — Thin entrypoint trampoline
import 'dart:io';
import 'package:my_cli/src/cli.dart';
Future<void> main(List<String> args) async {
exitCode = await runCli(args);
}
2. 出力、診断、フォーマット
- データと診断情報:プログラムの意図した結果と機械可読データは、
stdoutのみに出力してください。警告、エラーメッセージ、デバッグログは、stderrのみに出力してください。 - エラー時の使用法ルール:引数の解析エラーまたは必須オプションのエラー(
FormatException、UsageException、またはmandatory: trueのオプションへのアクセス時に、オプションが指定されていないためresults.option(...)がスローするArgumentError)が発生した場合、エラーメッセージと使用方法のテキストは、どちらもstderrに出力する必要があります。終了コードには64(EX_USAGE/ExitCode.usage.code)を返してください。ユーザーが--helpまたは-hを明示的に指定した場合に限り、使用方法のヘルプをstdoutに出力してください。 - エラーハンドラーで
print()を使わない:print()はstdoutに出力します。すべての失敗通知にはstderr.writeln()を使用してください。標準出力には、avoid_printリントルールに準拠するため、print()の代わりにstdout.writeln()を使用してください(ただし、analysis_options.yamlでavoid_print: falseが明示的に設定されている場合を除きます)。 - ターミナル機能の検出と
NO_COLOR:ANSI カラーやカーソルのエスケープコードを出力する前に、stdout.hasTerminal、stdout.supportsAnsiEscapes、および!Platform.environment.containsKey('NO_COLOR')を確認してください。bool get useAnsi => stdout.hasTerminal && stdout.supportsAnsiEscapes && !Platform.environment.containsKey('NO_COLOR'); - 機械可読モード:
--jsonまたは--machineフラグが渡された場合は、データを JSON 形式でstdoutに出力し、ログはstderrに出力してください。
3. プロジェクトの構成とパッケージ化
スキャフォールディングと Pubspec の実行可能ファイルのマッピング(executables:)
dart create -t console <package_name> を使って新しいコマンドラインプロジェクトをスキャフォールドすると、標準の bin/ および lib/ レイアウトが初期化されます。pubspec.yaml の executables: の下に必ず実行可能ファイルを宣言し、コマンド名を bin/ 内のスクリプトにマッピングしてください。これにより、dart run <command>(bin/...dart を指定せずに実行できます)で簡単に呼び出せるようになり、dart install 用のグローバルなバイナリシンボリックリンクも構成されます。
name: my_cli
description: High-performance CLI tool.
version: 1.0.0
executables:
my_cli: # Maps to bin/my_cli.dart
secondary_cmd: helper # Maps to bin/helper.dart
単一ソースでのバージョン管理(package:build_version)
bin/*.dart に --version の文字列をハードコードしたり、定数ファイルを手動で同期したりすることは避けてください。package:build_version を使用すると、ビルド時に pubspec.yaml から const packageVersion = 'x.y.z'; を含む lib/src/version.dart が生成されます。
キャッシュの規約
一時的なキャッシュファイルは .dart_tool/<package_name>/ に保存してください。プロジェクトのルートに永続的なキャッシュファイルを直接書き込まないでください。
4. 引数の解析とコマンドのルーティング
コマンドライン引数を管理するには、package:args をインポートしてください。
- 単純なスクリプト:
ArgParserをaddFlag()およびaddOption()と直接組み合わせて使用してください。 - 複数コマンドのツール:
CommandRunner<int>を実装し、各サブコマンドにはCommand<int>を拡張して、POSIX の終了コードを直接返してください。 - 型安全なアクセサー:手動での型キャスト(
as bool、as String?)をなくすため、マップのインデックス参照operator []の代わりに、results.flag('name')、results.option('name')、results.multiOption('name')(package:args2.5 以降で利用可能)を使用してください。 - 複雑なオプションモデル:フラグが多いアプリケーションでは、
package:build_cliを使用して、型安全性の高いオプションクラスを生成してください。名前付きのデフォルト値オーバーライド(例:{String? hostDefaultOverride})を活用すると、構成ファイルと CLI フラグを簡潔に統合できます。
5. ネイティブな非同期処理と最新のスタックトレース
Chain.capture()を避ける:Dart VM は、awaitの中断ポイントをまたいで非同期スタックフレームをネイティブに保持します。Chain.captureはカスタム Zone でイベントループをラップするため、割り当てのオーバーヘッドが大幅に増加し、Zone の境界を越えてエラーを捕捉します。Trace.from(st).terseで整形:Zone をキャプチャせず、未捕捉のエラーに対してpackage:stack_traceの静的ユーティリティを使用してください。
import 'dart:io';
import 'package:io/io.dart' show ExitCode;
import 'package:stack_trace/stack_trace.dart';
Future<void> runMain(List<String> args) async {
try {
await executeLogic(args);
exitCode = ExitCode.success.code;
} catch (e, st) {
stderr.writeln('Fatal error: $e');
if (args.contains('-v') || args.contains('--verbose')) {
stderr.writeln(Trace.from(st).terse);
}
exitCode = ExitCode.software.code;
}
}
6. サブプロセスの起動と AOT の耐障害性
Dart SDK のサブプロセスを起動したり、ほかの Dart ツール(例:dart format、dart test、build_runner)を実行したりする場合:
Platform.resolvedExecutableやPlatform.executableがdartコマンドライン実行ファイルを指すとは限りません:スタンドアロンの AOT コンパイル済みバイナリ(dart install/dart compile exe)では、resolvedExecutableはコンパイル済みアプリケーションのバイナリを指すため、再帰的な自己起動ループや、フラグの拒否によるクラッシュが発生します。package:cli_utilを使用してください:独自に PATH やディレクトリを走査して処理を実装するのではなく、cli_util.dartExecutableまたはcli_util.sdkPathを使用して Dart SDK の実行ファイルを解決してください。- バージョン要件と詳しい技術ガイドについては、references/aot_sdk_discovery.md を参照してください。
7. シグナル処理とターミナルの終了処理
CLI がターミナルモードを変更する、スピナーを表示する、またはリッスンソケットを開く場合:
- Windows のシグナルガード:Windows では、
ProcessSignal.sigterm.watch()はUnsupportedErrorをスローします。if (!Platform.isWindows)でsigtermをガードしてください。 - エコーと行モードの終了処理:
stdin.echoMode = falseまたはstdin.lineMode = falseを設定する場合は、最初にif (!stdin.hasTerminal) return;を確認し、終了後もユーザーのキー入力が表示されるよう、SIGINTリスナーとfinallyブロックを設置して状態を復元してください。 - カーソルの表示:ANSI のカーソル非表示コード(
\x1B[?25l)を出力する場合は、終了時またはキャンセル時に必ずカーソルを再表示(\x1B[?25h)してください。 - ソケットのクリーンアップ:終了シグナルを受け取ったら、リッスン中の
HttpServerまたはServerSocketインスタンスを明示的に閉じ(server.close(force: true))、OS のポートを直ちに解放してください。 - 詳しい実装パターンについては、references/signals_and_terminal.md を参照してください。
8. CLI アプリケーションのテスト
テストは、次の 2 つの異なる層に分けて構成してください。
- ユニットテスト(インメモリ、
< 5ms):test/内でpackage:<pkg>/src/...をインポートし、コマンドクラス、オプションの解析、ビジネスロジックを直接テストします。 - 統合テスト(サブプロセス):
package:test_processとpackage:test_descriptorを使用して、バイナリのエンドツーエンド実行、プロセスの I/O ストリーミング、OS の終了コードを検証します。
import 'package:test/test.dart';
import 'package:test_descriptor/test_descriptor.dart' as d;
import 'package:test_process/test_process.dart';
void main() {
test('CLI processes input and exits cleanly', () async {
await d.file('input.txt', 'hello').create();
final process = await TestProcess.start('dart', [
'run',
'bin/my_cli.dart',
'--input',
d.path('input.txt'),
]);
await expectLater(process.stdout, emitsThrough('Processing complete.'));
await process.shouldExit(0);
});
}
9. 最新のコンパイルと配布
Dart 3.12+ では、CLI の配布方式が dart run と dart install を中心に標準化されています(dart pub global activate から移行)。
- 一時的な実行(JIT):
dart run <package>@<version> [args]は、CLI をオンデマンドでダウンロードして実行します。 - グローバルインストール(ネイティブ AOT):
dart install <package>は、パッケージのエントリーポイントを~/.dart/install/bin/に高速なネイティブのスタンドアロンバイナリとしてコンパイルします。 - ローカル開発:
dart run <command>(pubspec.yamlのexecutables:を通じて解決)またはdart run bin/cli.dartを使用してください。 - 動的ライブラリとコードアセットのバンドル:
dart build cliを使用してください。出力バンドルはbuild/cli/_/bundle/に作成されます。 - スタンドアロン実行ファイルのコンパイル:
dart compile exe bin/cli.dart -o <output_path>を使用してください。
10. ワークフローと監査チェックリスト
実装ワークフロー
-
pubspec.yamlのexecutables:の下にエントリーポイントを宣言します。 -
bin/*.dartは薄いエントリーポイントに留め、コマンドのロジックはlib/src/に配置します。 - 整数の終了コードを返すか、
exitCode = Nを設定します。exit(N)を直接呼び出すのは避けてください。 - 解析に失敗した場合、エラー、警告、使用方法を
stderrに出力します。 - 型安全性を確保するため、
results.flag()、results.option()、results.multiOption()を使用します。 - ANSI コードを出力する前に、
stdout.hasTerminal、supportsAnsiEscapes、NO_COLORを確認してuseAnsiを検証します。 - 子ツールの起動には
cli_util.dartExecutableを使用し、Platform.resolvedExecutableは決して使用しません。 - コマンドランナーはインメモリでユニットテストし、バイナリのエンドツーエンド実行は
test_processでテストします。
References & Examples
- Single-Command Tool Template: examples/single_command_tool.dart
- Multi-Command Runner Template: examples/multi_command_runner.dart
- AOT SDK Discovery & Subprocess Spawning: references/aot_sdk_discovery.md
- Signal Handling & Terminal Teardown: references/signals_and_terminal.md