Project 3: Auto-Formatter Pipeline - PostToolUse Hook

Project 3: Auto-Formatter Pipeline - PostToolUse Hook


Project Overview

Attribute Value
Project Number 3 of 40
Category Hooks System Mastery
Main Programming Language Bun/TypeScript
Alternative Languages Python, Bash, Deno
Difficulty Level Level 2: Intermediate
Time Estimate 1 Weekend
Coolness Level Level 3: Genuinely Clever
Business Potential Micro-SaaS / Pro Tool
Knowledge Area Hooks / Code Quality / Developer Experience
Primary Tool PostToolUse Hooks, Prettier, ESLint, Black
Main Reference “Clean Code” by Robert C. Martin

Summary: Build a PostToolUse hook that automatically runs formatters (Prettier, Black, gofmt) on files after Claude writes or edits them, ensuring all AI-generated code matches your project’s style guidelines.


Real World Outcome

After Claude writes any file, your hook automatically formats it:

You: Create a React component for user authentication

Claude: I'll create the component...
[Uses Write tool to create auth-form.tsx]

+--------------------------------------------------+
|              AUTO-FORMATTER RESULTS               |
+--------------------------------------------------+
|                                                   |
|  File: src/components/auth-form.tsx               |
|  Formatter: Prettier                              |
|  Status: Formatted                                |
|                                                   |
|  Changes:                                         |
|    - Added trailing commas                        |
|    - Fixed import order                           |
|    - Normalized quotes to single                  |
|                                                   |
+--------------------------------------------------+

[Uses Write tool to create auth-form.test.tsx]

+--------------------------------------------------+
|              AUTO-FORMATTER RESULTS               |
+--------------------------------------------------+
|                                                   |
|  File: src/components/auth-form.test.tsx          |
|  Formatter: Prettier                              |
|  Status: Formatted (1 fix applied)                |
|                                                   |
+--------------------------------------------------+

Every file Claude touches is automatically formatted to your project’s standards - no manual intervention required.

What This Teaches You

  • PostToolUse hook execution timing
  • Detecting file types from paths
  • Spawning child processes in Bun/TypeScript
  • Graceful error handling for missing formatters
  • Respecting project-specific formatter configurations

The Core Question You’re Answering

“How can I ensure that every file Claude Code modifies automatically conforms to my project’s style guidelines?”

PostToolUse hooks are fundamentally different from PreToolUse:

          PreToolUse                    PostToolUse
          ----------                    -----------
    BEFORE tool executes          AFTER tool completes

    +------------------+          +------------------+
    |                  |          |                  |
    | Can BLOCK action |          | Can POST-PROCESS |
    | Exit 2 = deny    |          | Exit 2 = no-op   |
    |                  |          |                  |
    | Use for:         |          | Use for:         |
    |  - Security      |          |  - Formatting    |
    |  - Validation    |          |  - Logging       |
    |  - Rate limiting |          |  - Notifications |
    |                  |          |  - Syncing       |
    +------------------+          +------------------+

PostToolUse hooks cannot block - the tool has already executed. Instead, they’re perfect for:

  • Auto-formatting code after writes
  • Logging tool usage for analytics
  • Triggering notifications on completion
  • Syncing changes to external systems

Concepts You Must Understand First

Stop and research these before coding:

1. PostToolUse vs PreToolUse

Aspect PreToolUse PostToolUse
Timing Before tool runs After tool completes
Can Block Yes (exit 2) No
Has Result No Yes (tool_output)
Use Case Security, validation Formatting, logging
Exit 2 Effect Block tool Nothing special

PostToolUse payload includes tool_output:

{
  "hook_event_name": "PostToolUse",
  "session_id": "abc123",
  "tool_name": "Write",
  "tool_input": {
    "file_path": "/path/to/app.tsx",
    "content": "..."
  },
  "tool_output": {
    "success": true,
    "bytes_written": 1024
  }
}

Reference: Claude Code Docs - “Hook Events”

2. Formatter Ecosystem

Know which formatters handle which languages:

