Dartで業務自動化コマンドラインツールを作成する

![Untitled.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2700602/05122f79-45ec-071f-f800-41da3186d1e1.png)

## この記事について

ターミナルアプリからコマンドひとつで呼び出して実行できる「コマンドラインツール」をDartで作成します。
[**ソースコードはこちら**](https://github.com/kazmawedton/dart_simple_cli)。

## 作成するコマンドラインツールの機能について

- コマンドとして`mycli`など任意の文字列を設定できます
- テンプレートから`.md`ファイルを作成してPCに保存します
今回は会議の議事録を想定した`.md`ファイルを作成しますが、`.txt`や`.puml`など、テキスト系ファイルであれば微調整で実装可能です。
- オプションを利用して引数を渡すことができます
(下記例では`-t`がオプションで`title.md`が引数です)
    ```shell
    mycli -t title.md
    ```

## 開発環境

```
OS:    macOS 12.6
dart:  2.18.6
```

Dartのインストールは[こちらを参照](https://dart.dev/get-dart)してください。
今回はmacOSで解説していますので、WindowsやLinuxの場合はコードの一部を変更してください。

## 1. プロジェクト作成と準備

### 1.1 プロジェクト作成

- 任意のディレクトリでプロジェクト作成コマンド`dart create`を実行
    ```shell
    dart create -t console-simple dart_simple_cli
    ```
- `-t`オプションに`console-simple`を指定し、コマンドラインツール用テンプレートを利用しています。
(その他のオプションについては[こちら](https://dart.dev/tools/dart-create)を参照)

### 1.2 パッケージのインストール

- インストールするパッケージ

|package|用途|
|:--|:--|
|[**args**](https://pub.dev/packages/args)|コマンドラインツールのオプションやフラグの設定などができる便利メソッドを利用|
|[**path**](https://pub.dev/documentation/path/latest/)|ファイル名まわりの文字列操作をするメソッドを利用|
|[**intl**](https://pub.dev/packages/intl)|日時の取得やフォーマットをするメソッドを利用|

- 1.1で作成したプロジェクトのディレクトリで１行ずつ実行
    ```shell
    dart pub add args
    dart pub add path
    dart pub add intl
    ```
- `pubspec.yaml`の`dependencies`にパッケージが追記されているのを確認
    ```pubspec.yaml
    name: dart_simple_cli
    description: A simple command-line application.
    version: 1.0.0
    
    ...中略...
    
    dependencies:
      args: ^2.3.2
      path: ^1.8.3
      intl: ^0.18.0
    ```
- 今回は実装しませんが、APIなどを実行する場合は[**http**](https://pub.dev/packages/http)などのパッケージが利用可能です。

## 2. メソッド作成

- Dartプロジェクトの実行部にあたる`bin/dart_simple_cli.dart`の`main()`の前に、`main()`内で利用するメソッドを先に作成します。
- 作成するメソッドは以下の２つです
    |メソッド|説明|
    |:--|:--|
    |`getHomeDir()`|macOSの`Users/username/`にあたるディレクトリのパスを返す<br>macOS, Windows, Linuxそれぞれに対応したパスを取得するのに`dart:io`を利用しています。|
    |`getMinutesTemplate()`|出力する議事録`.md`ファイルのテンプレートを`String`で返す<br>今回は固定値を返すので関数化のメリットは小さいですが、動的に戻り値を生成するなど適宜実装してください。|

```bin/dart_simple_cli.dart
import 'dart:io';

void main() {}

//  ---------- ホームディレクトリのパス取得 ----------

String getHomeDir() {
  // 出力用文字列
  late String home;
  // 実行環境によるの変数の取得
  Map<String, String> envVars = Platform.environment;

  // OSで分岐してホームディレクトリを取得
  if (Platform.isMacOS) {
    home = envVars['HOME']!;
  } else if (Platform.isLinux) {
    home = envVars['HOME']!;
  } else if (Platform.isWindows) {
    home = envVars['UserProfile']!;
  }

  // 出力
  return '$home/';
}

// ---------- テンプレート文字列出力 ----------

String getMinutesTemplate() {
  return '''## 場所

- 

## 参加者

- 

## アジェンダ

- 

## 議事録

- 
''';
}

```

## 3. `main()`の実装

### 3.1 オプションの設定

- プロジェクトをコマンドラインツールとして利用する際に、オプションやフラグ、引数は全て`main()`に`List<String>`型として渡されます。
例えば、下のように実行すると、`['--option', 'value']`という配列が`main()`に渡されます。
    ```shell
    dart bin/dart_simple_cli.dart --option value
    ```
- このままではコーディング時もコマンド利用時にも勝手が良くないため、[**args**]()の`ArgPerser`クラスを使い、フラグやオプションとその引数を簡単に、そして正しく抽出できるようにします。
- `ArgPerser`クラスの`addOption()`メソッドでオプションを設定を追加します。
    下記コード内１つ目の`perser.addOption()`の設定内容は以下の通りです
    |引数と値|説明|
    |:--|:--|
    |`'output'`|コマンドラインツールに`--output`オプションを追加|
    |`abbr: 'o'`|`--output`の省略形として`-o`を設定|
    |`help: '..略..'`|`--help`実行時のヘルプメッセージの設定|
    |`valueHelp: '..略..'`|`--help`実行時の引数の入力例を設定|
- 引数を持たない`--help`などは`addFlag()`を利用して、フラグとして追加します。
    一番下の`parser.addFlag()`の設定内容は以下の通り。
    |引数と値|説明|
    |:--|:--|
    |`'help'`|`--help`フラグを追加|
    |`abbr: 'h'`|`--help`の省略形として`-h`を設定|
    |`hide: true`|ヘルプメッセージに表示されないよう設定|

```bin/dart_simple_cli.dart
import 'dart:io';
import 'package:path/path.dart'; // <- 追加
import 'package:args/args.dart'; // <- 追加

void main(List<String> args) {
  // ---------- 引数パース ----------
  final ArgParser parser = ArgParser();
  // オプション：出力ファイルのパス指定
  parser.addOption(
    'output',
    abbr: 'o',
    help: 'Define output filename and path',
    valueHelp: '/path/to/file.md',
  );
  // オプション：出力ファイル名指定
  parser.addOption('title',
      abbr: "t",
      help: 'Define output filename (won\'t work with \'--output\' option)',
      valueHelp: 'file.md',
  );
  // オプション：出力ディレクトリ指定
  parser.addOption(
    'directory',
    abbr: "d",
    help: 'Difile output directory (won\'t work with \'--output\' option)',
    valueHelp: '/path/to/directory/',
  );
  // フラグ：ヘルプ表示
  parser.addFlag('help', abbr: 'h', hide: true);
  // 因数取得
  final ArgResults results = parser.parse(args);
}
```
- 今回は下記の3つの想定でオプションを実装しています。
    |オプション|説明|
    |:--|:--|
    |`--output`|出力するパスをファイル名込みで渡すオプション|
    |`--title`|出力するファイル名を渡すオプション (`--output`と併用不可)|
    |`--directory`|出力するディレクトリパスを渡すオプション (`--output`と併用不可)|

ここまでで、受け取った引数をコード内で利用する準備ができました。

### 3.2 ヘルプメッセージの実装 - フラグを使用

- `--help`のオプションで実行時に、ヘルプメッセージを表示する実装をします。

```bin/dart_simple_cli.dart
import 'dart:io';
import 'package:args/args.dart';
import 'package:path/path.dart';

void main(List<String> args) {
  
  // ...中略...

  // ---------- ヘルプの表示 ----------

  if (results['help']) {
    print(parser.usage);
    return; // 早期リターン
  }
}
```

### 3.3 ファイル生成の準備 - ファイル名と出力パスの初期化

この項の下で解説の通り、出力するファイル名には`yyyy-MM-dd_01.md`のように動的に連番をつける場合がある都合上、ファイル名は柔軟に生成や変更ができる方が便利です。そのためにパスはファイル名とディレクトリに、そしてファイル名は拡張子とそれ以外の部分にそれぞれ分割して格納しています。
|定数名・変数名|説明|
|:--|:--|
|`String timestamp`|ファイル名に使用する日付|
|`String desktopPath`|デフォルトのファイル出力先|
|`String fileName`|出力ファイル名|
|`String fileExtention`|出力ファイルの拡張子|
|`String fileNameRaw`|出力ファイルの拡張子以前の部分|
|`String fileDir`|出力するディレクトリのパス|
|`String filePath`|出力するディレクトリとファイル名を合わせたパス|

```bin/dart_simple_cli.dart
import 'dart:io';
import 'package:args/args.dart';
import 'package:path/path.dart';
import 'package:intl/intl.dart'; // <- 追加

void main(List<String> args) {
  
  // ...中略...

  // ---------- 変数、定数 ----------

  // 年月日文字列
  final DateTime now = DateTime.now();
  final DateFormat formatter = DateFormat('yyyy-MM-dd');
  final String timestamp = formatter.format(now);

  // ホームとデスクトップのディレクトリパス
  final String homePath = getHomeDir();
  final String desktopPath = "$homePath/Desktop/";

  // 出力ファイル名 (値は下行で決定するため、ここではlateで遅延初期化まで)
  late String fileName;
  // ファイル拡張子
  late String fileExtention = extension(fileName) == '' ? '.md' : extension(fileName); // ファイル拡張子
  late String fileNameRaw = basenameWithoutExtension(fileName); // ファイル名の拡張子以前の部分

  late String fileDir; // 出力ディレクトリパス
  late String filePath; // 出力ファイルパス
  int fileIndex = 1; // ファイル名の連番用
}
```
- 出力ファイルのパスのデフォルトは`Users/username/Desktop/yyyy-MM-dd.md`
- デスクトップのパスはOSにより異なるため、他OS対応する場合は、`desktopPath`を適宜変更。
- 最終的な出力ファイル名やパスはオプションによって決定するため、ここでは`late`で遅延初期化をします。
  遅延初期化について[詳しくはこちら (英語)](https://dart.dev/guides/language/language-tour#late-variables)
- 意図せず既存のファイルを上書きしないように、ファイル名の重複時には`yyyy-MM-dd_01.md`のように末尾に連番を足す処理を実装しています。最下部の`int fileIndex`はその際に利用します。

### 3.4 出力パスの決定 - オプションの利用

```bin/dart_simple_cli.dart
import 'dart:io';
import 'package:args/args.dart';
import 'package:path/path.dart';
import 'package:intl/intl.dart';

void main(List<String> args) {
  
  // ...中略...

  // ---------- 出力パスの決定 ----------

  // オプションで分岐して、ファイル名と出力ディレクトリパスの決定
  // outputオプションで出力パスを指定
  if (results['output'] != null) {
    // 引数の出力パスをディレクトリとファイル名に分解
    final filePathArray = results['output'].toString().split('/');
    fileName = filePathArray.removeLast();
    fileDir = '${filePathArray.join('/')}/';
  } else {
    // titleオプションでファイル名指定
    if (results['title'] != null) {
      fileName = '$timestamp' '_' '${results['title']}.md';
    } else {
      fileName = '$timestamp.md';
    }

    // directoryオプションでディレクトリ指定
    if (results['directory'] != null) {
      fileDir = results['directory'];
      // スラッシュの補完
      if (fileDir[fileDir.length - 1] != '/') fileDir += '/';
    } else {
      fileDir = desktopPath;
    }
  }
  // ファイルパスの決定
  filePath = fileDir + fileNameRaw + fileExtention;
}
```

### 3.5 ファイルの生成

テンプレートとファイルのパスが決定したので、`File`クラスの`create()`でファイルを生成することが出来ますが、その際に注意が必要です。出力するファイルと同名のファイルがすでに存在する場合、`create()`メソッドはファイルを上書きしてしまいます。
そのためここでは、既存のファイルに同名がないかを確認して、あった場合はファイルに連番を付けて上書きを回避する処理をしています。

- ファイルの生成や編集は非同期で行われるため、`main()`も`async`キーワードを付けて、処理の待機ができるようにします。
- 出力するファイル名が決定し、実際にファイル生成するまで`while`ループを回します。
- ループの最初に`File(filePath).exists()`で同名のファイルがないかを取得します。
`exists()`の戻り値によって以下のように分岐します。
    |`exits()`|処理|
    |:--|:--|
    |`true`|`fileIndex`をインクリメントして、連番付`filePath`を再定義|
    |`false`|`filePath`にファイルを生成し、`writeAsString()`メソッドでテンプレートの文字列をファイルに書き込む<br>完了したら`created = true`でループを終了|

```bin/dart_simple_cli.dart
import 'dart:io';
import 'package:args/args.dart';
import 'package:path/path.dart';
import 'package:intl/intl.dart';

Future<void> main(List<String> args) async { // <- Futureに変更し、asyncを追加

  // ...中略...

  // ---------- ファイル名に連番をつけて重複を防ぐ処理 ----------

  bool created = false; // ファイル生成が完了したかチェック
  while (!created) {
    // すでに同名のファイルが存在する場合はindexをインクリメント
    if (await File(filePath).exists()) {
      // インクリメント
      fileIndex++;
      // indexを2桁のStringにする
      final String fileIndexStr = fileIndex.toString().padLeft(2, '0');

      // ファイル名とパスの再決定
      filePath = '$fileDir${fileNameRaw}_$fileIndexStr$fileExtention';
    } else {
      // 同名のファイルがなければ生成して、ループ終了
      await File(filePath).create().then((file) async {
        await file.writeAsString(getMinutesTemplate()).then((_) => created = true);
      });
    }
  }
}
```
これで`.dart`ファイルの実装は完成しました。

## 4. コマンドラインツールとしてアクティベート

作成したプロジェクトは、下記のように`dart`コマンドで実行することができますが、毎回ファイルのパスを指定する必要があり、このままでは不便です。
```shell
$ dart path/to/dart_simple_cli/bin/dart_simple_cli.dart // <- 毎回は面倒
```
コマンドラインツールとしてアクティベートして、`mycli`のように任意の文字列をコマンドとして設定することができます。
```shell
$ mycli // <- これだけ！
```

### 4.1 コマンドとスクリプトの割り当て

今回は`mycli`というコマンドで`dart_simple_cli.dart`を実行できるようにします。

- `pubspec.yaml`に`executables`を追加
    ```pubspec.yaml
    executables:
      mycli: dart_simple_cli.dart
    ```
- コマンドのアクティベート
`pubspec.yaml`に追記したコマンドをアクティベートします。
    ```
    dart pub global activate --source path .
    ```
### 4.2 動作確認

これでコマンドラインツールの作成とアクティベートが完了です！
実際にご利用のターミナルアプリからコマンドを実行して、ファイルが生成されることを確認してください。
ファイルが上書きされないこと、それぞれのオプションが期待通りに動作していること、テンプレートが正しく適用されていることもあわせて確認しましょう。

```zsh
mycli // <- デスクトップにyyyy-MM-dd.mdを生成
mycli // <- デスクトップにyyyy-MM-dd_02.mdを生成
mycli -o /Users/username/Download/test.md // <- Downloadにtest.mdを生成
mycli -t test02.md -d /Users/username/Documents // <- Documentsにtest02.mdを生成
```

`.dart`ファイルを変更するたびに再アクティベートが必要なので、注意してください。

解説は以上です！

## おわりに

Dart以外の言語でもコマンドツールを作成する方法はたくさんあると思いますが、Flutterに慣れてる開発者にとっては文法やパッケージの知識がそのまま使えるので、便利そうだと感じました。
Dartのインストールが必要な分、実行環境を選ぶデメリットはありますが、自分や仲間用にちょっとした処理の自動化をする用途では色々と試してみる価値がありそうです！

ご覧いただきありがとうございました。
質問やコメントなどあれば、いつでもお待ちしております^ ^

## 参考記事

https://qiita.com/mozukichi/items/fe6618cb1c1f741754d8