[PostToolUse]
scut claude hook post-tool-use — fires after a Claude Code tool call succeeds. Silently formats files written by Claude, applying gofmt for Go source and goldmark-prettier-markdown for Markdown. Formatting is in-place; the hook never blocks tool results or injects context.
// 01Overview
Fires after a Claude Code tool call succeeds. The hook silently formats files written by Claude — applying gofmt for Go source and goldmark-prettier-markdown for Markdown. Formatting happens in-place; the hook never blocks tool results or injects context.
// 02Input
Deserialized from stdin as claudecode.PostToolUseInput.
| Field | Type | Description |
|---|---|---|
| (base fields) | claudecode.Input | session_id, transcript_path, cwd, hook_event_name, permission_mode, agent_id, agent_type |
| tool_name | string | Name of the tool that was called (e.g., Bash, Edit, mcp__github__search_repositories) |
| tool_use_id | string | Unique identifier for this tool invocation |
| tool_input | json.RawMessage | The input that was passed to the tool — shape varies per tool |
| tool_response | json.RawMessage | The tool's response — shape varies per tool |
FilePath extraction
PostToolUseInput.FilePath() extracts tool_input.file_path from the raw JSON. Returns "" if the field is absent, empty, or not a string. This method lives in the shared hooks/claudecode package.
// 03Output
Written to stdout as claudecode.PostToolUseOutput.
| Field | Type | Description |
|---|---|---|
| (base fields) | claudecode.BaseOutput | continue, stopReason, suppressOutput, systemMessage |
| decision | *Decision | Set to "block" to reject the tool result |
| reason | *string | Explanation sent to Claude when blocking |
| additionalContext | *string | Text injected into Claude's context |
| updatedMCPToolOutput | json.RawMessage | Replacement output for MCP tools — Claude sees this instead of the original response |
The current implementation always returns an empty PostToolUseOutput{}. Formatting is silent — Claude never knows it happened.
// 04Dispatch Flow
- Decode
PostToolUseInputfrom stdin - Extract
file_pathviain.FilePath()— bail if empty fs.Stat(fp)— bail if file doesn't exist- Switch on
filepath.Ext(fp)to select a formatter — bail if no match afero.ReadFile(fs, fp)— bail on error- Call formatter — bail if result is
nil(declined) or unchanged afero.WriteFile(fs, fp, formatted, info.Mode())— preserves original file permissions- Write empty
PostToolUseOutput{}to stdout
// 05Byte Formatters
Formatters live in the internal/format package and are pure functions with signature func(src []byte) ([]byte, error):
| Function | Extensions | Backend | Behavior on bad input |
|---|---|---|---|
| format.FormatGo | .go | go/format.Source |
Returns nil, nil (syntax error — let the compiler catch it) |
| format.FormatMarkdown | .md, .mdx | goldmark + goldmark-prettier-markdown with ProseWrapPreserve. Parser extensions enabled for GFM (tables, strikethrough, task checkboxes), footnotes, and definition lists — renderer-only, no HTML renderers added. |
Returns nil, nil (parse error) |
Returning nil, nil means "decline to format" — the command skips the write.
// 06File I/O via afero.Fs
The command receives afero.Fs via Kong dependency injection (see kong-base-setup). In production this is afero.OsFs (real filesystem). In tests it's afero.MemMapFs (in-memory), which allows seeding files and verifying writes without touching disk.
// 07Adding a New Formatter
- Add a
func Format<Name>(src []byte) ([]byte, error)function tointernal/format/format.go(or a sibling file in the same package) - Add test cases under
internal/format/— pure bytes in, bytes out - Add a
caseto the extension switch ininternal/cmd/claude/hook/posttooluse.gothat selectsformat.Format<Name> - Add a dispatch test case in
internal/cmd/claude/hook/posttooluse_test.go
// 08Testing
Three layers, each independently testable:
| Layer | File | What it tests | Mocks |
|---|---|---|---|
| FilePath extraction | hooks/claudecode/claudecode_test.go | JSON parsing of tool_input.file_path |
None — pure JSON |
| Byte formatters | internal/format/format_go_test.go, internal/format/format_markdown_test.go | Formatting correctness, edge cases | None — pure bytes |
| Command dispatch | internal/cmd/claude/hook/posttooluse_test.go | Extension routing, file I/O, end-to-end | afero.MemMapFs |
// 09Code
| Role | Path |
|---|---|
| Command | internal/cmd/claude/hook/posttooluse.go |
Formatters (FormatGo, FormatMarkdown) |
internal/format/format.go |
Types (PostToolUseInput, PostToolUseOutput, FilePath()) |
hooks/claudecode/claudecode.go |
See also: kong-base-setup for how afero.Fs is wired via BindTo, and claude-hook-commands for the full hook command inventory.