Claude Media
Claude Codeでoclif製CLIを作る — 規約をCLAUDE.mdに置き実行結果で検証

Claude Codeでoclif製CLIを作る — 規約をCLAUDE.mdに置き実行結果で検証

oclifでTypeScript製CLIを作るとき、コマンド定義の規約をCLAUDE.mdに書き、bin/run.jsの出力と終了コードで確かめる流れを、実際に生成したプロジェクトの出力つきでまとめます。

oclifでCLIを作るなら、Claude Codeに任せる前に決めておくことは3つです。コマンドの置き場所、フラグと引数の型の書き方、そして「動いた」と判断する材料です。これをCLAUDE.mdに規約として置き、bin/run.jsの標準出力と終了コードで毎回確かめると、手戻りがかなり減ります。

この記事は、oclifのgenerateで実際にプロジェクトを作り、コマンドを足して動かした結果をもとにしています。規約の断片、テストの落とし穴、ビルド失敗をClaudeに返すhookまでを順に扱います。

oclif generateで骨格を作って動かす

oclifはNode.js製のCLIフレームワークです。oclif generateでTypeScriptのプロジェクトができ、コマンドはsrc/commands/のファイルがそのままコマンド名になります。

npx oclif generate mycli --yes --module-type ESM --package-manager npm
cd mycli
./bin/run.js hello world

--yesは全プロンプトを既定値で通すフラグで、--module-typeでCommonJSかESMを選べます。npmで公開されているoclif 6.0.10は、enginesにnode >=22.0.0を持っています。Node 20で実行すると警告が出るので、Node 22系で試しました。

生成直後に確認できた点は次の3つです。

  • 実行スクリプトはbin/run.js(ビルド済みのdistを実行)とbin/dev.js(ts-nodeでTypeScriptを直接実行)の2本。./bin/runという名前ではない
  • 生成されたpackage.jsonは@oclif/core ^4、@oclif/test ^4、mocha、chaiを使う構成で、テストはnpm testで回る
  • bin/dev.jsはNode 22.22.2でDEP0180の非推奨警告をstderrに出す

3つ目は地味ですが効きます。Claudeに出力を読ませるとき、stderrの警告が混ざると「エラーが出た」と誤読されがちです。検証は標準出力と終了コードに絞ると決めておくと安定します。

CLAUDE.mdに書くoclifの規約

CLAUDE.mdはClaudeが毎セッション読む指示ですが、強制される設定ではありません。そのため「検証できる粒度」で書くのが基本です。「テストを通すこと」ではなく「npm testを実行してから完了を報告する」のように書きます。長さは1ファイル200行未満が目安です。

oclifの場合、規約にすべき項目はおおむね次の形に収まります。

## oclif CLI の規約
- コマンドは `src/commands/<topic>/<name>.ts` に置き、`export default class` で書く
- フラグは `Flags.*`、引数は `Args.*` で型を付ける。型なしの `process.argv` は読まない
- `run()` の先頭で `const {args, flags} = await this.parse(クラス名)` を呼ぶ
- 出力は `this.log()`、エラーは `this.error(msg, {code, exit})`。`console.log` は使わない
- 機械可読にしたいコマンドは `static enableJsonFlag = true` にして `run()` から値を返す
- `run()` 内の Promise は必ず await する(await しないと実行が途中で打ち切られる)
- 変更後は `npm run build` → `./bin/run.js <コマンド> --help` と実行 → `npm test` の順で確認する
- 確認は標準出力と終了コードで判断する。stderr の警告だけで失敗と判断しない

各行の根拠はoclifのドキュメントにあります。this.log()を使う理由は、console.log()が--jsonフラグ指定時に自動で抑制されないからです。awaitの規則も同じ理由で、Command.runが解決した10秒後にoclifがNodeプロセスを終了させるため、待っていないPromiseは完了前に切られます。

フラグと引数の型を決める

規約で型を要求した場合の実装例です。名前を受け取って挨拶するコマンドを作りました。

import {Args, Command, Flags} from '@oclif/core'
 
export default class Greet extends Command {
  static args = {
    name: Args.string({description: '挨拶する相手', required: true}),
  }
  static description = '名前を指定して挨拶する'
  static enableJsonFlag = true
  static flags = {
    shout: Flags.boolean({char: 's', description: '大文字にする'}),
    times: Flags.integer({char: 't', default: 1, description: '繰り返す回数', max: 5, min: 1}),
  }
 
  async run(): Promise<{lines: string[]}> {
    const {args, flags} = await this.parse(Greet)
    if (args.name === 'nobody') this.error('その名前は使えません', {code: 'E_NAME', exit: 2})
    const text = flags.shout ? `HELLO ${args.name}`.toUpperCase() : `hello ${args.name}`
    const lines = Array.from({length: flags.times}, () => text)
    for (const l of lines) this.log(l)
    return {lines}
  }
}

フラグに渡せるオプションは、規約に入れる価値のあるものだけ拾えば足ります。

