Claude CodeのHooks実行結果を対話中に返却、再帰修正を促すCLIを作った話

Claude CodeのHooks実行結果を対話中に返却、再帰修正を促すCLIを作った話

Clock Icon2025.07.10

はじめに

Claude Code にはHooksという機能があります。Claude Code側で定義されたイベントから、CLIなどのツールを実行させる機能です。

https://docs.anthropic.com/en/docs/claude-code/hooks

Hooksから呼ばれるツール実行は、exit codeによってClaude Codeの挙動が変わります。

https://docs.anthropic.com/en/docs/claude-code/hooks#simple%3A-exit-code

上記のドキュメントの項目を引用し、翻訳すると以下の通りです。

フックは終了コード、標準出力(stdout)、標準エラー出力(stderr)を通じてステータスを伝えます:
終了コード 0:成功。stdout はトランスクリプトモード(CTRL-R)でユーザーに表示されます。

終了コード 2:ブロッキングエラー。stderr は自動的に処理するためClaudeにフィードバックされます。以下のフックイベントごとの動作を参照してください。

その他の終了コード:非ブロッキングエラー。stderr はユーザーに表示され、実行は継続されます。

⚠️ リマインダー:Claude Codeは終了コードが0の場合、stdoutを見ません。

Claude Codeへ結果をフィードバックするには、以下の条件が必要です。

  • exit code 2 を返す
  • フィードバック内容をstderr に書き込む

上記の仕様だとCLIによっては、Claude Codeに結果をフィードバック出来ないケースがありますので対応するCLIを作成しました。

Claude CodeへHooksの実行結果がフィードバックされないパターン

cspellclippyの例を紹介します。前者はJSのtypoチェックツールで、後者はRustの静的解析ツールです。

cspell

cspellでtypoが検出された際の挙動は、標準出力にエラー詳細を、標準エラー出力に経過や対象ファイルを表示しています。exit codeは1です。

cspellの実行結果
$ npx cspell lint . --cache --gitignore > stdout.log 2> stderr.log
cspellの標準出力内容
$ cat stdout.log
src/index.ts:2:15 - Unknown word (Helloo)
cspellの標準エラー出力内容
$ cat stderr.log
1/4 cspell.json cached
2/4 package.json cached
3/4 src/index.ts 410.49ms X
4/4 tsconfig.json cached
CSpell: Files checked: 4 (3 from cache), Issues found: 1 in 1 file.
$ pnpm spell-check

> claude-code-hooks-test@1.0.0 spell-check /Users/shuntaka/repos/github.com/shuntaka9576/claude-code-hooks-test
> cspell lint . --cache --gitignore

1/4 cspell.json cached
2/4 package.json cached
3/4 src/index.ts cached X
src/index.ts:2:15 - Unknown word (Helloo)
4/4 tsconfig.json cached
CSpell: Files checked: 4 (4 from cache), Issues found: 1 in 1 file.
 ELIFECYCLE  Command failed with exit code 1.

$ echo $?
1

exit codeが1だと標準出力をClaude Codeは無視するので、具体的なエラーの内容をClaude Codeは受け取ることが出来ません。なのでこの結果は無視されます。仮にexit code 2で返したところで標準出力に書き込まれるのでこの場合エラーの詳細はわからず、index.tsを読み込んで探すことになります。ハルシーネーションされたり、再度cspellを実行されるのも非効率です。

実際にClaude Codeでどのような挙動になるか見てみます。Claude Codeの設定は、ファイル編集後にスペルチェックが走るようにします。

.claude/settings.json
{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Write|Edit|MultiEdit",
        "hooks": [
          {
            "type": "command",
            "command": "pnpm spell-check"
          }
        ]
      }
    ]
  }
}

ファイルを編集するように指示し、実際にClaude Codeがファイルを編集するとcspellが発火し、ユーザーにstderrが表示されます。ただexit code 1なので、以下の赤枠のようにユーザーにフィードバックされますが、Claude Codeはブロッキングせず処理が継続されます。
CleanShot 2025-07-10 at 11.37.54@2x