Language Formatter Config File Install
JavaScript/TypeScript Prettier .prettierrc npm i -g prettier
Python Black pyproject.toml pip install black
Python (fast) Ruff pyproject.toml pip install ruff
Go gofmt (built-in) (with Go)
Rust rustfmt rustfmt.toml rustup component add rustfmt
Java google-java-format - Download JAR
C/C++ clang-format .clang-format With LLVM
SQL sql-formatter - npm i -g sql-formatter
Markdown Prettier .prettierrc npm i -g prettier

Reference: Each formatter’s documentation

3. Bun as a Scripting Runtime

Bun is a fast JavaScript runtime ideal for CLI scripts:

// Reading stdin
const payload = await Bun.stdin.json();

// Spawning processes
const result = Bun.spawnSync(["prettier", "--write", filePath]);

// Checking exit codes
if (result.exitCode === 0) {
  console.log("Success!");
}

Why Bun over Node?

  • Faster startup time (~10x)
  • Built-in TypeScript support (no compilation)
  • Simpler APIs for CLI tools
  • Better shell integration

Reference: Bun documentation - “Scripting”


Questions to Guide Your Design

Before implementing, think through these decisions:

1. Which Tools Trigger Formatting?

+----------------+------------------+---------------------------+
| Tool           | Creates/Modifies | Should Format?            |
+----------------+------------------+---------------------------+
| Write          | Creates files    | YES                       |
| Edit           | Modifies files   | YES                       |
| MultiEdit      | Batch modifies   | YES                       |
| NotebookEdit   | Jupyter cells    | MAYBE (nbqa + formatters) |
| Read           | No changes       | NO                        |
| Glob           | No changes       | NO                        |
| Grep           | No changes       | NO                        |
| Bash           | Maybe            | COMPLEX (detect file ops) |
+----------------+------------------+---------------------------+

2. How to Map Extensions to Formatters?

Design your extension-to-formatter mapping:

const FORMATTERS: Record<string, {command: string[], check?: string[]}> = {
  // JavaScript/TypeScript
  '.js': { command: ['prettier', '--write'] },
  '.jsx': { command: ['prettier', '--write'] },
  '.ts': { command: ['prettier', '--write'] },
  '.tsx': { command: ['prettier', '--write'] },
  '.mjs': { command: ['prettier', '--write'] },
  '.cjs': { command: ['prettier', '--write'] },

  // Python
  '.py': { command: ['black'] },
  '.pyi': { command: ['black'] },

  // Go
  '.go': { command: ['gofmt', '-w'] },

  // Rust
  '.rs': { command: ['rustfmt'] },

  // Web
  '.css': { command: ['prettier', '--write'] },
  '.scss': { command: ['prettier', '--write'] },
  '.html': { command: ['prettier', '--write'] },
  '.json': { command: ['prettier', '--write'] },
  '.md': { command: ['prettier', '--write'] },
  '.yaml': { command: ['prettier', '--write'] },
  '.yml': { command: ['prettier', '--write'] },
};

3. Error Handling

What if the formatter isn’t installed or the file has syntax errors?

+------------------------+----------------------------------+
| Scenario               | How to Handle                    |
+------------------------+----------------------------------+
| Formatter not found    | Skip silently, log warning       |
| Syntax error in file   | Log error, don't fail hook       |
| Formatter timeout      | Kill process, log warning        |
| Unknown extension      | Skip (no formatter for this)     |
| File was deleted       | Skip (nothing to format)         |
+------------------------+----------------------------------+

Thinking Exercise

Map the Formatter Pipeline

Trace what happens when Claude writes a Python file:

