Open CLI Framework "oclif" - 独自CLIコマンド作成のためのオープンソースフレームワーク

oclif.png

oclifはHerokuチームがオープンソースで公開したCLIコマンドを手軽に作成するためのnode.jsベースのフレームワークです。

https://github.com/oclif/oclif

非常に簡単かつ柔軟にCLIを構築でき、Herokuチーム自身が提供しているHeroku CLIやSalesforceが提供しているSalesforce CLIなどが実際にこのoclifをベースに構築されているそうで、特にHeroku CLIは古くから多くの開発者に利用されており使いやすさには定評があります。

機能と特徴

oclifのGithubページでは、以下のような特徴が挙げられています。

  • フラグや引数の処理

    • 良いフラグパーサ無しではCLIフレームワークは成立しないです。Herokuが何年もの間をかけて課題を解決してきたOclifは、処理の一貫性を保ちつつもユーザがCLIを使いやすいように、入力に対してフレキシブルに処理を可能にし、厳密な入力を要求しません。
  • 超速い

    • Oclif CLIコマンドにはオーバーヘッドが殆どありません。また依存関係も最小限に抑えられていて、コマンド実行に必要なものはnodeだけです。たくさんのコマンドを持つ大きなCLIを構築してm、単一コマンドの小さいものと同等の速さで動作します。
  • CLIジェネレータ

    • 簡単なコマンドでフル機能のCLIのscaffoldを出力でき、すぐに使い始められます。
  • テストヘルパー

    • Herokuチームはコマンドのテスト作成を簡単にし、標準出力を行うためのモックの構築に多くの労力をかけました。ジェネレータは自動的にテストの雛形も構築してくれます。
  • 自動ドキュメント

    • 標準で --help でフラグオプションや引数の情報などを表示する機能が搭載されています。この情報はCLIをパブリッシュした際などにも自動的にnpm パッケージのREADMEにも反映されます。
  • プラグイン

    • プラグインを利用すると、CLIのユーザが新しい機能を追加してコマンドを拡張できます。CLIはモジュラーコンポーネントごとに分割し、複数のCLI間で機能を共有することが可能です。
  • フック

    • ライフサイクルフックを利用するとCLIの開始など任意のタイミングで処理の実行が可能です。カスタムの処理がCLIの複数のコマンドで必要になるような場合に利用できます。
  • TypeScript対応

    • oclifのコア自体もTypeScriptで記述されており、ジェネレータはTypeScript CLIかプレインなJavaScript CLIを選択して出力できます。TypeScriptの静的型付けによってシンタックスはより明確になりますが、どちらを選んだとしてもoclifは完璧に動作します。プラグインサポートを利用するとCLIは自動的に ts-node をプラグインの実行に使用し、oclif CLI上でTypeScriptを最小限の定型文で使用できるようにします。
  • 全てカスタマイズ可能

    • 引数/フラグ のパーサも含め、oclif 内の多くの処理入れ替えることが可能です。
  • (今後の予定): man ページ

    • CLI上の --help およびREADME マークダウンヘルプの生成に加えてCLI が自動的に全てのコマンド用の man ページを自動的に生成するようになります。
  • (今後の予定): 入力補完

    • 自動的に入力補完がCLIに追加されます。これは単なるコマンド名およびフラグ名のみならず、フラグ値にも適用されます。例えばHeroku CLI ではHeroku app名(--appフラグ)の入力への補完が実現されています。

基本的な利用方法

oclifを利用するにはoclifコマンドをnpmでインストールするか、npxコマンドでの実行にも対応しているようです。
ここでは multi という引数を使用していますが、 singleとすればシングルコマンド用のCLIの生成や、プラグイン、フックなどの生成も可能です。

npmを使用
$ npm install -g oclif
$ oclif multi {{作りたいCLIコマンド名}}
npxを使用の場合
$ npx oclif multi {{作りたいCLIコマンド名}}
oclifコマンドの引数
  command  既存のCLIやプラグインにコマンドを追加
  help     ヘルプの表示
  hook     既存のCLIやプラグインにフックを追加
  multi    マルチコマンドCLIを生成
  plugin   CLIのプラグインを生成
  single   シングルコマンドのCLIを生成

一度コマンドを入力すれば、あとはoclifがCLIコマンドの雛形を生成してくれます。

shell
$ oclif multi mycli

     _-----_     ╭──────────────────────────╮
    |       |    │      Time to build a     │
    |--(o)--|    │  multi-command CLI with  │
   `---------´   │  oclif! Version: 1.7.28  │
    ( _´U`_ )    ╰──────────────────────────╯
    /___A___\   /
     |  ~  |     
   __'.___.'__   
 ´   `  |° ´ Y ` 

