DEV Community

Shrijith Venkatramana
Shrijith Venkatramana

Posted on

Building Truly Cross-Platform Claude Code Hooks with Go, Bash, PowerShell, WSL, and Git-Bash

Hello, I'm Shrijith Venkatramana. I'm building git-lrc, an AI code reviewer that runs on every commit. Star Us to help devs discover the project. Do give it a try and share your feedback for improving the product.


Claude Code hooks are powerful. They let you intercept tool execution, enforce policies, run validations, collect telemetry, or integrate external systems before and after Claude performs actions.

Unfortunately, the moment you try to distribute hooks to real developers, you run into a problem:

  • Some developers use Linux
  • Some use macOS
  • Some use Windows PowerShell
  • Some use Git-Bash
  • Some use WSL
  • Some use combinations of all of the above

A simple shell script quickly turns into a compatibility nightmare.

After experimenting with several approaches, I arrived at a surprisingly effective pattern:

Use thin platform-specific wrappers whose only job is downloading and launching a Go binary. Put all real logic inside the Go executable.

This gives you the convenience of native hooks while keeping the implementation portable, testable, and maintainable.

Let's walk through the architecture.

The Cross-Platform Hook Problem

Suppose you build a hook that validates commands before Claude executes them.

The naive implementation might look like this:

#!/usr/bin/env bash

python validate.py
Enter fullscreen mode Exit fullscreen mode

Looks fine until:

  • Python isn't installed
  • The user runs PowerShell
  • The user runs Git-Bash
  • The user runs WSL
  • Path handling differs
  • Quoting rules differ

Now you have:

validate.sh
validate.ps1
validate.py
requirements.txt
Enter fullscreen mode Exit fullscreen mode

And eventually:

works-on-my-machine/
Enter fullscreen mode Exit fullscreen mode

The problem isn't Claude.

The problem is that shells are operating-system specific.

The Better Architecture

Instead, think of the hook as a bootstrapper.

Claude Hook
     |
     v
Thin Wrapper
     |
     v
Download Go Binary (if needed)
     |
     v
Execute Go Binary
     |
     v
Actual Hook Logic
Enter fullscreen mode Exit fullscreen mode

The wrapper becomes extremely small.

The Go executable contains:

  • Policy checks
  • Configuration loading
  • JSON parsing
  • API calls
  • Logging
  • Cross-platform filesystem access
  • Everything else

Once the binary exists locally, future hook invocations bypass installation entirely.

Bootstrapping on First Run

The wrapper checks whether the executable exists.

If not:

  1. Detect platform
  2. Download correct binary
  3. Make executable if needed
  4. Run binary

Example release layout:

releases/
├── hook-linux-amd64
├── hook-linux-arm64
├── hook-darwin-amd64
├── hook-darwin-arm64
├── hook-windows-amd64.exe
└── hook-windows-arm64.exe
Enter fullscreen mode Exit fullscreen mode

A GitHub Releases page works perfectly for hosting.

Example Bash wrapper:

#!/usr/bin/env bash

set -e

HOOK_DIR="$HOME/.claude-hooks"
BIN="$HOOK_DIR/hook"

mkdir -p "$HOOK_DIR"

if [ ! -f "$BIN" ]; then
    curl -L \
      https://example.com/hook-linux-amd64 \
      -o "$BIN"

    chmod +x "$BIN"
fi

exec "$BIN" "$@"
Enter fullscreen mode Exit fullscreen mode

The wrapper is tiny and almost never changes.

Supporting PowerShell

Windows users deserve first-class support.

PowerShell wrapper:

$HookDir = "$env:USERPROFILE\.claude-hooks"
$Binary = "$HookDir\hook.exe"

