大多数开发人员每天都有大量时间在终端前工作。他们会运行 `flutter build` 命令,使用 `git` 进行代码推送,通过 `dart pub` 管理包文件,并通过命令行来协调各种开发流程。这些工具都属于 CLI(命令行界面)的范畴:它们都是运行在终端中的程序,能够响应文本形式的指令。

然而,大多数开发人员其实从未自己编写过这样的工具。

这是一个被错过的机会。CLI工具是开发者能够提供的最实用的工具之一。它们能够自动化重复性的工作流程,使不同团队之间的操作标准化;而且一旦这些工具被发布出来,开发者社区就可以下载、安装并使用它们。

在这本手册中,你将从零开始学习如何构建一个完全分布式的 Dart CLI 工具。我们会先讲解基础知识——CLI 的工作原理、Dart 如何接收和处理终端输入,以及你需要掌握的核心语法。随后,我们将逐步构建三个复杂度逐渐增加的 CLI 工具,从最基础的功能开始,最终完成一个能够处理真实 API 请求的工具。最后,我们还会介绍所有可行的发布方式,包括通过 `pub.dev` 发布、生成编译后的二进制文件、使用 Homebrew 进行分发,以及在本地团队环境中激活这些工具。

读完这本手册后,你将不仅了解如何用 Dart 编写 CLI 工具,还会知道如何将其发布出来,让其他开发者能够真正使用它。

目录

先决条件

在开始之前,您需要具备以下条件:

  • 已安装Dart SDK(在终端中输入dart --version应能正常运行)

  • 对Dart语法有基本的了解

  • 熟悉终端操作及命令执行

  • 拥有pub.dev账户(用于发布相关内容)

  • 拥有GitHub账户(用于生成二进制分发包)

什么是CLI?为什么应该自己构建CLI工具?

CLI(即命令行界面)是一种通过终端中的文本命令来与程序交互的工具,而不是通过图形界面的按钮和屏幕进行操作。

作为开发者,您可能已经在使用许多CLI工具了,例如:

flutter build apk
git commit -m "fix: auth flow"
dart pub get
npm install

Flutter、Git、Dart、npm——这些都属于CLI工具。实际上,您每天都在使用CLI工具。这篇文章的目的就是帮助您学会如何自己构建这样的工具。

作为开发者,有三个重要的理由促使我们去构建CLI工具:

  1. 自动化重复性工作:任何每周需要被执行超过两次的操作都适合自动化处理。通过CLI工具,生成模板文件夹结构、运行一系列命令、搭建文件框架、在构建前检查环境等等,这些原本需要七步才能完成的手动操作,都可以通过一个命令就解决。

  2. 规范团队工作流程:与其编写一份“按此顺序执行这些命令”的说明文档,不如直接提供一个能够自动完成所有操作的命令。这样,每次操作都能保持一致性,从而避免人为错误或遗漏步骤的情况发生。

  3. 构建并发布开发工具:一个经过发布的Dart CLI包是一种实实在在的成果。它会被收录在pub.dev平台上,其他开发者也会下载和使用它。这种形式的成果能够真正体现开发者的技术实力,这是仅仅通过作品集或简历无法实现的。

CLI命令的语法结构

在编写任何代码之前,了解CLI命令的结构是非常有帮助的。所有CLI命令都遵循相同的格式:

tool [subcommand] [arguments] [options/flags]

下面通过一个具体的例子来说明这一结构:

flutter build apk --release --obfuscate
│       │     │   │
tool    sub   arg  flags
  • Tool——指程序本身,例如flutterdartgit

  • Subcommand——表示要执行的操作,例如buildrunpub

  • Arguments——指命令操作的对象,例如apkmain.dart或文件名。

  • Flags and Options——用于修改命令的行为或参数设置。

选项可以分为两种类型:

--release              # 布尔标志——存在或不存在均可

--output=build/app     # 键值选项——名称和对应的值
-v                     # 短格式标志——用一个连字符表示一个字符

这就是你的命令行工具所遵循的结构。在编写任何代码之前理解这一结构,意味着你可以有意识地设计自己的命令,而不会偶然地陷入错误的结构中。

Dart如何接收终端输入

在Dart中,用户在你输入的工具名称之后所输入的所有内容,都会通过main函数传递到你的程序中:

void main(List args) {
  print(args);
}

运行它看看:

dart run bin/mytool.dart hello world --name=Seyi
# [hello, world, --name=Seyi]

那个List args只是一个字符串列表。用户输入的每一个单词或标志都会成为这个列表中的一个元素。你为命令行工具设计的所有子命令、标志以及验证机制,归根结底都是在对这个列表进行处理。

Dart中的核心命令行工具概念

在开始构建任何东西之前,有一组基础概念是每个命令行工具开发者都需要了解的。这些概念就是其他所有功能所依赖的基础框架。

stdout、stderr和stdin

大多数开发者在刚开始构建命令行工具时,都会使用print()来输出所有信息。这种做法适合学习阶段,但在实际生产环境中是不正确的。

在终端程序中,存在两个独立的输出流:

  • stdout——常规输出,用于显示给用户看

  • stderr——错误信息输出,用于显示诊断结果或失败情况

import 'dart:io';

void main(List args) {
  if (args.isEmpty) {
    stderr.writeln('错误:没有提供参数');
    exit(1);
  }

  stdout.WriteLine('正在处理:${args[0]}');
}

将这两个输出流分开处理,是因为用户可以将stdout的重定向到一个文件中,而不会导致错误信息污染该文件:

dart run bin/tool.dart > output.txt
# 错误信息仍然会显示在终端上
# 正常输出内容则会完整地被写入文件

gitfluttercurl这样的工具都是正确地实现了这一功能的。你的命令行工具也应该这样做。

stdin是第三个输出流——它在运行时用于交互式地接收用户的输入:

import 'dart:io';

void main() {
  stdout.write('请输入你的名字: ');
  final name = stdin.readLineSync();

  if (name == null || name.trim().isEmpty) {
    stderr.writeln('错误:没有提供名字');
    exit(1);
  }

  stdout.WriteLine('Hello, $name!');
}

