Build This Now
Build This Now
Keyboard ShortcutsStatus Line Guide
Hooks GuideCross-Platform Hooks for Claude CodeClaude Code Setup HooksStop HooksSelf-Validating Claude Code AgentsClaude Code Session HooksContext Backup Hooks for Claude CodeSkill Activation HookClaude Code Permission Hook
Get Build This Now
speedy_devvkoen_salo
Blog/Toolkit/Hooks/Cross-Platform Hooks for Claude Code

Cross-Platform Hooks for Claude Code

Skip the .cmd, .sh, and .ps1 wrappers. Invoke node directly and the same hook file runs on every operating system your team uses.

Problem: A hook you shipped from Windows via cmd /c or PowerShell lights up red the moment a Linux teammate opens the repo. The workaround most people land on is ugly. Three shim scripts per hook: a .cmd for Windows, a .sh for Linux, a .ps1 for PowerShell. All three do exactly the same thing, which is call the .mjs file that actually matters.

Quick Win: Throw the wrappers away. Point the hook config at Node.js directly:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Write|Edit",
        "hooks": [
          {
            "type": "command",
            "command": "node .claude/hooks/formatter.mjs"
          }
        ]
      }
    ]
  }
}

Runs on Windows, Linux, and macOS without changes. node is always on the $PATH because Claude Code lists Node.js as a hard requirement.

Why Hooks Break When the OS Changes

If nobody else touches your setup, none of this bites. Trouble begins the moment .claude/settings.json gets shared, the repo goes public, or you start alternating between a Windows tower and a macOS laptop. The second bash or powershell appears inside a command, half the team cannot run it.

Most tutorials go platform-specific anyway:

// Windows-only
"command": "cmd /c \".claude\\hooks\\formatter.cmd\""
 
// Linux-only
"command": "bash .claude/hooks/formatter.sh"
 
// PowerShell-only
"command": "powershell -NoProfile -File .claude/hooks/statusline.ps1"

Each of those wrappers is two lines of boilerplate around a node call. Three files, three platforms, same job on all of them. If the only layer that knows about the OS is the shim, you can delete the shim.

The Universal Pattern in settings.json

Hooks inside settings.json share one shape:

{
  "statusLine": {
    "type": "command",
    "command": "node .claude/hooks/statusline-monitor.mjs"
  },
  "hooks": {
    "UserPromptSubmit": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "node .claude/hooks/skill-activation.mjs"
          }
        ]
      }
    ],
    "PreCompact": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "node .claude/hooks/backup.mjs",
            "async": true
          }
        ]
      }
    ]
  }
}

No cmd /c. No bash. No powershell. Just node. Every hook type takes the same shape, so PostToolUse, SessionStart/SessionEnd, Stop, and all 12 lifecycle events wire up identically.

Three Rules for Cross-Platform Hook Logic

Inside each .mjs file, three habits keep the logic portable no matter which OS ends up running it.

Use os.homedir() Instead of Platform Variables

import { homedir } from "os";
import { join } from "path";
 
// Cross-platform: resolves to C:\Users\you or /home/you
const settingsPath = join(homedir(), ".claude", "settings.json");

Keep $HOME, $env:USERPROFILE, and %USERPROFILE% out of the source. Let the helper pick the right one.

Use os.tmpdir() for Temporary File Paths

import { tmpdir } from "os";
import { join } from "path";
 
// Cross-platform: resolves to C:\Users\you\AppData\Local\Temp or /tmp
const cacheFile = join(tmpdir(), "my-hook-cache.json");

Do not reach for /tmp or $env:TEMP by hand.

Use path.join() for All File Path Construction

import { join } from "path";
 
// Cross-platform path construction
const logFile = join(".claude", "hooks", "logs", "hook.log");

Stop gluing paths together with / or \\ by hand. Node.js already knows the right separator for whichever OS the hook lands on.

Permissions That Cover Both Platforms

The permissions block in settings.json should hold the Windows name and the Unix name side by side:

{
  "permissions": {
    "allow": [
      "Bash(where:*)",
      "Bash(which:*)",
      "Bash(tasklist:*)",
      "Bash(ps:*)",
      "Bash(taskkill:*)",
      "Bash(kill:*)",
      "Bash(findstr:*)",
      "Bash(node:*)"
    ]
  }
}

Whichever command is not installed on the current machine stays silent. Listing both has no cost, and your hook picks whichever one actually exists without tripping a permission dialog. For deeper automation on this side, see the Permission Hook guide.

Complete Example: Cross-Platform File Logger

One full hook, portable as-is. The job is a file logger. Any Write or Edit that Claude performs gets appended to a log:

#!/usr/bin/env node
import { readFileSync, appendFileSync, mkdirSync } from "fs";
import { join } from "path";
 
