diff --git a/.claude/hooks/post-edit-zig-format.js b/.claude/hooks/post-edit-zig-format.js new file mode 100755 index 0000000000..3cf96837b9 --- /dev/null +++ b/.claude/hooks/post-edit-zig-format.js @@ -0,0 +1,90 @@ +#!/usr/bin/env bun +import { extname } from "path"; +import { spawnSync } from "child_process"; + +const input = await Bun.stdin.json(); + +const toolName = input.tool_name; +const toolInput = input.tool_input || {}; +const filePath = toolInput.file_path; + +// Only process Write, Edit, and MultiEdit tools +if (!["Write", "Edit", "MultiEdit"].includes(toolName)) { + process.exit(0); +} + +const ext = extname(filePath); + +// Only format known files +if (!filePath) { + process.exit(0); +} + +function formatZigFile() { + try { + // Format the Zig file + const result = spawnSync("vendor/zig/zig.exe", ["fmt", filePath], { + cwd: process.env.CLAUDE_PROJECT_DIR || process.cwd(), + encoding: "utf-8", + }); + + if (result.error) { + console.error(`Failed to format ${filePath}: ${result.error.message}`); + process.exit(0); + } + + if (result.status !== 0) { + console.error(`zig fmt failed for ${filePath}:`); + if (result.stderr) { + console.error(result.stderr); + } + process.exit(0); + } + } catch (error) {} +} + +function formatTypeScriptFile() { + try { + // Format the TypeScript file + const result = spawnSync( + "./node_modules/.bin/prettier", + ["--plugin=prettier-plugin-organize-imports", "--config", ".prettierrc", "--write", filePath], + { + cwd: process.env.CLAUDE_PROJECT_DIR || process.cwd(), + encoding: "utf-8", + }, + ); + } catch (error) {} +} + +if (ext === ".zig") { + formatZigFile(); +} else if ( + [ + ".cjs", + ".css", + ".html", + ".js", + ".json", + ".jsonc", + ".jsx", + ".less", + ".mjs", + ".pcss", + ".postcss", + ".sass", + ".scss", + ".styl", + ".stylus", + ".toml", + ".ts", + ".tsx", + ".yaml", + ].includes(ext) +) { + formatTypeScriptFile(); +} else if (ext === ".cpp" || ext === ".c" || ext === ".h") { + formatCppFile(); +} + +process.exit(0); diff --git a/.claude/hooks/pre-bash-zig-build.js b/.claude/hooks/pre-bash-zig-build.js new file mode 100755 index 0000000000..24b47b06fc --- /dev/null +++ b/.claude/hooks/pre-bash-zig-build.js @@ -0,0 +1,175 @@ +#!/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])); + +// Check if it's zig or zig.exe +if (argv0 === "zig") { + // Filter out flags (starting with -) to get positional arguments + const positionalArgs = tokens.slice(1).filter(arg => !arg.startsWith("-")); + + // Check if the positional args contain "build" followed by "obj" + if (positionalArgs.length >= 2 && positionalArgs[0] === "build" && positionalArgs[1] === "obj") { + denyWithReason("error: Use `bun bd` to build Bun and wait patiently"); + } +} + +// 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")) { + const positionalArgs = restArgs.filter(arg => !arg.startsWith("-")); + 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); diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000000..387633d5fd --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,26 @@ +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/pre-bash-zig-build.js" + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "Write|Edit|MultiEdit", + "hooks": [ + { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/post-edit-zig-format.js" + } + ] + } + ] + } +}