Clippy

Clippyだと以下の例だとexit codeは101で、stderrに全て書き込まれています。

$ cargo clippy --all-targets --all-features -- -D warnings
   Compiling trr v0.1.1 (/Users/shuntaka/repos/github.com/shuntaka9576/trr)
error: variables can be used directly in the `format!` string
  --> src/main.rs:57:9
   |
57 |         println!("{}", APP_VERSION);
   |         ^^^^^^^^^^^^^^^^^^^^^^^^^^^
   |
   = help: for further information visit https://rust-lang.github.io/rust-clippy/master/index.html#uninlined_format_args
   = note: `-D clippy::uninlined-format-args` implied by `-D warnings`
   = help: to override `-D warnings` add `#[allow(clippy::uninlined_format_args)]`
help: change this to
   |
57 -         println!("{}", APP_VERSION);
57 +         println!("{APP_VERSION}");
   |

error: could not compile `trr` (bin "trr") due to 1 previous error
warning: build failed, waiting for other jobs to finish...
error: could not compile `trr` (bin "trr" test) due to 1 previous error

$ echo $?
101

$ cargo clippy --all-targets --all-features -- -D warnings > stdout.log 2> stderr.log
$ cat stdout.log
$ cat stderr.log
   Compiling trr v0.1.1 (/Users/shuntaka/repos/github.com/shuntaka9576/trr)
error: variables can be used directly in the `format!` string
  --> src/main.rs:57:9
   |
57 |         println!("{}", APP_VERSION);
   |         ^^^^^^^^^^^^^^^^^^^^^^^^^^^
   |
   = help: for further information visit https://rust-lang.github.io/rust-clippy/master/index.html#uninlined_format_args
   = note: `-D clippy::uninlined-format-args` implied by `-D warnings`
   = help: to override `-D warnings` add `#[allow(clippy::uninlined_format_args)]`
help: change this to
   |
57 -         println!("{}", APP_VERSION);
57 +         println!("{APP_VERSION}");
   |

error: could not compile `trr` (bin "trr") due to 1 previous error
warning: build failed, waiting for other jobs to finish...
error: could not compile `trr` (bin "trr" test) due to 1 previous error
.claude/settings.json
{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Write|Edit|MultiEdit",
        "hooks": [
          {
            "type": "command",
            "command": "cargo clippy --all-targets --all-features -- -D warnings"
          }
        ]
      }
    ]
  }
}

Cspell同様に、以下の赤枠のようにユーザーにフィードバックされますが、Claude Codeはブロッキングせず処理が継続されます。
CleanShot 2025-07-10 at 12.05.32@2x

結果から思うこと

この挙動はClaudeモデルが実装を非同期で進められる反面、Hooksからフィードバックは無視されることになります。自動で修正がしきれる確度が高いコードフォーマッタなら良いですが、静的解析系だと修正しきれないケースも多く、個人的には内容を元に再帰修正して欲しいです。

実行結果がexit code 1ならexit code 2に変更して、標準出力と標準エラー出力を標準エラー出力に書き込み直すツールを作りました。

ツールの導入方法、使い方

導入方法

リポジトリは以下の通りです。

https://github.com/shuntaka9576/blocc

以下の方法でインストールできます。

brew install shuntaka9576/tap/blocc

使い方

簡単に使い方を紹介します。Clippyに検知される変更を入れます。

     if cli.version {
-        println!("{APP_VERSION}");
+        println!("{}", APP_VERSION);
         std::process::exit(0);
     }

blocc の引数に実行したいコマンドを配列で指定します。今回はわかりやすさ重視で1つだけです。(Clippyはデフォルトだとexit 0なのでオプションをつけています)