? npm package name mycli
? command bin name the CLI will export mycli
? description sample command for personal use
? author Mitsuhiro Okamoto @mokamoto
? version 0.0.0
? license MIT
? node version supported >=8.0.0
? github owner of repository (https://github.com/OWNER/repo) mokamoto
? github name of repository (https://github.com/owner/REPO) mycli
? optional components to include yarn (npm alternative), mocha (testing framework), typescript (static typing for javasc
ript), tslint (static analysis tool for typescript), semantic-release (automated version management)
   create package.json
   create .circleci/config.yml
   create tslint.json
... (以下続く)

一度CLIの雛形を作ると、すでにbin/runにマルチコマンドCLIの起点が構築されているので、実行すればUsageが表示されます。
npm linkでシンボリックリンクを貼っておけば、とりあえずはコマンド名そのものでの実行も可能になります。

shell
$ cd mycli
$ npm link
(...中略)
$ mycli
VERSION
  mycli/0.0.0 darwin-x64 node-v9.11.1

USAGE
  $ mycli [COMMAND]

COMMANDS
  hello  describe the command here
  help   display help for mycli

$ bin/run 
VERSION
  mycli/0.0.0 darwin-x64 node-v9.11.1

USAGE
  $ mycli [COMMAND]

COMMANDS
  hello  describe the command here
  help   display help for mycli

ということで、デフォルトではすでのhelloコマンドがすでに出来上がった状態です。
マルチコマンドCLIの場合は、コマンドを任意で追加できますので oclif commandを実行します。

shell
$ oclif command mycommand

     _-----_     ╭──────────────────────────╮
    |       |    │    Adding a command to   │
    |--(o)--|    │    mycommand Version:    │
   `---------´   │          1.7.28          │
    ( _´U`_ )    ╰──────────────────────────╯
    /___A___\   /
     |  ~  |     
   __'.___.'__   
 ´   `  |° ´ Y ` 

   create src/commands/myart.ts
   create test/commands/myart.test.ts

追加したコマンドが表示されているのがわかります。

shell
$ mycli
VERSION
  mycli/0.0.0 darwin-x64 node-v9.11.1

USAGE
  $ mycli [COMMAND]

COMMANDS
  hello  describe the command here
  help   display help for mycli
  mycommand  describe the command here   #こちらが追加

コマンドの実装

各コマンドの実装クラスはシンプルです。
Command を継承したクラスを作成し、説明、使用例、フラグ、引数、実際の処理を記述していくだけです。
基本的にはどれも定義をしておけば上書きされますが、なければデフォルトが利用されますので、必要なのはrun()だけです。

Mycommand.ts
import Command, {flags} from '@oclif/command'

export class Mycommand extends Command {
  static description = `
  コマンドの概要を記載
  複数行に渡って説明を描いても反映される`

  // ヘルプからコマンドを隠すか(隠しコマンド化するか)
  // デフォルトでは false
  static hidden = false

  // カスタムusage
  // デフォルトのusageを上書きする場合にはこちらを定義
  static usage = 'mycommand [TARGET] -t simple'

  // ヘルプで表示されるサンプルコマンド
  static examples = [
    '$ mycommand salesforce -t dev',
    '$ mycommand heroku',
  ]

  // フラグの定義
  static flags = {
    //ヘルプの定義
    help: flags.help({char: 'h'}),
    // フラグと値を定義するこの場合は -t もしくは --type=VALUE の形式となる
    type: flags.string({char: 't', description: 'Type(タイプ)を指定してください'})
  }

  // 引数の定義
  static args = [{name: 'platform'}]


  // パーサが無効な引数を受け取っても実行を続けるようにします
  // デフォルトでは true
  // 可変引数を受け入れる場合にはfalseを指定します
  static strict = true

  // 実際の処理を記述
  async run() {
    const {args, flags} = this.parse(Mycommand)
    let type = flags.type
    let platform = args.platform

    if (!type) {
      // エラー
      this.error('タイプが指定されていません')
      // エラーコードで終了
      this.exit(1)
    }
    if(platform){
      // 処理を記述
      this.log(`${platform}${type} のタイプが指定されました`)
    }else{
      //警告
      this.warn('プラットフォームが指定されてないです')
    }
  }
}

トピックの作成

例えばheroku CLIでは heroku pipelines:XXX のようにトピックを利用してさらに多くの機能を実現していますが、oclifでもこのトピックをコマンドに追加することが可能になっています。
mybadgeトピックに対して以下の3つのコマンドを追加したい場合には、トピック名のディレクトリを作成してコマンドを階層化するだけです。

追加したいトピック
mybadge:create
mybadge:delete
mybadge:edit
フォルダ構造
package.json
src/
└── commands/
    └── mybadge/
        ├── crate.ts
        ├── delete.ts
        └── edit.ts

加えて、CLIコマンドのコマンド一覧にこのtopicの内容を反映させるためにpackage.jsonファイルに以下を加えます。

package.json
{
  "oclif": {
    "topics": {
      "mybadge": { "description": "badge create/delete/edit" }
    }
  }
}

実行してみると、トピックが作成され、コマンドが一覧で表示されているのがわかります。

shell
$ mycli mybadge
badge create/delete/edit

USAGE
  $ mycli mybadge:COMMAND

COMMANDS
  mybadge:create  create badge
  mybadge:delete  delete badge
  mybadge:edit    edit badge

フックの作成

全てのコマンドが事前に認証を必要としていて、コマンドを横断して任意のタイミングで処理を行いたい場合などには、フックを利用します。
例えばコマンド実行の直前に任意の処理を差し込みたい場合には以下のようなフックを実装します。

myhook.ts
import {Hook} from '@oclif/config'

const hook: Hook<'prerun'> = async function (opts) {
  process.stdout.write(`========= フックしました============\n`)
  process.stdout.write(`入力内容 : ${opts.config.name} ${opts.Command.name} ${opts.argv}\n`)
  process.stdout.write(`==================================\n`)
}

export default hook

作成したフックはpackage.json内のoclifの箇所に記載しておきます。

package.json
"oclif": {
    "commands": "./lib/commands",
    "hooks": {
      "prerun": "./lib/hooks/prerun/myhook"
    }
  }

oclifのデフォルトでは以下の3つのライフサイクルイベントが用意されています

  • init - CLIが初期化されたタイミングでコマンドを探す前
  • prerun - initが終わり、対象のコマンドが見つかったタイミングのコマンド実行直前
  • command_not_found - コマンドを探したが見つからず、エラーを表示する前

これ以外にもコマンド側から任意のタイミングで呼び出すことのできるカスタムイベントを定義することも可能です。

Dosomething.ts
export class Dosomething extends Command {
  async run() {
    //何らかの処理
    await this.config.runHook('customtiming', {processStr: 'myprocess'})
  }
}
costomhook.ts
export default const hook = async function (options: {processStr: string}) {
  //フック処理を記述
}
package.json
"oclif": {
    "commands": "./lib/commands",
    "hooks": {
      "customtiming": "./lib/hooks/customtiming/costomhook"
    }
  }

テスト

oclif commnadやhookなどでクラスを作成するたびにテストクラスも自動的に生成されます。
フックやコマンドはcommand.run()実行時にpromiseを返すようになっているので、ネットワーク通信を介するようなコマンドでもwaitが可能です。
自動的に生成されるテストクラスの雛形にはmochaとfancy-testを利用したテストが生成されているので、すぐにテストコードを記述できるようになっています。

mycommand.test.ts
import {expect, test} from '@oclif/test'

describe('mycommand', () => {
  test
  .stdout()
  .command(['mycommand'])
  .catch(err => expect(err.message).to.contain('タイプが指定されていません'))
  .it('runs mycommand')

  test
  .stdout()
  .command(['mycommand', 'salesforce', '-t', 'dev'])
  .it('runs mycommand salesforce -t dev', ctx => {
    expect(ctx.stdout).to.contain('salesforce プラットフォームに dev のタイプが指定されました')
  })
})

実行
$ npm test
  hello
    ✓ runs hello (228ms)
    ✓ runs hello --name jeff

  mycommand
    ✓ runs mycommand
    ✓ runs mycommand salesforce -t dev
(...続く)

コマンドのリリース

コマンドを世の中に広くリリースしたい場合には、gitリポジトリを作りnpm pulishを実行してnpmに登録します。

リリース
$ npm version 
$ npm publish
利用する
$ npm install -g mycommand
$ mycommand
# OR
$ npx mycommand

特に世の中に公開するわけではなくて、社内など特定の範囲向けにリリースしたい場合には、oclif-dev packを利用することで、コマンドをtarballに固めることができます。

shell
$ npm install -g @oclif/dev-cli
$ oclif-dev manifest #マニフェストの作成
$ oclif-dev pack     # tarballの作成

また、AWS_ACCESS_KEY_ID および AWS_SECRET_ACCESS_KEYを事前に設定しておけば、oclif-dev publishでs3へアップロードする機能も実装されています。

その他色々な機能

こちらは特にoclif自体の機能ではないですが、既存のnodeモジュールと合わせて比較的簡単に色々な機能が実現できるようになっています。全てnode.js製ですのでこういったエコシステムを利用することができるのもoclifの一つの強みですね。

通知

notifierを使うことによってOSの通知を呼び出すことが可能です。

import {Command} from '@oclif/command'
import * as notifier from 'node-notifier'

export class MyCommand extends Command {
  async run() {
    notifier.notify({
      title: 'My notification',
      message: 'Hello!'
    })
  }
}

選択

inquirerをインストールすることで実現できます。

let type = flags.type
    if (!type) {
      let responses: any = await inquirer.prompt([{
        name: 'type',
        message: 'select a type',
        type: 'list',
        choices: [{name: 'heroku'}, {name: 'salesforce'}, {name: 'mulesoft'}],
      }])
      type = responses.type
    }

まとめ

  • oclifはOSSのCLI作成のためのフレームワーク
  • TypeScriptで記述され、マルチコマンド化やトピックの追加簡単にカスタマイズが可能
  • テストやパブリッシュなども含めてツールが提供されており、CLIの開発に集中できる
Sign up for free and join this conversation.
Sign Up
If you already have a Qiita account log in.