const logDir = join(".claude", "hooks", "logs");
mkdirSync(logDir, { recursive: true });
 
try {
  const input = JSON.parse(readFileSync(0, "utf-8"));
  const toolName = input.tool_name;
  const filePath = input.tool_input?.file_path || "unknown";
  const timestamp = new Date().toISOString();
 
  appendFileSync(
    join(logDir, "file-changes.log"),
    `${timestamp} | ${toolName} | ${filePath}\n`,
  );
} catch {
  // Silent fail -- don't block Claude
}
 
process.exit(0);

Register it in your settings.json:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Write|Edit",
        "hooks": [
          {
            "type": "command",
            "command": "node .claude/hooks/file-logger.mjs"
          }
        ]
      }
    ]
  }
}

Same behavior on Windows 11, Arch Linux, and macOS Sequoia. Zero wrappers.

Debugging When One OS Breaks

Got a hook that runs on one machine and fails on the next? Walk this list top to bottom:

  1. Hardcoded path separators. Search every .mjs file for / or \\ appearing inside a path. Hand those cases to path.join().
  2. Environment variable references. Look for process.env.HOME, process.env.USERPROFILE, or process.env.TEMP and swap each one out for os.homedir() or os.tmpdir().
  3. Shell-specific commands in settings.json. Any command entry that mentions bash, cmd, powershell, or sh breaks everywhere except its home OS. Point it at node.

Fire the hook manually so the failure cannot hide:

echo '{"tool_name":"Write","tool_input":{"file_path":"test.js"}}' | node .claude/hooks/your-hook.mjs
echo $?  # Should output 0

A 0 on one OS and something else on the next points you at the .mjs file's path handling, never the hook entry in settings.

Release Checklist

Run through this list before a team rollout or a public release:

  • Every command in settings.json points at node, not cmd, powershell, or bash
  • Home directories come from os.homedir(), never $HOME or %USERPROFILE%
  • Temp paths come from os.tmpdir(), never /tmp or $env:TEMP
  • Paths get built with path.join(), not hand-typed separators
  • Permissions list both the Windows and the Unix equivalents
  • The statusLine command resolves to node, not powershell

One file. Three platforms. No upkeep.

Do Claude Code hooks work on Windows?

Yes. Call them through node rather than a shell that only lives on one OS, and they run the same on Windows, Linux, and macOS. Node.js is a hard requirement of Claude Code on every platform, so node is always on the $PATH. Put node .claude/hooks/your-hook.mjs in settings.json and you get identical behavior on all three.

Can I use Python instead of Node.js for hooks?

Python works too, provided every teammate already has an interpreter on their machine. In the command field, reach for python3 rather than python, because certain Linux distros never ship a plain python. Node.js stays the safer pick: Claude Code promises it on every platform, Python it does not.

How do I handle line endings across platforms?

You mostly do not. readFileSync and writeFileSync already normalize the endings. Every hook reads JSON off stdin, and the JSON parser does not care whether the newlines are CRLF or LF. The exception is any hook that emits its own shell script. For that path, write \n and leave the rest to Git's autocrlf setting.

More in this guide

  • Keyboard Shortcuts
    Configure custom keyboard shortcuts in Claude Code.
  • Status Line Guide
    Set up a custom Claude Code status line showing model name, git branch, cost, and context usage.
  • AI SEO and GEO Optimization
    A rundown of Generative Engine Optimization: how to get content cited inside ChatGPT, Claude, and Perplexity responses instead of just ranked on Google.
  • Claude Code vs Cursor in 2026
    A side-by-side look at Claude Code and Cursor in 2026: agent models, context windows, pricing tiers, and how each tool fits different developer workflows.
  • Claude Code VSCode Extension
    Anthropic's VS Code extension puts Claude Code inside the editor sidebar as a Spark-icon panel, with inline diffs, plan mode, subagents, and MCP support.

Stop configuring. Start building.

SaaS builder templates with AI orchestration.

Get Build This Now

Hooks Guide

Claude Code hooks from first principles: exit codes, JSON output, async, HTTP, and production patterns.

Claude Code Setup Hooks

Run a deterministic setup script, then hand the output to an agent for diagnosis. One command, three modes, zero drifted docs.

On this page

Why Hooks Break When the OS Changes
The Universal Pattern in settings.json
Three Rules for Cross-Platform Hook Logic
Use os.homedir() Instead of Platform Variables
Use os.tmpdir() for Temporary File Paths
Use path.join() for All File Path Construction
Permissions That Cover Both Platforms
Complete Example: Cross-Platform File Logger
Debugging When One OS Breaks
Release Checklist
Do Claude Code hooks work on Windows?
Can I use Python instead of Node.js for hooks?
How do I handle line endings across platforms?

Stop configuring. Start building.

SaaS builder templates with AI orchestration.

Get Build This Now