.claude/settings.json
{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Write|Edit|MultiEdit",
        "hooks": [
          {
            "type": "command",
            "command": "blocc 'cargo clippy --all-targets --all-features -- -D warnings'"
          }
        ]
      }
    ]
  }
}

実際Claude Codeを走らせた結果が以下の通りです。hooksテストです。@src/main.rs 1行編集してというテスト用の依頼を投げます。①で、結果がClaude Codeにフィードバックされています。なので全く関係ない修正にも関わらず、②でClippyのエラー修正対応が実行されています。
CleanShot 2025-07-10 at 12.13.43@2x

説明

bloccは、以下のような形で引数に設定したものを実行し、exit code 0以外ならexit code 2にしてstderrに書き込みます。これによりClaude Codeにフィードバックしています。

$ blocc 'cargo clippy --all-targets --all-features -- -D warnings'
{
  "message": "1 command(s) failed",
  "results": [
    {
      "command": "cargo clippy --all-targets --all-features -- -D warnings",
      "exitCode": 101,
      "stderr": "   Compiling trr v0.1.1 (/Users/shuntaka/repos/github.com/shuntaka9576/trr)\nerror: variables can be used directly in the `format!` string\n  --\u003e src/main.rs:58:9\n   |\n58 |         println!(\"{}\", APP_VERSION);\n   |         ^^^^^^^^^^^^^^^^^^^^^^^^^^^\n   |\n   = help: for further information visit https://rust-lang.github.io/rust-clippy/master/index.html#uninlined_format_args\n   = note: `-D clippy::uninlined-format-args` implied by `-D warnings`\n   = help: to override `-D warnings` add `#[allow(clippy::uninlined_format_args)]`\nhelp: change this to\n   |\n58 -         println!(\"{}\", APP_VERSION);\n58 +         println!(\"{APP_VERSION}\");\n   |\n\nerror: could not compile `trr` (bin \"trr\" test) due to 1 previous error\nwarning: build failed, waiting for other jobs to finish...\nerror: could not compile `trr` (bin \"trr\") due to 1 previous error\n"
    }
  ]
}

cspellのようなイレギューケースもあるので標準出力も、エラーに書き直しています。

{
  "message": "1 command(s) failed",
  "results": [
    {
      "command": "cspell lint . --cache --gitignore",
      "exitCode": 1,
      "stderr": "1/4 cspell.json cached\n2/4 package.json cached\n3/4 src/index.ts 355.29ms X\n4/4 tsconfig.json cached\nCSpell: Files checked: 4 (3 from cache), Issues found: 1 in 1 file.\n",
      "stdout": "src/index.ts:2:15 - Unknown word (Helloo)\n"
    }
  ]
}

メッセージの内容は -m で変更可能です。

$ blocc -m 'Hook execution completed with errors. Please address the following issues' 'cargo clippy --all-targets --all-features -- -D warnings'
{
  "message": "Hook execution completed with errors. Please address the following issues",
  "results": [
    {
      "command": "cargo clippy --all-targets --all-features -- -D warnings",
      "exitCode": 101,
      "stderr": "    Checking trr v0.1.1 (/Users/shuntaka/repos/github.com/shuntaka9576/trr)\nerror: expected one of `!` or `::`, found keyword `fn`\n  --\u003e src/main.rs:55:1\n   |\n53 | a\n   |  - expected one of `!` or `::`\n54 | // Hook test edit - Testing hooks functionality\n55 | fn main() {\n   | ^^ unexpected token\n   |\nhelp: there is a keyword `as` with a similar name\n   |\n53 | as\n   |  +\n\nerror: could not compile `trr` (bin \"trr\") due to 1 previous error\nwarning: build failed, waiting for other jobs to finish...\nerror: could not compile `trr` (bin \"trr\" test) due to 1 previous error\n"
    }
  ]
}

複数コマンドを指定すれば、複数実行可能です。