目的オプション
必須にするオプションrequired
選択肢を絞るオプションoptions: ['a', 'b']
環境変数を既定値にするオプションenv
他のフラグと組み合わせを縛るオプションdependsOn / exclusive / exactlyOne
複数回指定を許すオプションmultiple

引数の型はstring、integer、boolean、url、file、directory、customです。可変長にするmultiple: trueは、前後の引数をすべてrequired: trueにする決まりがあります。また引数はstdinから渡せるので、スクリプトから呼ぶCLIではecho arg1 | mycliの形でも動きます。無効にしたい引数にはignoreStdin: trueを付けます。

入力の書き方とエラーの出し方を決める

フラグの入力形は、ユーザー側が想像するより広く受け付けられます。-f, --file=fileと-v, --verboseを持つコマンドなら、--file=foo、--file foo、-f foo、-f=foo、-ffoo、-vffooがすべて通ります。Claudeがテストを書くとき、入力形を1つに固定してよいか迷う場面があります。パーサーが吸収するので、ここはテスト対象にせず、コマンドの振る舞いに集中させる方が効率的です。

複数のコマンドで同じ意味のフラグを使うなら、Flags.customで共有フラグを作れます。src/flags.tsに定義して各コマンドからimportする形で、defaultに非同期関数も渡せます。設定ファイルからチーム名を読むような既定値が、その例です。規約に「共通フラグはsrc/flags.ts」と書いておくと、同じフラグがコマンドごとに別々の定義で増えません。

エラーはthis.error(message, options)で出します。optionsに取れるのは次の4つです。

キー役割
exit役割終了コード
code役割エラーの種類を表す一意のコード
suggestions役割次に試せることの配列
ref役割関連ドキュメントのURL

この形のエラーはトレースバックを出さず、DEBUG=*を付けたときだけスタックが見えます。Claudeに終了コードの設計を任せるなら、「入力の誤りは2、未ログインは100のように、意味ごとにexitを分ける」と一文を足しておくと、テストの期待値が決めやすくなります。whoamiの例では、401をthis.error('not logged in', {exit: 100})に変換し、テスト側がerror?.oclif?.exitで100を確かめています。

実行結果と終了コードで検証する

ビルドしてから、境界を突く入力を実際に流しました。Claudeに同じ確認を繰り返させるなら、この表のような「入力と期待値」をCLAUDE.mdかプロンプトに置くのが手早い方法です。

npm run build
./bin/run.js greet bob -t 2 -s
./bin/run.js greet bob --times 9; echo "exit $?"
./bin/run.js greet nobody; echo "exit $?"
./bin/run.js greet bob --json
入力標準出力・メッセージ終了コード
greet bob -t 2 -s標準出力・メッセージHELLO BOBが2行終了コード0
greet bob --times 9標準出力・メッセージExpected an integer less than or equal to 5 but received: 9終了コード2
greet nobody標準出力・メッセージError: その名前は使えません と Code: E_NAME終了コード2
greet(引数なし)標準出力・メッセージMissing 1 required arg: とusage終了コード2
greet bob --json標準出力・メッセージ{"lines": ["hello bob"]}をインデント付きJSONで表示終了コード0

--jsonはenableJsonFlag = trueにしただけで付き、run()の戻り値がそのままJSONになります。this.log()の出力はこのとき抑制されます。上の例でhello bobの行が標準出力に出ないのはそのためです。存在しないフラグを渡すとNonexistent flag: --bogusと表示され、usageが続きます。

落とし穴

シェルの引数展開で検証が空振りする

zshでfor a in "bob -t 2 -s"; do ./bin/run.js greet $a; doneと書くと、変数が単語分割されず、bob -t 2 -sが1つの引数として渡ります。結果はhello bob -t 2 -sと出力され、フラグが効いていないのに終了コードは0です。Claudeに検証ループを書かせるときは、引数を配列で持つか、コマンドを1行ずつ展開して並べさせると安全です。

テストは@oclif/testのrunCommandで書く

生成されたテストは、@oclif/testのrunCommandでコマンド名を文字列で渡す形です。

import {runCommand} from '@oclif/test'
import {expect} from 'chai'
 
describe('greet', () => {
  it('--times の回数だけ出力する', async () => {
    const {stdout} = await runCommand('greet bob --times 2')
    expect(stdout).to.equal('hello bob\nhello bob\n')
  })
 
  it('nobody は終了コード 2', async () => {
    const {error} = await runCommand('greet nobody')
    expect(error?.oclif?.exit).to.equal(2)
  })
})

npm testが通らないときは、テスト本体より先に次の2点を疑います。

  1. posttestでlintが走る。生成されたpackage.jsonでは"posttest": "npm run lint"です。mochaが4件通っても、ESLintのperfectionist/sort-objectsがオブジェクトのキー順を、mocha/consistent-spacing-between-blocksがitの間の空行を要求します。上のコードを最初に書いたとき、キー順の違反が3件、空行の違反が1件出てnpm testは失敗で終わりました。npx eslint --fixで直ります。規約に「npm testの後にnpx eslint --fixを実行」と足しておくと、往復が1回減ります
  2. Vitestに替える場合はdisableConsoleIntercept: trueが要る。Vitestはconsole.logなどを既定で横取りするため、runCommand()がstdoutを拾えなくなります

