Claude Media
Claude CodeでTyper製CLIを作る — CliRunnerのテストで完了を判定する

Claude CodeでTyper製CLIを作る — CliRunnerのテストで完了を判定する

PythonのCLIフレームワークTyperを、Claude CodeにCliRunnerのテスト付きで作らせる手順です。引数設計、終了コード、CLAUDE.md、Stopフックまで具体例で示します。

PythonでCLIを作るなら、TyperのコマンドはClaude Codeに任せやすい題材です。引数や終了コードは入力と出力が明確で、CliRunnerで呼び出して結果をassertできるからです。「テストが通ったら完了」という判定をClaudeに渡せば、画面を見張らなくても実装と修正の反復が回ります。

ここでは、Typerのテスト手順を土台に、引数の設計、終了コード、対話入力、CLAUDE.mdの規約、pytestの許可設定、Stopフックによる完了判定までを順に扱います。

なぜTyperのCLIはClaude Codeと相性がよいのか

Claude Codeのドキュメントは、Claudeに「実行できる検証手段」を渡すことを、ベストプラクティスの冒頭近くに置いています。検証手段がないと、Claudeが頼れるのは「できたように見える」という感触だけで、間違いの発見は人間の役目になります。テストスイートやビルドの終了コードのように、合否が返る確認があれば、Claudeは実装、実行、結果の読み取り、修正を自分で回します。

Typerのテストはこの条件を満たします。CliRunnerはコマンドライン引数のリストを受け取ってアプリを呼び出し、終了コードと出力を返します。人間がターミナルで試す操作がそのまま関数呼び出しになるので、合否が確定します。

つまり作業の中心は、機能をClaudeに頼む前に「どの入力でどの終了コードと出力になるか」をテストとして先に決めることです。

最小のTyperアプリとCliRunnerのテスト

Typerのテスト解説は、次のアプリを題材にしています。app/main.pyに置き、空のapp/__init__.pyを同じディレクトリに置いてPythonパッケージにします。

import typer
 
app = typer.Typer()
 
@app.command()
def main(name: str, city: str | None = None):
    print(f"Hello {name}")
    if city:
        print(f"Let's have a coffee in {city}")
 
if __name__ == "__main__":
    app()

テストはapp/test_main.pyに書きます。CliRunnerを作り、invoke()の第1引数にTyperアプリ、第2引数にコマンドラインで渡す文字列のリストを渡します。

from typer.testing import CliRunner
 
from .main import app
 
runner = CliRunner()
 
def test_app():
    result = runner.invoke(app, ["Camila", "--city", "Berlin"])
    assert result.exit_code == 0
    assert "Hello Camila" in result.output
    assert "Let's have a coffee in Berlin" in result.output

実行はpytestです。解説の例はuv経由で呼んでいます。

uv run pytest

出力の検証には2通りあります。result.outputは標準出力を見る書き方です。標準出力と標準エラー出力を別々に確かめたいときは、result.stdoutとresult.stderrを使います。エラーメッセージをstderrに出す設計にしたCLIでは、後者でないと検証が成り立ちません。

引数とオプションの設計をClaudeに伝える

Typerには、既定の挙動が決まっています。CLI引数(argument)は既定で必須、CLIオプション(option)は既定で任意です。これはCLIプログラムの一般的な慣習に沿った動きで、逆にしたいときだけ明示します。

  • 任意の引数にしたい: typer.Argument()でデフォルト値を与える
  • 必須のオプションにしたい: typer.Option()をAnnotatedで包み、デフォルト値を付けない

Annotatedによる書き方は、Typer 0.9.0で対応し推奨されるようになりました。それ以前のバージョンで使うとエラーになります。Claudeが古い書き方のtyper.Option(...)を出してきたときも、どちらも動きますが、Annotated版が推奨とされています。プロジェクトのTyperが0.9.0以上であることを確認したうえで、新しい書き方に統一するよう規約に書いておくと、書き方がファイルごとにぶれません。

引数を設計するとき、Claudeには文章で仕様を渡すより、テストの形で渡すほうが誤解が減ります。次のように、期待する呼び出しと結果の対応を並べて頼みます。

app/main.py に Typer の CLI を作って。仕様は次のテストで表す。
- ["Camila"] で exit_code 0、出力に "Hello Camila"
- ["Camila", "--city", "Berlin"] で出力に "Berlin" を含む
- 引数なしの呼び出しは exit_code が 0 以外
先にテストを app/test_main.py に書き、uv run pytest が通るまで実装を直して。
通った出力をそのまま見せて。