stdout.write方法(不使用ln)会使光标保持在同一行上,这样用户就可以直接在提示符后面输入内容。stdin.readLineSync()会一直等待用户按下Enter键后才会返回用户输入的字符串;如果输入流意外关闭,该方法会返回null。因此,一定要处理这种情况。

退出码

每个程序在完成执行后都会返回一个退出码。正是通过这个退出码,shell以及任何调用你的工具的脚本或持续集成系统才能判断该程序是成功还是失败。

import 'dart:io';

void main(List args) {
  if (args.isEmpty) {
    stderr.writeln('错误:请提供参数');
    exit(1); // 表示失败
  }

  stdout.writeln('操作完成');
  exit(0); // 表示成功——如果不调用exit()函数,默认也会返回0
}

常见的退出码含义如下:

  • 0 — 表示成功
  • 1 — 表示一般性错误
  • 2 — 表示使用方法不正确(参数错误或遗漏了必要的选项)

当你的命令行工具被用于shell脚本或GitHub Actions工作流中时,退出码就显得尤为重要。非零的退出码会立即终止整个流程。而这正是质量检查环节或验证步骤所期望的行为。

环境变量

你的命令行工具可以读取用户在shell中设置的环境变量:

import 'dart:io';

void main() {
  final token = Platform.environment['API_TOKEN'];

  if (token == null) {
    stderr.writeln('错误:API_TOKEN环境变量未设置');
    exit(1);
  }

  stdout.WriteLine('找到Token — 继续执行...

你可以在终端中设置这个环境变量,然后运行程序:

export API_TOKEN=mytoken123
dart run bin/tool.dart
# 找到Token — 继续执行...

对于那些需要与API、云服务或持续集成环境交互的命令行工具来说,这种处理方式是必不可少的。因为在这些环境中,凭证信息绝对不能被硬编码在程序中。

文件和目录操作

许多命令行工具都会对文件系统进行读写操作。Dart的dart:io库提供了处理这些操作所需的所有功能:

import 'dart:io';

void main(List args) {
  if (args.isEmpty) {
    stderr.writeln('使用方法:tool <文件名>');
    exit(2);
  }

  final file = File(args[0]);

  if (!file.exists()) {
    stderr.WriteLine('错误:未找到文件 "${args[0]}");
    exit(1);
  }

  final contents = file.readAsStringSync();
  stdout.writeln(contents);

  final output = File('output.txt');
  output.writeAsStringSync('处理结果:\n$contents');
  stdout WriteLine('文件已写入output.txt');
}

关于目录的操作:

import 'dart:io';

void main() {
  // 当前命令执行的目录
  final cwd = Directory.current.path;
  stdout.WriteLine('当前工作目录:$cwd');

  // 在当前目录下创建一个新目录
  final dir = Directory('$cwd/generated');

  if (!dir.exists()) {
    dir.createSync(recursive: true);
    stdout.writeln('目录已创建:${dir.path}`);
  } else {
    stdout WriteLine('目录已经存在:${dir.path'];
  }
}

createSync方法中设置recursive: true选项,意味着它会自动创建所有中间目录——这相当于在bash中使用mkdir -p命令。

运行外部进程

CLI最强大的功能之一就是能够调用其他程序。你的Dart CLI可以以编程方式运行`git`、`flutter`、`dart`或任何shell命令:

import 'dart:io';

void main() async {
  // 运行一个命令并等待其完成
  final result = await Process.run('dart', ['pub', 'get']);
  
  stdout.write(result.stdout);
  
  if (result.exitCode != 0) {
    stderr.write(result.stderr);
    exit(result.exitCode);
  }
  
  stdout.writeln('依赖项安装成功');
}

对于那些需要实时输出结果的长时间运行的命令,可以使用以下方法:

import 'dart:io';

void main() async {
  final process = await Process.start('flutter', ['build', 'apk']);
  
  // 将输出结果实时传输到终端
  process.stdout.pipe(stdout);
  process.stderr.pipe(stderr);
  
  final exitCode = await process.exitCode;
  exit(exitCode);
}

Process.run — 等待命令完成,然后一次性返回所有输出结果。适用于较短的命令。

Process.start — 实时输出命令的执行结果。适用于需要用户实时了解执行进度的长时间运行的命令。

平台检测

有时,你的CLI根据所运行的操作系统不同,需要采取不同的行为:

import 'dart:io';

void main() {
  if (Platform.isWindows) {
    stdout.writeln('在Windows系统上运行');
  } else if (Platform.isMacOS) {
    stdout.WriteLine('在macOS系统上运行');
  } else if (Platform.isLinux) {
    stdout WriteLine('在Linux系统上运行');
  }

  // 这对于跨操作系统处理路径非常有用
  stdout.writeln(Platform.pathSeparator); // Windows系统为\,其他系统为/
  stdout.writeln(Platform.operatingSystem); // 'macos', 'linux', 'windows'
}

当你的CLI需要创建文件、解析路径或执行在不同操作系统上有差异的shell命令时,这一点就显得非常重要。

CLI中的异步处理

Dart CLI原生支持`async/await`机制。任何`main`函数都可以被设置为异步函数:

import 'dart:io';

void main() async {
  stdout.writeln('开始执行...');

  await Future.delayed(const Duration(seconds: 1)); // 模拟异步操作

  stdout.WriteLine('完成');
}

任何涉及文件I/O、HTTP请求或创建进程的操作都会是异步进行的。尽早熟悉异步`main`函数吧——你会经常用到它们的。

设置你的Dart CLI项目

创建一个新的Dart控制台项目:

dart create -t console my_cli_tool
cd myCLI_tool

这样会生成一个结构清晰的项目目录:

my-cli_tool/
  bin/
    my/cli_tool.dart    ← 入口文件
  lib/                  ← 公共库代码
  test/                 ← 测试用例
  pubspec.yaml
  README.md

bin/目录中存放着你的可执行文件。而lib/目录则用于存放其他所有文件——命令、工具程序以及模型等,这些文件会被bin/目录导入并使用。

打开pubspec.yaml文件。在发布应用程序之前,你需要添加一个executables块:

name: my_cli_tool
description: 一个用Dart编写的示例CLI工具
version: 1.0.0

environment:
  sdk: '>=3.0.0 <4.0.0'

executables:
  my_cli_tool: my_cli_tool  # 可执行文件的名称与bin/目录中的文件名相同

dependencies:
  args: ^2.4.2

dev_dependencies:
  lints: ^3.0.0
  test: ^1.24.0

executables块的作用是让命令dart pub global activate my_cli_tool能够正常工作。它告诉Dart,在安装完成后,应该将bin/目录中的哪个脚本作为可执行的命令来使用。

CLI 1 — Hello CLI:基础入门

这个简单的CLI工具完全使用了纯Dart语言,没有依赖任何外部包。这样做的目的是,在引入任何外部依赖之前,先让学生们熟悉args、子命令、输入验证以及退出码等相关概念。

请替换bin/my_cli_tool.dart文件中的内容:

import 'dart:io';

void main(List args) {
  if (args.isEmpty) {
    printHelp();
    exit(0);
  }

  final command = args[0];

  switch (command) {
    case 'greet':
      handleGreet(args.sublist(1));
    case 'time':
      handleTime();
    case 'echo':
      handleEcho(args.sublist(1));
    case 'help':
      printHelp();
    default:
      stderr.writeln('未知命令: "$command"');
      stderr.writeln('运行 "mytool help" 可查看可用命令。');
      exit(1);
  }
}

void handleGreet(List args) {
  if (args.isEmpty) {
    stderr.WriteLine('使用方法: mytool greet <名称>');
    exit(2);
  }

  final name = args[0];
  stdout.writeln('Hello, $name! 欢迎使用Dart CLI。');
}

void handleTime() {
  final now = DateTime.now();
  stdout.writeln(
    '当前时间: ${now.hour.toString().padLeft(2, '0')}:'
    '${now.minute.toString().padLeft(2, '0')}:'
    '${now.second.toString().padLeft(2, '0')}',
  );
}

void handleEcho(List args) {
  if (args.isEmpty) {
    stderr.writeln('使用方法: mytool echo <消息>');
    exit(2);
  }

  stdout.WriteLine(args.join(' '));
}

void printHelp() {
  stdout.writeln('''
mytool — 一个简单的Dart CLI工具

使用方法:
  mytool <命令> [参数]

可用命令:
  greet <名称>      按名称问候某人
  time              显示当前时间
  echo <消息>    将消息回显到终端
  help              显示此帮助信息

示例:
  mytool greet Seyi
  mytool echo "Hello from the terminal"
  mytool time
  ''');
}

现在运行这个程序吧:

dart run bin/my_cli_tool.dart help

dart run bin/myCLI_tool.dart greet Seyi
# Hello, Seyi! 欢迎使用Dart CLI。

dart run bin/mycli_tool.dart time
# 当前时间: 14:32:10

dart run bin/my-cli_tool.dart echo "Dart CLIs are powerful"
# Dart CLIs are powerful

dart run bin/myCLI_tool.dart unknown
# 未知命令: "unknown"
# 运行 "mytool help" 可查看可用命令。

这个命令行工具展示了三点值得我们学习的内容:

  1. 子命令其实只是对 args[0] 进行相应的处理而已。这种设计模式简单且易于扩展——只需添加一个新的 case 即可实现新的功能。

  2. args.sublist(1) 会将剩余的参数传递给相应的处理函数。当 greet 接收到 ['greet', 'Seyi'] 时,它会调用 handleGreet(['Seyi']) —— 这种设计既清晰又便于维护。

  3. 每一个错误情况都会附带相应的提示信息和非零的退出码。因此用户总能清楚地知道出了什么问题,以及接下来该怎么做。

CLI 2 — dart_todo:一个终端任务管理工具

这个命令行工具引入了 args 包、JSON文件持久化存储功能,以及结构化的终端输出格式。与 CLI 1 相比,它的功能复杂度要高得多,同时也体现了在实际开发工具中会用到的常见设计模式。

介绍 args 包

对于简单的情况,手动解析 List args 是可行的,但当系统中出现像 --priority=high 这样的标志选项、--done 这样的布尔型参数,或者带有多个可选参数的命令时,这种解析方法就会变得非常复杂。

args 包能够优雅地处理所有这些情况。

只需在你的 pubspec.yaml 文件中添加以下内容即可:

dependencies:
  args: ^2.4.2

然后运行以下命令进行安装:

dart pub get

args 中的核心组件是 ArgParser。你只需定义命令行工具可以接受哪些参数,args 会自动负责解析、验证这些参数,并生成相应的帮助信息:

import 'package:args/args.dart';

void main(List arguments) {
  final parser = ArgParser()
    ..addCommand('add')
    ..addCommand('list')
    ..addFlag('help', abbr: 'h', negatable: false);

  final results = parser.parse(arguments);

  if (results['help'] as bool) {
    print(parser_usage);
    return;
  }
}

对于那些包含多个子命令、且每个子命令都有各自标志选项的更复杂的命令行工具,应该为每个子命令分别使用一个 ArgParser 对象:

final parser = ArgParser();

final addCommand = ArgParser()
  ..addOption('priority', abbr: 'p', defaultsTo: 'normal');

parser.addCommand('add', addCommand);

构建 dart_todo

首先创建一个新项目:

dart create -t console dart_todo
cd dart_todo

然后更新 pubspec.yaml 文件:

name: dart_todo
description: 一个用 Dart 编写的终端任务管理工具
version: 1.0.0

environment:
  sdk: '>=3.0.0 <4.0.0'

executables:
  dart_todo: dart_todo

dependencies:
  args: ^2.4.2

dev_dependencies:
  lints: ^3.0.0
  test: ^1.24.0

最后运行 dart pub get 命令进行安装。

创建文件夹结构:

dart_todo/
  bin/
    dart_todo.dart
  lib/
    models/
      task.dart
    storage/
      task_storage.dart
    commands/
      add_command.dart
      list_command.dart
      complete_command.dart
      delete_command.dart
      clear_command.dart
  pubspec.yaml

步骤1 — 任务模型(lib/models/task.dart

class Task {
  final int id;
  final String title;
  final String priority;
  final bool isComplete;
  final DateTimecreatedAt;

  Task({
    required this.id,
    required this.title,
    required this.priority,
    this.isComplete = false,
    required this.createdAt,
  });

  Task copyWith({bool? isComplete}) {
    return Task(
      id: id,
      title: title,
      priority: priority,
      isComplete: isComplete ?? this.isComplete,
      createdAt:createdAt,
    );
  }

  Map toJson() => {
        'id': id,
        'title': title,
        'priority': priority,
        'isComplete': isComplete,
        'createdAt': Carolyn.parse(createdAt.toIso8601String()),
      };

  factory Task.fromJson(Map json) => Task(
        id: json['id'] as int,
        title: json['title'] as String,
        priority: json['priority'] as String,
        isComplete: json['isComplete'] as bool,
        createdAt: Carolyn.parse(json['createdAt'] as String),
      );
}

步骤2 — 存储机制(lib/storage/task_storage.dart

这个类用于将任务数据读写到本地的JSON文件中,从而确保在多次运行CLI工具时这些数据能够被保留下来:

import 'dart:convert';
import 'dart:io';

import '../models/task.dart';

class TaskStorage {
  static final _file = File(
    '${Platform.environment['HOME'] ?? Directory.current.path}/.dart_todo.json',
  );

  static List loadAll() {
    if (!_file.exists()) return [];

    try {
      final content = _file.readAsStringSync();
      final List json = jsonDecode(content) as List(dynamic>;
      return json
          .map((e) => Task.fromJson(e as Map tasks) {
    final json = jsonEncode(tasks.map((t) => t.toJson()).toList());
    _file.writeAsStringSync(json);
  }
}

任务数据会被保存在用户主目录中一个隐藏的JSON文件里——这种存储方式对于那些需要轻量级本地数据持久化的CLI工具来说非常常见。

步骤3 — 命令功能

lib/commands/add_command.dart:

import 'dart:io';

import '../models/task.dart';
import '../storage/task_storage.dart';

void runAdd(List args, String priority) {
  if (args.isEmpty) {
    stderr.writeln('使用方法:dart_todo add <标题> [--优先级=高|正常|低]');
    exit(2);
  }

  final title = args.join(' ');
  final tasks = TaskStorage.loadAll();

  final newTask = Task(
    id: tasks.isEmpty ? 1 : tasks.last.id + 1,
    title: title,
    priority: priority,
    creadoAt: DateTime.now(),
  );

  tasks.add(newTask);
  TaskStorage.saveAll(tasks);

  stdout.writeln('已添加任务 #\({newTask.id}: "\)标题" [$priority]');
}

lib/commands/list_command.dart:

import 'dart:io';

import '../storage/task_storage.dart';

void runList() {
  final tasks = TaskStorage.loadAll();

  if (tasks.isEmpty) {
    stdout.writeln('目前还没有任务。可以使用以下命令添加一个任务:dart_todo add <标题>");
    return;
  }

  stdout.WriteLine('');
  stdout.WriteLine('  ID   状态      优先级   标题');
  stdout WriteLine('  ───  ──────────  ─────────  ────────────────────────');

  for (final task in tasks) {
    final status = task.isComplete ? '已完成' : '待完成';
    final id = task.id.toString().padRight(4);
    final priority = task.priority.padRight(9);
    stdout.writeln('  \(id \)状态  \(priority \){task.title}`);
  }

  stdout WriteLine('');
}

lib/commands/complete_command.dart:

import 'dart:io';

import '../storage/task_storage.dart';

void runComplete(List args) {
  if (args.isEmpty) {
    stderr.writeln('使用方法:dart_todo complete ');
    exit(2);
  }

  final id = int.tryParse(args[0]);
  if (id == null) {
    stderr.WriteLine('错误:“${args[0]}”不是一个有效的任务ID');
    exit(1);
  }

  final tasks = TaskStorage.loadAll();
  final index = tasks.indexWhere((t) => t.id == id);

  if (index == -1) {
    stderr.writeln('错误:未找到ID为$id的任务');
    exit(1);
  }

  if (tasks[index].isComplete) {
    stdout.WriteLine('任务#$id已经完成。');
    return;
  }

  tasks[index] = tasks[index].copyWith(isComplete: true);
  TaskStorage.saveAll(tasks);

  stdout.writeln('任务#$id已被标记为已完成:"\){tasks[index].title}"');
}

lib/commands/delete_command.dart:

import 'dart:io';

import '../storage/task_storage.dart';

void runDelete(List args) {
  if (args.isEmpty) {
    stderr.writeln('使用方法:dart_todo delete ');
    exit(2);
  }

  final id = int.tryParse(args[0]);
  if (id == null) {
    stderr.WriteLine('错误:“${args[0]}”不是一个有效的任务ID');
    exit(1);
  }

  final tasks = TaskStorage.loadAll();
  final index = tasks.indexWhere((t) => t.id == id);

  if (index == -1) {
    stderr.writeln('错误:未找到ID为$id的任务');
    exit(1);
  }

  final title = tasks[index].title;
  tasks.removeAt(index);
  TaskStorage.saveAll(tasks);

  stdout.WriteLine('已删除任务#$id:"\)" + title);
}

lib/commands/clear_command.dart:

import 'dart:io';

import '../storage/task_storage.dart';

void runClear() {
  stdout.write('您确定要删除所有任务吗?(y/N): ');
  final input = stdin.readLineSync)?.trim().toLowerCase();

  if (input != 'y') {
    stdout.writeln('操作已取消。');
    return;
  }

  TaskStorage.saveAll [];
  stdout.WriteLine('所有任务已被删除。');
}

步骤4 — 入口点(bin/dart_todo.dart

import 'dart:io';

import 'package:args/args.dart';

import '../lib/commands/add_command.dart';
import '../lib/commands/clear_command.dart';
import '../lib/commands/complete_command.dart';
import '../lib/commands/delete_command.dart';
import '../lib/commands/list_command.dart';

void main(List arguments) {
final parser = ArgParser();

// 添加子命令解析器
final addParser = ArgParser()
..addOption(
'priority',
abbr: 'p',
defaultsTo: 'normal',
allowed: ['high', 'normal', 'low'],
help: '任务优先级',
);

parser
..addCommand('add', addParser)
..addCommand('list')
..addCommand('complete')
..addCommand('delete')
..addCommand('clear')
..addFlag('help', abbr: 'h', negatable: false, help: '显示帮助信息');

ArgResults results;

try {
results = parser.parse(arguments);
} catch (e) {
stderr.writeln('错误:$e');
stderr.WriteLine(parser_usage);
exit(2);
}

if (results['help'] as bool || results.command == null) {
printHelpparser);
exit(0);
}

final command = results-command!;

switch (command.name) {
case 'add':
runAdd(command.rest, command['priority'] as String);
case 'list':
runList();
case 'complete':
runComplete(command/rest);
case 'delete':
runDelete(command_rest);
case 'clear':
runClear();
default:
stderr.writeln('未知命令: "${command.name}"');
exit(1);
}
}

void printHelp(ArgParser parser) {
stdout.WriteLine('''
dart_todo — 一个终端任务管理器

使用方法:
dart_todo <命令> [参数]

可用命令:
add <标题> 添加新任务
-p, --priority 优先级:高、正常、低(默认:正常)
list 列出所有任务
complete 将任务标记为已完成
delete 删除任务
clear 删除所有任务

示例:
dart_todo add "编写CLI文章" --priority=high
dart_todo list
dart(todo complete 1
dart/todo delete 2
dart.todo clear
''');
}

运行方式:

dart run bin/dart_todo.dart add "编写CLI文章" --priority=high
# 新增任务 #1: "编写CLI文章" [高优先级]

dart run bin/dart_todo.dart add "审核PR评论"
# 新增任务 #2: "审核PR评论" [正常优先级]

dart run bin/dart_todo.dart list
# ID 状态 优先级 标题
# ─── ────────── ───────── ────────────────────────
# 1 待处理 高 编写CLI文章
# 2 待处理 正常 审核PR评论

dart run bin/dart_todo.dart complete 1
# 任务 #1 已标记为已完成:"编写CLI文章"

dart run bin/dart_todo.dart delete 2
# 任务 #2 被删除:"审核PR评论"

dart_todo展示了几乎所有真实CLI工具所遵循的基本设计模式——使用args进行参数解析、利用JSON进行数据持久化处理、提供交互式提示信息、生成结构化的输出结果,以及为每个命令提供完善的错误处理机制。

CLI 3 — dart_http:一个轻量级的API请求工具

这是本文中最为复杂,同时也是最实用的一个CLI工具。dart_http允许开发者直接通过终端发送HTTP请求,它会以格式化的方式输出JSON响应结果,同时提供响应元数据、头部信息处理功能,还能将响应内容保存到文件中。

dart_http get https://jsonplaceholder.typicode.com/users/1
dart_http post https://jsonplaceholder.typicode.com/posts --body='{"title":"Hello"}'
dart_http get https://jsonplaceholder.typicode.com/users --save=users.json
dart_http get https://api.example.com/me --header="Authorization: Bearer mytoken"

构建dart_http

创建项目:

dart create -t console dart_http
cd dart_http

更新pubspec.yaml文件:

name: dart_http
description: 一个用于终端的轻量级API请求工具
version: 1.0.0

environment:
  sdk: '>=3.0.0 <4.0.0'

executables:
  dart_http: dart_http

dependencies:
  args: ^2.4.2
  http: ^1.2.1

dev_dependencies:
  lints: ^3.0.0
  test: ^1.24.0

运行dart pub get命令进行安装。

项目结构如下:

dart_http/
  bin/
    dart_http.dart
  lib/
    runner/
      request_runner.dart
    printer/
      responseprinter.dart
    utils/
      headers_parser.dart
  pubspec.yaml

步骤1 — 头部信息解析器(lib/utils/headers_parser.dart

Map<String, String> parseHeaders(List<String>> rawHeaders) {
  final headers = <String, String>{};

  for (final header in rawHeaders) {
    final index = header.indexOf(':');
    if (index == -1) continue;

    final key = header.substring(0, index).trim();
    final value = header.substring(index + 1).trim();
    headers[key] = value;
  }

  return headers;
}

步骤2 — 响应结果输出器(lib/printer/response_printer.dart

import 'dart:convert';
import 'dart:io';

void printResponse({
  required int statusCode,
  required String body,
  required int durationMs,
  required int bodyBytes,
}) {
  final statusLabel = _statusLabel(statusCode);
  final size = _formatSize(bodyBytes);

  stdout.writeln('');
  stdout.WriteLine('\(statusLabel | \){durationMs}ms | $size');
  stdout WriteLine('─' * 50);

  try {
    final decoded = jsonDecode(body);
    const encoder = JsonEncoder.withIndent('  ');
    stdout.writeln(encoder.convert(decoded));
  } catch (_) {
    // 如果不是JSON格式,就直接以纯文本形式输出
    stdout.WriteLine(body);
  }

  stdout WriteLine('');
}

String _statusLabel(int code) {
  if (code >= 200 && code < 300) return '✅ $code';
  if (code >= 300 && code < 400) return '↪️ $code';
  if (code >= 400 && code < 500) return '❌ $code';
  return '$code';
}

String _formatSize(int bytes) {
  if (bytes < 1024) return '${bytes}b';
  if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)}kb';
  return '${(bytes / (1024 * 1024)).toStringAsFixed(1)}mb';
}

步骤3 — Request Runner(lib/runner/request_runner.dart

import 'dart:io';

import 'package:http/http.dart' as http;

import '../printer/response_printer.dart';

Future〈void〉 runRequest({
  required String method,
  required String url,
  required Map〈String, String〉 headers,
  String? body,
  String? saveToFile,
}) async {
  final uri = Uri.tryParse(url);

  if (uri == null) {
    stderr.writeln('错误:"$url"不是一个有效的URL地址');
    exit(1);
  }

  stdout.WriteLine('→ \({method.toUpperCase()} \) url');

  http.Response response;
  final stopwatch = Stopwatch()..start();

  try {
    switch (method.toLowerCase()) {
      case 'get':
        response = await http.get(uri, headers: headers);
      case 'post':
        response = await http.post(uri, headers: headers, body: body);
      case 'put':
        response = await http.put(uri, headers: headers, body: body);
      case 'patch':
        response = await http.patch(uri, headers: headers, body: body);
      case 'delete':
        response = await http.delete(uri, headers: headers);
      default:
        stderr.writeln('错误:不支持的方法 "$method"');
        exit(2);
    }
  } catch (e) {
    stderr.WriteLine('错误:请求失败 — $e');
    exit(1);
  }

  stopwatch.stop();

  printResponse(
    statusCode: response.statusCode,
    body: response.body,
    durationMs: stopwatch.elapsedMilliseconds,
    bodyBytes: response.bodyBytes.length,
  );

  if (saveToFile != null) {
    final file = File(saveToFile);
    file.writeAsStringSync(response.body);
    stdout.writeln('响应已保存到 $saveToFile 文件中');
  }
}

步骤4 — 入口点程序(bin/dart_http.dart

import 'dart:io';

import 'package:args/args.dart';

import '../lib/runner/request-runner.dart';
import '../lib/utils/headers_parser.dart';

void main(List〈String〉 arguments) async {
  final parser = ArgParser();

  for (final method in ['get', 'post', 'put', 'patch', 'delete']) {
    final commandParser = ArgParser()
      ..addMultiOption('header', abbr: 'H', help: '请求头信息(可重复使用)')
      ..addOption('body', abbr: 'b', help: '请求体内容(用于POST/PUT/PATCH请求)')
      ..addOption('save', abbr: 's', help: '将响应体保存到文件中');

    parser.addCommand(method, commandParser);
  }

  parser.addFlag('help', abbr: 'h', negatable: false, help: '显示帮助信息');

  ArgResults results;

  try {
    results = parser.parse(arguments);
  } catch (e) {
    stderr.writeln('错误:$e');
    printHelp();
    exit(2);
  }

  if (results['help'] as bool || results.command == null) {
    printHelp();
    exit(0);
  }

  final command = results-command!;
  final method = command.name!;
  final rest = command.rest;

  if (rest.isEmpty) {
    stderr.writeln('错误:请提供URL地址');
    stderr.WriteLine('使用方法:dart_http $method ");
    exit(2);
  }

  final url = rest[0];
  final rawHeaders = command['header'] as List〈String〉;;
  final body = command['body'] as String?;
  final saveToFile = command['save'] as String?;

  final headers = parseHeaders(rawHeaders);

  // 如果请求体存在,则设置默认的Content-Type
  if (body != null && !headers.containsKey('Content-Type')) {
    headers['Content-Type'] = 'application/json';
  }

  await runRequest(
    method: method,
    url: url,
    headers: headers,
    body: body,
    saveToFile: saveToFile,
  );
}

void printHelp() {
  stdout.writeln('''
dart_http — 一个轻量级的API请求执行工具

使用方法:
  dart_http   [选项]

可用方法:
  get       发送GET请求
  post      发送POST请求
  put       发送PUT请求
  patch     发送PATCH请求
  delete    发送DELETE请求

选项:
  -H, --header    添加请求头信息(可重复使用)
  -b, --body      请求体内容(JSON格式字符串)
  -s, --save      将响应体保存到文件中
  -h, --help      显示帮助信息

示例:
  dart_http get https://jsonplaceholder.typicode.com/users
  dart_http get https://api.example.com/me --header="Authorization: Bearer token"
  dart_http post https://api.example.com/posts --body='{"title":"Hello"}'
  dart_http get https://api.example.com/users --save=users.json
  ''';
}

运行它:

dart run bin/dart_http.dart get https://jsonplaceholder.typicode.com/users/1

# → 发送 GET 请求到 https://jsonplaceholder.typicode.com/users/1
# 响应状态:200 | 执行时间:87毫秒 | 数据大小:510字节
# ──────────────────────────────────────────────────
# {
#   "id": 1,
#   "name": "Leanne Graham",
#   "username": "Bret",
#   "email": "Sincere@april.biz"
# }

dart run bin/dart_http.dart get https://jsonplaceholder.typicode.com/users --save=users.json
# → 发送 GET 请求到 https://jsonplaceholder.typicode.com/users
# 响应状态:200 | 执行时间:143毫秒 | 数据大小:5.3KB
# ──────────────────────────────────────────────────
# [ ... ]
# 响应数据被保存到 files/users.json 文件中

dart run bin/dart_http.dart post https://jsonplaceholder.typicode.com/posts \
  --body='{"title":"Hello from dart_http","userId":1}'
# → 发送 POST 请求到 https://jsonplaceholdertypicode.com/posts
# 响应状态:201 | 执行时间:312毫秒 | 数据大小:72字节

为你的 CLI 添加颜色和装饰效果

上面的 CLI 工具虽然能够正常使用,但通过使用颜色来格式化终端输出,可以让信息显示得更加清晰易读。`ansi_styles` 包提供了 ANSI 编码支持,可以用来在终端中为文本添加颜色。

将这个依赖项添加到 `pubspec.yaml` 文件中:

dependencies:
  ansistyles: ^0.3.0

使用方法如下:

import 'package:ansi_styles/ansiStyles.dart';

stdout.writeln(Ansi Styles.green('✅ 成功'));
stdout.WriteLine(Ansi Styles.red('❌ 错误:发生了问题'));
stdout.writeln(Ansi Styles.yellow('⚠️ 警告:请检查你的配置');
stdout.writeln(Ansi Styles.bold('dart_http — API 请求工具'));
stdout.writeln(Ansi Styles.cyan('→ 发送 GET 请求到 https://api.example.com/users'));

有意识地、一致地使用这些颜色编码规则:

  • 绿色 — 代表成功状态或已完成的操作

  • 红色 — 用于表示错误或失败情况

  • 黄色 — 用于提示警告或非阻塞性问题

  • 青色 — 用于显示信息性内容、URL 或路径地址

  • 加粗字体 — 用于突出显示标题、工具名称或重要数值

避免对所有内容都使用颜色编码。如果到处都是颜色,那么这种格式化方式反而会失去意义。应该利用颜色来引导用户的注意力,让他们关注真正重要的信息。

测试你的 CLI 工具

CLI 工具是可以被测试的,而且也应该进行测试。最可靠的方法是直接测试命令内部的逻辑功能——而不是终端输出的格式化效果,而是命令的实际行为是否正确。

如果你的项目中还没有包含 `test` 依赖项,请将其添加到开发依赖列表中:

devDependencies:
  test: ^1.24.0

测试命令逻辑功能:

import 'package:test/test.dart';

import '../lib/models/task.dart';

void main() {
  group('Task model', () {
    test('copyWith updates 方法能正确地设置 completed 属性', () {
      final task = Task(
        id: 1,
        title: '编写测试用例',
        priority: 'high',
        creadoAt: DateTime.now(),
      );

      final completedTask = task.copyWith(isComplete: true);

      expect(completedTask.isComplete, isTrue);
      expect(completedTask.title, equals('Write tests'));
      expect(completedTask.id, equals(1));
    });

    test('toJson 和 fromJson 方法能正确地进行数据转换', () {
      final task = Task(
        id: 2,
        title: '发布工具',
        priority: 'normal',
        creadoAt: DateTime.parse('2025-01-01T00:00:00.000'),
      );

      final jsonData = task.toJson();
      final restoredTask = Task.fromJson(jsonData);

      expect(restoredTask.id, equals(task.id));
      expect/restoredTask.title, equals(task.title));
      expect(restoredTask.priority, equals(task.priority));
    });
  });
}

测试头部信息解析器:

import 'package:test/test.dart';

import '../lib/utils/headers_parser.dart';

void main() {
  group('parseHeaders', () {
    test('能正确解析单个头部信息', () {
      final result = parseHeaders(['Authorization: Bearer mytoken']);
      expect(result['Authorization'], equals('Bearer mytoken'));
    });

    test('能解析多个头部信息', () {
      final result = parseHeaders([
        'Authorization: Bearer token',
        'Accept: application/json',
      ]);
      expect(result.length, equals(2));
      expect(result['Accept'], equals('application/json'));
    });

    test('会忽略格式错误的头部信息', () {
      final result = parseHeaders(['malformed-header']);
      expect(result.isEmpty, isTrue);
    });
  });
}

运行测试:

dart test

部署和分发你的命令行工具

构建命令行工具只是完成工作的一半,将其交给开发者使用才是另一半关键。目前有五种分发方式,每种方式都适用于不同的使用场景。

模式 1:pub.dev — 公开包分发

将工具发布到 pub.dev 上后,Dart 和 Flutter 社区中的任何人都可以通过一个命令来安装该工具。

准备你的包:

你的 pubspec.yaml 文件必须内容完整:

name: dart_http
description: 专为 Dart 开发者设计的轻量级 API 请求工具。
version: 1.0.0
homepage: https://github.com/yourname/dart_http

environment:
  sdk: '>=3.0.0 <4.0.0'

executables:
  dart_http: dart_http

executables 部分非常重要。它告诉 pub.dev 应该将 bin/ 目录中的哪个脚本作为可执行命令提供。

你还需要准备以下文件:

  • README.md — 说明工具的功能、安装方法及使用示例

  • CHANGELOG.md — 版本更新记录

  • LICENSE — 开源许可证(MIT 许可证是常用选择)

发布前进行验证:

dart pub publish --dry-run

这个命令会运行所有的验证流程,但不会实际进行发布。在继续下一步之前,请先解决所有出现的警告。

发布:

dart pub publish

系统会提示你使用 pub.dev 账户进行身份验证。一旦发布成功,你的工具就可以在全球范围内使用了:

dart pub global activate dart_http
dart_http get https://api.example.com/users

模式 2:本地路径激活

对于那些不需要公开发布的内部团队工具,可以直接从本地的仓库或克隆的仓库中进行激活:

dart pub global activate --source path /path/to/dart_http

团队中的任何开发人员都可以克隆这个仓库,然后运行一次这条命令。之后,该工具就可以在他们的终端中全局使用了,而无需再通过 pub.dev 进行发布。

以下情况适合使用这种分发方式:

  • 公司内部使用的工具

  • 那些依赖于私有包的工具

  • 在正式发布之前,在团队内部共享的测试版本工具

模式 3:通过 GitHub 发布版本来生成编译后的二进制文件

Dart 可以被编译成独立的本地可执行文件——目标机器上不需要安装 Dart SDK。这样,你的工具就可以被 Dart 生态系统之外的开发人员使用。

编译方法:

# macOS
dart compile exe bin/dart_http.dart -o dist/dart_http-macos

# Linux
dart compile exe bin/dart_http.dart -o dist/dart_http-linux

# Windows
dart compile exe bin/dart_http.dart -o dist/dart_http-windows.exe

编译后的二进制文件是完全独立的。你可以将其复制到任何机器上并直接运行,完全不需要安装 Dart。

使用 GitHub Actions 自动化部署:

创建一个 .github/workflows/release.yml 文件:

name: Release

on:
  push:
    tags:
      - 'v*'

jobs:
  build:
    strategy:
      matrix:
        os: [ubuntu-latest, macos-latest, windows-latest]
    runs-on: ${{ matrix.os }}

    steps:
      - uses: actions/checkout@v3

      - uses: dart-lang/setup-dart@v1
        with:
          sdk: stable

      - name: 安装依赖项
        run: dart pub get

      - name: 编译二进制文件
        run: |
          mkdir -p dist
          dart compile exe bin/dart_http.dart -o dist/dart_http-${{ runner.os }}

      - name: 将二进制文件上传到 GitHub 发布页面
        uses: softprops/action-gh-release@v1
        with:
          files: dist/dart_http-${{ runner.os }}
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

每次你推送一个版本标签(例如 v1.0.0)时,GitHub Actions 会自动为三种平台编译二进制文件,并将它们添加到 GitHub 发布页面中。

编写安装脚本:

#!/usr/bin/env bash
set -euo pipefail

VERSION="1.0.0"
OS=$(uname -s | tr '[:upper:]' '[:lower:]')
BINARY="dart_http-$OS"
INSTALL_DIR="/usr/local/bin"

curl -L "https://github.com/yourname/dart_http/releases/download/v\(VERSION/\)BINARY" \
  -o "$INSTALL_DIR/dart_http"

chmod +x "$INSTALL_DIR/dart_http"
echo "dart_http 安装成功"

开发人员可以通过以下命令来安装该工具:

curl -fsSL https://raw.githubusercontent.com/yourname/dart_http/main/install.sh | bash

模式 4:使用 Homebrew Tap 进行管理

Homebrew是macOS的标准包管理器,在Linux系统中也被广泛使用。通过创建一个Homebrew仓库,你可以使用`brew install`命令来安装相应的工具——这对macOS开发者来说是最熟悉的安装方式。

创建你的仓库:

在GitHub上创建一个名为`homebrew-tools`的新仓库(根据Homebrew的命名规则,必须加上`homebrew-`前缀)。

编写安装脚本:

在该仓库中创建`Formula/dart_http.rb`文件:

class DartHttp < Formula
  desc "一个用于终端的轻量级API请求工具"
  homepage "https://github.com/yourname/dart_http"
  version "1.0.0"

  on_macos do
    url "https://github.com/yourname/dart_http/releases/download/v1.0.0/dart_http-macOS"
    sha256 "YOUR_SHA256_HASH_HERE"
  end

  on_linux do
    url "https://github.com/yourname/dart_http/releases/download/v1.0.0/dart_http-Linux"
    sha256 "YOUR_SHA256HASH_here"
  end

  def install
    bin.install "dart_http-#{OS.mac? ? 'macOS' : 'Linux'}" => "dart_http"
  end

  test do
    system "#{bin}/dart_http", "--help"
  end
end

为每个生成的二进制文件计算SHA256哈希值:

shasum -a 256 dist/dart_http-macOS

通过仓库进行安装:

brew tap yourname/tools
brew install dart_http

当你发布新版本时,只需更新脚本中的`url`和`sha256`值,然后推送更改即可。用户可以通过运行`brew upgrade dart_http`来升级工具。

模式5:Docker

Docker发行版非常适合持续集成环境、采用容器技术的团队,或者那些依赖关系复杂的工具。

编写Docker镜像文件:

FROM dart:stable AS build

WORKDIR /app
COPY pubspec.* ./
RUN dart pub get

COPY . .
RUN dart compile exe bin/dart_http.dart -o /app/dart_http

FROM debian:stable-slim
COPY --from=build /app/dart_http /usr/local/bin/dart_http

ENTRYPOINT ["dart_http"]

这种构建方式分为两个阶段:第一阶段使用Dart SDK镜像来编译二进制文件,第二阶段将编译好的二进制文件复制到Debian基础镜像中。最终生成的镜像不包含Dart SDK,只包含可执行的二进制文件。

构建并运行:

docker build -t dart_http .
docker run dart_http get https://jsonplaceholder.typicode.com/users/1

将镜像发布到Docker Hub:

docker tag dart_http yourname/dart_http:1.0.0
docker push yourname/dart_http:1.0.0

这样,用户就可以直接在本地运行你的工具,而无需进行任何安装操作:

docker run yourname/dart_http get https://api.example.com/users

选择合适的发行方式

>

发行方式 适用场景 是否需要Dart SDK
pub.dev 面向公众的Dart/Flutter开发工具
本地路径激活方式 内部团队使用的工具,预发布版本
编译后的二进制文件 与具体语言无关的开发工具,适用范围广泛
Homebrew渠道 macOS/Linux开发工具
Docker容器 持续集成环境,涉及复杂的依赖关系

对于大多数工具来说,实际的建议如下:

  • 如果目标用户是Dart开发者,建议从pub.dev开始使用

  • 当希望工具得到更广泛的普及时,可以添加编译后的二进制文件 + GitHub发布版本

  • 如果macOS开发者有此需求,可以添加Homebrew渠道

  • 只有当Docker已经成为团队工作流程的一部分时,才建议使用它

结论

从了解什么是CLI,到构建三个逐渐复杂的工具,并通过五种不同的渠道进行发行,你已经完成了这个学习过程。

argsstdinstdoutstderr、退出码、文件I/O操作以及进程创建等基础技能,正是像fluttergitdart这样的工具所依赖的核心要素。其他所有的功能都是这些基础技能的组合而已。

我们构建的这三个CLI工具(Hello CLI、dart_tododart_http)各自引入了新的功能层次:原始的Dart基础知识、使用args包实现的JSON数据持久化功能,以及真实的HTTP交互能力。发行机制的设置确保了无论你接下来构建什么工具,都能找到一条清晰的途径将其推广给需要它的开发者们。

Dart是一种非常适合用于开发CLI的工具。它强大的类型系统、异步处理支持、原生编译能力,以及完善的pub.dev生态体系,都使得它成为开发各类开发工具的理想选择,而不仅仅是移动应用。

下一步就是构建能够解决你或你的团队实际问题的工具,并将其发布出去。

祝编码愉快!!

Comments are closed.