+-----------------------------------------------------------------------+
|                    FORMATTER PIPELINE EXECUTION                         |
+-----------------------------------------------------------------------+
|                                                                        |
|  1. Claude calls Write tool                                            |
|     tool_input: {file_path: "app.py", content: "..."}                 |
|                                                                        |
|  2. Write tool executes                                                |
|     Creates file at /path/to/app.py                                   |
|                                                                        |
|  3. PostToolUse event fires                                            |
|     +----------------------------------------------------------+      |
|     | Payload:                                                  |      |
|     | {                                                         |      |
|     |   hook_event_name: "PostToolUse",                        |      |
|     |   tool_name: "Write",                                     |      |
|     |   tool_input: {file_path: "app.py", content: "..."},     |      |
|     |   tool_output: {success: true}                            |      |
|     | }                                                         |      |
|     +----------------------------------------------------------+      |
|                                                                        |
|  4. Hook extracts file_path from tool_input                           |
|     path = "app.py"                                                    |
|                                                                        |
|  5. Hook detects .py extension                                         |
|     ext = ".py"                                                        |
|                                                                        |
|  6. Hook looks up formatter                                            |
|     formatter = ["black", "app.py"]                                   |
|                                                                        |
|  7. Hook runs: black app.py                                            |
|     +----------------------+                                           |
|     | reformatted app.py   |                                          |
|     +----------------------+                                           |
|                                                                        |
|  8. Hook logs result to stderr                                         |
|     "Formatted: app.py (Black)"                                       |
|                                                                        |
|  9. Hook exits 0                                                       |
|     Claude continues...                                                |
|                                                                        |
+-----------------------------------------------------------------------+

Questions While Tracing:

  1. What if the file_path is relative?
    • Need to resolve against cwd from payload or process.cwd()
  2. Should you check if Black is installed before running?
    • Yes, use which black or try-catch around spawn
  3. What if Black modifies the file - does Claude know?
    • No! Claude’s next read will see formatted version
    • This is usually fine, but could cause confusion in multi-file edits

The Interview Questions They’ll Ask

1. “How would you implement automatic code formatting in a CI/CD pipeline?”

Answer: Use a PostToolUse hook that detects file modifications, determines the appropriate formatter based on file extension, and runs it with project-specific configuration. The hook should:

  • Check if the formatter is available
  • Respect .prettierrc, pyproject.toml configs
  • Handle errors gracefully
  • Log results for debugging
  • Exit 0 regardless of formatting success (don’t break the pipeline)

2. “What’s the difference between blocking (PreToolUse) and post-processing (PostToolUse) hooks?”

Answer:

  • PreToolUse runs before the tool and can block with exit 2. The tool hasn’t executed yet, so you’re preventing an action.
  • PostToolUse runs after the tool completes. The action is done, so you can only react to it (format, log, notify). Exit 2 has no special meaning.

The key distinction: PreToolUse is preventive, PostToolUse is reactive.

3. “How would you handle a formatter that takes a long time (e.g., large files)?”

Answer:

  1. Set a timeout on the spawn process (e.g., 10 seconds)
  2. Kill the process if it exceeds timeout
  3. Log a warning but don’t fail the hook
  4. Consider async formatting (queue the format, return immediately)
  5. For very large files, skip formatting or use faster alternatives

4. “Should your hook respect .prettierignore files?”

Answer: Yes! The hook should:

  1. Check if a .prettierignore (or equivalent) exists
  2. Either pass the ignore file to the formatter, or
  3. Check the file against ignore patterns before running

Most formatters handle this automatically when you run them from the project root.

5. “How would you make the formatter hook configurable per-project?”

Answer: Create a .claude/formatter-config.json in each project:

{
  "enabled": true,
  "formatters": {
    ".py": {"command": ["ruff", "format"]},  // Use ruff instead of black
    ".ts": {"command": ["biome", "format", "--write"]}  // Use biome
  },
  "ignore": ["*.generated.ts", "vendor/*"]
}

Load this config at hook start and merge with defaults.


Hints in Layers

Hint 1: Filter by Tool Name

Only run formatting for file-modifying tools:

const payload = await Bun.stdin.json();

if (!['Write', 'Edit', 'MultiEdit'].includes(payload.tool_name)) {
  process.exit(0); // Not a file modification, skip
}

Hint 2: Use a Formatter Map

Create an object mapping extensions to commands:

const formatters: Record<string, string[]> = {
  '.ts': ['prettier', '--write'],
  '.tsx': ['prettier', '--write'],
  '.py': ['black'],
  '.go': ['gofmt', '-w'],
};

const ext = path.extname(filePath);
const formatter = formatters[ext];

