#!/usr/bin/env bun import { basename, extname } from "path"; const input = await Bun.stdin.json(); const toolName = input.tool_name; const toolInput = input.tool_input || {}; const command = toolInput.command || ""; const timeout = toolInput.timeout; const cwd = input.cwd || ""; // Get environment variables from the hook context // Note: We check process.env directly as env vars are inherited let useSystemBun = process.env.USE_SYSTEM_BUN; if (toolName !== "Bash" || !command) { process.exit(0); } function denyWithReason(reason) { const output = { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: reason, }, }; console.log(JSON.stringify(output)); process.exit(0); } // Parse the command to extract argv0 and positional args let tokens; try { // Simple shell parsing - split on spaces but respect quotes (both single and double) tokens = command.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g)?.map(t => t.replace(/^['"]|['"]$/g, "")) || []; } catch { process.exit(0); } if (tokens.length === 0) { process.exit(0); } // Strip inline environment variable assignments (e.g., FOO=1 bun test) const inlineEnv = new Map(); let commandStart = 0; while ( commandStart < tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[commandStart]) && !tokens[commandStart].includes("/") ) { const [name, value = ""] = tokens[commandStart].split("=", 2); inlineEnv.set(name, value); commandStart++; } if (commandStart >= tokens.length) { process.exit(0); } tokens = tokens.slice(commandStart); useSystemBun = inlineEnv.get("USE_SYSTEM_BUN") ?? useSystemBun; // Get the executable name (argv0) const argv0 = basename(tokens[0], extname(tokens[0])); // Disallow direct `rustfmt`: it doesn't read the workspace edition from // Cargo.toml the way `cargo fmt` does, so its output can disagree with CI's // Format job (`cargo fmt --all --check`). if (argv0 === "rustfmt") { denyWithReason("error: Don't run `rustfmt` directly. Run `cargo fmt --all` — it's what CI checks."); } // Check if argv0 is timeout and the command is "bun bd" if (argv0 === "timeout") { // Find the actual command after timeout and its arguments const timeoutArgEndIndex = tokens.slice(1).findIndex(t => !t.startsWith("-") && !/^\d/.test(t)); if (timeoutArgEndIndex === -1) { process.exit(0); } const actualCommandIndex = timeoutArgEndIndex + 1; if (actualCommandIndex >= tokens.length) { process.exit(0); } const actualCommand = basename(tokens[actualCommandIndex]); const restArgs = tokens.slice(actualCommandIndex + 1); // Check if it's "bun bd" or "bun-debug bd" without other positional args if (actualCommand === "bun" || actualCommand.includes("bun-debug")) { // Claude is a sneaky fucker let positionalArgs = restArgs.filter(arg => !arg.startsWith("-")); const redirectStderrToStdoutIndex = positionalArgs.findIndex(arg => arg === "2>&1"); if (redirectStderrToStdoutIndex !== -1) { positionalArgs.splice(redirectStderrToStdoutIndex, 1); } const redirectStdoutToStderrIndex = positionalArgs.findIndex(arg => arg === "1>&2"); if (redirectStdoutToStderrIndex !== -1) { positionalArgs.splice(redirectStdoutToStderrIndex, 1); } const redirectToFileIndex = positionalArgs.findIndex(arg => arg === ">"); if (redirectToFileIndex !== -1) { positionalArgs.splice(redirectToFileIndex, 2); } const redirectToFileAppendIndex = positionalArgs.findIndex(arg => arg === ">>"); if (redirectToFileAppendIndex !== -1) { positionalArgs.splice(redirectToFileAppendIndex, 2); } const redirectTOFileInlineIndex = positionalArgs.findIndex(arg => arg.startsWith(">")); if (redirectTOFileInlineIndex !== -1) { positionalArgs.splice(redirectTOFileInlineIndex, 1); } const pipeIndex = positionalArgs.findIndex(arg => arg === "|"); if (pipeIndex !== -1) { positionalArgs = positionalArgs.slice(0, pipeIndex); } positionalArgs = positionalArgs.map(arg => arg.trim()).filter(Boolean); if (positionalArgs.length === 1 && positionalArgs[0] === "bd") { denyWithReason("error: Run `bun bd` without a timeout"); } } } // Check if command is "bun .* test" or "bun-debug test" with -u/--update-snapshots AND -t/--test-name-pattern if (argv0 === "bun" || argv0.includes("bun-debug")) { const allArgs = tokens.slice(1); // Check if "test" is in positional args or "bd" followed by "test" const positionalArgs = allArgs.filter(arg => !arg.startsWith("-")); const hasTest = positionalArgs.includes("test") || (positionalArgs[0] === "bd" && positionalArgs[1] === "test"); if (hasTest) { const hasUpdateSnapshots = allArgs.some(arg => arg === "-u" || arg === "--update-snapshots"); const hasTestNamePattern = allArgs.some(arg => arg === "-t" || arg === "--test-name-pattern"); if (hasUpdateSnapshots && hasTestNamePattern) { denyWithReason("error: Cannot use -u/--update-snapshots with -t/--test-name-pattern"); } } } // Check if timeout option is set for "bun bd" command if (timeout !== undefined && (argv0 === "bun" || argv0.includes("bun-debug"))) { const positionalArgs = tokens.slice(1).filter(arg => !arg.startsWith("-")); if (positionalArgs.length === 1 && positionalArgs[0] === "bd") { denyWithReason("error: Run `bun bd` without a timeout"); } } // Check if running "bun test " without USE_SYSTEM_BUN=1 if ((argv0 === "bun" || argv0.includes("bun-debug")) && useSystemBun !== "1") { const allArgs = tokens.slice(1); const positionalArgs = allArgs.filter(arg => !arg.startsWith("-")); // Check if it's "test" (not "bd test") if (positionalArgs.length >= 1 && positionalArgs[0] === "test" && positionalArgs[0] !== "bd") { denyWithReason( "error: In development, use `bun bd test ` to test your changes. If you meant to use a release version, set USE_SYSTEM_BUN=1", ); } } // Check if running "bun bd test" from bun repo root or test folder without a file path if (argv0 === "bun" || argv0.includes("bun-debug")) { const allArgs = tokens.slice(1); const positionalArgs = allArgs.filter(arg => !arg.startsWith("-")); // Check if it's "bd test" if (positionalArgs.length >= 2 && positionalArgs[0] === "bd" && positionalArgs[1] === "test") { // Check if cwd is the bun repo root or test folder const isBunRepoRoot = cwd === "/workspace/bun" || cwd.endsWith("/bun"); const isTestFolder = cwd.endsWith("/bun/test"); if (isBunRepoRoot || isTestFolder) { // Check if there's a file path argument (looks like a path: contains / or has test extension) const hasFilePath = positionalArgs .slice(2) .some( arg => arg.includes("/") || arg.endsWith(".test.ts") || arg.endsWith(".test.js") || arg.endsWith(".test.tsx") || arg.endsWith(".test.jsx"), ); if (!hasFilePath) { denyWithReason( "error: `bun bd test` from repo root or test folder will run all tests. Use `bun bd test ` with a specific test file.", ); } } } } // Allow the command to proceed process.exit(0);