关于 dart-build-cli-app
面向 Dart 命令行应用开发者的实用指南,涵盖命令结构、进程生命周期、输出处理、测试与分发,并关注资源清理和 AOT 子进程行为。
主要功能
- 01将精简入口与可测试命令逻辑分离
- 02将数据与诊断信息写入不同流
- 03使用类型安全的参数访问器
- 04处理退出码、信号和终端清理
- 05以内存方式及子进程测试命令
- 06构建和分发 Dart 命令行应用
适用场景
- 01设计多命令 Dart CLI
- 02分离错误输出与机器可读结果
- 03测试 CLI 行为和进程退出码
- 04准备 CLI 进行原生编译或安装
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ dart-lang/ skills --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)。它会立即终止 OS 进程,而不会展开 Dart 堆栈:
- 调试器断开:使用
--pause-isolates-on-exit启动时,VM Service 会在关闭前暂停 isolates,以便 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 文件用作最简入口跳板(实例化 runner、传递 args、等待退出码)。将所有命令定义、参数解析器、格式化器和业务逻辑放在 lib/src/ 中。
- 原因:无法通过
package:URI 干净地导入bin/中的代码。将逻辑移入lib/src/后,整个命令 runner、子命令层级和业务逻辑便可在内存中进行单元测试,耗时仅几毫秒(< 2ms),且无需生成 OS 子进程。
// 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,或通过results.option(...)访问缺失的mandatory: true选项时抛出的ArgumentError),错误消息和用法文本都必须写入stderr,并返回退出代码64(EX_USAGE/ExitCode.usage.code)。只有用户通过--help或-h明确请求帮助时,才可以将用法帮助写入stdout。 - 错误处理程序中不要使用
print():print()会将内容写入stdout。所有失败通知都应使用stderr.writeln()。标准输出优先使用stdout.writeln(),而非print(),以符合avoid_printlint 规则(除非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 退出代码。 - 类型安全访问器:使用
results.flag('name')、results.option('name')和results.multiOption('name')(在package:args2.5+ 中可用),而非使用映射索引运算符operator [],以免手动进行类型转换(as bool、as String?)。 - 复杂选项模型:对于包含大量标志的应用,使用
package:build_cli生成强类型选项类。利用命名默认值覆盖项(例如{String? hostDefaultOverride}),以便将配置文件与 CLI 标志清晰地合并。
5. 原生异步与现代堆栈跟踪
- 避免使用
Chain.capture():Dart VM 会原生保留跨越await挂起点的异步堆栈帧。Chain.capture会使用自定义 Zone 包装事件循环,带来显著的分配开销,并会捕获跨 Zone 边界的错误。 - 使用
Trace.from(st).terse清理堆栈跟踪:在未捕获的错误上使用package:stack_trace中的静态工具,而无需捕获 Zone:
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:使用cli_util.dartExecutable或cli_util.sdkPath解析 Dart SDK 可执行文件,不要自行编写 PATH 或目录扫描器。 - 版本要求和详细技术指南请参阅 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)),以便立即释放操作系统端口。 - 详细模式请参阅 references/signals_and_terminal.md。
8. 测试 CLI 应用程序
将测试分为两个不同层次:
- 单元测试(内存中,
< 5ms):在test/中直接导入package:<pkg>/src/...,测试命令类、选项解析和业务逻辑。 - 集成测试(子进程):使用
package:test_process和package:test_descriptor验证端到端二进制执行、进程 I/O 流以及操作系统退出代码:
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 代码前,验证
useAnsi(检查stdout.hasTerminal、supportsAnsiEscapes和NO_COLOR)。 - 使用
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