Hint 3: Spawn Formatters Correctly

Use Bun.spawnSync for synchronous execution:

if (formatter) {
  const result = Bun.spawnSync([...formatter, filePath]);
  if (result.exitCode === 0) {
    console.error(`Formatted: ${filePath}`);
  } else {
    console.error(`Format failed: ${result.stderr.toString()}`);
  }
}

Hint 4: Handle Missing Formatters

Check if the formatter exists before running:

function formatterExists(name: string): boolean {
  const result = Bun.spawnSync(['which', name]);
  return result.exitCode === 0;
}

if (formatter && formatterExists(formatter[0])) {
  // Run formatter
} else {
  console.error(`Skipping format: ${formatter?.[0] ?? 'unknown'} not found`);
}

Books That Will Help

Topic Book Chapter Why It Helps
Code formatting philosophy “Clean Code” by Robert C. Martin Ch. 5 Why consistent formatting matters
TypeScript CLI tools “Programming TypeScript” by Boris Cherny Ch. 1, 12 Writing type-safe CLI scripts
Shell process spawning “The Linux Command Line” by William Shotts Ch. 24 Understanding process execution
Async patterns “JavaScript: The Good Parts” by Crockford Ch. 4 Handling async formatters
Node.js patterns “Node.js Design Patterns” by Casciaro Ch. 6 Process management patterns

Implementation Guide

Complete Bun/TypeScript Implementation

#!/usr/bin/env bun
/**
 * Auto-Formatter Pipeline - PostToolUse Hook
 *
 * Automatically formats files after Claude writes or edits them.
 * Respects project-specific formatter configurations.
 */

import { $ } from "bun";
import * as path from "path";
import * as fs from "fs";

// ===== CONFIGURATION =====

interface FormatterConfig {
  command: string[];
  extensions: string[];
  checkInstalled?: string;
}

const DEFAULT_FORMATTERS: FormatterConfig[] = [
  // JavaScript/TypeScript (Prettier)
  {
    command: ["prettier", "--write"],
    extensions: [".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs"],
    checkInstalled: "prettier",
  },

  // Web formats (Prettier)
  {
    command: ["prettier", "--write"],
    extensions: [".css", ".scss", ".less", ".html", ".json", ".md", ".yaml", ".yml"],
    checkInstalled: "prettier",
  },

  // Python (Black)
  {
    command: ["black", "--quiet"],
    extensions: [".py", ".pyi"],
    checkInstalled: "black",
  },

  // Go (gofmt)
  {
    command: ["gofmt", "-w"],
    extensions: [".go"],
    checkInstalled: "gofmt",
  },

  // Rust (rustfmt)
  {
    command: ["rustfmt"],
    extensions: [".rs"],
    checkInstalled: "rustfmt",
  },

  // Shell (shfmt)
  {
    command: ["shfmt", "-w"],
    extensions: [".sh", ".bash"],
    checkInstalled: "shfmt",
  },
];

const MODIFYING_TOOLS = ["Write", "Edit", "MultiEdit"];

// ===== HELPER FUNCTIONS =====

interface HookPayload {
  hook_event_name: string;
  session_id: string;
  tool_name: string;
  tool_input: {
    file_path?: string;
    content?: string;
    edits?: Array<{ file_path: string }>;
  };
  tool_output: {
    success?: boolean;
  };
  cwd?: string;
}

async function isInstalled(command: string): Promise<boolean> {
  try {
    const result = Bun.spawnSync(["which", command]);
    return result.exitCode === 0;
  } catch {
    return false;
  }
}

function getFormatter(extension: string): FormatterConfig | undefined {
  return DEFAULT_FORMATTERS.find((f) => f.extensions.includes(extension));
}

function resolvePath(filePath: string, cwd?: string): string {
  if (path.isAbsolute(filePath)) {
    return filePath;
  }
  return path.resolve(cwd || process.cwd(), filePath);
}