最後の「通った出力を見せて」は、Claude Codeのドキュメントが勧める確認の取り方に沿った一行です。成功を口頭で主張させず、テストの出力という証拠を出させると、離れて見ているときでも確認が速くなります。

終了コードを仕様として固定する

CLIの完了判定で重要なのは、終了コードです。Typerではtyper.Exit()を送出して処理を途中で打ち切れます。これは必ずしもエラーを意味せず、「以降の処理は不要」というだけの場合に使えます。Typerが例外を受け取って、プログラムを正常に終了させます。

typer.Exit()はcode引数を取り、既定は0(エラーなし)です。0以外を渡すと、端末にエラー終了を伝えられます。似た機能としてtyper.Abort()があり、Aborted!と表示して中断します。実行が中止されたことを明示したい場面向きです。

この違いは、次のようにテストへそのまま落とせます。以下は、typer.Exitの説明を踏まえた例です。

import typer
from typer.testing import CliRunner
 
app = typer.Typer()
 
@app.command()
def create(name: str):
    if name == "root":
        print("The root user is reserved")
        raise typer.Exit(code=1)
    print(f"New user created: {name}")
 
runner = CliRunner()
 
def test_reserved_name_exits_with_error():
    result = runner.invoke(app, ["root"])
    assert result.exit_code == 1
    assert "reserved" in result.output
 
def test_normal_name_succeeds():
    result = runner.invoke(app, ["Camila"])
    assert result.exit_code == 0
    assert "New user created: Camila" in result.output

終了コードをテストに入れておく利点は、Claudeが「出力は合っているが終了コードが0のまま」という中途半端な実装で止まらなくなることです。シェルスクリプトやCIから呼ばれるCLIでは、終了コードがそのまま成否の合図になります。

サブコマンドが複数あるアプリのテスト

Typerは、git pushやgit pullのように、1つのCLIに複数のコマンド(サブコマンド)を持たせられます。ユーザーを管理するアプリにcreateとdeleteの2コマンドがある場合、ヘルプの表示はUsage: main.py [OPTIONS] COMMAND [ARGS]...の形になります。コマンド名は--の付かない名前で、引数に似ていますが、あらかじめ決まった名前で機能をグループ分けするものです。

テストでは、コマンド名をinvoke()の引数リストの先頭に置きます。次は、createとdeleteがユーザー名の引数を取る場合の例です。

def test_create_command():
    result = runner.invoke(app, ["create", "Camila"])
    assert result.exit_code == 0
 
def test_delete_command():
    result = runner.invoke(app, ["delete", "Camila"])
    assert result.exit_code == 0

コマンドが増えたら、1コマンドにつき最低1本のテストを置く、と規約に書いておくと、Claudeが追加したコマンドだけテストが無い状態を避けられます。

プロンプトと関数だけのスクリプトをテストする

typer.Option(prompt=True)のように、実行時にユーザーへ入力を求めるCLIは、invoke()のinput引数でテストできます。端末で打った文字は標準入力に渡り、OSが仮想的なファイルとして扱う、という仕組みを再現するものです。Enterキーは改行文字\nなので、メールアドレスを入力して確定する操作はinput="camila@example.com\n"と書きます。

def test_app():
    result = runner.invoke(app, ["Camila"], input="camila@example.com\n")
    assert result.exit_code == 0
    assert "Hello Camila, your email is: camila@example.com" in result.output

もう一つ、typer.Typer()のアプリを作らずtyper.run(main)で動かす単機能スクリプトも、テスト側でアプリを組み立てれば検証できます。

import typer
from typer.testing import CliRunner
 
from .main import main
 
app = typer.Typer()
app.command()(main)
 
runner = CliRunner()
 
def test_app():
    result = runner.invoke(app, ["--name", "Camila"])
    assert result.exit_code == 0
    assert "Hello Camila" in result.output

app.command()(main)は、@app.command()というデコレーターを関数に手動で適用する書き方です。Typerのデコレーターは関数自体を変えず、内部に登録するだけで元の関数を返します。ただしTyperの解説も、テスト対象のスクリプトならmain.py側に明示的なTyperアプリを作るほうがきれいだと述べています。既存のスクリプトを触りたくないときの回避策と考えるのが妥当です。

CLAUDE.mdに書くTyper開発の規約

