Skip to content

Format on Edit

Run biome on every TS/JS file the agent writes. One script, three hosts.

Hook script

ts
#!/usr/bin/env bun
// .hooks/format.ts
import { defineHook, run } from "@pivanov/agent-hooks-bridge";

const FORMATTED_EXTS = /\.(ts|tsx|js|jsx|mjs|cjs|json)$/;

const hooks = defineHook({
  PostToolUse: async (event) => {
    if (event.tool !== "Edit" && event.tool !== "Write") {
      return { decision: "allow" };
    }

    const file = event.tool_input.file_path;
    if (typeof file !== "string" || !FORMATTED_EXTS.test(file)) {
      return { decision: "allow" };
    }

    const proc = Bun.spawn(["biome", "check", "--write", file], {
      stderr: "pipe",
      stdout: "pipe",
    });
    await proc.exited;

    if (proc.exitCode !== 0) {
      const stderr = await new Response(proc.stderr).text();
      return {
        decision: "allow",
        additional_context: `biome could not auto-fix ${file}:\n${stderr.slice(0, 500)}`,
      };
    }

    return {
      decision: "allow",
      additional_context: `biome auto-fixed ${file}`,
    };
  },
});

await run(hooks);

chmod +x .hooks/format.ts and:

bash
bunx @pivanov/agent-hooks-bridge install ./.hooks/format.ts --events PostToolUse

What happens cross-host

Same script, three different stdin shapes:

jsonc
{
  "hook_event_name": "PostToolUse",
  "tool_name": "Edit",
  "tool_input": {
    "file_path": "src/foo.ts"
  }
}
jsonc
{
  "hook_event_name": "afterFileEdit",
  "file_path": "src/foo.ts",
  "edits": [
    {
      "old_string": "...",
      "new_string": "..."
    }
  ]
}
jsonc
{
  "hook_event_name": "PostToolUse",
  "tool_name": "apply_patch",
  "turn_id": "...",
  "tool_input": {
    "patch": "..."
  }
}

And three different stdouts:

jsonc
{
  "hookSpecificOutput": {
    "hookEventName": "PostToolUse",
    "additionalContext": "biome auto-fixed src/foo.ts"
  }
}
text
(empty)
jsonc
{
  "hookSpecificOutput": {
    "hookEventName": "PostToolUse",
    "additionalContext": "biome auto-fixed src/foo.ts"
  }
}

Cursor's afterFileEdit is observational; no response fields are accepted. The biome run still happens (the handler runs to completion), but the model doesn't see the additional_context message; the bridge logs the drop to stderr. To surface format activity to the model on Cursor, accumulate it across runs and emit it from a SessionStart hook on the next session.

Codex aliases apply_patch to Edit, so event.tool === "Edit" matches. But event.tool_input.file_path is undefined; Codex's apply_patch sends a unified patch string instead of a file path.

To handle Codex robustly, parse the patch:

ts
PostToolUse: async (event) => {
  if (event.tool !== "Edit" && event.tool !== "Write") {
    return { decision: "allow" };
  }

  let file: string | null = null;
  if (typeof event.tool_input.file_path === "string") {
    file = event.tool_input.file_path;
  } else if (typeof event.tool_input.patch === "string") {
    const m = event.tool_input.patch.match(/^\*\*\* (?:Update|Add) File: (.+)$/m);
    file = m?.[1] ?? null;
  }
  if (file === null || !FORMATTED_EXTS.test(file)) {
    return { decision: "allow" };
  }
  // ...
}

Wire-level tool_input keeps the host's native shape. The bridge unifies event.tool and event names; per-host fields inside tool_input are your handler's responsibility.

Not affiliated with Anthropic, Anysphere, or OpenAI. Supported by LogicStar AI.