$ blocc 'cargo clippy --all-targets --all-features -- -D warnings' 'cargo fmt'
{
  "message": "2 command(s) failed",
  "results": [
    {
      "command": "cargo clippy --all-targets --all-features -- -D warnings",
      "exitCode": 101,
      "stderr": "   Compiling trr v0.1.1 (/Users/shuntaka/repos/github.com/shuntaka9576/trr)\nerror: expected one of `!` or `::`, found keyword `fn`\n  --\u003e src/main.rs:55:1\n   |\n53 | a\n   |  - expected one of `!` or `::`\n54 | // Hook test edit - Testing hooks functionality\n55 | fn main() {\n   | ^^ unexpected token\n   |\nhelp: there is a keyword `as` with a similar name\n   |\n53 | as\n   |  +\n\nerror: could not compile `trr` (bin \"trr\" test) due to 1 previous error\nwarning: build failed, waiting for other jobs to finish...\nerror: could not compile `trr` (bin \"trr\") due to 1 previous error\n"
    },
    {
      "command": "cargo fmt",
      "exitCode": 1,
      "stderr": "error: expected one of `!` or `::`, found keyword `fn`\n  --\u003e /Users/shuntaka/repos/github.com/shuntaka9576/trr/src/main.rs:55:1\n   |\n53 | a\n   |  - expected one of `!` or `::`\n54 | // Hook test edit - Testing hooks functionality\n55 | fn main() {\n   | ^^ unexpected token\n   |\nhelp: there is a keyword `as` with a similar name\n   |\n53 | as\n   |  +\n\n"
    }
  ]
}

デフォルトシーケンシャル実行ですが、 -p で並列実行も可能です。

$ blocc -p 'cargo clippy --all-targets --all-features -- -D warnings' 'cargo fmt'
{
  "message": "2 command(s) failed",
  "results": [
    {
      "command": "cargo fmt",
      "exitCode": 1,
      "stderr": "error: expected one of `!` or `::`, found keyword `fn`\n  --\u003e /Users/shuntaka/repos/github.com/shuntaka9576/trr/src/main.rs:55:1\n   |\n53 | a\n   |  - expected one of `!` or `::`\n54 | // Hook test edit - Testing hooks functionality\n55 | fn main() {\n   | ^^ unexpected token\n   |\nhelp: there is a keyword `as` with a similar name\n   |\n53 | as\n   |  +\n\n"
    },
    {
      "command": "cargo clippy --all-targets --all-features -- -D warnings",
      "exitCode": 101,
      "stderr": "    Checking trr v0.1.1 (/Users/shuntaka/repos/github.com/shuntaka9576/trr)\nerror: expected one of `!` or `::`, found keyword `fn`\n  --\u003e src/main.rs:55:1\n   |\n53 | a\n   |  - expected one of `!` or `::`\n54 | // Hook test edit - Testing hooks functionality\n55 | fn main() {\n   | ^^ unexpected token\n   |\nhelp: there is a keyword `as` with a similar name\n   |\n53 | as\n   |  +\n\nerror: could not compile `trr` (bin \"trr\") due to 1 previous error\nwarning: build failed, waiting for other jobs to finish...\nerror: could not compile `trr` (bin \"trr\" test) due to 1 previous error\n"
    }
  ]
}

最後に

Claude CodeのHooksを使って再帰修正するCLIの紹介でした!ドッグフーディングして改善できそうなところはしていきたいと思っています!今回Goで作ったのは、静的解析はCPUバウンドな処理が多いかなと思い、goroutineを使いました。Rustだとtokioとうより、rayonの方が合ってそうな気がし筒、使い方がよく分からなかった。。という理由です。

個人的にはClaude Codeのこの仕様少し不思議な気がしています。。何か自分が知らないお作法があるような気がしてなりません。。もっと簡単に出来るような気もするので知見がある方いれば @shuntaka_jp まで教えて頂けるとありがたいです!

Share this article

facebook logohatena logotwitter logo

© Classmethod, Inc. All rights reserved.