New-Item `
  -ItemType Directory `
  -Force `
  -Path $HookDir | Out-Null

if (!(Test-Path $Binary)) {

    Invoke-WebRequest `
        -Uri "https://example.com/hook-windows-amd64.exe" `
        -OutFile $Binary
}

& $Binary $args
exit $LASTEXITCODE
Enter fullscreen mode Exit fullscreen mode

The important detail is that PowerShell's quoting rules differ significantly from Bash.

By moving all logic into Go, you avoid maintaining duplicate implementations.

Handling WSL and Git-Bash

This is where things become interesting.

Many Windows developers don't actually run PowerShell.

They run:

  • WSL Ubuntu
  • Git-Bash
  • MSYS2
  • Cygwin

Each environment reports itself differently.

A good Go bootstrapper can detect them.

Example:

func detectEnvironment() string {

    if runtime.GOOS != "windows" {
        return "native"
    }

    if os.Getenv("WSL_DISTRO_NAME") != "" {
        return "wsl"
    }

    if os.Getenv("MSYSTEM") != "" {
        return "git-bash"
    }

    return "powershell"
}
Enter fullscreen mode Exit fullscreen mode

You can then adjust behavior.

For example:

switch detectEnvironment() {

case "wsl":
    // Linux paths

case "git-bash":
    // Mixed Windows/POSIX paths

case "powershell":
    // Native Windows paths
}
Enter fullscreen mode Exit fullscreen mode

This is dramatically easier than maintaining separate shell implementations.

Cross-Platform Techniques in Go

The Go standard library already solves most portability issues.

Use filepath

Avoid hardcoded separators.

Bad:

path := home + "/config/settings.json"
Enter fullscreen mode Exit fullscreen mode

Good:

path := filepath.Join(
    home,
    "config",
    "settings.json",
)
Enter fullscreen mode Exit fullscreen mode

Use os.UserHomeDir

Avoid platform assumptions.

home, err := os.UserHomeDir()
Enter fullscreen mode Exit fullscreen mode

Works on:

  • Linux
  • macOS
  • Windows
  • WSL

Use os.Executable

Finding your own binary location:

exe, err := os.Executable()
Enter fullscreen mode Exit fullscreen mode

Useful when loading bundled resources.

Detect Operating System

switch runtime.GOOS {

case "windows":
    // Windows

case "linux":
    // Linux

case "darwin":
    // macOS
}
Enter fullscreen mode Exit fullscreen mode

Detect Architecture

fmt.Println(runtime.GOARCH)
Enter fullscreen mode Exit fullscreen mode

Possible values:

amd64
arm64
386
Enter fullscreen mode Exit fullscreen mode

Useful for selecting downloads.

Full Example Downloader

A minimal self-updating launcher:

package main

import (
    "io"
    "net/http"
    "os"
)

func download(url string, dest string) error {

    resp, err := http.Get(url)
    if err != nil {
        return err
    }
    defer resp.Body.Close()

    out, err := os.Create(dest)
    if err != nil {
        return err
    }
    defer out.Close()

    _, err = io.Copy(out, resp.Body)

    return err
}
Enter fullscreen mode Exit fullscreen mode

Combined with:

runtime.GOOS
runtime.GOARCH
Enter fullscreen mode Exit fullscreen mode

you can dynamically fetch the correct binary.

Why This Pattern Scales Better

The biggest benefit isn't portability.

It's maintainability.

Without this pattern:

hook.sh
hook.ps1
hook.py
hook.js
Enter fullscreen mode Exit fullscreen mode

With this pattern:

hook.sh      (tiny)
hook.ps1     (tiny)

hook-go/
    all logic
Enter fullscreen mode Exit fullscreen mode

The wrappers rarely change.

The Go binary evolves independently.

Testing becomes easier.

Distribution becomes easier.

Versioning becomes easier.

And most importantly, you stop fighting shell differences.

Final Thoughts

Many engineering teams start by writing Claude hooks as shell scripts because it feels fast.

That works for one machine.

The moment multiple operating systems enter the picture, the complexity grows rapidly.

A small bootstrap wrapper plus a Go executable gives you a surprisingly robust deployment model:

  • Bash support
  • PowerShell support
  • Linux support
  • macOS support
  • Windows support
  • WSL support
  • Git-Bash support
  • Single implementation of business logic

The shell becomes a launcher.

Go becomes the platform.

That's usually the point where hook maintenance stops being a headache.

How are you handling cross-platform automation today—shell scripts, Node.js, Python, or compiled binaries? I'd be interested to hear which approach has held up best as your team and environments grew.


*AI agents write code fast. They also silently remove logic, change behavior, and introduce bugs -- without telling you. You often find out in production.

git-lrc fixes this. It hooks into git commit and reviews every diff before it lands. 60-second setup. Completely free.*

Any feedback or contributors are welcome! It's online, source-available, and ready for anyone to use.

GitHub logo HexmosTech / git-lrc

Free, Micro AI Code Reviews That Run on Commit




AI agents write code fast. They also silently remove logic, change behavior, and introduce bugs -- without telling you. You often find out in production.

git-lrc fixes this. It hooks into git commit and reviews every diff before it lands. 60-second setup. Completely free.

See It In Action

See git-lrc catch serious security issues such as leaked credentials, expensive cloud operations, and sensitive material in log statements

git-lrc-intro-60s.mp4

Why

  • 🤖 AI agents silently break things. Code removed. Logic changed. Edge cases gone. You won't notice until production.
  • 🔍 Catch it before it ships. AI-powered inline comments show you exactly what changed and what looks wrong.
  • 🔁 Build a

Top comments (0)