もう1つ、生成されたhello worldコマンドはthis.parseを呼びません。そのためnpm testの実行中にも、bin/run.jsの実行時にもCommand hello:world did not parse its arguments. Did you forget to call 'this.parse'?という警告が出ます。規約に「run()の先頭でthis.parseを呼ぶ」と入れたのは、この警告を自分のコマンドで再現させないためです。

ビルド失敗をClaudeに返すhookを足す

bin/run.jsが実行するのはdistなので、TypeScriptを直してもnpm run buildを忘れると古いコードの結果を読むことになります。編集のたびにtscを回すhookを足すと、型エラーが即座に返ります。

PostToolUseのhookは、終了コード2で標準エラー出力をClaudeへ返します。そのときツールはすでに実行済みです。スクリプトは次のとおりです。

#!/bin/bash
# src/ か test/ の編集後だけ tsc を回し、失敗は stderr に出して exit 2
file=$(jq -r '.tool_input.file_path // empty')
case "$file" in
  */src/*|*/test/*) npm run build --silent >&2 || exit 2 ;;
esac

これを.claude/hooks/oclif-build.shに置き、.claude/settings.jsonに登録します。

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/oclif-build.sh"
          }
        ]
      }
    ]
  }
}

スクリプト単体を試すと、README.mdの編集では何もせず終了コード0、正常なsrc/commands/greet.tsでも0でした。const linesをconst lines: numberに書き換えて型エラーを起こすと、TS2322など3件のエラーを標準エラー出力に出して終了コード2になります。hookの仕組み全体はClaude Code Hooks完全ガイドに、テスト実行を載せる使い分けはClaude Code hooksでテストを自動実行するにあります。

既存プロジェクトに足す場合とコマンドの増やし方

すでにあるNodeプロジェクトにoclifを入れるなら、oclif initを使います。bin/run.js・bin/run.cmd・bin/dev.js・bin/dev.cmdの4本、package.jsonのoclifセクション(bin・dirname・commands・topicSeparator)、@oclif/coreの依存、ts-nodeの開発依存が追加されます。トピックの区切りを空白にするかコロンにするかは、このときのプロンプトで選びます。今回生成したプロジェクトのpackage.jsonでは"topicSeparator": " "、つまりmycli hello worldの形でした。

コマンドの追加はoclif generate command foo:barで、src/commands/foo/bar.tsができます。フックの雛形はoclif generate hook my-hook --event initです。この2つを規約に書くと、Claudeはファイルを手で作る代わりにジェネレーターの出力から始められます。コマンド名とファイルパスが一致する決まりも、手作りで崩れにくくなります。

利用条件も見落とせません。oclifが対応するのはNodeのLTS版だけです。ユーザーのNodeバージョンを揃えたいなら、CLIの依存にnodeパッケージを加えます。手元のNodeが古いと、npmがenginesの警告を並べます。Node 20でoclif 6.0.10を実行したときもそうでした。最初にnode -vを確かめさせるのが近道です。

つまずきやすい点

  • oclifのバージョンと@oclif/coreの世代がずれる。npmの最新はoclifが6.0.10、@oclif/coreが5.1.8、@oclif/testが5.0.3で、いずれもNode 22以上が前提です。一方、今回のgenerateが作ったpackage.jsonは^4系で、実際に入ったのは@oclif/core 4.14.0と@oclif/test 4.2.0でした。ドキュメントのコード例を貼る前に、package.jsonの世代を確かめます
  • this.parseを呼ばないとフラグ・引数が読めない。型付けしたフラグはparseの戻り値からしか取れません
  • strict = falseの誤用。引数の個数違反を無視する設定なので、可変長にしたいコマンド以外では付けません
  • JSON出力のコマンドでconsole.logを使う。 --json指定時に抑制されず、JSONに余計な行が混ざります

同じ「規約をCLAUDE.mdに置き、実行結果で確かめる」型は、Rustの場合をClaude Codeでclap製CLIをnextestで回すで、PythonのWebアプリの場合をClaude CodeでFlaskアプリを開発するで扱っています。

まとめ

oclifは「コマンド定義が型で書ける」ぶん、規約を言葉にしやすいフレームワークです。FlagsとArgsで型を縛り、this.parse・this.log・this.errorを使うと決めておけば、Claudeが書いたコードの良し悪しは--helpの表示、標準出力、終了コードで判定できます。

検証の入口はbin/run.jsの出力に置き、npm testにはposttestのlintが付くことを前提にします。ビルド失敗を返すhookまで入れておくと、distが古いまま結果を読む事故も防げます。

この記事を共有:XはてブLinkedIn