Build This Now
Build This Now
Keyboard ShortcutsStatus Line Guide
speedy_devvkoen_salo
Blog/Toolkit/Hooks/Cross-Platform Hooks for Claude Code

Cross-Platform Hooks for Claude Code

Cross-platform Claude Code hooks: skip .cmd, .sh, and .ps1 wrappers and invoke node directly so one .mjs file runs on macOS, Linux, and Windows across the team.

Stop configuring. Start building.

SaaS builder templates with AI orchestration.

Published Jan 16, 2026Toolkit hubHooks index

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.

Continue in Hooks

  • Claude Code Setup Hooks
    Braid scripts, agents, and docs into Claude Code setup hooks. One command runs a deterministic script, hands output to a diagnosing agent, logs living docs.
  • Context Backup Hooks for Claude Code
    A StatusLine-driven Claude Code context backup hook. Writes structured snapshots every 10K tokens so auto-compaction never eats errors, signatures, decisions.
  • Hooks Guide
    Claude Code hooks from first principles: exit codes, JSON output, async commands, HTTP endpoints, PreToolUse and PostToolUse matchers, production patterns.
  • MCP Tool Hooks in Claude Code
    How to call MCP server tools directly from Claude Code hooks using type: mcp_tool — schema, substitution syntax, use cases, and production patterns.
  • Claude Code Permission Hook
    Install a three-tier Claude Code permission hook: instant allow for safe calls, instant deny for dangerous ones, LLM check for the gray area. No skip flag.
  • Self-Validating Claude Code Agents
    Self-validating Claude Code agents: wire PostToolUse lint hooks, Stop hooks, and read-only reviewer sub-agents into agent definitions so bad output never ships.

More from Toolkit

  • Keyboard Shortcuts
    Configure Claude Code keybindings.json: 17 contexts, keystroke syntax, chord sequences, modifier combinations, and how to unbind any default shortcut instantly.
  • Status Line Guide
    Set up a Claude Code status line for model name, git branch, session cost, and context usage. settings.json config, JSON input, bash, Python, Node.js scripts.
  • 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 Bolt.new: Which Should You Use?
    Bolt.new prototypes in 28 minutes with zero setup. Claude Code takes 90 minutes but ships production-ready code. Here is how to pick the right tool.

Stop configuring. Start building.

SaaS builder templates with AI orchestration.

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.