function loadProjectConfig(projectPath: string): Partial<{
  enabled: boolean;
  formatters: Record<string, { command: string[] }>;
  ignore: string[];
}> {
  const configPath = path.join(projectPath, ".claude", "formatter-config.json");
  try {
    if (fs.existsSync(configPath)) {
      return JSON.parse(fs.readFileSync(configPath, "utf-8"));
    }
  } catch {
    // Config doesn't exist or is invalid
  }
  return {};
}

function shouldIgnore(filePath: string, ignorePatterns: string[]): boolean {
  const fileName = path.basename(filePath);
  const relativePath = filePath;

  for (const pattern of ignorePatterns) {
    // Simple glob matching
    if (pattern.startsWith("*")) {
      const suffix = pattern.slice(1);
      if (fileName.endsWith(suffix) || relativePath.endsWith(suffix)) {
        return true;
      }
    } else if (pattern.endsWith("*")) {
      const prefix = pattern.slice(0, -1);
      if (relativePath.startsWith(prefix)) {
        return true;
      }
    } else if (relativePath.includes(pattern)) {
      return true;
    }
  }

  return false;
}

async function formatFile(
  filePath: string,
  formatter: FormatterConfig,
  projectConfig: ReturnType<typeof loadProjectConfig>
): Promise<{ success: boolean; message: string }> {
  // Check if formatter is installed
  const checkCmd = formatter.checkInstalled || formatter.command[0];
  if (!(await isInstalled(checkCmd))) {
    return {
      success: false,
      message: `Formatter '${checkCmd}' not installed`,
    };
  }

  // Check if file exists
  if (!fs.existsSync(filePath)) {
    return {
      success: false,
      message: `File not found: ${filePath}`,
    };
  }

  // Check project-specific override
  const ext = path.extname(filePath);
  let command = formatter.command;
  if (projectConfig.formatters?.[ext]?.command) {
    command = projectConfig.formatters[ext].command;
  }

  // Run formatter
  try {
    const result = Bun.spawnSync([...command, filePath], {
      cwd: path.dirname(filePath),
      timeout: 10000, // 10 second timeout
    });

    if (result.exitCode === 0) {
      return {
        success: true,
        message: `Formatted with ${command[0]}`,
      };
    } else {
      const stderr = result.stderr.toString().trim();
      return {
        success: false,
        message: stderr || `${command[0]} exited with code ${result.exitCode}`,
      };
    }
  } catch (error) {
    return {
      success: false,
      message: `Error running formatter: ${error}`,
    };
  }
}

function printResult(filePath: string, success: boolean, message: string) {
  const icon = success ? "+" : "!";
  const status = success ? "Formatted" : "Skipped";

  console.error("");
  console.error(`[${icon}] ${path.basename(filePath)}`);
  console.error(`    Status: ${status}`);
  console.error(`    ${message}`);
}

// ===== MAIN HANDLER =====

async function main() {
  // Read payload from stdin
  let payload: HookPayload;
  try {
    payload = await Bun.stdin.json();
  } catch {
    // Can't parse payload, exit silently
    process.exit(0);
  }

  // Only process file-modifying tools
  if (!MODIFYING_TOOLS.includes(payload.tool_name)) {
    process.exit(0);
  }

  // Check if tool was successful
  if (payload.tool_output?.success === false) {
    process.exit(0);
  }

  // Collect file paths to format
  const filePaths: string[] = [];

  if (payload.tool_input.file_path) {
    filePaths.push(payload.tool_input.file_path);
  }

  // MultiEdit may have multiple files
  if (payload.tool_input.edits) {
    for (const edit of payload.tool_input.edits) {
      if (edit.file_path && !filePaths.includes(edit.file_path)) {
        filePaths.push(edit.file_path);
      }
    }
  }

  if (filePaths.length === 0) {
    process.exit(0);
  }

  // Load project config
  const cwd = payload.cwd || process.cwd();
  const projectConfig = loadProjectConfig(cwd);

  // Check if formatting is disabled
  if (projectConfig.enabled === false) {
    process.exit(0);
  }

  const ignorePatterns = projectConfig.ignore || [];

  // Format each file
  console.error("\n+--------------------------------------------------+");
  console.error("|              AUTO-FORMATTER RESULTS               |");
  console.error("+--------------------------------------------------+");

  for (const filePath of filePaths) {
    const resolvedPath = resolvePath(filePath, cwd);

    // Check ignore patterns
    if (shouldIgnore(resolvedPath, ignorePatterns)) {
      printResult(filePath, false, "Ignored by config");
      continue;
    }

    // Find formatter for this file type
    const ext = path.extname(resolvedPath);
    const formatter = getFormatter(ext);

    if (!formatter) {
      printResult(filePath, false, `No formatter for ${ext}`);
      continue;
    }

    // Format the file
    const result = await formatFile(resolvedPath, formatter, projectConfig);
    printResult(filePath, result.success, result.message);
  }

  console.error("+--------------------------------------------------+\n");

  // Always exit 0 - formatting failures shouldn't break the session
  process.exit(0);
}