CLAUDE.mdは強制される設定ではなく、Claudeが読む文脈です。Claude Codeのドキュメントは、CLAUDE.mdに書く内容の例にテストの指示や好みのテストランナーを挙げ、長すぎると規則が埋もれるとも述べています。短く具体的に保ちます。

## Python / Typer 規約
- CLI は Typer。引数・オプションは Annotated で書く(Typer 0.9.0 以上)
- テストは app/test_main.py に CliRunner で書き、実行は `uv run pytest`
- 新しいコマンドは、終了コードと出力の両方を assert する
- エラーメッセージを stderr に出す場合は result.stderr で検証する
- 変更後は必ず `uv run pytest` を実行し、通った出力を提示する

pytestの命名規則まで含めて規約化する方法は、Claude CodeにpytestのCLAUDE.md規約を教えるで扱っています。テストファイルだけに効かせたい規則は、パススコープ付きのルールファイルに分けると、関係ないファイルを触るときに文脈を消費しません。

pytestの実行を許可して反復を止めない

テストを何度も回す作業では、実行のたびに許可を求められると反復が止まります。Claude Codeの権限ルールは、Bash(npm run *)のように、サブコマンドの後ろに*を置く書き方を取ります。同じ形でpytestを許可できます。

{
  "permissions": {
    "allow": [
      "Bash(uv run pytest *)"
    ]
  }
}

これを.claude/settings.jsonに置くと、uv run pytest系のコマンドが許可対象になります。Bash(uv run *)のように広く許可すると任意のスクリプトまで通るため、範囲はpytestに絞るほうが安全です。許可は使う道具を絞るためのもので、CLAUDE.mdの規約とは役割が違います。

Stopフックで「テストが通るまで終わらせない」

規約に「通った出力を提示する」と書いても、Claudeが従う保証はありません。確実に止めたいなら、Stopフックを使います。フックのガイドには、Claudeが止まる前にテストが通ることを確認するフックの例があります。これはtype: "agent"のフックで、実行したコマンドと結果を確かめて合否を返します。

{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "agent",
            "prompt": "Verify that all unit tests pass. Run the test suite and check the results. $ARGUMENTS",
            "timeout": 120
          }
        ]
      }
    ]
  }
}

注意点が2つあります。一つ目は、エージェントフックが実験的機能とされていることです。本番の運用では、コマンドフックを優先するようガイドが書いています。二つ目は、Stopフックには連続ブロックの上限があることです。フックが8回続けて止めると、Claude Codeはフックを上書きして終了します。スクリプトでstop_hook_activeを確認して早期に抜ける書き方と、CLAUDE_CODE_STOP_HOOK_BLOCK_CAPで上限を上げる方法が案内されています。上限は、テストが落ち続けるときに人間へ戻すための歯止めとして働きます。

フックの全体像はClaude Codeのフック入門ガイドにまとめています。同じ「検査コマンドの合否で完了を判定する」発想の別言語版として、Phoenixのmix precommitとAxumのRust APIの記事も参考になります。

よくあるつまずき

症状原因と確認点
pytestがテストを拾わない原因と確認点ファイル名と関数名がtest_で始まっているか。Typerの解説も、この命名でpytestが自動検出すると注記している
from .main import appが失敗する原因と確認点相対importにはパッケージが必要。app/__init__.py(空でよい)があるか
result.outputにエラーメッセージが出ない原因と確認点stderrに出している可能性。result.stderrで別に検証する
Annotatedでエラーになる原因と確認点Typerが0.9.0より古い。バージョンを上げてから使う
対話入力のテストが止まる原因と確認点input="...\n"の改行が抜けていないか

Claudeに直させるときは、失敗したpytestの出力をそのまま貼り、「原因を直して再実行し、通った出力を見せて」と頼みます。症状ではなく原因を直させる、というのがベストプラクティスで示された頼み方です。

作業の頼み方の流れ

最後に、1コマンド追加する作業を、完了条件から逆算して並べます。

  1. 呼び出しと期待結果(終了コード、出力、stderr)をテストの形で書かせる
  2. 実装前にuv run pytestを実行させ、テストが落ちることを確認する
  3. 実装させ、テストが通るまで直させる
  4. 通ったテスト出力を提示させる
  5. Stopフックで、通っていなければ終われないようにする

この流れの核は、仕様をテストとして先に固定する点です。TyperはCliRunnerが引数も終了コードも入力も扱えるため、CLIの振る舞いをほぼテストで表せます。頼む側の工夫は、「何を落とすか」の設計に集中できます。

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