main();

Configuration in settings.json

{
  "hooks": [
    {
      "event": "PostToolUse",
      "type": "command",
      "command": "bun run ~/.claude/hooks/auto-formatter.ts",
      "timeout": 15000
    }
  ]
}

Project-Specific Config (.claude/formatter-config.json)

{
  "enabled": true,
  "formatters": {
    ".py": {
      "command": ["ruff", "format"]
    },
    ".ts": {
      "command": ["biome", "format", "--write"]
    }
  },
  "ignore": [
    "*.generated.ts",
    "vendor/*",
    "dist/*",
    "node_modules/*"
  ]
}

Architecture Diagram

+-----------------------------------------------------------------------+
|                    AUTO-FORMATTER HOOK FLOW                             |
+-----------------------------------------------------------------------+
|                                                                        |
|  Claude: Write tool                                                    |
|  file_path: "src/components/Button.tsx"                               |
|       |                                                                |
|       v                                                                |
|  +--------------------+                                                |
|  | Write Tool Executes|                                                |
|  | Creates file       |                                                |
|  +--------------------+                                                |
|       |                                                                |
|       v                                                                |
|  +--------------------+     +----------------------------------------+ |
|  | PostToolUse fires  |---->| auto-formatter.ts                      | |
|  +--------------------+     |                                        | |
|                             | 1. Parse payload                       | |
|                             |    tool_name: "Write"                  | |
|                             |    file_path: "Button.tsx"             | |
|                             |                                        | |
|                             | 2. Check tool_name in MODIFYING_TOOLS  | |
|                             |    ["Write", "Edit", "MultiEdit"] - Yes| |
|                             |                                        | |
|                             | 3. Load project config                 | |
|                             |    .claude/formatter-config.json       | |
|                             |                                        | |
|                             | 4. Check ignore patterns               | |
|                             |    Not in ignore list - continue       | |
|                             |                                        | |
|                             | 5. Get extension: ".tsx"               | |
|                             |                                        | |
|                             | 6. Find formatter                       | |
|                             |    .tsx -> ["prettier", "--write"]     | |
|                             |                                        | |
|                             | 7. Check if prettier installed         | |
|                             |    which prettier -> /usr/local/bin    | |
|                             |                                        | |
|                             | 8. Run formatter                        | |
|                             |    prettier --write Button.tsx         | |
|                             |                                        | |
|                             | 9. Print result to stderr              | |
|                             |                                        | |
|                             | 10. exit(0)                             | |
|                             +----------------------------------------+ |
|                                                                        |
|  Claude continues with next action...                                  |
|                                                                        |
+-----------------------------------------------------------------------+

Extension-Formatter Mapping Diagram

+-----------------------------------------------------------------------+
|                    FILE EXTENSION ROUTING                               |
+-----------------------------------------------------------------------+
|                                                                        |
|                          Input File Extension                          |
|                                 |                                      |
|        +------+------+------+---+---+------+------+------+            |
|        |      |      |      |       |      |      |      |            |
|        v      v      v      v       v      v      v      v            |
|      .ts    .tsx   .py    .go     .rs    .sh    .json  .md           |
|        |      |      |      |       |      |      |      |            |
|        +------+      |      |       |      |      +------+            |
|             |        |      |       |      |           |              |
|             v        v      v       v      v           v              |
|        +--------+ +-----+ +-----+ +------+ +-----+ +--------+         |
|        |Prettier| |Black| |gofmt| |rustfmt| |shfmt| |Prettier|        |
|        +--------+ +-----+ +-----+ +------+ +-----+ +--------+         |
|             |        |      |       |      |           |              |
|             v        v      v       v      v           v              |
|        +-----------------------------------------------------+        |
|        |               Formatted File Output                 |        |
|        +-----------------------------------------------------+        |
|                                                                        |
+-----------------------------------------------------------------------+

Learning Milestones

Milestone 1: Files Are Formatted After Write

Goal: See Prettier format a .ts file after Claude creates it

Test:

You: Create a simple TypeScript function at test.ts

# After Claude writes the file, you should see:
# [+] test.ts
#     Status: Formatted
#     Formatted with prettier

What You’ve Learned:

  • PostToolUse timing
  • Basic formatter invocation
  • Bun process spawning

Milestone 2: Multiple Formatters Work

Goal: Python files use Black, Go files use gofmt

Test:

You: Create hello.py and hello.go with hello world programs

# Should see:
# [+] hello.py - Formatted with black
# [+] hello.go - Formatted with gofmt

What You’ve Learned:

  • Extension-to-formatter routing
  • Multiple formatter configurations
  • Checking formatter availability

Milestone 3: Errors Are Handled Gracefully

Goal: Missing formatters don’t crash the hook

Test:

# With rustfmt not installed
You: Create a Rust file at hello.rs

# Should see:
# [!] hello.rs
#     Status: Skipped
#     Formatter 'rustfmt' not installed

What You’ve Learned:

  • Graceful degradation
  • User-friendly error messages
  • Always exiting 0

Common Mistakes to Avoid

Mistake 1: Blocking on Formatter Errors

// WRONG - Exits non-zero on format failure
if (result.exitCode !== 0) {
  process.exit(1);  // This doesn't block but signals error
}

// RIGHT - Always exit 0 for PostToolUse
if (result.exitCode !== 0) {
  console.error("Format failed, but continuing...");
}
process.exit(0);  // Always 0

Mistake 2: Not Handling Relative Paths

// WRONG - Assumes absolute path
const result = Bun.spawnSync(['prettier', '--write', filePath]);

// RIGHT - Resolve relative paths first
const resolved = path.isAbsolute(filePath)
  ? filePath
  : path.resolve(cwd, filePath);
const result = Bun.spawnSync(['prettier', '--write', resolved]);

Mistake 3: No Timeout on Formatter

// WRONG - Could hang forever on large files
const result = Bun.spawnSync(['prettier', '--write', file]);

// RIGHT - Set reasonable timeout
const result = Bun.spawnSync(['prettier', '--write', file], {
  timeout: 10000,  // 10 seconds
});

Mistake 4: Formatting Deleted Files

// WRONG - Crashes if file was deleted
const result = Bun.spawnSync(['prettier', '--write', filePath]);

// RIGHT - Check file exists first
if (!fs.existsSync(filePath)) {
  console.error("File not found, skipping format");
  return;
}

Extension Ideas

Once the basic formatter works, consider these enhancements:

  1. Diff Output: Show what the formatter changed (prettier –check first)
  2. ESLint Integration: Run ESLint –fix after Prettier
  3. Pre-commit Style: Only format staged files in git repos
  4. Format on Save Log: Keep history of all formatting operations
  5. Formatter Metrics: Track which formatters run most, time spent
  6. Smart Formatter Detection: Detect from package.json, pyproject.toml

Summary

This project taught you to build a PostToolUse hook for automatic code formatting:

  • PostToolUse Timing: Understanding when post-processing hooks run
  • Formatter Routing: Mapping file extensions to appropriate formatters
  • Process Spawning: Running external tools from Bun/TypeScript
  • Error Handling: Graceful degradation when formatters are missing
  • Project Config: Respecting project-specific preferences

With auto-formatting in place, Claude’s output always matches your style. Project 4 will explore the Notification hook for multi-channel alerts, and Project 5 will tackle UserPromptSubmit for input validation.